fuzzyworld

bubbles / files / src / App.ts

App.ts

5.9 KB · history · blame

1import { ILevel } from './levels/ILevel.js';
2import { WaterLevel } from './levels/WaterLevel.js';
3import { Vector2 } from './physics/Vector2.js';
4import { TiltInput } from './input/TiltInput.js';
5
6const FIXED_STEP = 1 / 60; // 60 Hz physics
7
8export 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