bubbles / files / src / main.ts
main.ts
dcc4757f 2026-04-09 Pavel Safronov | 1 | import { App } from './App.js'; |
775e32f9 2026-04-09 Pavel Safronov | 2 | import { WaterLevel } from './levels/WaterLevel.js'; |
| 3 | import { LavaLampLevel } from './levels/LavaLampLevel.js'; | |
| 4 | import { ShampooLevel } from './levels/ShampooLevel.js'; | |
| 5 | import { SandWaterLevel } from './levels/SandWaterLevel.js'; | |
fc6e1fbf 2026-04-09 Pavel Safronov | 6 | import QRCode from 'qrcode'; |
dcc4757f 2026-04-09 Pavel Safronov | 7 | |
| 8 | const canvas = document.getElementById('canvas') as HTMLCanvasElement | null; | |
fc6e1fbf 2026-04-09 Pavel Safronov | 9 | const startScreen = document.getElementById('start-screen') as HTMLDivElement; |
| 10 | const startBtn = document.getElementById('start-btn') as HTMLButtonElement; | |
dcc4757f 2026-04-09 Pavel Safronov | 11 | if (!canvas) throw new Error('No canvas element found'); |
| 12 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 13 | const isIOS = /iP(hone|od|ad)/.test(navigator.userAgent); |
| 14 | const supportsFullscreen = document.fullscreenEnabled | |
| 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 16 | || (document as any).webkitFullscreenEnabled; | |
| 17 | ||
| 18 | let orientationLocked = false; | |
| 19 | ||
| 20 | async function tryLockOrientation(): Promise<void> { | |
| 21 | try { | |
| 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 23 | await (screen.orientation as any).lock('portrait'); | |
| 24 | orientationLocked = true; | |
| 25 | } catch { /* not supported — CSS landscape overlay handles it */ } | |
| 26 | } | |
| 27 | ||
| 28 | async function enterFullscreen(): Promise<void> { | |
| 29 | if (!isIOS && supportsFullscreen) { | |
| 30 | try { | |
| 31 | await document.documentElement.requestFullscreen({ navigationUI: 'hide' }); | |
| 32 | } catch { /* ignore */ } | |
| 33 | } | |
| 34 | await tryLockOrientation(); | |
| 35 | } | |
| 36 | ||
| 37 | document.addEventListener('fullscreenchange', () => { | |
| 38 | if (document.fullscreenElement && !orientationLocked) { | |
| 39 | void tryLockOrientation(); | |
| 40 | } | |
| 41 | }); | |
| 42 | ||
| 43 | screen.orientation.addEventListener('change', () => { | |
| 44 | if (orientationLocked) void tryLockOrientation(); | |
| 45 | }); | |
| 46 | ||
775e32f9 2026-04-09 Pavel Safronov | 47 | // ── App + levels ────────────────────────────────────────────────────────── |
fc6e1fbf 2026-04-09 Pavel Safronov | 48 | |
dcc4757f 2026-04-09 Pavel Safronov | 49 | const app = new App(canvas); |
fc6e1fbf 2026-04-09 Pavel Safronov | 50 | |
775e32f9 2026-04-09 Pavel Safronov | 51 | // Build all levels sharing the same ctx/glass geometry from app |
| 52 | // (App exposes canvas; levels read glass dims on construction via glassX/Y/W/H) | |
| 53 | const ctx = app.canvas.getContext('2d')!; | |
| 54 | ||
| 55 | function makeAllLevels(): void { | |
| 56 | const W = window.innerWidth; | |
| 57 | const H = window.innerHeight; | |
| 58 | const glassW = Math.min(W * 0.82, 420); | |
| 59 | const glassH = H * 0.72; | |
| 60 | const glassX = (W - glassW) / 2; | |
| 61 | const glassY = (H - glassH) / 2 - H * 0.02; | |
| 62 | ||
| 63 | app.setLevels([ | |
| 64 | new WaterLevel(ctx, glassX, glassY, glassW, glassH), | |
| 65 | new LavaLampLevel(ctx, glassX, glassY, glassW, glassH), | |
| 66 | new ShampooLevel(ctx, glassX, glassY, glassW, glassH), | |
| 67 | new SandWaterLevel(ctx, glassX, glassY, glassW, glassH), | |
| 68 | ]); | |
| 69 | } | |
| 70 | makeAllLevels(); | |
| 71 | ||
| 72 | // ── QR body ─────────────────────────────────────────────────────────────── | |
fc6e1fbf 2026-04-09 Pavel Safronov | 73 | |
| 74 | void (async () => { | |
| 75 | const qrCanvas = document.createElement('canvas'); | |
| 76 | await QRCode.toCanvas(qrCanvas, window.location.href, { | |
| 77 | width: 200, | |
| 78 | margin: 1, | |
| 79 | color: { dark: '#000000', light: '#ffffff' }, | |
| 80 | }); | |
| 81 | app.spawnQRBody(qrCanvas); | |
| 82 | })(); | |
| 83 | ||
775e32f9 2026-04-09 Pavel Safronov | 84 | // ── Reset button ────────────────────────────────────────────────────────── |
| 85 | ||
| 86 | const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; | |
| 87 | resetBtn.addEventListener('click', () => app.reset()); | |
| 88 | ||
| 89 | // ── Level dot indicator ─────────────────────────────────────────────────── | |
| 90 | ||
| 91 | const dotsEl = document.getElementById('level-dots') as HTMLDivElement; | |
| 92 | let dotsTimeout = 0; | |
| 93 | ||
| 94 | function updateDots(index: number, total: number): void { | |
| 95 | dotsEl.innerHTML = ''; | |
| 96 | for (let i = 0; i < total; i++) { | |
| 97 | const dot = document.createElement('span'); | |
| 98 | dot.className = 'dot' + (i === index ? ' active' : ''); | |
| 99 | dotsEl.appendChild(dot); | |
| 100 | } | |
| 101 | dotsEl.classList.add('visible'); | |
| 102 | clearTimeout(dotsTimeout); | |
| 103 | dotsTimeout = window.setTimeout(() => dotsEl.classList.remove('visible'), 2000); | |
| 104 | } | |
| 105 | ||
| 106 | app.onLevelChange = (index, total) => updateDots(index, total); | |
| 107 | ||
| 108 | // ── Touch: tap to drop, swipe to switch level ───────────────────────────── | |
| 109 | ||
| 110 | let pointerStartX = 0; | |
| 111 | let pointerStartY = 0; | |
| 112 | let pointerMoved = false; | |
| 113 | ||
| 114 | canvas.addEventListener('pointerdown', async (e: PointerEvent) => { | |
| 115 | pointerStartX = e.clientX; | |
| 116 | pointerStartY = e.clientY; | |
| 117 | pointerMoved = false; | |
| 118 | await app.requestPermissionOnce(); | |
| 119 | }); | |
| 120 | ||
| 121 | canvas.addEventListener('pointermove', (e: PointerEvent) => { | |
| 122 | if (Math.abs(e.clientX - pointerStartX) > 8 || Math.abs(e.clientY - pointerStartY) > 8) { | |
| 123 | pointerMoved = true; | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | canvas.addEventListener('pointerup', (e: PointerEvent) => { | |
| 128 | const dx = e.clientX - pointerStartX; | |
| 129 | const dy = e.clientY - pointerStartY; | |
| 130 | const dist = Math.sqrt(dx * dx + dy * dy); | |
| 131 | ||
| 132 | if (dist > 40 && Math.abs(dx) > Math.abs(dy) * 1.5) { | |
| 133 | // Horizontal swipe | |
| 134 | if (dx < 0) app.nextLevel(); else app.prevLevel(); | |
| 135 | updateDots(app.levelIndex, app.levelCount); | |
| 136 | } else if (!pointerMoved || dist < 12) { | |
| 137 | // Tap | |
| 138 | app.touch(e.clientX, e.clientY); | |
| 139 | } | |
| 140 | pointerMoved = false; | |
| 141 | }); | |
| 142 | ||
| 143 | // ── Start button ────────────────────────────────────────────────────────── | |
| 144 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 145 | startBtn.addEventListener('click', async () => { |
| 146 | await enterFullscreen(); | |
| 147 | startScreen.classList.add('hidden'); | |
775e32f9 2026-04-09 Pavel Safronov | 148 | updateDots(app.levelIndex, app.levelCount); |
fc6e1fbf 2026-04-09 Pavel Safronov | 149 | app.start(); |
| 150 | }, { once: true }); | |
| 151 | ||
775e32f9 2026-04-09 Pavel Safronov | 152 |