JavaScript · 12655 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 // Color palette - vibrant Wind Waker yellows
9 const bodyColor = 0xffdc50 // Warm yellow
10 const bodyHighlight = 0xfff0a0 // Light yellow
11 const beakColor = 0xff9020 // Bright orange
12 const eyeWhite = 0xffffff
13 const eyePupil = 0x191410
14
15 // Toon materials
16 const bodyMaterial = new THREE.MeshToonMaterial({
17 color: bodyColor,
18 gradientMap: gradientMap
19 })
20
21 const highlightMaterial = new THREE.MeshToonMaterial({
22 color: bodyHighlight,
23 gradientMap: gradientMap
24 })
25
26 const beakMaterial = new THREE.MeshToonMaterial({
27 color: beakColor,
28 gradientMap: gradientMap
29 })
30
31 const eyeWhiteMaterial = new THREE.MeshToonMaterial({
32 color: eyeWhite,
33 gradientMap: gradientMap
34 })
35
36 const eyePupilMaterial = new THREE.MeshBasicMaterial({
37 color: eyePupil
38 })
39
40 // Body - elongated pear shape using multiple parts
41 // Main body (more oval, less ball)
42 const bodyGeom = new THREE.SphereGeometry(0.42, 8, 6) // Lower poly for angular look
43 bodyGeom.scale(1.4, 0.75, 0.9)
44 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
45 body.position.y = 0.28
46 body.rotation.z = 0.1 // Slight tilt forward
47 group.add(body)
48
49 // Rear bump (makes it pear-shaped)
50 const rearGeom = new THREE.SphereGeometry(0.28, 6, 5)
51 rearGeom.scale(1, 0.8, 0.9)
52 const rear = new THREE.Mesh(rearGeom, bodyMaterial)
53 rear.position.set(-0.35, 0.22, 0)
54 group.add(rear)
55
56 // Chest puff (front bump)
57 const chestGeom = new THREE.SphereGeometry(0.25, 6, 5)
58 chestGeom.scale(0.8, 0.9, 0.85)
59 const chest = new THREE.Mesh(chestGeom, highlightMaterial)
60 chest.position.set(0.25, 0.32, 0)
61 group.add(chest)
62
63 // Subtle feather tufts on body (toned down)
64 const tuftMaterial = bodyMaterial
65 const featherPositions = [
66 { x: -0.38, y: 0.44, z: 0.1, rx: 0.2, rz: 0.3 },
67 { x: -0.38, y: 0.44, z: -0.1, rx: -0.2, rz: 0.3 },
68 ]
69
70 for (const f of featherPositions) {
71 const featherGeom = new THREE.ConeGeometry(0.04, 0.12, 4)
72 const feather = new THREE.Mesh(featherGeom, tuftMaterial)
73 feather.position.set(f.x, f.y, f.z)
74 feather.rotation.x = f.rx
75 feather.rotation.z = f.rz
76 group.add(feather)
77 }
78
79 // Tail feathers - subtle curl up
80 const tailGroup = new THREE.Group()
81 const tailFeathers = [
82 { x: -0.55, y: 0.36, z: 0, rx: 1.6, rz: 0, scale: 1.0 },
83 { x: -0.52, y: 0.40, z: 0.08, rx: 1.4, rz: 0.2, scale: 0.7 },
84 { x: -0.52, y: 0.40, z: -0.08, rx: 1.4, rz: -0.2, scale: 0.7 },
85 ]
86
87 for (const t of tailFeathers) {
88 const tailGeom = new THREE.ConeGeometry(0.04, 0.18, 4)
89 const tail = new THREE.Mesh(tailGeom, bodyMaterial)
90 tail.position.set(t.x, t.y, t.z)
91 tail.rotation.x = t.rx
92 tail.rotation.z = t.rz
93 tail.scale.setScalar(t.scale)
94 tailGroup.add(tail)
95 }
96 group.add(tailGroup)
97
98 // Wings - more angular, feather-like
99 const wingGeom = new THREE.ConeGeometry(0.18, 0.4, 4)
100
101 const leftWing = new THREE.Mesh(wingGeom, bodyMaterial)
102 leftWing.position.set(-0.1, 0.32, 0.38)
103 leftWing.rotation.x = 1.2
104 leftWing.rotation.z = 0.4
105 group.add(leftWing)
106
107 const rightWing = new THREE.Mesh(wingGeom, bodyMaterial)
108 rightWing.position.set(-0.1, 0.32, -0.38)
109 rightWing.rotation.x = -1.2
110 rightWing.rotation.z = 0.4
111 group.add(rightWing)
112
113 // Wing feather details
114 const wingFeatherGeom = new THREE.ConeGeometry(0.08, 0.22, 3)
115
116 const leftWingFeather = new THREE.Mesh(wingFeatherGeom, bodyMaterial)
117 leftWingFeather.position.set(-0.2, 0.28, 0.42)
118 leftWingFeather.rotation.x = 1.4
119 leftWingFeather.rotation.z = 0.6
120 group.add(leftWingFeather)
121
122 const rightWingFeather = new THREE.Mesh(wingFeatherGeom, bodyMaterial)
123 rightWingFeather.position.set(-0.2, 0.28, -0.42)
124 rightWingFeather.rotation.x = -1.4
125 rightWingFeather.rotation.z = 0.6
126 group.add(rightWingFeather)
127
128 // Head - slightly egg-shaped, not perfectly round
129 const headGeom = new THREE.SphereGeometry(0.28, 7, 6)
130 headGeom.scale(1.1, 1.0, 0.95)
131 const head = new THREE.Mesh(headGeom, bodyMaterial)
132 head.position.set(0.42, 0.68, 0)
133 group.add(head)
134
135 // Cheek puffs
136 const cheekGeom = new THREE.SphereGeometry(0.1, 5, 4)
137 const leftCheek = new THREE.Mesh(cheekGeom, highlightMaterial)
138 leftCheek.position.set(0.48, 0.62, 0.18)
139 leftCheek.scale.set(0.8, 0.7, 0.6)
140 group.add(leftCheek)
141
142 const rightCheek = new THREE.Mesh(cheekGeom, highlightMaterial)
143 rightCheek.position.set(0.48, 0.62, -0.18)
144 rightCheek.scale.set(0.8, 0.7, 0.6)
145 group.add(rightCheek)
146
147 // Head tuft - simple pair of feathers
148 const tuftGeom = new THREE.ConeGeometry(0.035, 0.12, 4)
149 const tuft1 = new THREE.Mesh(tuftGeom, bodyMaterial)
150 tuft1.position.set(0.34, 0.93, 0.03)
151 tuft1.rotation.z = -0.3
152 tuft1.rotation.x = 0.2
153 group.add(tuft1)
154
155 const tuft2 = new THREE.Mesh(tuftGeom, bodyMaterial)
156 tuft2.position.set(0.34, 0.93, -0.03)
157 tuft2.rotation.z = -0.3
158 tuft2.rotation.x = -0.2
159 group.add(tuft2)
160
161 // Beak - flatter, more duck-like
162 const beakGeom = new THREE.ConeGeometry(0.09, 0.32, 6)
163 beakGeom.scale(1, 1, 0.6) // Flatten it
164 const beak = new THREE.Mesh(beakGeom, beakMaterial)
165 beak.rotation.z = -Math.PI / 2
166 beak.position.set(0.75, 0.62, 0)
167 group.add(beak)
168
169 // Eyes - big and expressive Wind Waker style
170 const eyeGeom = new THREE.SphereGeometry(0.1, 12, 8)
171
172 const leftEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
173 leftEye.position.set(0.65, 0.78, 0.15)
174 leftEye.scale.set(0.8, 1, 0.6)
175 group.add(leftEye)
176
177 const rightEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
178 rightEye.position.set(0.65, 0.78, -0.15)
179 rightEye.scale.set(0.8, 1, 0.6)
180 group.add(rightEye)
181
182 // Pupils
183 const pupilGeom = new THREE.SphereGeometry(0.05, 8, 6)
184
185 const leftPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
186 leftPupil.position.set(0.72, 0.78, 0.15)
187 group.add(leftPupil)
188
189 const rightPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
190 rightPupil.position.set(0.72, 0.78, -0.15)
191 group.add(rightPupil)
192
193 // Eye shine
194 const shineGeom = new THREE.SphereGeometry(0.025, 6, 4)
195 const shineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
196
197 const leftShine = new THREE.Mesh(shineGeom, shineMaterial)
198 leftShine.position.set(0.73, 0.82, 0.13)
199 group.add(leftShine)
200
201 const rightShine = new THREE.Mesh(shineGeom, shineMaterial)
202 rightShine.position.set(0.73, 0.82, -0.17)
203 group.add(rightShine)
204
205 // Add to scene
206 scene.add(group)
207
208 // Duck state
209 const state = {
210 position: new THREE.Vector3(0, 0, 0),
211 targetPosition: new THREE.Vector3(0, 0, 0),
212 rotation: 0,
213 targetRotation: 0,
214 mode: 'idle', // 'idle', 'waiting', 'swimming'
215 waitTimer: 0,
216 waitDuration: 0,
217 idleTimer: 0,
218 nextIdleMove: 3 + Math.random() * 4,
219 wobble: 0,
220 headBob: 0,
221 rippleTimer: 0,
222 isMoving: false
223 }
224
225 // Movement speeds
226 const idleSpeed = 0.5
227 const swimSpeed = 1.2
228 const turnSpeed = 4 // radians per second
229
230 // Helper to lerp angles properly (handles wraparound)
231 function lerpAngle(from, to, t) {
232 let diff = to - from
233 // Normalize to -PI to PI
234 while (diff > Math.PI) diff -= Math.PI * 2
235 while (diff < -Math.PI) diff += Math.PI * 2
236 return from + diff * t
237 }
238
239 function pickIdleTarget(pond) {
240 const angle = Math.random() * Math.PI * 2
241 const radius = Math.random() * (pond.radius * 0.7) + pond.radius * 0.1
242 state.targetPosition.set(
243 Math.cos(angle) * radius,
244 0,
245 Math.sin(angle) * radius
246 )
247 }
248
249 function update(delta, elapsed, breadBits, pond) {
250 // Find closest bread
251 let closestBread = null
252 let closestDist = Infinity
253
254 for (const bread of breadBits) {
255 if (!bread.eaten) {
256 const dx = bread.position.x - state.position.x
257 const dz = bread.position.z - state.position.z
258 const dist = Math.sqrt(dx * dx + dz * dz)
259 if (dist < closestDist) {
260 closestDist = dist
261 closestBread = bread
262 }
263 }
264 }
265
266 // React to bread
267 if (closestBread && state.mode === 'idle') {
268 state.mode = 'waiting'
269 state.waitTimer = 0
270 state.waitDuration = 0.5 + Math.random() * 0.8 // Quirky delay
271 state.pendingTarget = closestBread.position.clone()
272 }
273
274 // Handle waiting (the quirky pause)
275 if (state.mode === 'waiting') {
276 state.waitTimer += delta
277 state.headBob = Math.sin(state.waitTimer * 8) * 0.1
278 head.position.y = 0.7 + state.headBob
279
280 if (state.waitTimer >= state.waitDuration) {
281 state.mode = 'swimming'
282 state.targetPosition.copy(state.pendingTarget)
283 }
284 }
285
286 // Movement
287 const dx = state.targetPosition.x - state.position.x
288 const dz = state.targetPosition.z - state.position.z
289 const dist = Math.sqrt(dx * dx + dz * dz)
290
291 if (dist > 0.1) {
292 // Calculate desired rotation to face target
293 state.targetRotation = Math.atan2(dx, dz)
294
295 // Smoothly turn toward target direction
296 state.rotation = lerpAngle(state.rotation, state.targetRotation, turnSpeed * delta)
297
298 // Check if we're facing roughly the right direction (within ~30 degrees)
299 let angleDiff = Math.abs(state.targetRotation - state.rotation)
300 while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
301 angleDiff = Math.abs(angleDiff)
302
303 // Only move forward if facing the right way
304 if (angleDiff < 0.5) {
305 const speed = state.mode === 'swimming' ? swimSpeed : idleSpeed
306 const moveAmount = Math.min(speed * delta, dist)
307
308 // Move in the direction Doug is FACING (not toward target directly)
309 const moveX = Math.sin(state.rotation) * moveAmount
310 const moveZ = Math.cos(state.rotation) * moveAmount
311
312 state.position.x += moveX
313 state.position.z += moveZ
314
315 // Wobble animation while moving
316 state.wobble += delta * 8
317 state.isMoving = true
318
319 // Create swimming ripples
320 state.rippleTimer += delta
321 const rippleInterval = state.mode === 'swimming' ? 0.25 : 0.5
322 if (state.rippleTimer >= rippleInterval) {
323 state.rippleTimer = 0
324 // Ripple slightly behind the duck
325 const rippleX = state.position.x - Math.sin(state.rotation) * 0.3
326 const rippleZ = state.position.z - Math.cos(state.rotation) * 0.3
327 pond.addRipple(rippleX, rippleZ)
328 }
329 } else {
330 state.isMoving = false
331 }
332 } else {
333 state.isMoving = false
334 // Arrived
335 if (state.mode === 'swimming') {
336 state.mode = 'idle'
337 // Eat nearby bread - create splash ripple!
338 for (const bread of breadBits) {
339 if (!bread.eaten) {
340 const bx = bread.position.x - state.position.x
341 const bz = bread.position.z - state.position.z
342 if (Math.sqrt(bx * bx + bz * bz) < 0.4) {
343 bread.eaten = true
344 // Eating splash ripple
345 pond.addRipple(state.position.x, state.position.z)
346 // Damp crunch sound
347 playMonch()
348 }
349 }
350 }
351 }
352 }
353
354 // Idle wandering
355 if (state.mode === 'idle' && !closestBread) {
356 state.idleTimer += delta
357 if (state.idleTimer >= state.nextIdleMove) {
358 pickIdleTarget(pond)
359 state.idleTimer = 0
360 state.nextIdleMove = 3 + Math.random() * 4
361 }
362 }
363
364 // Apply transforms
365 group.position.x = state.position.x
366 group.position.z = state.position.z
367
368 // Bobbing on water
369 const bobAmount = Math.sin(elapsed * 2)
370 group.position.y = bobAmount * 0.03
371
372 // Occasional idle bob ripples (when bobbing down)
373 if (!state.isMoving && bobAmount < -0.9 && state.rippleTimer > 1.5) {
374 state.rippleTimer = 0
375 pond.addRipple(state.position.x, state.position.z)
376 }
377 if (!state.isMoving) {
378 state.rippleTimer += delta
379 }
380
381 // Rotation (face direction of movement)
382 // Duck model faces +X locally, so offset by -PI/2 to align with movement
383 group.rotation.y = state.rotation - Math.PI / 2
384
385 // Body wobble while swimming
386 const wobbleAmount = Math.sin(state.wobble) * 0.08
387 body.rotation.z = wobbleAmount
388 head.position.x = 0.45 + wobbleAmount * 0.2
389
390 // Wing flap animation
391 leftWing.rotation.z = Math.sin(elapsed * 3) * 0.1
392 rightWing.rotation.z = -Math.sin(elapsed * 3) * 0.1
393
394 // Gentle head bob
395 if (state.mode !== 'waiting') {
396 head.position.y = 0.7 + Math.sin(elapsed * 2) * 0.02
397 }
398 }
399
400 return {
401 group,
402 update,
403 getPosition: () => state.position.clone()
404 }
405 }