fuzzyworld

nuts / goo / files / src / main.ts

Branch goo · default branch · all branches

main.ts

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