JavaScript · 5076 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 // Scene
42 scene = new THREE.Scene()
43 scene.background = new THREE.Color(0x55a04b)
44
45 // Camera
46 const aspect = window.innerWidth / window.innerHeight
47 const frustumSize = 12
48 camera = new THREE.OrthographicCamera(
49 -frustumSize * aspect / 2,
50 frustumSize * aspect / 2,
51 frustumSize / 2,
52 -frustumSize / 2,
53 0.1,
54 100
55 )
56 camera.position.set(10, 10, 10)
57 camera.lookAt(0, 0, 0)
58
59 // Renderer
60 renderer = new THREE.WebGLRenderer({ antialias: true })
61 renderer.setSize(window.innerWidth, window.innerHeight)
62 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
63 container.appendChild(renderer.domElement)
64
65 // Lighting
66 const sunLight = new THREE.DirectionalLight(0xffffee, 1.5)
67 sunLight.position.set(5, 10, 5)
68 scene.add(sunLight)
69
70 const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x55a04b, 0.6)
71 scene.add(hemiLight)
72
73 const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
74 fillLight.position.set(-5, 5, -5)
75 scene.add(fillLight)
76
77 const toonGradient = createToonGradient()
78
79 // Create objects
80 pond = createPond(scene, toonGradient)
81 doug = createDoug(scene, toonGradient)
82 breadManager = new BreadManager(scene, toonGradient)
83
84 // Post-processing
85 composer = new EffectComposer(renderer)
86 composer.addPass(new RenderPass(scene, camera))
87
88 outlinePass = new OutlinePass(
89 new THREE.Vector2(window.innerWidth, window.innerHeight),
90 scene,
91 camera
92 )
93 outlinePass.edgeStrength = 3
94 outlinePass.edgeGlow = 0
95 outlinePass.edgeThickness = 1.5
96 outlinePass.visibleEdgeColor.set(0x191410)
97 outlinePass.hiddenEdgeColor.set(0x191410)
98 outlinePass.selectedObjects = [doug.group, pond.group]
99 composer.addPass(outlinePass)
100 composer.addPass(new OutputPass())
101
102 // Raycaster
103 const raycaster = new THREE.Raycaster()
104 const mouse = new THREE.Vector2()
105
106 // Unlock audio on first touch (for mobile)
107 renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
108
109 renderer.domElement.addEventListener('click', (event) => {
110 // Unlock audio on first interaction (required for mobile)
111 unlockAudio()
112
113 mouse.x = (event.clientX / window.innerWidth) * 2 - 1
114 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
115
116 raycaster.setFromCamera(mouse, camera)
117 const intersects = raycaster.intersectObject(pond.water)
118
119 if (intersects.length > 0) {
120 const point = intersects[0].point
121 breadManager.spawnBread(point.x, point.z)
122 pond.addRipple(point.x, point.z)
123 outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
124 }
125 })
126
127 // Resize handler
128 window.addEventListener('resize', onResize)
129
130 clock = new THREE.Clock()
131 animate()
132 }
133
134 function onResize() {
135 const aspect = window.innerWidth / window.innerHeight
136 const frustumSize = 12
137 camera.left = -frustumSize * aspect / 2
138 camera.right = frustumSize * aspect / 2
139 camera.top = frustumSize / 2
140 camera.bottom = -frustumSize / 2
141 camera.updateProjectionMatrix()
142
143 renderer.setSize(window.innerWidth, window.innerHeight)
144 composer.setSize(window.innerWidth, window.innerHeight)
145 }
146
147 function animate() {
148 animationId = requestAnimationFrame(animate)
149
150 const delta = clock.getDelta()
151 const elapsed = clock.getElapsedTime()
152
153 doug.update(delta, elapsed, breadManager.getActiveBits(), pond)
154 breadManager.update(delta, elapsed)
155 pond.update(delta, elapsed)
156
157 composer.render()
158 }
159
160 export function stop() {
161 if (animationId) {
162 cancelAnimationFrame(animationId)
163 animationId = null
164 }
165
166 window.removeEventListener('resize', onResize)
167
168 if (renderer) {
169 renderer.domElement.remove()
170 renderer.dispose()
171 }
172
173 if (composer) {
174 composer.dispose()
175 }
176
177 scene = null
178 camera = null
179 renderer = null
180 composer = null
181 doug = null
182 pond = null
183 breadManager = null
184 }
185
186 export const name = '3D'