bubbles / files / src / main.ts
main.ts
| 1 | import { App } from './App.js'; |
| 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'; |
| 6 | import QRCode from 'qrcode'; |
| 7 | |
| 8 | const canvas = document.getElementById('canvas') as HTMLCanvasElement | null; |
| 9 | const startScreen = document.getElementById('start-screen') as HTMLDivElement; |
| 10 | const startBtn = document.getElementById('start-btn') as HTMLButtonElement; |
| 11 | if (!canvas) throw new Error('No canvas element found'); |
| 12 | |
| 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 | |
| 47 | // ── App + levels ────────────────────────────────────────────────────────── |
| 48 | |
| 49 | const app = new App(canvas); |
| 50 | |
| 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 ─────────────────────────────────────────────────────────────── |
| 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 | |
| 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 | |
| 145 | startBtn.addEventListener('click', async () => { |
| 146 | await enterFullscreen(); |
| 147 | startScreen.classList.add('hidden'); |
| 148 | updateDots(app.levelIndex, app.levelCount); |
| 149 | app.start(); |
| 150 | }, { once: true }); |
| 151 | |
| 152 |