JavaScript · 17488 bytes Raw Blame History
1 // Three.js 3D renderer for dougk
2 import * as THREE from 'three'
3 import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
4 import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
5 import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
6 import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
7 import { createDoug } from './duck.js'
8 import { createPond } from './pond.js'
9 import { BreadManager } from './bread.js'
10 import { createDonny } from './narwhal.js'
11 import { createKoiSchool } from './koi.js'
12 import { createOllie } from './octopus.js'
13 import { PlacementManager } from './buildingPlacement.js'
14 import { unlockAudio } from './sounds.js'
15 import gameState from './gameState.js'
16 import { openShop, closeShop, isShopOpen } from './shop/shopUI.js'
17 import { showDialog, closeDialog, isDialogOpen } from './shop/dialogUI.js'
18 import { getDialogForCharacter, getReturnDialog } from './shop/dialogScripts.js'
19 import inventory from './shop/inventory.js'
20 import { getItem, CHARACTERS, getAllItems } from './shop/items.js'
21
22 let scene, camera, renderer, composer, outlinePass
23 let doug, pond, breadManager, donny, koiSchool, ollie, placementManager
24 let clock
25 let animationId = null
26 let koiCounterEl = null
27
28 // Capture state
29 let captureState = {
30 isHolding: false,
31 targetKoi: null,
32 clickPos: null // Where bread would spawn if not capturing
33 }
34
35 // Auto-capture state (when Doug hovers over koi)
36 let autoCapture = {
37 targetKoi: null,
38 hoverTime: 0,
39 CAPTURE_DELAY: 0.5 // seconds Doug must be over koi to auto-capture
40 }
41
42 // Camera zoom state for dialog focus effect
43 const cameraZoom = {
44 defaultFrustum: 12,
45 dialogFrustum: 8, // Zoomed in during dialog
46 currentFrustum: 12,
47 targetFrustum: 12
48 }
49
50 function createKoiCounter(container) {
51 const counter = document.createElement('div')
52 counter.id = 'koi-counter'
53 counter.style.cssText = `
54 position: fixed;
55 top: 16px;
56 left: 16px;
57 background: rgba(0, 0, 0, 0.6);
58 color: #ffd700;
59 padding: 8px 16px;
60 border-radius: 8px;
61 font-family: 'Courier New', monospace;
62 font-size: 18px;
63 font-weight: bold;
64 z-index: 1000;
65 pointer-events: none;
66 display: flex;
67 align-items: center;
68 gap: 8px;
69 `
70 counter.innerHTML = `<span style="font-size: 24px;">🐟</span> <span id="koi-count">${gameState.getKoi()}</span>`
71 container.appendChild(counter)
72
73 // Listen for game state changes
74 gameState.addListener((count) => {
75 const countEl = document.getElementById('koi-count')
76 if (countEl) countEl.textContent = count
77 })
78
79 return counter
80 }
81
82 function createToonGradient() {
83 const canvas = document.createElement('canvas')
84 canvas.width = 4
85 canvas.height = 1
86 const ctx = canvas.getContext('2d')
87
88 ctx.fillStyle = '#444444'
89 ctx.fillRect(0, 0, 1, 1)
90 ctx.fillStyle = '#888888'
91 ctx.fillRect(1, 0, 1, 1)
92 ctx.fillStyle = '#cccccc'
93 ctx.fillRect(2, 0, 1, 1)
94 ctx.fillStyle = '#ffffff'
95 ctx.fillRect(3, 0, 1, 1)
96
97 const texture = new THREE.CanvasTexture(canvas)
98 texture.minFilter = THREE.NearestFilter
99 texture.magFilter = THREE.NearestFilter
100 return texture
101 }
102
103 export function start(container) {
104 if (animationId) return
105
106 // Unlock audio on any user interaction (document level for mobile)
107 const unlockEvents = ['touchstart', 'touchend', 'pointerdown', 'click', 'keydown']
108 const unlockHandler = () => {
109 unlockAudio()
110 // Remove all listeners after first unlock
111 unlockEvents.forEach(e => document.removeEventListener(e, unlockHandler))
112 }
113 unlockEvents.forEach(e => document.addEventListener(e, unlockHandler, { passive: true }))
114
115 // Scene
116 scene = new THREE.Scene()
117 scene.background = new THREE.Color(0x55a04b)
118
119 // Camera
120 const aspect = window.innerWidth / window.innerHeight
121 const frustumSize = 12
122 camera = new THREE.OrthographicCamera(
123 -frustumSize * aspect / 2,
124 frustumSize * aspect / 2,
125 frustumSize / 2,
126 -frustumSize / 2,
127 0.1,
128 100
129 )
130 camera.position.set(10, 10, 10)
131 camera.lookAt(0, 0, 0)
132
133 // Renderer
134 renderer = new THREE.WebGLRenderer({ antialias: true })
135 renderer.setSize(window.innerWidth, window.innerHeight)
136 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
137 container.appendChild(renderer.domElement)
138
139 // Lighting
140 const sunLight = new THREE.DirectionalLight(0xffffee, 1.5)
141 sunLight.position.set(5, 10, 5)
142 scene.add(sunLight)
143
144 const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x55a04b, 0.6)
145 scene.add(hemiLight)
146
147 const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
148 fillLight.position.set(-5, 5, -5)
149 scene.add(fillLight)
150
151 const toonGradient = createToonGradient()
152
153 // Create objects
154 pond = createPond(scene, toonGradient)
155 doug = createDoug(scene, toonGradient)
156 breadManager = new BreadManager(scene, toonGradient)
157 donny = createDonny(scene, toonGradient)
158 koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
159 ollie = createOllie(scene, toonGradient)
160 placementManager = new PlacementManager(scene, pond, camera, toonGradient)
161 placementManager.initForbiddenZones(pond.getInitialForbiddenZones())
162
163 // Initialize shop approach tracking (for smart creature behavior)
164 gameState.initShopTracking(getAllItems(), inventory)
165
166 // Post-processing
167 composer = new EffectComposer(renderer)
168 composer.addPass(new RenderPass(scene, camera))
169
170 outlinePass = new OutlinePass(
171 new THREE.Vector2(window.innerWidth, window.innerHeight),
172 scene,
173 camera
174 )
175 outlinePass.edgeStrength = 3
176 outlinePass.edgeGlow = 0
177 outlinePass.edgeThickness = 1.5
178 outlinePass.visibleEdgeColor.set(0x191410)
179 outlinePass.hiddenEdgeColor.set(0x191410)
180 outlinePass.selectedObjects = [doug.group, pond.group, donny.group, koiSchool.group, ollie.group, placementManager.getPlacedBuildingsGroup()]
181 composer.addPass(outlinePass)
182 composer.addPass(new OutputPass())
183
184 // Raycaster
185 const raycaster = new THREE.Raycaster()
186 const mouse = new THREE.Vector2()
187
188 // Unlock audio on first touch (for mobile)
189 renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
190
191 // Mouse move - handle placement preview
192 renderer.domElement.addEventListener('pointermove', (event) => {
193 if (placementManager.isPlacing()) {
194 placementManager.onMouseMove(event, window.innerWidth, window.innerHeight)
195 }
196 })
197
198 // Helper to handle tap-to-shop for a creature
199 const handleCreatureTap = (creature, characterName) => {
200 if (!creature.isTappable()) return false
201 if (isDialogOpen() || isShopOpen()) return false
202
203 // Show return dialog, then open shop
204 const dialogLines = getReturnDialog(characterName)
205 showDialog(characterName, dialogLines, container, () => {
206 openShop(characterName, container, {
207 onClose: () => {
208 creature.dismissShop()
209 },
210 onPurchase: (item, action) => {
211 if (item.character) {
212 handleOutfitChange(item, action)
213 }
214 if (action === 'place' && item.buildingType) {
215 placementManager.startPlacement(item.buildingType, {
216 onComplete: () => {},
217 onCancel: () => {}
218 })
219 }
220 }
221 })
222 })
223
224 // Transition creature to shop mode if surfaced
225 creature.triggerShopFromTap(pond, doug)
226 return true
227 }
228
229 // Pointer down - handle placement, capture, or bread spawn
230 renderer.domElement.addEventListener('pointerdown', (event) => {
231 unlockAudio()
232
233 // If placing a building, try to place it
234 if (placementManager.isPlacing()) {
235 if (placementManager.onClick(event, window.innerWidth, window.innerHeight)) {
236 // Building placed successfully
237 return
238 }
239 // Clicked in invalid spot - cancel placement
240 placementManager.cancelPlacement()
241 return
242 }
243
244 mouse.x = (event.clientX / window.innerWidth) * 2 - 1
245 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
246
247 raycaster.setFromCamera(mouse, camera)
248
249 // Check if clicked on Donny or Ollie (tap to reopen shop)
250 if (donny.isTappable()) {
251 const donnyIntersects = raycaster.intersectObject(donny.group, true)
252 if (donnyIntersects.length > 0) {
253 if (handleCreatureTap(donny, 'donny')) return
254 }
255 }
256 if (ollie.isTappable()) {
257 const ollieIntersects = raycaster.intersectObject(ollie.group, true)
258 if (ollieIntersects.length > 0) {
259 if (handleCreatureTap(ollie, 'ollie')) return
260 }
261 }
262
263 const intersects = raycaster.intersectObject(pond.water)
264
265 if (intersects.length > 0) {
266 const point = intersects[0].point
267 captureState.clickPos = { x: point.x, z: point.z }
268 captureState.isHolding = true
269
270 // Check if Doug is over a koi
271 const dougPos = doug.getPosition()
272 const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.4)
273
274 if (koiUnderDoug) {
275 // Start capturing
276 koiSchool.startCapture(koiUnderDoug)
277 captureState.targetKoi = koiUnderDoug
278 }
279 }
280 })
281
282 // Pointer up - complete capture or spawn bread
283 renderer.domElement.addEventListener('pointerup', () => {
284 if (!captureState.isHolding) return
285
286 if (captureState.targetKoi) {
287 // If still capturing (not completed), cancel it
288 if (captureState.targetKoi.state.beingCaptured) {
289 koiSchool.cancelCapture(captureState.targetKoi)
290 }
291 } else if (captureState.clickPos) {
292 // No capture was started - spawn bread
293 breadManager.spawnBread(captureState.clickPos.x, captureState.clickPos.z)
294 pond.addRipple(captureState.clickPos.x, captureState.clickPos.z)
295 koiSchool.triggerPanic(captureState.clickPos.x, captureState.clickPos.z)
296 outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
297 }
298
299 // Reset capture state
300 captureState.isHolding = false
301 captureState.targetKoi = null
302 captureState.clickPos = null
303 })
304
305 // Create koi counter UI
306 koiCounterEl = createKoiCounter(container)
307
308 // Helper to get character object by name
309 const getCharacter = (charName) => {
310 switch (charName) {
311 case 'doug': return doug
312 case 'donny': return donny
313 case 'ollie': return ollie
314 default: return null
315 }
316 }
317
318 // Handle outfit equip/unequip
319 const handleOutfitChange = (item, action) => {
320 const character = getCharacter(item.character)
321 if (!character) return
322
323 if (action === 'equip') {
324 character.applyOutfit(item)
325 } else if (action === 'unequip') {
326 character.removeOutfit(item)
327 }
328 }
329
330 // Set up shop callbacks
331 const handleShopReady = (shopkeeper) => {
332 // Get dialog lines for this character
333 const dialogLines = getDialogForCharacter(shopkeeper)
334
335 // Show dialog first, then open shop when complete
336 showDialog(shopkeeper, dialogLines, container, () => {
337 // Dialog complete - now open the shop
338 openShop(shopkeeper, container, {
339 onClose: () => {
340 // Dismiss the appropriate shopkeeper
341 if (shopkeeper === 'donny') {
342 donny.dismissShop()
343 } else if (shopkeeper === 'ollie') {
344 ollie.dismissShop()
345 }
346 },
347 onPurchase: (item, action) => {
348 // Handle outfit equip/unequip
349 if (item.character) {
350 handleOutfitChange(item, action)
351 }
352 // Handle building placement
353 if (action === 'place' && item.buildingType) {
354 placementManager.startPlacement(item.buildingType, {
355 onComplete: (buildingType, zone) => {
356 // Building placed successfully
357 },
358 onCancel: () => {
359 // Placement cancelled
360 }
361 })
362 }
363 }
364 })
365 })
366 }
367
368 donny.setShopReadyCallback(handleShopReady)
369 ollie.setShopReadyCallback(handleShopReady)
370
371 // Load saved equipped outfits
372 const loadEquippedOutfits = () => {
373 for (const charName of Object.values(CHARACTERS)) {
374 const equipped = inventory.getEquipped(charName)
375 const character = getCharacter(charName)
376 if (character) {
377 for (const itemId of equipped) {
378 const item = getItem(itemId)
379 if (item) {
380 character.applyOutfit(item)
381 }
382 }
383 }
384 }
385 }
386 loadEquippedOutfits()
387
388 // Load saved placed buildings
389 placementManager.loadSavedBuildings()
390
391 // Resize handler
392 window.addEventListener('resize', onResize)
393
394 clock = new THREE.Clock()
395 animate()
396 }
397
398 function onResize() {
399 const aspect = window.innerWidth / window.innerHeight
400 const frustumSize = 12
401 camera.left = -frustumSize * aspect / 2
402 camera.right = frustumSize * aspect / 2
403 camera.top = frustumSize / 2
404 camera.bottom = -frustumSize / 2
405 camera.updateProjectionMatrix()
406
407 renderer.setSize(window.innerWidth, window.innerHeight)
408 composer.setSize(window.innerWidth, window.innerHeight)
409 }
410
411 function animate() {
412 animationId = requestAnimationFrame(animate)
413
414 const delta = clock.getDelta()
415 const elapsed = clock.getElapsedTime()
416
417 // Check if in dialog/shop mode for camera zoom and movement pause
418 const inConversation = isDialogOpen() || isShopOpen()
419
420 // Animate camera zoom
421 cameraZoom.targetFrustum = inConversation ? cameraZoom.dialogFrustum : cameraZoom.defaultFrustum
422 if (Math.abs(cameraZoom.currentFrustum - cameraZoom.targetFrustum) > 0.01) {
423 cameraZoom.currentFrustum += (cameraZoom.targetFrustum - cameraZoom.currentFrustum) * delta * 3
424 const aspect = window.innerWidth / window.innerHeight
425 camera.left = -cameraZoom.currentFrustum * aspect / 2
426 camera.right = cameraZoom.currentFrustum * aspect / 2
427 camera.top = cameraZoom.currentFrustum / 2
428 camera.bottom = -cameraZoom.currentFrustum / 2
429 camera.updateProjectionMatrix()
430 }
431
432 // Get active bread positions for koi attraction
433 const activeBread = breadManager.getActiveBits()
434 const breadPositions = activeBread.map(b => ({ x: b.position.x, z: b.position.z }))
435
436 // Update koi attraction to bread
437 koiSchool.attractToBread(breadPositions)
438
439 // Handle capture in progress (manual click-hold)
440 if (captureState.isHolding && captureState.targetKoi) {
441 const completed = koiSchool.updateCapture(captureState.targetKoi, delta)
442 if (completed) {
443 // Capture completed
444 captureState.targetKoi = null
445 captureState.isHolding = false
446 captureState.clickPos = null
447 }
448 }
449
450 // Auto-capture: when Doug hovers over a koi for a moment
451 if (!captureState.isHolding) {
452 const dougPos = doug.getPosition()
453 const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.5)
454
455 if (koiUnderDoug) {
456 if (autoCapture.targetKoi === koiUnderDoug) {
457 // Same koi - accumulate hover time
458 autoCapture.hoverTime += delta
459 if (autoCapture.hoverTime >= autoCapture.CAPTURE_DELAY) {
460 // Start and immediately complete capture
461 koiSchool.startCapture(koiUnderDoug)
462 // Fast-forward capture to completion
463 while (!koiSchool.updateCapture(koiUnderDoug, 0.2)) {}
464 autoCapture.targetKoi = null
465 autoCapture.hoverTime = 0
466 }
467 } else {
468 // New koi - reset timer
469 autoCapture.targetKoi = koiUnderDoug
470 autoCapture.hoverTime = 0
471 }
472 } else {
473 // No koi under Doug - reset
474 autoCapture.targetKoi = null
475 autoCapture.hoverTime = 0
476 }
477 }
478
479 // Determine focus target for Doug during conversation
480 let focusTarget = null
481 if (inConversation) {
482 // Focus on whoever is in shop mode
483 if (donny.isInShopMode()) {
484 focusTarget = { x: donny.group.position.x, z: donny.group.position.z }
485 } else if (ollie.isInShopMode()) {
486 focusTarget = { x: ollie.group.position.x, z: ollie.group.position.z }
487 }
488 }
489
490 doug.update(delta, elapsed, activeBread, pond, {
491 paused: inConversation,
492 focusTarget: focusTarget
493 })
494 breadManager.update(delta, elapsed)
495 pond.update(delta, elapsed)
496 donny.update(delta, elapsed, pond, doug)
497 koiSchool.update(delta, elapsed)
498 ollie.update(delta, elapsed, pond, doug)
499 placementManager.update(delta, elapsed)
500
501 // Try to trigger shop if player has enough koi
502 // Only try if no dialog/shop is currently open and no creature is in shop mode
503 if (!isDialogOpen() && !isShopOpen() && !donny.isInShopMode() && !ollie.isInShopMode()) {
504 const koiCount = gameState.getKoi()
505 // Try Donny first (lower threshold), then Ollie
506 if (!donny.tryTriggerShop(koiCount, pond, doug)) {
507 ollie.tryTriggerShop(koiCount, pond, doug)
508 }
509 }
510
511 composer.render()
512 }
513
514 export function stop() {
515 if (animationId) {
516 cancelAnimationFrame(animationId)
517 animationId = null
518 }
519
520 window.removeEventListener('resize', onResize)
521
522 // Close dialog and shop if open
523 if (isDialogOpen()) {
524 closeDialog()
525 }
526 if (isShopOpen()) {
527 closeShop()
528 }
529
530 if (renderer) {
531 renderer.domElement.remove()
532 renderer.dispose()
533 }
534
535 if (composer) {
536 composer.dispose()
537 }
538
539 if (koiCounterEl) {
540 koiCounterEl.remove()
541 koiCounterEl = null
542 }
543
544 // Reset capture state
545 captureState.isHolding = false
546 captureState.targetKoi = null
547 captureState.clickPos = null
548
549 if (placementManager) {
550 placementManager.dispose()
551 }
552
553 scene = null
554 camera = null
555 renderer = null
556 composer = null
557 doug = null
558 pond = null
559 donny = null
560 koiSchool = null
561 ollie = null
562 breadManager = null
563 placementManager = null
564 }
565
566 export const name = '3D'