JavaScript · 8005 bytes Raw Blame History
1 // Doug the Duck - 3D low-poly model with Wind Waker cel-shading
2 import * as THREE from 'three'
3
4 export function createDoug(scene, gradientMap) {
5 const group = new THREE.Group()
6
7 // Color palette - vibrant Wind Waker yellows
8 const bodyColor = 0xffdc50 // Warm yellow
9 const bodyHighlight = 0xfff0a0 // Light yellow
10 const beakColor = 0xff9020 // Bright orange
11 const eyeWhite = 0xffffff
12 const eyePupil = 0x191410
13
14 // Toon materials
15 const bodyMaterial = new THREE.MeshToonMaterial({
16 color: bodyColor,
17 gradientMap: gradientMap
18 })
19
20 const highlightMaterial = new THREE.MeshToonMaterial({
21 color: bodyHighlight,
22 gradientMap: gradientMap
23 })
24
25 const beakMaterial = new THREE.MeshToonMaterial({
26 color: beakColor,
27 gradientMap: gradientMap
28 })
29
30 const eyeWhiteMaterial = new THREE.MeshToonMaterial({
31 color: eyeWhite,
32 gradientMap: gradientMap
33 })
34
35 const eyePupilMaterial = new THREE.MeshBasicMaterial({
36 color: eyePupil
37 })
38
39 // Body - stretched sphere
40 const bodyGeom = new THREE.SphereGeometry(0.5, 16, 12)
41 bodyGeom.scale(1.2, 0.9, 1)
42 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
43 body.position.y = 0.3
44 group.add(body)
45
46 // Body highlight (chest area)
47 const chestGeom = new THREE.SphereGeometry(0.35, 12, 8)
48 chestGeom.scale(1, 0.8, 0.8)
49 const chest = new THREE.Mesh(chestGeom, highlightMaterial)
50 chest.position.set(0.15, 0.35, 0.2)
51 group.add(chest)
52
53 // Tail feathers
54 const tailGroup = new THREE.Group()
55 for (let i = 0; i < 3; i++) {
56 const tailGeom = new THREE.ConeGeometry(0.08, 0.3, 6)
57 const tail = new THREE.Mesh(tailGeom, bodyMaterial)
58 tail.rotation.x = Math.PI / 2 + (i - 1) * 0.15
59 tail.rotation.z = (i - 1) * 0.2
60 tail.position.set(-0.55 - i * 0.05, 0.35, (i - 1) * 0.08)
61 tailGroup.add(tail)
62 }
63 group.add(tailGroup)
64
65 // Wings
66 const wingGeom = new THREE.SphereGeometry(0.25, 8, 6)
67 wingGeom.scale(0.6, 1, 0.3)
68
69 const leftWing = new THREE.Mesh(wingGeom, bodyMaterial)
70 leftWing.position.set(-0.1, 0.35, 0.45)
71 leftWing.rotation.x = 0.2
72 group.add(leftWing)
73
74 const rightWing = new THREE.Mesh(wingGeom, bodyMaterial)
75 rightWing.position.set(-0.1, 0.35, -0.45)
76 rightWing.rotation.x = -0.2
77 group.add(rightWing)
78
79 // Head
80 const headGeom = new THREE.SphereGeometry(0.32, 16, 12)
81 const head = new THREE.Mesh(headGeom, bodyMaterial)
82 head.position.set(0.45, 0.7, 0)
83 group.add(head)
84
85 // Head tuft (little feather on top)
86 const tuftGeom = new THREE.ConeGeometry(0.05, 0.15, 6)
87 const tuft = new THREE.Mesh(tuftGeom, bodyMaterial)
88 tuft.position.set(0.4, 1.0, 0)
89 tuft.rotation.z = -0.3
90 group.add(tuft)
91
92 // Beak - cone pointing forward
93 const beakGeom = new THREE.ConeGeometry(0.1, 0.35, 8)
94 const beak = new THREE.Mesh(beakGeom, beakMaterial)
95 beak.rotation.z = -Math.PI / 2
96 beak.position.set(0.8, 0.65, 0)
97 group.add(beak)
98
99 // Eyes - big and expressive Wind Waker style
100 const eyeGeom = new THREE.SphereGeometry(0.1, 12, 8)
101
102 const leftEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
103 leftEye.position.set(0.65, 0.78, 0.15)
104 leftEye.scale.set(0.8, 1, 0.6)
105 group.add(leftEye)
106
107 const rightEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
108 rightEye.position.set(0.65, 0.78, -0.15)
109 rightEye.scale.set(0.8, 1, 0.6)
110 group.add(rightEye)
111
112 // Pupils
113 const pupilGeom = new THREE.SphereGeometry(0.05, 8, 6)
114
115 const leftPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
116 leftPupil.position.set(0.72, 0.78, 0.15)
117 group.add(leftPupil)
118
119 const rightPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
120 rightPupil.position.set(0.72, 0.78, -0.15)
121 group.add(rightPupil)
122
123 // Eye shine
124 const shineGeom = new THREE.SphereGeometry(0.025, 6, 4)
125 const shineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
126
127 const leftShine = new THREE.Mesh(shineGeom, shineMaterial)
128 leftShine.position.set(0.73, 0.82, 0.13)
129 group.add(leftShine)
130
131 const rightShine = new THREE.Mesh(shineGeom, shineMaterial)
132 rightShine.position.set(0.73, 0.82, -0.17)
133 group.add(rightShine)
134
135 // Add to scene
136 scene.add(group)
137
138 // Duck state
139 const state = {
140 position: new THREE.Vector3(0, 0, 0),
141 targetPosition: new THREE.Vector3(0, 0, 0),
142 rotation: 0,
143 mode: 'idle', // 'idle', 'waiting', 'swimming'
144 waitTimer: 0,
145 waitDuration: 0,
146 idleTimer: 0,
147 nextIdleMove: 3 + Math.random() * 4,
148 wobble: 0,
149 headBob: 0
150 }
151
152 // Movement speeds
153 const idleSpeed = 0.5
154 const swimSpeed = 1.2
155
156 function pickIdleTarget(pond) {
157 const angle = Math.random() * Math.PI * 2
158 const radius = Math.random() * (pond.radius * 0.7) + pond.radius * 0.1
159 state.targetPosition.set(
160 Math.cos(angle) * radius,
161 0,
162 Math.sin(angle) * radius
163 )
164 }
165
166 function update(delta, elapsed, breadBits, pond) {
167 // Find closest bread
168 let closestBread = null
169 let closestDist = Infinity
170
171 for (const bread of breadBits) {
172 if (!bread.eaten) {
173 const dx = bread.position.x - state.position.x
174 const dz = bread.position.z - state.position.z
175 const dist = Math.sqrt(dx * dx + dz * dz)
176 if (dist < closestDist) {
177 closestDist = dist
178 closestBread = bread
179 }
180 }
181 }
182
183 // React to bread
184 if (closestBread && state.mode === 'idle') {
185 state.mode = 'waiting'
186 state.waitTimer = 0
187 state.waitDuration = 0.5 + Math.random() * 0.8 // Quirky delay
188 state.pendingTarget = closestBread.position.clone()
189 }
190
191 // Handle waiting (the quirky pause)
192 if (state.mode === 'waiting') {
193 state.waitTimer += delta
194 state.headBob = Math.sin(state.waitTimer * 8) * 0.1
195 head.position.y = 0.7 + state.headBob
196
197 if (state.waitTimer >= state.waitDuration) {
198 state.mode = 'swimming'
199 state.targetPosition.copy(state.pendingTarget)
200 }
201 }
202
203 // Movement
204 const dx = state.targetPosition.x - state.position.x
205 const dz = state.targetPosition.z - state.position.z
206 const dist = Math.sqrt(dx * dx + dz * dz)
207
208 if (dist > 0.1) {
209 const speed = state.mode === 'swimming' ? swimSpeed : idleSpeed
210 const moveAmount = Math.min(speed * delta, dist)
211 const moveX = (dx / dist) * moveAmount
212 const moveZ = (dz / dist) * moveAmount
213
214 state.position.x += moveX
215 state.position.z += moveZ
216
217 // Face movement direction
218 state.rotation = Math.atan2(dz, dx)
219
220 // Wobble animation while moving
221 state.wobble += delta * 8
222 } else {
223 // Arrived
224 if (state.mode === 'swimming') {
225 state.mode = 'idle'
226 // Eat nearby bread
227 for (const bread of breadBits) {
228 if (!bread.eaten) {
229 const bx = bread.position.x - state.position.x
230 const bz = bread.position.z - state.position.z
231 if (Math.sqrt(bx * bx + bz * bz) < 0.4) {
232 bread.eaten = true
233 }
234 }
235 }
236 }
237 }
238
239 // Idle wandering
240 if (state.mode === 'idle' && !closestBread) {
241 state.idleTimer += delta
242 if (state.idleTimer >= state.nextIdleMove) {
243 pickIdleTarget(pond)
244 state.idleTimer = 0
245 state.nextIdleMove = 3 + Math.random() * 4
246 }
247 }
248
249 // Apply transforms
250 group.position.x = state.position.x
251 group.position.z = state.position.z
252
253 // Bobbing on water
254 group.position.y = Math.sin(elapsed * 2) * 0.03
255
256 // Rotation (face direction of movement)
257 group.rotation.y = -state.rotation + Math.PI / 2
258
259 // Body wobble while swimming
260 const wobbleAmount = Math.sin(state.wobble) * 0.08
261 body.rotation.z = wobbleAmount
262 head.position.x = 0.45 + wobbleAmount * 0.2
263
264 // Wing flap animation
265 leftWing.rotation.z = Math.sin(elapsed * 3) * 0.1
266 rightWing.rotation.z = -Math.sin(elapsed * 3) * 0.1
267
268 // Gentle head bob
269 if (state.mode !== 'waiting') {
270 head.position.y = 0.7 + Math.sin(elapsed * 2) * 0.02
271 }
272 }
273
274 return {
275 group,
276 update,
277 getPosition: () => state.position.clone()
278 }
279 }