bubbles / files / src / App.ts
App.ts
775e32f9 2026-04-09 Pavel Safronov | 1 | import { ILevel } from './levels/ILevel.js'; |
| 2 | import { WaterLevel } from './levels/WaterLevel.js'; | |
fc6e1fbf 2026-04-09 Pavel Safronov | 3 | import { Vector2 } from './physics/Vector2.js'; |
dcc4757f 2026-04-09 Pavel Safronov | 4 | import { TiltInput } from './input/TiltInput.js'; |
| 5 | ||
| 6 | const FIXED_STEP = 1 / 60; // 60 Hz physics | |
| 7 | ||
| 8 | export class App { | |
| 9 | private readonly tilt: TiltInput; | |
775e32f9 2026-04-09 Pavel Safronov | 10 | private readonly levels: ILevel[]; |
| 11 | private _levelIndex = 0; | |
dcc4757f 2026-04-09 Pavel Safronov | 12 | |
| 13 | private rafId = 0; | |
| 14 | private lastTime = 0; | |
| 15 | private accumulator = 0; | |
| 16 | private permissionRequested = false; | |
| 17 | ||
775e32f9 2026-04-09 Pavel Safronov | 18 | readonly canvas: HTMLCanvasElement; |
| 19 | private ctx: CanvasRenderingContext2D; | |
| 20 | ||
| 21 | // Geometry — kept in sync with resize events | |
| 22 | private glassX = 0; | |
| 23 | private glassY = 0; | |
| 24 | private glassW = 0; | |
| 25 | private glassH = 0; | |
| 26 | ||
| 27 | /** Callback fired when the level name changes (for UI dot updates). */ | |
| 28 | onLevelChange: ((index: number, total: number, name: string) => void) | undefined; | |
| 29 | ||
dcc4757f 2026-04-09 Pavel Safronov | 30 | constructor(canvas: HTMLCanvasElement) { |
775e32f9 2026-04-09 Pavel Safronov | 31 | this.canvas = canvas; |
| 32 | const ctx = canvas.getContext('2d'); | |
| 33 | if (!ctx) throw new Error('Could not get 2D context'); | |
| 34 | this.ctx = ctx; | |
dcc4757f 2026-04-09 Pavel Safronov | 35 | |
775e32f9 2026-04-09 Pavel Safronov | 36 | this.computeGlassDims(); |
dcc4757f 2026-04-09 Pavel Safronov | 37 | |
775e32f9 2026-04-09 Pavel Safronov | 38 | // WaterLevel is the only level constructed immediately — others are lazy |
| 39 | // so they don't allocate until first visited. | |
| 40 | const wl = new WaterLevel( | |
| 41 | this.ctx, this.glassX, this.glassY, this.glassW, this.glassH, | |
| 42 | ); | |
| 43 | this.levels = [wl]; | |
dcc4757f 2026-04-09 Pavel Safronov | 44 | |
| 45 | this.tilt = new TiltInput(canvas); | |
775e32f9 2026-04-09 Pavel Safronov | 46 | |
| 47 | window.addEventListener('resize', this.handleResize); | |
| 48 | this.handleResize(); | |
dcc4757f 2026-04-09 Pavel Safronov | 49 | } |
| 50 | ||
775e32f9 2026-04-09 Pavel Safronov | 51 | // ── Public API ─────────────────────────────────────────────────────────── |
| 52 | ||
| 53 | get currentLevel(): ILevel { return this.levels[this._levelIndex]!; } | |
| 54 | get levelCount(): number { return this.levels.length; } | |
| 55 | get levelIndex(): number { return this._levelIndex; } | |
| 56 | ||
| 57 | /** Register all levels (called once after construction with lazy factories). */ | |
| 58 | setLevels(levels: ILevel[]): void { | |
| 59 | (this.levels as ILevel[]).length = 0; | |
| 60 | for (const l of levels) (this.levels as ILevel[]).push(l); | |
| 61 | this._levelIndex = Math.min(this._levelIndex, this.levels.length - 1); | |
| 62 | } | |
| 63 | ||
| 64 | nextLevel(): void { | |
| 65 | this._levelIndex = (this._levelIndex + 1) % this.levels.length; | |
| 66 | this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name); | |
| 67 | } | |
| 68 | ||
| 69 | prevLevel(): void { | |
| 70 | this._levelIndex = (this._levelIndex - 1 + this.levels.length) % this.levels.length; | |
| 71 | this.onLevelChange?.(this._levelIndex, this.levels.length, this.currentLevel.name); | |
| 72 | } | |
| 73 | ||
| 74 | /** Called after the QR canvas has been generated. */ | |
fc6e1fbf 2026-04-09 Pavel Safronov | 75 | spawnQRBody(qrTexture: HTMLCanvasElement): void { |
775e32f9 2026-04-09 Pavel Safronov | 76 | const wl = this.levels[0]; |
| 77 | if (wl instanceof WaterLevel) wl.spawnQRBody(qrTexture); | |
| 78 | } | |
| 79 | ||
| 80 | /** Deliver a tap at canvas coordinates. Routed to the active level. */ | |
| 81 | touch(clientX: number, clientY: number): void { | |
| 82 | const rect = this.canvas.getBoundingClientRect(); | |
| 83 | const canvasX = clientX - rect.left; | |
| 84 | const canvasY = clientY - rect.top; | |
| 85 | const localX = canvasX - this.glassX; | |
| 86 | const localY = canvasY - this.glassY; | |
| 87 | this.currentLevel.onTouch(localX, localY, this.tilt.gravity); | |
| 88 | } | |
| 89 | ||
| 90 | /** Request iOS device orientation permission (must be in user gesture). */ | |
| 91 | async requestPermissionOnce(): Promise<void> { | |
| 92 | if (this.permissionRequested) return; | |
| 93 | this.permissionRequested = true; | |
| 94 | await this.tilt.requestPermission(); | |
fc6e1fbf 2026-04-09 Pavel Safronov | 95 | } |
| 96 | ||
dcc4757f 2026-04-09 Pavel Safronov | 97 | start(): void { |
| 98 | this.lastTime = performance.now(); | |
| 99 | this.rafId = requestAnimationFrame(this.loop); | |
| 100 | } | |
| 101 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 102 | reset(): void { |
775e32f9 2026-04-09 Pavel Safronov | 103 | this.currentLevel.reset(); |
fc6e1fbf 2026-04-09 Pavel Safronov | 104 | } |
| 105 | ||
dcc4757f 2026-04-09 Pavel Safronov | 106 | stop(): void { |
| 107 | cancelAnimationFrame(this.rafId); | |
| 108 | this.tilt.destroy(); | |
775e32f9 2026-04-09 Pavel Safronov | 109 | window.removeEventListener('resize', this.handleResize); |
dcc4757f 2026-04-09 Pavel Safronov | 110 | } |
| 111 | ||
775e32f9 2026-04-09 Pavel Safronov | 112 | // ── Private ────────────────────────────────────────────────────────────── |
| 113 | ||
dcc4757f 2026-04-09 Pavel Safronov | 114 | private readonly loop = (now: number): void => { |
775e32f9 2026-04-09 Pavel Safronov | 115 | const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); |
dcc4757f 2026-04-09 Pavel Safronov | 116 | this.lastTime = now; |
| 117 | this.accumulator += rawDt; | |
| 118 | ||
775e32f9 2026-04-09 Pavel Safronov | 119 | const gravity = this.tilt.gravity; |
dcc4757f 2026-04-09 Pavel Safronov | 120 | |
| 121 | while (this.accumulator >= FIXED_STEP) { | |
775e32f9 2026-04-09 Pavel Safronov | 122 | this.currentLevel.step(FIXED_STEP, gravity); |
dcc4757f 2026-04-09 Pavel Safronov | 123 | this.accumulator -= FIXED_STEP; |
| 124 | } | |
| 125 | ||
775e32f9 2026-04-09 Pavel Safronov | 126 | this.renderFrame(rawDt); |
dcc4757f 2026-04-09 Pavel Safronov | 127 | this.rafId = requestAnimationFrame(this.loop); |
| 128 | }; | |
| 129 | ||
775e32f9 2026-04-09 Pavel Safronov | 130 | private renderFrame(_dt: number): void { |
| 131 | const dpr = window.devicePixelRatio || 1; | |
| 132 | const W = this.canvas.width / dpr; | |
| 133 | const H = this.canvas.height / dpr; | |
| 134 | ||
| 135 | this.ctx.clearRect(0, 0, W, H); | |
| 136 | this.ctx.fillStyle = '#0f172a'; | |
| 137 | this.ctx.fillRect(0, 0, W, H); | |
| 138 | ||
| 139 | this.currentLevel.draw( | |
| 140 | this.ctx, this.glassX, this.glassY, this.glassW, this.glassH, | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | private computeGlassDims(): void { | |
| 145 | const W = window.innerWidth; | |
| 146 | const H = window.innerHeight; | |
| 147 | this.glassW = Math.min(W * 0.82, 420); | |
| 148 | this.glassH = H * 0.72; | |
| 149 | this.glassX = (W - this.glassW) / 2; | |
| 150 | this.glassY = (H - this.glassH) / 2 - H * 0.02; | |
dcc4757f 2026-04-09 Pavel Safronov | 151 | } |
775e32f9 2026-04-09 Pavel Safronov | 152 | |
| 153 | private readonly handleResize = (): void => { | |
| 154 | const dpr = window.devicePixelRatio || 1; | |
| 155 | const W = window.innerWidth; | |
| 156 | const H = window.innerHeight; | |
| 157 | ||
| 158 | this.canvas.width = W * dpr; | |
| 159 | this.canvas.height = H * dpr; | |
| 160 | this.canvas.style.width = `${W}px`; | |
| 161 | this.canvas.style.height = `${H}px`; | |
| 162 | this.ctx.scale(dpr, dpr); | |
| 163 | ||
| 164 | this.computeGlassDims(); | |
| 165 | ||
| 166 | for (const level of this.levels) { | |
| 167 | if (level instanceof WaterLevel) { | |
| 168 | level.setGlassOrigin(this.glassX, this.glassY); | |
| 169 | } | |
| 170 | level.resize(this.glassW, this.glassH); | |
| 171 | } | |
| 172 | }; | |
dcc4757f 2026-04-09 Pavel Safronov | 173 | } |
775e32f9 2026-04-09 Pavel Safronov | 174 |