JavaScript · 22549 bytes Raw Blame History
1 // Ollie the Octopus - curious inspector of the pond
2 import * as THREE from 'three'
3 import gameState from './gameState.js'
4
5 export function createOllie(scene, gradientMap) {
6 const group = new THREE.Group()
7
8 // Store gradientMap for accessory creation
9 const storedGradientMap = gradientMap
10
11 // Default colors - purple theme
12 const defaultColors = {
13 body: 0x7b4b94,
14 belly: 0xb89bc9,
15 suckers: 0xd4a5c9,
16 magRim: 0xd4af37,
17 magGlass: 0x88ccff
18 }
19
20 // Materials (stored for outfit swapping)
21 const bodyMaterial = new THREE.MeshToonMaterial({
22 color: defaultColors.body,
23 gradientMap: gradientMap
24 })
25
26 const bellyMaterial = new THREE.MeshToonMaterial({
27 color: defaultColors.belly,
28 gradientMap: gradientMap
29 })
30
31 const suckerMaterial = new THREE.MeshToonMaterial({
32 color: defaultColors.suckers,
33 gradientMap: gradientMap
34 })
35
36 const glassRimMaterial = new THREE.MeshToonMaterial({
37 color: defaultColors.magRim,
38 gradientMap: gradientMap
39 })
40
41 const glassMaterial = new THREE.MeshBasicMaterial({
42 color: defaultColors.magGlass,
43 transparent: true,
44 opacity: 0.3
45 })
46
47 // Accessory tracking
48 const accessories = {
49 head: null,
50 clothing: null
51 }
52
53 // Mount points
54 const mountPoints = {
55 head: new THREE.Vector3(0, 0.95, 0)
56 }
57
58 // Head/Mantle - bulbous dome
59 const mantleGeom = new THREE.SphereGeometry(0.5, 10, 8)
60 mantleGeom.scale(1.2, 1.4, 1.0)
61 const mantle = new THREE.Mesh(mantleGeom, bodyMaterial)
62 mantle.position.y = 0.3
63 group.add(mantle)
64
65 // Lower mantle/body connector
66 const lowerMantleGeom = new THREE.SphereGeometry(0.4, 8, 6)
67 lowerMantleGeom.scale(1.1, 0.8, 1.0)
68 const lowerMantle = new THREE.Mesh(lowerMantleGeom, bellyMaterial)
69 lowerMantle.position.y = -0.1
70 group.add(lowerMantle)
71
72 // Eyes - big and expressive Wind Waker style
73 const eyeGeom = new THREE.SphereGeometry(0.12, 8, 6)
74 const eyeWhiteMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
75 const pupilMaterial = new THREE.MeshBasicMaterial({ color: 0x1a1a1a })
76
77 // Left eye
78 const leftEyeWhite = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
79 leftEyeWhite.position.set(0.25, 0.35, 0.35)
80 leftEyeWhite.scale.set(1, 1.2, 0.8)
81 group.add(leftEyeWhite)
82
83 const leftPupilGeom = new THREE.SphereGeometry(0.06, 6, 4)
84 const leftPupil = new THREE.Mesh(leftPupilGeom, pupilMaterial)
85 leftPupil.position.set(0.32, 0.35, 0.4)
86 group.add(leftPupil)
87
88 // Right eye
89 const rightEyeWhite = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
90 rightEyeWhite.position.set(0.25, 0.35, -0.35)
91 rightEyeWhite.scale.set(1, 1.2, 0.8)
92 group.add(rightEyeWhite)
93
94 const rightPupil = new THREE.Mesh(leftPupilGeom, pupilMaterial)
95 rightPupil.position.set(0.32, 0.35, -0.4)
96 group.add(rightPupil)
97
98 // Eye shines
99 const shineGeom = new THREE.SphereGeometry(0.03, 6, 4)
100 const shineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
101
102 const leftShine = new THREE.Mesh(shineGeom, shineMaterial)
103 leftShine.position.set(0.35, 0.4, 0.38)
104 group.add(leftShine)
105
106 const rightShine = new THREE.Mesh(shineGeom, shineMaterial)
107 rightShine.position.set(0.35, 0.4, -0.38)
108 group.add(rightShine)
109
110 // Create 8 tentacles - splayed outward evenly around the body like \./
111 const tentacles = []
112 const tentacleGroup = new THREE.Group()
113 tentacleGroup.position.y = -0.05 // Raised up so tentacles emerge above water
114
115 for (let i = 0; i < 8; i++) {
116 const angle = (i / 8) * Math.PI * 2
117 const tentacle = createTentacle(bodyMaterial, suckerMaterial, gradientMap)
118
119 // Position at edge of lower body
120 tentacle.position.x = Math.cos(angle) * 0.35
121 tentacle.position.z = Math.sin(angle) * 0.35
122
123 // Simply rotate around Y to spread evenly - tentacles extend in +Z and curve up
124 tentacle.rotation.y = angle
125
126 tentacles.push(tentacle)
127 tentacleGroup.add(tentacle)
128 }
129
130 group.add(tentacleGroup)
131
132 // Magnifying glass - will be attached to front tentacle (index 0)
133 const magGlassGroup = new THREE.Group()
134
135 // Handle - longer and thicker for visibility
136 const handleGeom = new THREE.CylinderGeometry(0.03, 0.035, 0.25, 6)
137 const handle = new THREE.Mesh(handleGeom, glassRimMaterial)
138 handle.rotation.z = Math.PI / 2
139 handle.position.x = -0.18
140 magGlassGroup.add(handle)
141
142 // Rim
143 const rimGeom = new THREE.TorusGeometry(0.15, 0.02, 8, 16)
144 const rim = new THREE.Mesh(rimGeom, glassRimMaterial)
145 magGlassGroup.add(rim)
146
147 // Glass lens
148 const lensGeom = new THREE.CircleGeometry(0.14, 16)
149 const lens = new THREE.Mesh(lensGeom, glassMaterial)
150 lens.position.z = 0.01
151 magGlassGroup.add(lens)
152
153 // Back of lens for visibility from other side
154 const lensBack = new THREE.Mesh(lensGeom, glassMaterial)
155 lensBack.position.z = -0.01
156 lensBack.rotation.y = Math.PI
157 magGlassGroup.add(lensBack)
158
159 // Attach magnifying glass to the TIP of tentacle 2 (side tentacle, away from body)
160 // Navigate to the last joint of that tentacle
161 let magTentacle = tentacles[2]
162 let lastJoint = magTentacle.children[0] // First joint
163 while (lastJoint.children.length > 1 || (lastJoint.children[0] && lastJoint.children[0].type === 'Group')) {
164 // Find the child that is a Group (the next joint)
165 const nextJoint = lastJoint.children.find(c => c.type === 'Group')
166 if (nextJoint) {
167 lastJoint = nextJoint
168 } else {
169 break
170 }
171 }
172
173 // Position at the tip of the last segment
174 magGlassGroup.position.set(0, 0.05, 0.15)
175 magGlassGroup.rotation.x = 0.5 // Angle it forward/up for visibility
176 lastJoint.add(magGlassGroup)
177
178 // Ollie starts hidden below the water
179 group.position.y = -3
180 group.visible = false
181
182 scene.add(group)
183
184 // State
185 const state = {
186 mode: 'waiting',
187 timer: 45 + Math.random() * 30, // First appearance in 45-75 seconds (after narwhal)
188 emergeX: 0,
189 emergeZ: 0,
190 surfaceTime: 0,
191 // Shop state
192 shopMode: false,
193 shopCooldown: 0,
194 onShopReady: null
195 }
196
197 // Shop trigger thresholds
198 const SHOP_KOI_THRESHOLD = 10 // Second shop unlocks after Donny
199 const SHOP_COOLDOWN = 45 // seconds between shop approaches
200
201 function createTentacle(bodyMat, suckerMat, gradient) {
202 // Build tentacle as a chain of segments, each one a child of the previous
203 // This creates a smooth curve by rotating each joint
204 const tentacleObj = new THREE.Group()
205
206 const numSegments = 5
207 let currentParent = tentacleObj
208
209 for (let idx = 0; idx < numSegments; idx++) {
210 const radius = 0.065 - idx * 0.01
211 const length = 0.18 - idx * 0.015
212
213 // Create a joint group for this segment
214 const joint = new THREE.Group()
215
216 // Position joint at end of parent (except first one at origin)
217 if (idx > 0) {
218 joint.position.z = 0.16 - (idx - 1) * 0.012 // Length of previous segment
219 }
220
221 // Rotate joint to curve upward - more curve toward the tip
222 joint.rotation.x = -0.28 - idx * 0.08
223
224 // Create the segment mesh
225 const segGeom = new THREE.CylinderGeometry(radius * 0.7, radius, length, 6)
226 segGeom.rotateX(Math.PI / 2) // Lay along Z axis
227 segGeom.translate(0, 0, length / 2) // Move so base is at origin
228
229 const segMesh = new THREE.Mesh(segGeom, bodyMat)
230 joint.add(segMesh)
231
232 // Add suckers on underside
233 if (idx < 4) {
234 const suckerGeom = new THREE.SphereGeometry(0.015, 4, 4)
235 const sucker = new THREE.Mesh(suckerGeom, suckerMat)
236 sucker.position.set(0, -radius * 0.85, length * 0.5)
237 sucker.scale.set(1, 0.5, 1)
238 joint.add(sucker)
239 }
240
241 currentParent.add(joint)
242 currentParent = joint
243 }
244
245 // Curly tip at the end
246 const tipGeom = new THREE.SphereGeometry(0.02, 6, 4)
247 const tip = new THREE.Mesh(tipGeom, bodyMat)
248 tip.position.z = 0.12
249 currentParent.add(tip)
250
251 return tentacleObj
252 }
253
254 function startRumble(pond) {
255 state.mode = 'rumbling'
256 state.timer = 0
257
258 // Pick random spot in outer zone of pond (70-90% radius), avoiding forbidden zones
259 let attempts = 0
260 let angle, dist
261 do {
262 angle = Math.random() * Math.PI * 2
263 dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7
264 state.emergeX = Math.cos(angle) * dist
265 state.emergeZ = Math.sin(angle) * dist
266 attempts++
267 } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20)
268
269 group.position.x = state.emergeX
270 group.position.z = state.emergeZ
271 group.rotation.y = angle + Math.PI / 2
272 }
273
274 function startShopApproach(pond, doug) {
275 state.mode = 'shop_approaching'
276 state.shopMode = true
277 state.timer = 0
278
279 // Emerge near Doug
280 const dougPos = doug.getPosition()
281 const approachAngle = Math.random() * Math.PI * 2
282 const approachDist = 1.5
283
284 state.emergeX = dougPos.x + Math.cos(approachAngle) * approachDist
285 state.emergeZ = dougPos.z + Math.sin(approachAngle) * approachDist
286
287 // Clamp to pond bounds
288 const distFromCenter = Math.hypot(state.emergeX, state.emergeZ)
289 if (distFromCenter > pond.radius * 0.85) {
290 const scale = (pond.radius * 0.85) / distFromCenter
291 state.emergeX *= scale
292 state.emergeZ *= scale
293 }
294
295 group.position.x = state.emergeX
296 group.position.z = state.emergeZ
297 group.visible = true
298 group.position.y = -2
299 }
300
301 // Helper to smoothly interpolate angles
302 function lerpAngle(from, to, t) {
303 let diff = to - from
304 while (diff > Math.PI) diff -= Math.PI * 2
305 while (diff < -Math.PI) diff += Math.PI * 2
306 return from + diff * t
307 }
308
309 function update(delta, elapsed, pond, doug) {
310 state.timer += delta
311
312 // Calculate angle to face Doug
313 let angleToDoug = 0
314 if (doug) {
315 const dougPos = doug.getPosition()
316 const dx = dougPos.x - group.position.x
317 const dz = dougPos.z - group.position.z
318 angleToDoug = Math.atan2(dx, dz)
319 }
320
321 // Animate tentacles (always, when visible)
322 if (group.visible) {
323 tentacles.forEach((t, i) => {
324 const baseAngle = (i / 8) * Math.PI * 2
325 const phase = i * (Math.PI / 4)
326 // Gentle swaying - each tentacle waves side to side
327 t.rotation.y = baseAngle + Math.sin(elapsed * 1.5 + phase) * 0.12
328 // Slight up/down bob
329 t.rotation.x = Math.sin(elapsed * 2 + phase) * 0.08
330 })
331
332 // Magnifying glass gets a little extra wobble for curious inspection look
333 magGlassGroup.rotation.z = Math.sin(elapsed * 3) * 0.1
334 magGlassGroup.rotation.y = Math.sin(elapsed * 2) * 0.15
335 }
336
337 switch (state.mode) {
338 case 'waiting':
339 if (state.timer >= 75) {
340 startRumble(pond)
341 }
342 break
343
344 case 'rumbling':
345 // Create rumble ripples
346 if (state.timer < 2) {
347 if (Math.random() < delta * 6) {
348 const rx = state.emergeX + (Math.random() - 0.5) * 1.0
349 const rz = state.emergeZ + (Math.random() - 0.5) * 1.0
350 pond.addRipple(rx, rz)
351 }
352 } else {
353 state.mode = 'emerging'
354 state.timer = 0
355 group.visible = true
356 group.position.y = -2
357 group.rotation.y = angleToDoug
358 }
359 break
360
361 case 'emerging':
362 const emergeProgress = Math.min(state.timer / 1.8, 1)
363 const easeOut = 1 - Math.pow(1 - emergeProgress, 3)
364 group.position.y = -2 + easeOut * 2.2
365
366 // Slowly turn toward Doug - curious inspection
367 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.6)
368
369 // Gentle wobble during emerge
370 group.rotation.x = Math.sin(state.timer * 3) * 0.05
371 group.rotation.z = Math.cos(state.timer * 2.5) * 0.04
372
373 if (emergeProgress >= 1) {
374 state.mode = 'surfaced'
375 state.timer = 0
376 state.surfaceTime = 5 + Math.random() * 3 // Stay 5-8 seconds
377 }
378 break
379
380 case 'surfaced':
381 // Bob gently
382 group.position.y = 0.2 + Math.sin(elapsed * 2) * 0.05
383
384 // Track Doug with magnifying glass - curious inspection!
385 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.4)
386
387 // Gentle rocking
388 group.rotation.x = Math.sin(elapsed * 1.2) * 0.03
389 group.rotation.z = Math.cos(elapsed * 1.0) * 0.02
390
391 // Extra curious magnifying glass wobble when pointed at Doug
392 magGlassGroup.rotation.y = -0.5 + Math.sin(elapsed * 4) * 0.1
393
394 // Occasional ripples
395 if (Math.random() < delta * 0.4) {
396 pond.addRipple(
397 group.position.x + (Math.random() - 0.5) * 0.6,
398 group.position.z + (Math.random() - 0.5) * 0.6
399 )
400 }
401
402 if (state.timer >= state.surfaceTime) {
403 state.mode = 'submerging'
404 state.timer = 0
405 }
406 break
407
408 case 'submerging':
409 const submergeProgress = Math.min(state.timer / 1.5, 1)
410 const easeIn = Math.pow(submergeProgress, 2)
411 group.position.y = 0.2 - easeIn * 2.5
412
413 // Tentacles curl as submerging
414 tentacles.forEach((t, i) => {
415 const baseAngle = (i / 8) * Math.PI * 2
416 const phase = i * (Math.PI / 4)
417 t.rotation.y = baseAngle + Math.sin(elapsed * 2 + phase) * 0.05
418 // Curl downward as sinking
419 t.rotation.x = easeIn * 0.4 + Math.sin(elapsed * 2 + phase) * 0.05
420 })
421
422 // Add ripples as submerging
423 if (Math.random() < delta * 5) {
424 pond.addRipple(
425 group.position.x + (Math.random() - 0.5) * 0.8,
426 group.position.z + (Math.random() - 0.5) * 0.8
427 )
428 }
429
430 if (submergeProgress >= 1) {
431 state.mode = 'waiting'
432 state.timer = 0
433 group.visible = false
434 group.position.y = -3
435 group.rotation.x = 0
436 group.rotation.z = 0
437 }
438 break
439
440 case 'shop_approaching':
441 // Rise from water for shop
442 const shopEmergeProgress = Math.min(state.timer / 1.5, 1)
443 const shopEaseOut = 1 - Math.pow(1 - shopEmergeProgress, 3)
444 group.position.y = -2 + shopEaseOut * 2.2
445
446 // Face Doug
447 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.8)
448
449 // Animate tentacles during emergence
450 tentacles.forEach((t, i) => {
451 const baseAngle = (i / 8) * Math.PI * 2
452 const phase = i * (Math.PI / 4)
453 t.rotation.y = baseAngle + Math.sin(elapsed * 1.5 + phase) * 0.12
454 t.rotation.x = Math.sin(elapsed * 2 + phase) * 0.08
455 })
456
457 if (shopEmergeProgress >= 1) {
458 state.mode = 'shop_ready'
459 state.timer = 0
460 if (state.onShopReady) {
461 state.onShopReady('ollie')
462 }
463 }
464 break
465
466 case 'shop_ready':
467 // Bob gently while shop is open
468 group.position.y = 0.2 + Math.sin(elapsed * 2) * 0.05
469 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.4)
470 group.rotation.x = Math.sin(elapsed * 1.2) * 0.03
471 group.rotation.z = Math.cos(elapsed * 1.0) * 0.02
472
473 // Follow Doug while talking - Ollie is eager and curious!
474 if (doug) {
475 const dougPos = doug.getPosition()
476 const dx = dougPos.x - group.position.x
477 const dz = dougPos.z - group.position.z
478 const distToDoug = Math.hypot(dx, dz)
479 const minDist = 0.8 // Ollie gets closer (curious!)
480
481 if (distToDoug > minDist) {
482 // Swim toward Doug - faster when farther, Ollie is eager!
483 const approachSpeed = Math.min(distToDoug * 0.6, 1.8) * delta
484 group.position.x += (dx / distToDoug) * approachSpeed
485 group.position.z += (dz / distToDoug) * approachSpeed
486
487 // Clamp to pond bounds
488 const distFromCenter = Math.hypot(group.position.x, group.position.z)
489 if (distFromCenter > pond.radius * 0.85) {
490 const scale = (pond.radius * 0.85) / distFromCenter
491 group.position.x *= scale
492 group.position.z *= scale
493 }
494 }
495 }
496
497 // Tentacle animation
498 tentacles.forEach((t, i) => {
499 const baseAngle = (i / 8) * Math.PI * 2
500 const phase = i * (Math.PI / 4)
501 t.rotation.y = baseAngle + Math.sin(elapsed * 1.5 + phase) * 0.12
502 t.rotation.x = Math.sin(elapsed * 2 + phase) * 0.08
503 })
504
505 // Magnifying glass wobble
506 magGlassGroup.rotation.z = Math.sin(elapsed * 3) * 0.1
507 magGlassGroup.rotation.y = Math.sin(elapsed * 2) * 0.15
508
509 // Occasional ripples
510 if (Math.random() < delta * 0.4) {
511 pond.addRipple(
512 group.position.x + (Math.random() - 0.5) * 0.6,
513 group.position.z + (Math.random() - 0.5) * 0.6
514 )
515 }
516 break
517
518 case 'shop_departing':
519 const shopSubmergeProgress = Math.min(state.timer / 1.5, 1)
520 const shopEaseIn = Math.pow(shopSubmergeProgress, 2)
521 group.position.y = 0.2 - shopEaseIn * 2.5
522
523 // Tentacles curl as departing
524 tentacles.forEach((t, i) => {
525 const baseAngle = (i / 8) * Math.PI * 2
526 const phase = i * (Math.PI / 4)
527 t.rotation.y = baseAngle + Math.sin(elapsed * 2 + phase) * 0.05
528 t.rotation.x = shopEaseIn * 0.4 + Math.sin(elapsed * 2 + phase) * 0.05
529 })
530
531 if (Math.random() < delta * 5) {
532 pond.addRipple(
533 group.position.x + (Math.random() - 0.5) * 0.8,
534 group.position.z + (Math.random() - 0.5) * 0.8
535 )
536 }
537
538 if (shopSubmergeProgress >= 1) {
539 state.mode = 'waiting'
540 state.timer = 0
541 state.shopMode = false
542 state.shopCooldown = SHOP_COOLDOWN
543 group.visible = false
544 group.position.y = -3
545 group.rotation.x = 0
546 group.rotation.z = 0
547 }
548 break
549 }
550
551 // Decrement shop cooldown
552 if (state.shopCooldown > 0) {
553 state.shopCooldown -= delta
554 }
555 }
556
557 // Try to trigger shop approach
558 // Only approaches Doug on first emergence or when player unlocked new affordable items
559 function tryTriggerShop(koiCount, pond, doug) {
560 if (state.shopCooldown > 0) return false
561 if (koiCount < SHOP_KOI_THRESHOLD) return false
562
563 // Check if there's a reason to approach Doug
564 if (!gameState.shouldCreatureApproach('ollie')) {
565 return false
566 }
567
568 // If waiting, do full approach sequence
569 if (state.mode === 'waiting') {
570 startShopApproach(pond, doug)
571 gameState.markApproachComplete('ollie')
572 return true
573 }
574
575 // If already surfaced, transition directly to shop mode
576 if (state.mode === 'surfaced') {
577 state.mode = 'shop_ready'
578 state.shopMode = true
579 state.timer = 0
580 gameState.markApproachComplete('ollie')
581 if (state.onShopReady) {
582 state.onShopReady('ollie')
583 }
584 return true
585 }
586
587 return false
588 }
589
590 // Dismiss shop and start departing
591 function dismissShop() {
592 if (state.mode === 'shop_ready') {
593 state.mode = 'shop_departing'
594 state.timer = 0
595 }
596 }
597
598 // Set callback for when shop is ready
599 function setShopReadyCallback(callback) {
600 state.onShopReady = callback
601 }
602
603 // Check if in shop mode
604 function isInShopMode() {
605 return state.shopMode
606 }
607
608 // Apply an outfit to Ollie
609 function applyOutfit(outfit) {
610 if (!outfit) return
611
612 switch (outfit.type) {
613 case 'color_body':
614 if (outfit.colors) {
615 if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
616 if (outfit.colors.belly) bellyMaterial.color.setHex(outfit.colors.belly)
617 if (outfit.colors.suckers) suckerMaterial.color.setHex(outfit.colors.suckers)
618 }
619 break
620
621 case 'accessory_held':
622 // Magnifying glass color swap
623 if (outfit.colors) {
624 if (outfit.colors.rim) glassRimMaterial.color.setHex(outfit.colors.rim)
625 if (outfit.colors.glass) glassMaterial.color.setHex(outfit.colors.glass)
626 }
627 break
628
629 case 'accessory_head':
630 // Remove existing head accessory
631 if (accessories.head) {
632 group.remove(accessories.head)
633 accessories.head = null
634 }
635 // Add new accessory
636 if (outfit.meshFactory) {
637 accessories.head = outfit.meshFactory(storedGradientMap)
638 accessories.head.position.copy(mountPoints.head)
639 group.add(accessories.head)
640 }
641 break
642
643 case 'clothing_body':
644 // Remove existing clothing
645 if (accessories.clothing) {
646 group.remove(accessories.clothing)
647 accessories.clothing = null
648 }
649 // Add new clothing
650 if (outfit.meshFactory) {
651 accessories.clothing = outfit.meshFactory(storedGradientMap)
652 // Clothing is positioned relative to body origin
653 group.add(accessories.clothing)
654 }
655 break
656 }
657 }
658
659 // Remove an outfit from Ollie
660 function removeOutfit(outfit) {
661 if (!outfit) return
662
663 switch (outfit.type) {
664 case 'color_body':
665 bodyMaterial.color.setHex(defaultColors.body)
666 bellyMaterial.color.setHex(defaultColors.belly)
667 suckerMaterial.color.setHex(defaultColors.suckers)
668 break
669
670 case 'accessory_held':
671 glassRimMaterial.color.setHex(defaultColors.magRim)
672 glassMaterial.color.setHex(defaultColors.magGlass)
673 break
674
675 case 'accessory_head':
676 if (accessories.head) {
677 group.remove(accessories.head)
678 accessories.head = null
679 }
680 break
681
682 case 'clothing_body':
683 if (accessories.clothing) {
684 group.remove(accessories.clothing)
685 accessories.clothing = null
686 }
687 break
688 }
689 }
690
691 // Check if Ollie can be tapped to open shop
692 function isTappable() {
693 // Tappable when visible and surfaced (not emerging/submerging)
694 return group.visible && (state.mode === 'surfaced' || state.mode === 'shop_ready')
695 }
696
697 // Manually trigger shop (for tap-to-shop feature)
698 function triggerShopFromTap(pond, doug) {
699 if (state.mode === 'surfaced') {
700 // Already surfaced - transition to shop mode
701 state.mode = 'shop_ready'
702 state.shopMode = true
703 state.timer = 0
704 if (state.onShopReady) {
705 state.onShopReady('ollie')
706 }
707 return true
708 }
709 return false
710 }
711
712 return {
713 group,
714 update,
715 tryTriggerShop,
716 dismissShop,
717 setShopReadyCallback,
718 isInShopMode,
719 isTappable,
720 triggerShopFromTap,
721 applyOutfit,
722 removeOutfit
723 }
724 }