fuzzyworld

nuts / lava / files / src / main.ts

Branch lava · default branch · all branches

main.ts

Blame: the commit that last changed each line · file · history

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