create-playcanvas 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ // The game, and the file to replace to make this starter your own (see "Make it your own" in
2
+ // README.md). Only the entry file uses it, to attach `DodgeGame` and drive the HUD from the
3
+ // `game:state` app event and `CARDS`. lens.ts, lens.css, vite.config.ts and scripts/ don't depend
4
+ // on the game, so keep them
5
+ import type { CameraComponent } from 'playcanvas';
6
+ import { Color, Entity, Script, StandardMaterial, math } from 'playcanvas';
7
+
8
+ /** Where a run is: waiting for the first tap, in play, held while the Lens is hidden, or crashed. */
9
+ export type GameState = 'ready' | 'playing' | 'paused' | 'over';
10
+
11
+ /** The app event the game fires whenever its state or score changes, for the HUD to follow. */
12
+ export type GameEvents = {
13
+ 'game:state': (state: GameState, score: number) => void;
14
+ };
15
+
16
+ /** What the HUD card says in each state. `null` hides the card. */
17
+ export const CARDS: Record<GameState, { title: string; hint: string } | null> = {
18
+ ready: { title: 'Dodge', hint: 'Drag to steer. Tap to play' },
19
+ playing: null,
20
+ paused: { title: 'Paused', hint: 'Tap to resume' },
21
+ over: { title: 'Game over', hint: 'Tap to play again' }
22
+ };
23
+
24
+ const PLAYER_RADIUS = 0.3;
25
+ const BLOCK_HEIGHT = 0.45;
26
+ const BLOCK_DEPTH = 0.6;
27
+ const BLOCKS = 16;
28
+ const STARS = 24;
29
+ const STAR_DEPTH = -2;
30
+
31
+ // how many field widths the player travels for a drag across the whole canvas
32
+ const DRAG_SCALE = 1.4;
33
+
34
+ // seconds after a crash before a tap starts a new run, so the drag that crashed doesn't restart
35
+ const RESTART_DELAY = 0.8;
36
+
37
+ const YELLOW = new Color(1, 0.99, 0);
38
+ const BLOCK_COLORS = [new Color(1, 0.36, 0.42), new Color(0.2, 0.75, 1), new Color(0.62, 0.45, 1)];
39
+ const STAR_COLOR = new Color(0.45, 0.55, 1);
40
+
41
+ type Block = { entity: Entity; width: number; counted: boolean };
42
+
43
+ const createMaterial = (color: Color, glow: number) => {
44
+ const material = new StandardMaterial();
45
+ material.diffuse = color;
46
+ material.emissive = new Color(color.r * glow, color.g * glow, color.b * glow);
47
+ material.update();
48
+ return material;
49
+ };
50
+
51
+ /**
52
+ * A one-thumb dodge game: drag to steer the ball around the falling blocks. It renders the field
53
+ * at z = 0 for a camera that looks down -Z from its z position, and talks to the page only
54
+ * through the `game:state` app event.
55
+ */
56
+ class DodgeGame extends Script {
57
+ static scriptName = 'dodgeGame';
58
+
59
+ state: GameState = 'ready';
60
+ score = 0;
61
+
62
+ elapsed = 0;
63
+ spawnIn = 0;
64
+ overFor = 0;
65
+
66
+ // half the visible field at z = 0, refreshed every frame from the camera
67
+ halfWidth = 2.5;
68
+ halfHeight = 4.4;
69
+
70
+ playerX = 0;
71
+ pointerId = -1;
72
+ pointerX = 0;
73
+
74
+ camera: CameraComponent | null = null;
75
+ player!: Entity;
76
+ blocks: Block[] = [];
77
+ stars: Entity[] = [];
78
+
79
+ initialize() {
80
+ const blockMaterials = BLOCK_COLORS.map((color) => createMaterial(color, 0.3));
81
+ const playerMaterial = createMaterial(YELLOW, 0.5);
82
+ const starMaterial = createMaterial(STAR_COLOR, 1);
83
+
84
+ this.player = new Entity('player');
85
+ this.player.addComponent('render', { type: 'sphere', material: playerMaterial });
86
+ this.player.setLocalScale(PLAYER_RADIUS * 2, PLAYER_RADIUS * 2, PLAYER_RADIUS * 2);
87
+ this.entity.addChild(this.player);
88
+
89
+ // a fixed pool of blocks, recycled as they fall off the bottom
90
+ for (let i = 0; i < BLOCKS; i++) {
91
+ const entity = new Entity(`block-${i}`);
92
+ entity.addComponent('render', { type: 'box', material: blockMaterials[i % blockMaterials.length] });
93
+ entity.enabled = false;
94
+ this.entity.addChild(entity);
95
+ this.blocks.push({ entity, width: 1, counted: false });
96
+ }
97
+
98
+ // streaks behind the field that show the speed
99
+ for (let i = 0; i < STARS; i++) {
100
+ const star = new Entity(`star-${i}`);
101
+ star.addComponent('render', { type: 'box', material: starMaterial });
102
+ star.setLocalScale(0.03, math.random(0.2, 0.6), 0.03);
103
+ star.setLocalPosition(math.random(-4, 4), math.random(-7, 7), STAR_DEPTH);
104
+ this.entity.addChild(star);
105
+ this.stars.push(star);
106
+ }
107
+
108
+ const canvas = this.app.graphicsDevice.canvas;
109
+ canvas.addEventListener('pointerdown', this.onPointerDown);
110
+ canvas.addEventListener('pointermove', this.onPointerMove);
111
+ canvas.addEventListener('pointerup', this.onPointerUp);
112
+ canvas.addEventListener('pointercancel', this.onPointerUp);
113
+ canvas.addEventListener('contextmenu', this.onContextMenu);
114
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
115
+
116
+ this.on('destroy', () => {
117
+ canvas.removeEventListener('pointerdown', this.onPointerDown);
118
+ canvas.removeEventListener('pointermove', this.onPointerMove);
119
+ canvas.removeEventListener('pointerup', this.onPointerUp);
120
+ canvas.removeEventListener('pointercancel', this.onPointerUp);
121
+ canvas.removeEventListener('contextmenu', this.onContextMenu);
122
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
123
+ for (const material of [...blockMaterials, playerMaterial, starMaterial]) material.destroy();
124
+ });
125
+
126
+ this.setState('ready');
127
+ }
128
+
129
+ onPointerDown = (event: PointerEvent) => {
130
+ this.pointerId = event.pointerId;
131
+ this.pointerX = event.clientX;
132
+ this.app.graphicsDevice.canvas.setPointerCapture(event.pointerId);
133
+
134
+ if (this.state === 'paused') {
135
+ this.setState('playing');
136
+ } else if (this.state === 'ready' || (this.state === 'over' && this.overFor > RESTART_DELAY)) {
137
+ this.restart();
138
+ }
139
+ };
140
+
141
+ onPointerMove = (event: PointerEvent) => {
142
+ if (event.pointerId !== this.pointerId) return;
143
+
144
+ // the drag is relative, so the thumb never has to cover the ball, and normalized by the
145
+ // canvas width, so a swipe moves the ball the same distance on every phone
146
+ if (this.state === 'playing') {
147
+ const width = this.app.graphicsDevice.canvas.clientWidth;
148
+ this.playerX += ((event.clientX - this.pointerX) / width) * this.halfWidth * 2 * DRAG_SCALE;
149
+ }
150
+ this.pointerX = event.clientX;
151
+ };
152
+
153
+ onPointerUp = (event: PointerEvent) => {
154
+ if (event.pointerId === this.pointerId) this.pointerId = -1;
155
+ };
156
+
157
+ // a long press would otherwise open the context menu over the game
158
+ onContextMenu = (event: Event) => event.preventDefault();
159
+
160
+ // Snapchat can hide the Lens without closing it, so hold the run until the player taps back in
161
+ onVisibilityChange = () => {
162
+ if (document.hidden && this.state === 'playing') this.setState('paused');
163
+ };
164
+
165
+ get playerY() {
166
+ return -this.halfHeight * 0.7;
167
+ }
168
+
169
+ restart() {
170
+ for (const block of this.blocks) block.entity.enabled = false;
171
+ this.score = 0;
172
+ this.elapsed = 0;
173
+ this.spawnIn = 0;
174
+ this.playerX = 0;
175
+ this.setState('playing');
176
+ }
177
+
178
+ crash() {
179
+ this.overFor = 0;
180
+ this.setState('over');
181
+
182
+ // only Android vibrates, and only once the player has interacted, which a crash always follows
183
+ if ('vibrate' in navigator && navigator.userActivation.hasBeenActive) navigator.vibrate(80);
184
+ }
185
+
186
+ setState(state: GameState) {
187
+ this.state = state;
188
+ this.app.fire('game:state', state, this.score);
189
+ }
190
+
191
+ // fit the field to the camera: it fixes the width, so a taller phone sees further up and down
192
+ fitField() {
193
+ this.camera ??= this.app.root.findComponent('camera') as CameraComponent | null;
194
+ const { width, height } = this.app.graphicsDevice;
195
+ if (!this.camera || !width || !height) return;
196
+
197
+ const half = this.camera.entity.getPosition().z * Math.tan(this.camera.fov * 0.5 * math.DEG_TO_RAD);
198
+ const aspect = width / height;
199
+ this.halfWidth = this.camera.horizontalFov ? half : half * aspect;
200
+ this.halfHeight = this.camera.horizontalFov ? half / aspect : half;
201
+ }
202
+
203
+ spawn() {
204
+ const block = this.blocks.find((b) => !b.entity.enabled);
205
+ if (!block) return;
206
+
207
+ block.width = math.random(0.8, Math.min(1.8, this.halfWidth));
208
+ block.counted = false;
209
+ const room = this.halfWidth - block.width / 2;
210
+ block.entity.setLocalScale(block.width, BLOCK_HEIGHT, BLOCK_DEPTH);
211
+ block.entity.setLocalPosition(math.random(-room, room), this.halfHeight + BLOCK_HEIGHT, 0);
212
+ block.entity.enabled = true;
213
+ }
214
+
215
+ update(dt: number) {
216
+ this.fitField();
217
+
218
+ // blocks fall faster the longer a run lasts
219
+ const speed = Math.min(4 + this.elapsed * 0.12, 9);
220
+
221
+ // the stars sit behind the field, where the camera sees more, so they wrap beyond its edges
222
+ const top = this.halfHeight * 1.5;
223
+ const drift = (this.state === 'playing' ? speed * 0.5 : 0.8) * dt;
224
+ for (const star of this.stars) {
225
+ const { x, y } = star.getLocalPosition();
226
+ star.setLocalPosition(x, y - drift < -top ? y - drift + top * 2 : y - drift, STAR_DEPTH);
227
+ }
228
+
229
+ const limit = this.halfWidth - PLAYER_RADIUS;
230
+ this.playerX = math.clamp(this.playerX, -limit, limit);
231
+ const playerY = this.playerY;
232
+ this.player.setLocalPosition(this.playerX, playerY, 0);
233
+
234
+ if (this.state === 'over') this.overFor += dt;
235
+ if (this.state !== 'playing') return;
236
+
237
+ // and come more often
238
+ this.elapsed += dt;
239
+ this.spawnIn -= dt;
240
+ if (this.spawnIn <= 0) {
241
+ this.spawn();
242
+ this.spawnIn = Math.max(0.9 - this.elapsed * 0.012, 0.38);
243
+ }
244
+
245
+ for (const block of this.blocks) {
246
+ if (!block.entity.enabled) continue;
247
+
248
+ const { x, y: from } = block.entity.getLocalPosition();
249
+ const y = from - speed * dt;
250
+ block.entity.setLocalPosition(x, y, 0);
251
+
252
+ // circle against box: the distance from the ball's center to the nearest point of the block
253
+ const dx = Math.max(Math.abs(this.playerX - x) - block.width / 2, 0);
254
+ const dy = Math.max(Math.abs(playerY - y) - BLOCK_HEIGHT / 2, 0);
255
+ if (dx * dx + dy * dy < PLAYER_RADIUS * PLAYER_RADIUS) {
256
+ this.crash();
257
+ return;
258
+ }
259
+
260
+ if (!block.counted && y < playerY - BLOCK_HEIGHT) {
261
+ block.counted = true;
262
+ this.score++;
263
+ this.setState('playing');
264
+ }
265
+
266
+ if (y < -this.halfHeight - BLOCK_HEIGHT) block.entity.enabled = false;
267
+ }
268
+ }
269
+ }
270
+
271
+ export { DodgeGame };
@@ -0,0 +1,155 @@
1
+ :root {
2
+ color-scheme: dark;
3
+
4
+ /* Snapchat draws its own controls over the top of a Lens, so its top inset is much larger than a
5
+ notch. viewport-fit=cover in index.html makes these report real values */
6
+ --inset-top: env(safe-area-inset-top, 0px);
7
+ --inset-right: env(safe-area-inset-right, 0px);
8
+ --inset-bottom: env(safe-area-inset-bottom, 0px);
9
+ --inset-left: env(safe-area-inset-left, 0px);
10
+ }
11
+
12
+ /* development only (see lens.ts): reserve the room Snapchat's controls take */
13
+ :root.simulate-insets {
14
+ --inset-top: max(env(safe-area-inset-top, 0px), 120px);
15
+ }
16
+
17
+ [hidden] {
18
+ display: none !important;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ height: 100%;
24
+ margin: 0;
25
+ overflow: hidden;
26
+ overscroll-behavior: none;
27
+ touch-action: none;
28
+ -webkit-tap-highlight-color: transparent;
29
+ -webkit-touch-callout: none;
30
+ user-select: none;
31
+
32
+ /* the scene's clear color, so nothing flashes while the game loads */
33
+ background: #0b1020;
34
+ color: #fff;
35
+ font-family: ui-rounded, 'SF Pro Rounded', system-ui, sans-serif;
36
+ }
37
+
38
+ /* the whole screen on a phone, and a centered 9:16 column on anything wider, such as a desktop browser */
39
+ .lens {
40
+ position: relative;
41
+ width: min(100vw, 100dvh * 9 / 16);
42
+ height: 100dvh;
43
+ margin: 0 auto;
44
+ overflow: hidden;
45
+ }
46
+
47
+ .lens canvas,
48
+ .lens pc-app {
49
+ display: block;
50
+ width: 100%;
51
+ height: 100%;
52
+ }
53
+
54
+ .simulate-insets .lens::before {
55
+ content: 'Snapchat controls';
56
+ position: absolute;
57
+ inset: 0 0 auto;
58
+ z-index: 2;
59
+ display: flex;
60
+ align-items: flex-end;
61
+ justify-content: center;
62
+ height: var(--inset-top);
63
+ padding-bottom: 6px;
64
+ box-sizing: border-box;
65
+ border-bottom: 1px dashed #ffffff40;
66
+ background: repeating-linear-gradient(-45deg, #ffffff0d 0 8px, transparent 8px 16px);
67
+ color: #ffffff59;
68
+ font-size: 11px;
69
+ pointer-events: none;
70
+ }
71
+
72
+ /* taps pass through the HUD to the canvas, apart from its buttons */
73
+ .lens-hud {
74
+ position: absolute;
75
+ inset: 0;
76
+ z-index: 1;
77
+ pointer-events: none;
78
+ }
79
+
80
+ /* insets go on each positioned element itself: padding on a parent wouldn't move it */
81
+ .lens-bar {
82
+ position: absolute;
83
+ top: calc(var(--inset-top) + 12px);
84
+ right: calc(var(--inset-right) + 16px);
85
+ left: calc(var(--inset-left) + 16px);
86
+ display: grid;
87
+ grid-template-columns: 1fr auto 1fr;
88
+ align-items: center;
89
+ }
90
+
91
+ .lens-avatar {
92
+ grid-column: 1;
93
+ width: 44px;
94
+ height: 44px;
95
+ border-radius: 50%;
96
+ background: #ffffff1f;
97
+ object-fit: cover;
98
+ }
99
+
100
+ .lens-score {
101
+ grid-column: 2;
102
+ font-size: 48px;
103
+ font-variant-numeric: tabular-nums;
104
+ font-weight: 800;
105
+ text-shadow: 0 2px 12px #0008;
106
+ }
107
+
108
+ .lens-best {
109
+ grid-column: 3;
110
+ justify-self: end;
111
+ color: #ffffffb3;
112
+ font-size: 14px;
113
+ font-weight: 700;
114
+ letter-spacing: 0.06em;
115
+ text-transform: uppercase;
116
+ }
117
+
118
+ .lens-card {
119
+ position: absolute;
120
+ top: 50%;
121
+ left: 50%;
122
+ width: min(300px, calc(100% - 48px));
123
+ box-sizing: border-box;
124
+ padding: 24px;
125
+ border-radius: 24px;
126
+ background: #0b1020cc;
127
+ backdrop-filter: blur(10px);
128
+ text-align: center;
129
+ translate: -50% -50%;
130
+ }
131
+
132
+ .lens-title {
133
+ margin: 0;
134
+ font-size: 32px;
135
+ font-weight: 800;
136
+ }
137
+
138
+ .lens-leaderboard {
139
+ margin-top: 18px;
140
+ padding: 12px 22px;
141
+ border: 0;
142
+ border-radius: 999px;
143
+ background: #fffc00;
144
+ color: #000;
145
+ font: inherit;
146
+ font-size: 16px;
147
+ font-weight: 800;
148
+ pointer-events: auto;
149
+ }
150
+
151
+ .lens-hint {
152
+ margin: 14px 0 0;
153
+ color: #ffffffb3;
154
+ font-size: 16px;
155
+ }
@@ -0,0 +1,91 @@
1
+ import snapchat from '@snap/web-lens-api';
2
+
3
+ // Everything the game asks of Snapchat goes through here. Outside Snapchat, the Web Lens API
4
+ // answers from a built-in stub with canned data, so the game runs in any browser. Inside it, any
5
+ // call can still fail, so each one falls back to playing on without the feature
6
+
7
+ const BEST_KEY = 'best';
8
+
9
+ let ready: Promise<void> | undefined;
10
+ let best: Promise<number> | undefined;
11
+ let avatar: Promise<string | undefined> | undefined;
12
+ let saving = Promise.resolve();
13
+ let submitting = Promise.resolve();
14
+
15
+ // set once the first read succeeds. If it fails, saving stays off for the session rather than
16
+ // writing over a best score it couldn't read
17
+ let loaded = false;
18
+
19
+ const warn = (feature: string) => (err: Error) => console.warn(`Web Lens ${feature} unavailable:`, err.message);
20
+
21
+ // phone browsers report only their own notch, so in development reserve the room Snapchat's own
22
+ // controls take at the top of the Lens (see lens.css)
23
+ if (import.meta.env.DEV) document.documentElement.classList.add('simulate-insets');
24
+
25
+ /** Discovers what this host serves. Every other call here waits for it. */
26
+ export const initLens = () => (ready ??= snapchat.init().then(() => undefined, warn('API')));
27
+
28
+ /** Whether Snapchat can show this Lens's leaderboard. Valid once {@link initLens} resolves. */
29
+ export const hasLeaderboard = () => snapchat.isSupported('leaderboards');
30
+
31
+ /**
32
+ * Resolves the player's best score. Snapchat storage keeps it between launches, where
33
+ * `localStorage` starts empty every time the Lens opens.
34
+ */
35
+ export const loadBest = () =>
36
+ (best ??= initLens().then(async () => {
37
+ if (!snapchat.isSupported('storage')) return 0;
38
+
39
+ try {
40
+ // the first read opens the store, which can take seconds on a cold start
41
+ const result = await snapchat.storage.get(BEST_KEY);
42
+ loaded = true;
43
+ return result.found && typeof result.value === 'number' ? result.value : 0;
44
+ } catch (err) {
45
+ warn('storage')(err as Error);
46
+ return 0;
47
+ }
48
+ }));
49
+
50
+ /** Submits a finished run to the leaderboard and keeps the player's best. Resolves the best score. */
51
+ export const recordScore = (score: number) => {
52
+ submitting = initLens().then(() => {
53
+ if (!snapchat.isSupported('leaderboards')) return;
54
+ return snapchat.leaderboards
55
+ .submitScore({ score: Math.round(score) })
56
+ .then(() => undefined, warn('leaderboard'));
57
+ });
58
+
59
+ // each run waits for the first read and for the runs before it, so two runs that end while the
60
+ // read is still loading can't both compare against the same old best
61
+ best = loadBest().then((previous) => {
62
+ if (score <= previous) return previous;
63
+
64
+ // keep one write in flight at a time, and the chain going after a failed save
65
+ if (loaded) saving = saving.then(() => snapchat.storage.set(BEST_KEY, score)).catch(warn('storage'));
66
+ return score;
67
+ });
68
+ return best;
69
+ };
70
+
71
+ /** Opens Snapchat's leaderboard panel, once the latest score has reached it. */
72
+ export const showLeaderboard = async () => {
73
+ await submitting;
74
+ await snapchat.leaderboards.show().catch(warn('leaderboard'));
75
+ };
76
+
77
+ /** Resolves a URL for the player's Bitmoji portrait, or `undefined` if there is none to show. */
78
+ export const loadAvatar = () =>
79
+ (avatar ??= initLens().then(async () => {
80
+ // the desktop stub's avatar is an empty placeholder, so show none there
81
+ if (!snapchat.isSupported('bitmoji') || snapchat.isStub) return undefined;
82
+
83
+ try {
84
+ const { hasAvatar } = await snapchat.bitmoji.getAvatarInfo();
85
+ // the URL expires after about five minutes, so show it as soon as it resolves
86
+ return hasAvatar ? (await snapchat.bitmoji.get2DAvatar()).url : undefined;
87
+ } catch (err) {
88
+ warn('Bitmoji')(err as Error);
89
+ return undefined;
90
+ }
91
+ }));
@@ -15,15 +15,15 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "playcanvas": "^2.23.1"
18
+ "playcanvas": "^2.23.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@playcanvas/eslint-config": "3.0.0-beta.8",
22
- "@playcanvas/inspector": "^0.2.0",
22
+ "@playcanvas/inspector": "^0.3.0",
23
23
  "eslint": "^9.39.5",
24
- "prettier": "^3.9.9",
24
+ "prettier": "^3.9.10",
25
25
  "typescript": "~6.0.3",
26
- "vite": "^8.3.4"
26
+ "vite": "^8.3.5"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=22.23.2"
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>Dodge</title>
7
+ </head>
8
+ <body>
9
+ <main class="lens">
10
+ <canvas id="application-canvas"></canvas>
11
+ <div class="lens-hud">
12
+ <header class="lens-bar">
13
+ <img class="lens-avatar" alt="Your Bitmoji" hidden />
14
+ <span class="lens-score">0</span>
15
+ <span class="lens-best">Best 0</span>
16
+ </header>
17
+ <section class="lens-card">
18
+ <h1 class="lens-title">Dodge</h1>
19
+ <button class="lens-leaderboard" hidden>Leaderboard</button>
20
+ <p class="lens-hint">Drag to steer. Tap to play</p>
21
+ </section>
22
+ </div>
23
+ </main>
24
+ <script type="module" src="/src/main.ts"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,89 @@
1
+ import {
2
+ AppBase,
3
+ AppOptions,
4
+ CameraComponentSystem,
5
+ Color,
6
+ Entity,
7
+ FILLMODE_NONE,
8
+ LightComponentSystem,
9
+ RESOLUTION_AUTO,
10
+ RenderComponentSystem,
11
+ ScriptComponentSystem,
12
+ createGraphicsDevice
13
+ } from 'playcanvas';
14
+
15
+ import { CARDS, DodgeGame } from './dodge-game';
16
+ import type { GameState } from './dodge-game';
17
+ import { hasLeaderboard, loadAvatar, loadBest, recordScore, showLeaderboard } from './lens';
18
+ import './lens.css';
19
+
20
+ const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
21
+ const device = await createGraphicsDevice(canvas);
22
+ device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
23
+
24
+ const options = new AppOptions();
25
+ options.graphicsDevice = device;
26
+ options.componentSystems = [RenderComponentSystem, CameraComponentSystem, LightComponentSystem, ScriptComponentSystem];
27
+
28
+ const app = new AppBase(canvas);
29
+ app.init(options);
30
+ app.start();
31
+
32
+ // lens.css sizes the canvas, filling a phone screen and centering a 9:16 column anywhere wider,
33
+ // and the drawing buffer follows its size every frame
34
+ app.setCanvasFillMode(FILLMODE_NONE);
35
+ app.setCanvasResolution(RESOLUTION_AUTO);
36
+
37
+ const camera = new Entity('camera');
38
+ camera.addComponent('camera', {
39
+ clearColor: new Color().fromString('#0b1020'),
40
+ fov: 45,
41
+ // fix the field's width, so a taller phone shows more of the falling blocks
42
+ horizontalFov: true
43
+ });
44
+ camera.setPosition(0, 0, 7);
45
+ app.root.addChild(camera);
46
+
47
+ const light = new Entity('light');
48
+ light.addComponent('light', { type: 'directional', intensity: 1.5 });
49
+ light.setEulerAngles(40, 30, 0);
50
+ app.root.addChild(light);
51
+
52
+ // the HUD follows the game's state, and the Lens records each finished run
53
+ const hud = {
54
+ avatar: document.querySelector<HTMLImageElement>('.lens-avatar')!,
55
+ score: document.querySelector('.lens-score')!,
56
+ best: document.querySelector('.lens-best')!,
57
+ card: document.querySelector<HTMLElement>('.lens-card')!,
58
+ title: document.querySelector('.lens-title')!,
59
+ hint: document.querySelector('.lens-hint')!,
60
+ leaderboard: document.querySelector<HTMLButtonElement>('.lens-leaderboard')!
61
+ };
62
+ const showBest = (best: number) => {
63
+ hud.best.textContent = `Best ${best}`;
64
+ };
65
+
66
+ app.on('game:state', (state: GameState, score: number) => {
67
+ const card = CARDS[state];
68
+ hud.score.textContent = String(score);
69
+ hud.card.hidden = !card;
70
+ if (card) {
71
+ hud.title.textContent = card.title;
72
+ hud.hint.textContent = card.hint;
73
+ }
74
+ hud.leaderboard.hidden = state !== 'over' || !hasLeaderboard();
75
+ if (state === 'over') recordScore(score).then(showBest);
76
+ });
77
+ hud.leaderboard.addEventListener('click', showLeaderboard);
78
+
79
+ loadBest().then(showBest);
80
+ loadAvatar().then((url) => {
81
+ if (!url) return;
82
+ hud.avatar.src = url;
83
+ hud.avatar.hidden = false;
84
+ });
85
+
86
+ const game = new Entity('game');
87
+ game.addComponent('script');
88
+ game.script!.create(DodgeGame);
89
+ app.root.addChild(game);