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