// Three.js 3D renderer for dougk import * as THREE from 'three' import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js' import { OutputPass } from 'three/addons/postprocessing/OutputPass.js' import { createDoug } from './duck.js' import { createPond } from './pond.js' import { BreadManager } from './bread.js' import { createDonny } from './narwhal.js' import { createKoiSchool } from './koi.js' import { createOllie } from './octopus.js' import { PlacementManager } from './buildingPlacement.js' import { unlockAudio } from './sounds.js' import gameState from './gameState.js' import { openShop, closeShop, isShopOpen } from './shop/shopUI.js' import { showDialog, closeDialog, isDialogOpen } from './shop/dialogUI.js' import { getDialogForCharacter, getReturnDialog } from './shop/dialogScripts.js' import inventory from './shop/inventory.js' import { getItem, CHARACTERS, getAllItems } from './shop/items.js' let scene, camera, renderer, composer, outlinePass let doug, pond, breadManager, donny, koiSchool, ollie, placementManager let clock let animationId = null let koiCounterEl = null // Capture state let captureState = { isHolding: false, targetKoi: null, clickPos: null // Where bread would spawn if not capturing } // Auto-capture state (when Doug hovers over koi) let autoCapture = { targetKoi: null, hoverTime: 0, CAPTURE_DELAY: 0.5 // seconds Doug must be over koi to auto-capture } // Camera zoom state for dialog focus effect const cameraZoom = { defaultFrustum: 12, dialogFrustum: 8, // Zoomed in during dialog currentFrustum: 12, targetFrustum: 12 } function createKoiCounter(container) { const counter = document.createElement('div') counter.id = 'koi-counter' counter.style.cssText = ` position: fixed; top: 16px; left: 16px; background: rgba(0, 0, 0, 0.6); color: #ffd700; padding: 8px 16px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; z-index: 1000; pointer-events: none; display: flex; align-items: center; gap: 8px; ` counter.innerHTML = `🐟 ${gameState.getKoi()}` container.appendChild(counter) // Listen for game state changes gameState.addListener((count) => { const countEl = document.getElementById('koi-count') if (countEl) countEl.textContent = count }) return counter } function createToonGradient() { const canvas = document.createElement('canvas') canvas.width = 4 canvas.height = 1 const ctx = canvas.getContext('2d') ctx.fillStyle = '#444444' ctx.fillRect(0, 0, 1, 1) ctx.fillStyle = '#888888' ctx.fillRect(1, 0, 1, 1) ctx.fillStyle = '#cccccc' ctx.fillRect(2, 0, 1, 1) ctx.fillStyle = '#ffffff' ctx.fillRect(3, 0, 1, 1) const texture = new THREE.CanvasTexture(canvas) texture.minFilter = THREE.NearestFilter texture.magFilter = THREE.NearestFilter return texture } export function start(container) { if (animationId) return // Unlock audio on any user interaction (document level for mobile) const unlockEvents = ['touchstart', 'touchend', 'pointerdown', 'click', 'keydown'] const unlockHandler = () => { unlockAudio() // Remove all listeners after first unlock unlockEvents.forEach(e => document.removeEventListener(e, unlockHandler)) } unlockEvents.forEach(e => document.addEventListener(e, unlockHandler, { passive: true })) // Scene scene = new THREE.Scene() scene.background = new THREE.Color(0x55a04b) // Camera const aspect = window.innerWidth / window.innerHeight const frustumSize = 12 camera = new THREE.OrthographicCamera( -frustumSize * aspect / 2, frustumSize * aspect / 2, frustumSize / 2, -frustumSize / 2, 0.1, 100 ) camera.position.set(10, 10, 10) camera.lookAt(0, 0, 0) // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(window.innerWidth, window.innerHeight) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) container.appendChild(renderer.domElement) // Lighting const sunLight = new THREE.DirectionalLight(0xffffee, 1.5) sunLight.position.set(5, 10, 5) scene.add(sunLight) const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x55a04b, 0.6) scene.add(hemiLight) const fillLight = new THREE.DirectionalLight(0xffffff, 0.3) fillLight.position.set(-5, 5, -5) scene.add(fillLight) const toonGradient = createToonGradient() // Create objects pond = createPond(scene, toonGradient) doug = createDoug(scene, toonGradient) breadManager = new BreadManager(scene, toonGradient) donny = createDonny(scene, toonGradient) koiSchool = createKoiSchool(scene, toonGradient, pond.radius) ollie = createOllie(scene, toonGradient) placementManager = new PlacementManager(scene, pond, camera, toonGradient) placementManager.initForbiddenZones(pond.getInitialForbiddenZones()) // Initialize shop approach tracking (for smart creature behavior) gameState.initShopTracking(getAllItems(), inventory) // Post-processing composer = new EffectComposer(renderer) composer.addPass(new RenderPass(scene, camera)) outlinePass = new OutlinePass( new THREE.Vector2(window.innerWidth, window.innerHeight), scene, camera ) outlinePass.edgeStrength = 3 outlinePass.edgeGlow = 0 outlinePass.edgeThickness = 1.5 outlinePass.visibleEdgeColor.set(0x191410) outlinePass.hiddenEdgeColor.set(0x191410) outlinePass.selectedObjects = [doug.group, pond.group, donny.group, koiSchool.group, ollie.group, placementManager.getPlacedBuildingsGroup()] composer.addPass(outlinePass) composer.addPass(new OutputPass()) // Raycaster const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() // Unlock audio on first touch (for mobile) renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true }) // Mouse move - handle placement preview renderer.domElement.addEventListener('pointermove', (event) => { if (placementManager.isPlacing()) { placementManager.onMouseMove(event, window.innerWidth, window.innerHeight) } }) // Helper to handle tap-to-shop for a creature const handleCreatureTap = (creature, characterName) => { if (!creature.isTappable()) return false if (isDialogOpen() || isShopOpen()) return false // Show return dialog, then open shop const dialogLines = getReturnDialog(characterName) showDialog(characterName, dialogLines, container, () => { openShop(characterName, container, { onClose: () => { creature.dismissShop() }, onPurchase: (item, action) => { if (item.character) { handleOutfitChange(item, action) } if (action === 'place' && item.buildingType) { placementManager.startPlacement(item.buildingType, { onComplete: () => {}, onCancel: () => {} }) } } }) }) // Transition creature to shop mode if surfaced creature.triggerShopFromTap(pond, doug) return true } // Pointer down - handle placement, capture, or bread spawn renderer.domElement.addEventListener('pointerdown', (event) => { unlockAudio() // If placing a building, try to place it if (placementManager.isPlacing()) { if (placementManager.onClick(event, window.innerWidth, window.innerHeight)) { // Building placed successfully return } // Clicked in invalid spot - cancel placement placementManager.cancelPlacement() return } mouse.x = (event.clientX / window.innerWidth) * 2 - 1 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 raycaster.setFromCamera(mouse, camera) // Check if clicked on Donny or Ollie (tap to reopen shop) if (donny.isTappable()) { const donnyIntersects = raycaster.intersectObject(donny.group, true) if (donnyIntersects.length > 0) { if (handleCreatureTap(donny, 'donny')) return } } if (ollie.isTappable()) { const ollieIntersects = raycaster.intersectObject(ollie.group, true) if (ollieIntersects.length > 0) { if (handleCreatureTap(ollie, 'ollie')) return } } const intersects = raycaster.intersectObject(pond.water) if (intersects.length > 0) { const point = intersects[0].point captureState.clickPos = { x: point.x, z: point.z } captureState.isHolding = true // Check if Doug is over a koi const dougPos = doug.getPosition() const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.4) if (koiUnderDoug) { // Start capturing koiSchool.startCapture(koiUnderDoug) captureState.targetKoi = koiUnderDoug } } }) // Pointer up - complete capture or spawn bread renderer.domElement.addEventListener('pointerup', () => { if (!captureState.isHolding) return if (captureState.targetKoi) { // If still capturing (not completed), cancel it if (captureState.targetKoi.state.beingCaptured) { koiSchool.cancelCapture(captureState.targetKoi) } } else if (captureState.clickPos) { // No capture was started - spawn bread breadManager.spawnBread(captureState.clickPos.x, captureState.clickPos.z) pond.addRipple(captureState.clickPos.x, captureState.clickPos.z) koiSchool.triggerPanic(captureState.clickPos.x, captureState.clickPos.z) outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()] } // Reset capture state captureState.isHolding = false captureState.targetKoi = null captureState.clickPos = null }) // Create koi counter UI koiCounterEl = createKoiCounter(container) // Helper to get character object by name const getCharacter = (charName) => { switch (charName) { case 'doug': return doug case 'donny': return donny case 'ollie': return ollie default: return null } } // Handle outfit equip/unequip const handleOutfitChange = (item, action) => { const character = getCharacter(item.character) if (!character) return if (action === 'equip') { character.applyOutfit(item) } else if (action === 'unequip') { character.removeOutfit(item) } } // Set up shop callbacks const handleShopReady = (shopkeeper) => { // Get dialog lines for this character const dialogLines = getDialogForCharacter(shopkeeper) // Show dialog first, then open shop when complete showDialog(shopkeeper, dialogLines, container, () => { // Dialog complete - now open the shop openShop(shopkeeper, container, { onClose: () => { // Dismiss the appropriate shopkeeper if (shopkeeper === 'donny') { donny.dismissShop() } else if (shopkeeper === 'ollie') { ollie.dismissShop() } }, onPurchase: (item, action) => { // Handle outfit equip/unequip if (item.character) { handleOutfitChange(item, action) } // Handle building placement if (action === 'place' && item.buildingType) { placementManager.startPlacement(item.buildingType, { onComplete: (buildingType, zone) => { // Building placed successfully }, onCancel: () => { // Placement cancelled } }) } } }) }) } donny.setShopReadyCallback(handleShopReady) ollie.setShopReadyCallback(handleShopReady) // Load saved equipped outfits const loadEquippedOutfits = () => { for (const charName of Object.values(CHARACTERS)) { const equipped = inventory.getEquipped(charName) const character = getCharacter(charName) if (character) { for (const itemId of equipped) { const item = getItem(itemId) if (item) { character.applyOutfit(item) } } } } } loadEquippedOutfits() // Load saved placed buildings placementManager.loadSavedBuildings() // Resize handler window.addEventListener('resize', onResize) clock = new THREE.Clock() animate() } function onResize() { const aspect = window.innerWidth / window.innerHeight const frustumSize = 12 camera.left = -frustumSize * aspect / 2 camera.right = frustumSize * aspect / 2 camera.top = frustumSize / 2 camera.bottom = -frustumSize / 2 camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) composer.setSize(window.innerWidth, window.innerHeight) } function animate() { animationId = requestAnimationFrame(animate) const delta = clock.getDelta() const elapsed = clock.getElapsedTime() // Check if in dialog/shop mode for camera zoom and movement pause const inConversation = isDialogOpen() || isShopOpen() // Animate camera zoom cameraZoom.targetFrustum = inConversation ? cameraZoom.dialogFrustum : cameraZoom.defaultFrustum if (Math.abs(cameraZoom.currentFrustum - cameraZoom.targetFrustum) > 0.01) { cameraZoom.currentFrustum += (cameraZoom.targetFrustum - cameraZoom.currentFrustum) * delta * 3 const aspect = window.innerWidth / window.innerHeight camera.left = -cameraZoom.currentFrustum * aspect / 2 camera.right = cameraZoom.currentFrustum * aspect / 2 camera.top = cameraZoom.currentFrustum / 2 camera.bottom = -cameraZoom.currentFrustum / 2 camera.updateProjectionMatrix() } // Get active bread positions for koi attraction const activeBread = breadManager.getActiveBits() const breadPositions = activeBread.map(b => ({ x: b.position.x, z: b.position.z })) // Update koi attraction to bread koiSchool.attractToBread(breadPositions) // Handle capture in progress (manual click-hold) if (captureState.isHolding && captureState.targetKoi) { const completed = koiSchool.updateCapture(captureState.targetKoi, delta) if (completed) { // Capture completed captureState.targetKoi = null captureState.isHolding = false captureState.clickPos = null } } // Auto-capture: when Doug hovers over a koi for a moment if (!captureState.isHolding) { const dougPos = doug.getPosition() const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.5) if (koiUnderDoug) { if (autoCapture.targetKoi === koiUnderDoug) { // Same koi - accumulate hover time autoCapture.hoverTime += delta if (autoCapture.hoverTime >= autoCapture.CAPTURE_DELAY) { // Start and immediately complete capture koiSchool.startCapture(koiUnderDoug) // Fast-forward capture to completion while (!koiSchool.updateCapture(koiUnderDoug, 0.2)) {} autoCapture.targetKoi = null autoCapture.hoverTime = 0 } } else { // New koi - reset timer autoCapture.targetKoi = koiUnderDoug autoCapture.hoverTime = 0 } } else { // No koi under Doug - reset autoCapture.targetKoi = null autoCapture.hoverTime = 0 } } // Determine focus target for Doug during conversation let focusTarget = null if (inConversation) { // Focus on whoever is in shop mode if (donny.isInShopMode()) { focusTarget = { x: donny.group.position.x, z: donny.group.position.z } } else if (ollie.isInShopMode()) { focusTarget = { x: ollie.group.position.x, z: ollie.group.position.z } } } doug.update(delta, elapsed, activeBread, pond, { paused: inConversation, focusTarget: focusTarget }) breadManager.update(delta, elapsed) pond.update(delta, elapsed) donny.update(delta, elapsed, pond, doug) koiSchool.update(delta, elapsed) ollie.update(delta, elapsed, pond, doug) placementManager.update(delta, elapsed) // Try to trigger shop if player has enough koi // Only try if no dialog/shop is currently open and no creature is in shop mode if (!isDialogOpen() && !isShopOpen() && !donny.isInShopMode() && !ollie.isInShopMode()) { const koiCount = gameState.getKoi() // Try Donny first (lower threshold), then Ollie if (!donny.tryTriggerShop(koiCount, pond, doug)) { ollie.tryTriggerShop(koiCount, pond, doug) } } composer.render() } export function stop() { if (animationId) { cancelAnimationFrame(animationId) animationId = null } window.removeEventListener('resize', onResize) // Close dialog and shop if open if (isDialogOpen()) { closeDialog() } if (isShopOpen()) { closeShop() } if (renderer) { renderer.domElement.remove() renderer.dispose() } if (composer) { composer.dispose() } if (koiCounterEl) { koiCounterEl.remove() koiCounterEl = null } // Reset capture state captureState.isHolding = false captureState.targetKoi = null captureState.clickPos = null if (placementManager) { placementManager.dispose() } scene = null camera = null renderer = null composer = null doug = null pond = null donny = null koiSchool = null ollie = null breadManager = null placementManager = null } export const name = '3D'