JavaScript · 6067 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 { unlockAudio } from './sounds.js'
14
15 let scene, camera, renderer, composer, outlinePass
16 let doug, pond, breadManager, donny, koiSchool, ollie
17 let clock
18 let animationId = null
19
20 function createToonGradient() {
21 const canvas = document.createElement('canvas')
22 canvas.width = 4
23 canvas.height = 1
24 const ctx = canvas.getContext('2d')
25
26 ctx.fillStyle = '#444444'
27 ctx.fillRect(0, 0, 1, 1)
28 ctx.fillStyle = '#888888'
29 ctx.fillRect(1, 0, 1, 1)
30 ctx.fillStyle = '#cccccc'
31 ctx.fillRect(2, 0, 1, 1)
32 ctx.fillStyle = '#ffffff'
33 ctx.fillRect(3, 0, 1, 1)
34
35 const texture = new THREE.CanvasTexture(canvas)
36 texture.minFilter = THREE.NearestFilter
37 texture.magFilter = THREE.NearestFilter
38 return texture
39 }
40
41 export function start(container) {
42 if (animationId) return
43
44 // Unlock audio on any user interaction (document level for mobile)
45 const unlockEvents = ['touchstart', 'touchend', 'pointerdown', 'click', 'keydown']
46 const unlockHandler = () => {
47 unlockAudio()
48 // Remove all listeners after first unlock
49 unlockEvents.forEach(e => document.removeEventListener(e, unlockHandler))
50 }
51 unlockEvents.forEach(e => document.addEventListener(e, unlockHandler, { passive: true }))
52
53 // Scene
54 scene = new THREE.Scene()
55 scene.background = new THREE.Color(0x55a04b)
56
57 // Camera
58 const aspect = window.innerWidth / window.innerHeight
59 const frustumSize = 12
60 camera = new THREE.OrthographicCamera(
61 -frustumSize * aspect / 2,
62 frustumSize * aspect / 2,
63 frustumSize / 2,
64 -frustumSize / 2,
65 0.1,
66 100
67 )
68 camera.position.set(10, 10, 10)
69 camera.lookAt(0, 0, 0)
70
71 // Renderer
72 renderer = new THREE.WebGLRenderer({ antialias: true })
73 renderer.setSize(window.innerWidth, window.innerHeight)
74 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
75 container.appendChild(renderer.domElement)
76
77 // Lighting
78 const sunLight = new THREE.DirectionalLight(0xffffee, 1.5)
79 sunLight.position.set(5, 10, 5)
80 scene.add(sunLight)
81
82 const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x55a04b, 0.6)
83 scene.add(hemiLight)
84
85 const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
86 fillLight.position.set(-5, 5, -5)
87 scene.add(fillLight)
88
89 const toonGradient = createToonGradient()
90
91 // Create objects
92 pond = createPond(scene, toonGradient)
93 doug = createDoug(scene, toonGradient)
94 breadManager = new BreadManager(scene, toonGradient)
95 donny = createDonny(scene, toonGradient)
96 koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
97 ollie = createOllie(scene, toonGradient)
98
99 // Post-processing
100 composer = new EffectComposer(renderer)
101 composer.addPass(new RenderPass(scene, camera))
102
103 outlinePass = new OutlinePass(
104 new THREE.Vector2(window.innerWidth, window.innerHeight),
105 scene,
106 camera
107 )
108 outlinePass.edgeStrength = 3
109 outlinePass.edgeGlow = 0
110 outlinePass.edgeThickness = 1.5
111 outlinePass.visibleEdgeColor.set(0x191410)
112 outlinePass.hiddenEdgeColor.set(0x191410)
113 outlinePass.selectedObjects = [doug.group, pond.group, donny.group, koiSchool.group, ollie.group]
114 composer.addPass(outlinePass)
115 composer.addPass(new OutputPass())
116
117 // Raycaster
118 const raycaster = new THREE.Raycaster()
119 const mouse = new THREE.Vector2()
120
121 // Unlock audio on first touch (for mobile)
122 renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
123
124 renderer.domElement.addEventListener('click', (event) => {
125 // Unlock audio on first interaction (required for mobile)
126 unlockAudio()
127
128 mouse.x = (event.clientX / window.innerWidth) * 2 - 1
129 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
130
131 raycaster.setFromCamera(mouse, camera)
132 const intersects = raycaster.intersectObject(pond.water)
133
134 if (intersects.length > 0) {
135 const point = intersects[0].point
136 breadManager.spawnBread(point.x, point.z)
137 pond.addRipple(point.x, point.z)
138 koiSchool.triggerPanic(point.x, point.z)
139 outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
140 }
141 })
142
143 // Resize handler
144 window.addEventListener('resize', onResize)
145
146 clock = new THREE.Clock()
147 animate()
148 }
149
150 function onResize() {
151 const aspect = window.innerWidth / window.innerHeight
152 const frustumSize = 12
153 camera.left = -frustumSize * aspect / 2
154 camera.right = frustumSize * aspect / 2
155 camera.top = frustumSize / 2
156 camera.bottom = -frustumSize / 2
157 camera.updateProjectionMatrix()
158
159 renderer.setSize(window.innerWidth, window.innerHeight)
160 composer.setSize(window.innerWidth, window.innerHeight)
161 }
162
163 function animate() {
164 animationId = requestAnimationFrame(animate)
165
166 const delta = clock.getDelta()
167 const elapsed = clock.getElapsedTime()
168
169 doug.update(delta, elapsed, breadManager.getActiveBits(), pond)
170 breadManager.update(delta, elapsed)
171 pond.update(delta, elapsed)
172 donny.update(delta, elapsed, pond, doug)
173 koiSchool.update(delta, elapsed)
174 ollie.update(delta, elapsed, pond, doug)
175
176 composer.render()
177 }
178
179 export function stop() {
180 if (animationId) {
181 cancelAnimationFrame(animationId)
182 animationId = null
183 }
184
185 window.removeEventListener('resize', onResize)
186
187 if (renderer) {
188 renderer.domElement.remove()
189 renderer.dispose()
190 }
191
192 if (composer) {
193 composer.dispose()
194 }
195
196 scene = null
197 camera = null
198 renderer = null
199 composer = null
200 doug = null
201 pond = null
202 donny = null
203 koiSchool = null
204 ollie = null
205 breadManager = null
206 }
207
208 export const name = '3D'