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