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