fuzzyworld

nuts / files / src / main.ts

main.ts

21.4 KB · history · blame

1import { CONFIG } from './config'
2import { createPhysicsWorld } from './physics'
3import { createRenderer } from './renderer'
4import { requestMotionPermission, initMotion } from './motion'
5import { initUI, removeStartOverlay, type ItemKind, type GravityMode } from './ui'
6import { unlockVibration, setVibrateEnabled, setVibrateItems, setVibrateEdges } from './vibration'
7import { preloadSounds } from './sound'
8import { dbg, dbgError } from './debug'
9import { checkCompat, showUnsupportedWall } from './compat'
10import {
11 loadSettings, saveSettings,
12 loadSnapshot, saveSnapshot, clearSnapshot,
13} from './persistence'
14import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share'
15
16// ── Gallery shortcut ───────────────────────────────────────────────────────
17if (new URLSearchParams(window.location.search).has('gallery')) {
18 import('./gallery').then(({ runGallery }) => runGallery())
19} else {
20
21 // ── Bootstrap ──────────────────────────────────────────────────────────────
22
23 async function main(): Promise<void> {
24 const params = new URLSearchParams(window.location.search)
25 const compat = checkCompat()
26 if (!compat.ok && !params.has('screenshot')) {
27 showUnsupportedWall(compat.reason)
28 return
29 }
30
31 const canvas = document.getElementById('canvas') as HTMLCanvasElement
32
33 // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI
34 const dpr = window.devicePixelRatio || 1
35 let cssWidth = window.innerWidth
36 let cssHeight = window.innerHeight
37
38 function getPixelWidth(): number { return Math.round(cssWidth * dpr) }
39 function getPixelHeight(): number { return Math.round(cssHeight * dpr) }
40
41 canvas.width = getPixelWidth()
42 canvas.height = getPixelHeight()
43 canvas.style.width = `${cssWidth}px`
44 canvas.style.height = `${cssHeight}px`
45
46 // Scale the canvas context so all drawing coordinates are in CSS pixels
47 const ctx = canvas.getContext('2d')!
48 ctx.scale(dpr, dpr)
49
50 // Physics world operates in CSS pixel space
51 const physics = createPhysicsWorld(cssWidth, cssHeight)
52 const renderer = createRenderer(canvas)
53
54 // Correct for the DPR scale we already applied to the context
55 // The renderer draws at CSS px resolution; canvas is scaled by dpr above
56 renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr)
57
58 let currentNutRadius: number = CONFIG.nutBaseRadius
59 let tapsLocked = false
60 let gravityMode: GravityMode = 'device'
61 let vibrateEnabled = true
62 let vibrateItemsEnabled = true
63 let vibrateEdgesEnabled = true
64 let currentKind: ItemKind = 'random'
65 const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
66 let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
67 let refillEnabled = true
68
69 // ── Restore persisted settings ────────────────────────────────────────
70 const savedSettings = loadSettings()
71 if (savedSettings) {
72 currentNutRadius = savedSettings.nutRadius
73 if (savedSettings.gravityMode === 'tumbler' || savedSettings.gravityMode === 'rock') {
74 gravityMode = savedSettings.gravityMode
75 }
76 if (savedSettings.vibrateEnabled === false) {
77 vibrateEnabled = false
78 setVibrateEnabled(false)
79 }
80 if (savedSettings.vibrateItems === false) {
81 vibrateItemsEnabled = false
82 setVibrateItems(false)
83 }
84 if (savedSettings.vibrateEdges === false) {
85 vibrateEdgesEnabled = false
86 setVibrateEdges(false)
87 }
88 const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
89 if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
90 currentKind = savedSettings.kind as ItemKind
91 }
92 if (Array.isArray(savedSettings.randomPool)) {
93 const valid = savedSettings.randomPool.filter(k =>
94 (validKinds as string[]).includes(k) && k !== 'random'
95 ) as ItemKind[]
96 if (valid.length > 0) randomPool = valid
97 }
98 if (savedSettings.refill === false) refillEnabled = false
99 }
100
101 // ── Restore nut positions (share URL takes priority over snapshot) ────────
102 const parsedShare = parseShareFromURL()
103 if (parsedShare && (parsedShare.nuts.length > 0 || parsedShare.nutRadius !== null)) {
104 clearSnapshot()
105 if (parsedShare.nutRadius !== null) currentNutRadius = parsedShare.nutRadius
106 const sharedNuts = parsedShare.nuts
107 // hasSpace not available yet — define inline for initial placement
108 const placed: Array<{ x: number; y: number; r: number }> = []
109 function initialHasSpace(x: number, y: number, r: number): boolean {
110 for (const p of placed) {
111 const dx = p.x - x; const dy = p.y - y
112 if (dx * dx + dy * dy < (p.r + r) ** 2) return false
113 }
114 return true
115 }
116 placeSharedNuts(sharedNuts, cssWidth, cssHeight,
117 (x, y, r, kind, gv) => {
118 placed.push({ x, y, r })
119 if (kind === 'gem') physics.addGem(x, y, r, gv)
120 else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
121 else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
122 else if (kind === 'rock') physics.addRock(x, y, r)
123 else if (kind === 'stressball') physics.addStressBall(x, y, r, gv)
124 else if (kind === 'coin') physics.addCoin(x, y, r, gv)
125 else physics.addNut(x, y, r)
126 },
127 initialHasSpace,
128 )
129 } else {
130 const snapshot = loadSnapshot()
131 if (snapshot) {
132 for (const n of snapshot.nuts) {
133 const x = n.x * cssWidth
134 const y = n.y * cssHeight
135 if (n.kind === 'gem') physics.addGem(x, y, n.radius, n.gemVariant)
136 else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex)
137 else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
138 else if (n.kind === 'rock') physics.addRock(x, y, n.radius)
139 else if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex)
140 else if (n.kind === 'coin') physics.addCoin(x, y, n.radius, n.gemVariant)
141 else physics.addNut(x, y, n.radius)
142 }
143 }
144 }
145
146 // ── UI ───────────────────────────────────────────────────────────────────
147
148 function save(): void {
149 saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
150 }
151
152 initUI({
153 initialRadius: currentNutRadius,
154 initialLocked: tapsLocked,
155 initialGravityMode: gravityMode,
156 initialVibrateEnabled: vibrateEnabled,
157 initialVibrateItems: vibrateItemsEnabled,
158 initialVibrateEdges: vibrateEdgesEnabled,
159 initialKind: currentKind,
160 initialRandomPool: randomPool,
161 initialRefill: refillEnabled,
162 onSizeChange: (radius) => { currentNutRadius = radius; save() },
163 onClear: () => {
164 physics.clearNuts()
165 clearSnapshot()
166 },
167 onLockChange: (locked) => { tapsLocked = locked; save() },
168 onKindChange: (kind) => { currentKind = kind; save() },
169 onGravityModeChange: (mode) => { gravityMode = mode; save() },
170 onVibrateChange: (enabled) => { vibrateEnabled = enabled; save() },
171 onVibrateItemsChange: (enabled) => { vibrateItemsEnabled = enabled; save() },
172 onVibrateEdgesChange: (enabled) => { vibrateEdgesEnabled = enabled; save() },
173 onRandomPoolChange: (pool) => { randomPool = pool; save() },
174 onRefillChange: (enabled) => { refillEnabled = enabled; save() },
175 onSettingsOpen: () => { settingsPaused = true },
176 onSettingsClose: () => { settingsPaused = false },
177 getShareURL: () => buildShareURL(physics.nuts, currentNutRadius),
178 })
179
180 // ── Helpers ───────────────────────────────────────────────────────────────
181
182 function hasSpace(x: number, y: number, r: number): boolean {
183 for (const { body, radius } of physics.nuts) {
184 const dx = body.position.x - x
185 const dy = body.position.y - y
186 const minDist = radius + r
187 if (dx * dx + dy * dy < minDist * minDist) return false
188 }
189 return true
190 }
191
192 function tryAddNut(cx: number, cy: number): void {
193 if (tapsLocked) return
194 if (hasSpace(cx, cy, currentNutRadius)) {
195 let kind = currentKind
196 if (kind === 'random') {
197 const pool = randomPool.length > 0 ? randomPool : ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as ItemKind[]
198 kind = pool[Math.floor(Math.random() * pool.length)]
199 }
200 if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
201 else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
202 else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
203 else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
204 else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius)
205 else if (kind === 'coin') physics.addCoin(cx, cy, currentNutRadius)
206 else physics.addNut(cx, cy, currentNutRadius)
207 }
208 }
209
210 function spawnRefill(kind: string, radius: number): void {
211 // Try a few random positions along the top third of the screen
212 for (let attempt = 0; attempt < 20; attempt++) {
213 const x = radius + Math.random() * (cssWidth - radius * 2)
214 const y = radius + Math.random() * (cssHeight * 0.35)
215 if (!hasSpace(x, y, radius)) continue
216 if (kind === 'balloon') physics.addBalloon(x, y, radius)
217 else if (kind === 'rock') physics.addRock(x, y, radius)
218 else if (kind === 'gem') physics.addGem(x, y, radius)
219 else if (kind === 'metalnut') physics.addMetalNut(x, y, radius)
220 else if (kind === 'stressball') physics.addStressBall(x, y, radius)
221 else if (kind === 'coin') physics.addCoin(x, y, radius)
222 else physics.addNut(x, y, radius)
223 return
224 }
225 }
226
227 // ── Refill hook ──────────────────────────────────────────────────────────
228 physics.onRemoved = (kind, radius) => {
229 if (!refillEnabled) return
230 // Delay slightly so the removal settles before we add a new body
231 setTimeout(() => spawnRefill(kind, radius), 600)
232 }
233
234
235 // ── Pointer: tap/drag to add; tap/drag-through to erase ─────────────────
236 // Mode is decided on pointerdown: if we hit an existing item we erase,
237 // otherwise we add. The mode persists until pointerup.
238 let pointerDown = false
239 let eraseMode = false
240
241 canvas.addEventListener('pointerdown', (e: PointerEvent) => {
242 if (tapsLocked) return
243 pointerDown = true
244 try { canvas.setPointerCapture(e.pointerId) } catch { /* ignore */ }
245 const rect = canvas.getBoundingClientRect()
246 const x = e.clientX - rect.left
247 const y = e.clientY - rect.top
248 eraseMode = physics.removeAt(x, y)
249 if (!eraseMode) tryAddNut(x, y)
250 })
251
252 canvas.addEventListener('pointermove', (e: PointerEvent) => {
253 if (!pointerDown) return
254 const rect = canvas.getBoundingClientRect()
255 const x = e.clientX - rect.left
256 const y = e.clientY - rect.top
257 if (eraseMode) physics.removeAt(x, y)
258 else tryAddNut(x, y)
259 })
260
261 canvas.addEventListener('pointerup', () => { pointerDown = false; eraseMode = false })
262 canvas.addEventListener('pointercancel', () => { pointerDown = false; eraseMode = false })
263
264 // ── Resize ───────────────────────────────────────────────────────────────
265 window.addEventListener('resize', () => {
266 cssWidth = window.innerWidth
267 cssHeight = window.innerHeight
268 canvas.width = getPixelWidth()
269 canvas.height = getPixelHeight()
270 canvas.style.width = `${cssWidth}px`
271 canvas.style.height = `${cssHeight}px`
272 const c = canvas.getContext('2d')!
273 c.scale(dpr, dpr)
274 renderer.resize(cssWidth, cssHeight)
275 physics.resize(cssWidth, cssHeight)
276 })
277
278 // ── Motion (shake + tilt gravity) ────────────────────────────────────────
279 let motionEnabled = false
280
281 function startMotion(): void {
282 if (motionEnabled) return
283 motionEnabled = true
284 dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol)
285 initMotion({
286 onShake: (intensity) => {
287 physics.applyShakeForce(intensity)
288 },
289 onOrientation: (gx, gy) => {
290 if (gravityMode === 'device') {
291 physics.setGravity(gx, gy)
292 }
293 },
294 })
295 }
296
297 // ── Fullscreen + orientation lock ──────────────────────────────────────
298 async function enterFullscreen(): Promise<void> {
299 try {
300 await document.documentElement.requestFullscreen({ navigationUI: 'hide' })
301 } catch {
302 // Not supported or denied – ignore
303 }
304 try {
305 const orientation = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
306 await orientation.lock?.('portrait')
307 } catch {
308 // Not supported (desktop, or browser restriction) – ignore
309 }
310 }
311
312 // ── Fullscreen guard ──────────────────────────────────────────────────────
313 const fullscreenOverlay = document.getElementById('fullscreen-overlay')!
314 document.addEventListener('fullscreenchange', () => {
315 const isFullscreen = !!document.fullscreenElement
316 fullscreenOverlay.hidden = isFullscreen
317 })
318 fullscreenOverlay.addEventListener('click', async () => {
319 await enterFullscreen()
320 })
321
322 // ── Start overlay / permission ────────────────────────────────────────────
323 const startOverlay = document.getElementById('start-overlay')!
324 startOverlay.addEventListener(
325 'click',
326 async () => {
327 try {
328 await requestMotionPermission()
329 } catch (e) {
330 dbgError('requestMotionPermission failed', e)
331 }
332 unlockVibration() // must be called from gesture handler
333 preloadSounds() // kick off audio fetch after first gesture (autoplay policy)
334 await enterFullscreen()
335 startMotion()
336 removeStartOverlay()
337 startLoop()
338 },
339 { once: true }
340 )
341
342 // On non-iOS desktop, start automatically without waiting for tap
343 if (!('ontouchstart' in window)) {
344 // Small delay so the UI paints before we start the loop
345 setTimeout(() => {
346 startMotion()
347 removeStartOverlay()
348 startLoop()
349 }, 400)
350 }
351
352 // ── Slow-frame recovery ───────────────────────────────────────────────
353 const perfOverlay = document.getElementById('perf-overlay')!
354 const perfResetBtn = document.getElementById('perf-reset-btn')!
355 const perfContinueBtn = document.getElementById('perf-continue-btn')!
356 let perfPaused = false
357 const SLOW_FRAME_MS = 5000
358
359 perfResetBtn.addEventListener('click', () => {
360 physics.clearNuts()
361 clearSnapshot()
362 perfOverlay.hidden = true
363 perfPaused = false
364 lastTimestamp = 0
365 requestAnimationFrame(loop)
366 })
367
368 perfContinueBtn.addEventListener('click', () => {
369 perfOverlay.hidden = true
370 perfPaused = false
371 lastTimestamp = 0
372 requestAnimationFrame(loop)
373 })
374
375 // ── Game loop ─────────────────────────────────────────────────────────────
376 let lastTimestamp = 0
377 let loopRunning = false
378 let settingsPaused = false
379
380 function startLoop(): void {
381 if (loopRunning) return
382 loopRunning = true
383 requestAnimationFrame(loop)
384 }
385
386 function loop(timestamp: number): void {
387 // Pause when settings are open
388 if (settingsPaused) {
389 lastTimestamp = 0
390 requestAnimationFrame(loop)
391 return
392 }
393
394 // On mobile: pause physics + vibration when not fullscreen
395 const isMobile = 'ontouchstart' in window
396 const isFullscreen = !!document.fullscreenElement
397 if (isMobile && !isFullscreen) {
398 setVibrateEnabled(false)
399 lastTimestamp = 0
400 requestAnimationFrame(loop)
401 return
402 }
403 if (isMobile && vibrateEnabled) {
404 setVibrateEnabled(true)
405 }
406
407 const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
408 lastTimestamp = timestamp
409
410 // Detect runaway frame time — tab wake-up or physics explosion
411 if (delta > SLOW_FRAME_MS) {
412 perfOverlay.hidden = false
413 perfPaused = true
414 loopRunning = false
415 return
416 }
417
418 // ── Auto-gravity modes ───────────────────────────────────────────────
419 if (gravityMode === 'tumbler') {
420 // Full 360° rotation at 0.1 Hz (10 seconds per revolution)
421 const angle = (timestamp / 1000) * 0.1 * Math.PI * 2
422 physics.setGravity(Math.sin(angle), Math.cos(angle))
423 } else if (gravityMode === 'rock') {
424 // Oscillate ±45° around landscape-down (gravity pointing right in portrait)
425 const centerAngle = Math.PI / 2 // landscape "down" = 90° from vertical
426 const angle = centerAngle + Math.sin((timestamp / 30000) * Math.PI * 2) * (Math.PI / 4)
427 physics.setGravity(Math.sin(angle), Math.cos(angle))
428 }
429
430 physics.step(delta)
431
432 const g = physics.engine.gravity
433 renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, gravityMode !== 'device')
434
435 // Save snapshot every ~3 s
436 if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
437 saveSnapshot(physics.nuts, cssWidth, cssHeight)
438 }
439
440 requestAnimationFrame(loop)
441 }
442
443 // Also save on tab close / navigation
444 window.addEventListener('pagehide', () => {
445 saveSnapshot(physics.nuts, cssWidth, cssHeight)
446 saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, vibrateItems: vibrateItemsEnabled, vibrateEdges: vibrateEdgesEnabled, kind: currentKind, randomPool, refill: refillEnabled })
447 })
448 }
449
450 main().catch(console.error)
451}