bubbles / files / src / App.ts
App.ts
| 1 | import { ILevel } from './levels/ILevel.js'; |
| 2 | import { WaterLevel } from './levels/WaterLevel.js'; |
| 3 | import { Vector2 } from './physics/Vector2.js'; |
| 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; |
| 10 | private readonly levels: ILevel[]; |
| 11 | private _levelIndex = 0; |
| 12 | |
| 13 | private rafId = 0; |
| 14 | private lastTime = 0; |
| 15 | private accumulator = 0; |
| 16 | private permissionRequested = false; |
| 17 | |
| 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 | |
| 30 | constructor(canvas: HTMLCanvasElement) { |
| 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; |
| 35 | |
| 36 | this.computeGlassDims(); |
| 37 | |
| 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]; |
| 44 | |
| 45 | this.tilt = new TiltInput(canvas); |
| 46 | |
| 47 | window.addEventListener('resize', this.handleResize); |
| 48 | this.handleResize(); |
| 49 | } |
| 50 | |
| 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. */ |
| 75 | spawnQRBody(qrTexture: HTMLCanvasElement): void { |
| 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(); |
| 95 | } |
| 96 | |
| 97 | start(): void { |
| 98 | this.lastTime = performance.now(); |
| 99 | this.rafId = requestAnimationFrame(this.loop); |
| 100 | } |
| 101 | |
| 102 | reset(): void { |
| 103 | this.currentLevel.reset(); |
| 104 | } |
| 105 | |
| 106 | stop(): void { |
| 107 | cancelAnimationFrame(this.rafId); |
| 108 | this.tilt.destroy(); |
| 109 | window.removeEventListener('resize', this.handleResize); |
| 110 | } |
| 111 | |
| 112 | // ── Private ────────────────────────────────────────────────────────────── |
| 113 | |
| 114 | private readonly loop = (now: number): void => { |
| 115 | const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); |
| 116 | this.lastTime = now; |
| 117 | this.accumulator += rawDt; |
| 118 | |
| 119 | const gravity = this.tilt.gravity; |
| 120 | |
| 121 | while (this.accumulator >= FIXED_STEP) { |
| 122 | this.currentLevel.step(FIXED_STEP, gravity); |
| 123 | this.accumulator -= FIXED_STEP; |
| 124 | } |
| 125 | |
| 126 | this.renderFrame(rawDt); |
| 127 | this.rafId = requestAnimationFrame(this.loop); |
| 128 | }; |
| 129 | |
| 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; |
| 151 | } |
| 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 | }; |
| 173 | } |
| 174 |