create-playcanvas 0.10.1 → 0.11.1

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.
Files changed (102) hide show
  1. package/README.md +26 -3
  2. package/dist/index.mjs +41 -38
  3. package/package.json +6 -3
  4. package/templates/_features/physics/assets/public/ammo/SOURCE.md +4 -1
  5. package/templates/{react/interactive-sphere/src/App.css → _shared/interactive-sphere/src/counter.css} +0 -6
  6. package/templates/_shared/web-lens/README.md +42 -0
  7. package/templates/_shared/web-lens/package.json +9 -0
  8. package/templates/_shared/web-lens/scripts/package-lens.mjs +231 -0
  9. package/templates/_shared/web-lens/src/dodge-game.ts +271 -0
  10. package/templates/_shared/web-lens/src/lens.css +155 -0
  11. package/templates/_shared/web-lens/src/lens.ts +91 -0
  12. package/templates/engine/base/README.md +1 -1
  13. package/templates/engine/base/package.json +4 -4
  14. package/templates/engine/base/vite.config.ts +3 -0
  15. package/templates/engine/first-person-controller/src/main.ts +2 -3
  16. package/templates/engine/interactive-sphere/src/App.ts +1 -1
  17. package/templates/engine/interactive-sphere/src/main.ts +3 -5
  18. package/templates/engine/model-viewer/src/main.ts +3 -3
  19. package/templates/engine/physics-playground/src/main.ts +2 -3
  20. package/templates/engine/spinning-cube/src/main.ts +1 -1
  21. package/templates/engine/splat-viewer/src/main.ts +1 -1
  22. package/templates/engine/third-person-controller/src/main.ts +2 -3
  23. package/templates/engine/web-lens/index.html +26 -0
  24. package/templates/engine/web-lens/src/main.ts +89 -0
  25. package/templates/engine/web-lens/vite.config.ts +44 -0
  26. package/templates/index.ts +6 -0
  27. package/templates/react/base/README.md +1 -1
  28. package/templates/react/base/package.json +6 -6
  29. package/templates/react/base/vite.config.ts +3 -0
  30. package/templates/react/interactive-sphere/src/App.tsx +5 -6
  31. package/templates/react/interactive-sphere/src/Scene.tsx +1 -1
  32. package/templates/react/model-viewer/src/App.tsx +4 -2
  33. package/templates/react/spinning-cube/src/App.tsx +2 -0
  34. package/templates/react/splat-viewer/src/App.tsx +2 -0
  35. package/templates/react/{splat-viewer → web-lens}/index.html +2 -3
  36. package/templates/react/web-lens/src/App.tsx +85 -0
  37. package/templates/react/web-lens/vite.config.ts +46 -0
  38. package/templates/web-components/base/README.md +1 -1
  39. package/templates/web-components/base/package.json +4 -4
  40. package/templates/web-components/base/vite.config.ts +3 -0
  41. package/templates/web-components/first-person-controller/index.html +1 -6
  42. package/templates/web-components/interactive-sphere/src/main.ts +2 -2
  43. package/templates/web-components/model-viewer/index.html +2 -2
  44. package/templates/web-components/model-viewer/src/main.ts +0 -1
  45. package/templates/web-components/physics-playground/index.html +1 -6
  46. package/templates/web-components/spinning-cube/src/main.ts +0 -1
  47. package/templates/web-components/splat-viewer/src/main.ts +0 -1
  48. package/templates/web-components/third-person-controller/index.html +1 -6
  49. package/templates/web-components/web-lens/index.html +36 -0
  50. package/templates/web-components/web-lens/src/main.ts +47 -0
  51. package/templates/web-components/web-lens/vite.config.ts +44 -0
  52. package/templates/_features/physics/assets/public/ammo/ammo.js +0 -973
  53. package/templates/engine/interactive-sphere/index.html +0 -13
  54. package/templates/engine/interactive-sphere/src/style.css +0 -28
  55. package/templates/engine/model-viewer/index.html +0 -13
  56. package/templates/engine/model-viewer/src/style.css +0 -8
  57. package/templates/engine/spinning-cube/index.html +0 -13
  58. package/templates/engine/spinning-cube/src/style.css +0 -8
  59. package/templates/engine/splat-viewer/index.html +0 -13
  60. package/templates/engine/splat-viewer/src/style.css +0 -8
  61. package/templates/react/base/.prettierignore +0 -3
  62. package/templates/react/base/_gitignore +0 -24
  63. package/templates/react/base/_npmrc +0 -1
  64. package/templates/react/base/prettier.config.mjs +0 -1
  65. package/templates/react/base/src/assets/playcanvas.png +0 -0
  66. package/templates/react/base/src/inspector.css +0 -33
  67. package/templates/react/base/src/inspector.ts +0 -38
  68. package/templates/react/base/src/starter.css +0 -104
  69. package/templates/react/base/src/vite-env.d.ts +0 -19
  70. package/templates/react/interactive-sphere/index.html +0 -13
  71. package/templates/react/interactive-sphere/src/index.css +0 -4
  72. package/templates/react/interactive-sphere/src/main.tsx +0 -11
  73. package/templates/react/model-viewer/index.html +0 -13
  74. package/templates/react/model-viewer/src/index.css +0 -9
  75. package/templates/react/model-viewer/src/main.tsx +0 -11
  76. package/templates/react/spinning-cube/index.html +0 -13
  77. package/templates/react/spinning-cube/src/index.css +0 -9
  78. package/templates/react/spinning-cube/src/main.tsx +0 -11
  79. package/templates/react/splat-viewer/src/index.css +0 -9
  80. package/templates/react/splat-viewer/src/main.tsx +0 -11
  81. package/templates/web-components/base/.prettierignore +0 -3
  82. package/templates/web-components/base/_gitignore +0 -24
  83. package/templates/web-components/base/_npmrc +0 -1
  84. package/templates/web-components/base/prettier.config.mjs +0 -1
  85. package/templates/web-components/base/src/assets/playcanvas.png +0 -0
  86. package/templates/web-components/base/src/inspector.css +0 -33
  87. package/templates/web-components/base/src/inspector.ts +0 -38
  88. package/templates/web-components/base/src/starter.css +0 -104
  89. package/templates/web-components/base/src/vite-env.d.ts +0 -19
  90. package/templates/web-components/interactive-sphere/src/style.css +0 -53
  91. package/templates/web-components/model-viewer/src/style.css +0 -8
  92. package/templates/web-components/spinning-cube/src/style.css +0 -8
  93. package/templates/web-components/splat-viewer/src/style.css +0 -8
  94. /package/templates/{engine/base → _common}/.prettierignore +0 -0
  95. /package/templates/{engine/base → _common}/_gitignore +0 -0
  96. /package/templates/{engine/base → _common}/_npmrc +0 -0
  97. /package/templates/{engine/base → _common}/prettier.config.mjs +0 -0
  98. /package/templates/{engine/base → _common}/src/assets/playcanvas.png +0 -0
  99. /package/templates/{engine/base → _common}/src/inspector.css +0 -0
  100. /package/templates/{engine/base → _common}/src/inspector.ts +0 -0
  101. /package/templates/{engine/base → _common}/src/starter.css +0 -0
  102. /package/templates/{engine/base → _common}/src/vite-env.d.ts +0 -0
@@ -0,0 +1,231 @@
1
+ // Checks a build against the Snapchat Web Lens upload rules, then zips it for My Lenses:
2
+ // https://developers.snap.com/web-lenses/requirements
3
+ //
4
+ // Usage: node scripts/package-lens.mjs [build folder]
5
+ // `npm run package` builds first, then runs this on dist/lens.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import zlib from 'node:zlib';
10
+
11
+ const MB = 1024 * 1024;
12
+ const MAX_ZIP = 8 * MB;
13
+ const MAX_INDEX = MB;
14
+
15
+ // tags the Lens sandbox blocks
16
+ const BLOCKED_TAGS = ['base', 'embed', 'iframe', 'object'];
17
+
18
+ // hosts that bundles only mention as text: XML namespaces, and the docs that engine and React
19
+ // error messages link to
20
+ const TEXT_HOSTS = ['www.w3.org', 'github.com', 'react.dev', 'api.playcanvas.com'];
21
+
22
+ const buildDir = path.resolve(process.argv[2] ?? 'dist/lens');
23
+ const { name = 'web-lens' } = JSON.parse(fs.readFileSync('package.json', 'utf8'));
24
+ const zipFile = path.join(path.dirname(buildDir), `${name.replace(/^@/, '').replace('/', '-')}.zip`);
25
+
26
+ if (!fs.existsSync(buildDir)) {
27
+ console.error(`No build found at ${path.relative('.', buildDir)}. Run npm run package to build and package.`);
28
+ process.exit(1);
29
+ }
30
+
31
+ const errors = [];
32
+ const warnings = [];
33
+
34
+ const formatSize = (bytes) => (bytes < MB ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / MB).toFixed(2)} MB`);
35
+
36
+ // every file in the build, as the forward-slash path it takes in the zip
37
+ const walk = (dir) =>
38
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
39
+ const file = path.join(dir, entry.name);
40
+ return entry.isDirectory() ? walk(file) : [path.relative(buildDir, file).split(path.sep).join('/')];
41
+ });
42
+ const all = walk(buildDir).sort((a, b) => (a === 'index.html' ? -1 : b === 'index.html' ? 1 : a.localeCompare(b)));
43
+
44
+ // source maps only add weight, so leave them out of the zip
45
+ const maps = all.filter((file) => file.endsWith('.map'));
46
+ const files = all.filter((file) => !file.endsWith('.map'));
47
+ if (maps.length) {
48
+ warnings.push(
49
+ `Left ${maps.length} source map(s) out of the zip. Set build.sourcemap to false to stop generating them`
50
+ );
51
+ }
52
+
53
+ // the entry point
54
+ const indexSize = files.includes('index.html') ? fs.statSync(path.join(buildDir, 'index.html')).size : 0;
55
+ if (!indexSize) {
56
+ errors.push('index.html must sit at the root of the build');
57
+ } else if (indexSize > MAX_INDEX) {
58
+ errors.push(
59
+ `index.html is ${formatSize(indexSize)}, over the 1 MB limit. Load the bundle from a file rather than inlining it`
60
+ );
61
+ }
62
+
63
+ for (const file of files.filter((f) => f.startsWith('__snap/'))) {
64
+ errors.push(`${file}: __snap/ is reserved for Snapchat, so rename the folder`);
65
+ }
66
+
67
+ // URLs in markup and styles: anything on another host is blocked, and a root-absolute path
68
+ // resolves against Snapchat's origin rather than the Lens, so the game loads a blank screen
69
+ const checkUrl = (file, url) => {
70
+ if (/^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(url)) {
71
+ errors.push(
72
+ `${file}: loads ${url} from another host. A Lens can only load files from its own zip, so bundle it`
73
+ );
74
+ } else if (url.startsWith('/')) {
75
+ errors.push(`${file}: refers to ${url} by an absolute path. Use ./${url.slice(1)}`);
76
+ }
77
+ };
78
+
79
+ const checkCss = (file, css) => {
80
+ for (const [, , url] of css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) checkUrl(file, url);
81
+ for (const [, , url] of css.matchAll(/@import\s+(['"])([^'"]+)\1/g)) checkUrl(file, url);
82
+ };
83
+
84
+ const read = (file) => fs.readFileSync(path.join(buildDir, file), 'utf8');
85
+
86
+ for (const file of files.filter((f) => f.endsWith('.html'))) {
87
+ const html = read(file);
88
+ for (const [, tag, attributes] of html.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
89
+ const lower = tag.toLowerCase();
90
+ if (BLOCKED_TAGS.includes(lower)) {
91
+ errors.push(`${file}: the Lens sandbox blocks <${lower}>`);
92
+ continue;
93
+ }
94
+ if (lower === 'link' && /\brel\s*=\s*["']?manifest\b/i.test(attributes)) {
95
+ errors.push(`${file}: the Lens sandbox blocks web app manifests`);
96
+ continue;
97
+ }
98
+ // links navigate rather than load, so they don't count
99
+ if (lower === 'a') continue;
100
+
101
+ for (const [, attribute, ...values] of attributes.matchAll(
102
+ /\b(src|href|srcset|poster|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
103
+ )) {
104
+ const value = values.find((v) => v !== undefined) ?? '';
105
+ const urls =
106
+ attribute.toLowerCase() === 'srcset' ? value.split(',').map((s) => s.trim().split(/\s+/)[0]) : [value];
107
+ for (const url of urls) checkUrl(file, url);
108
+ }
109
+ }
110
+ for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) checkCss(file, css);
111
+ }
112
+
113
+ for (const file of files.filter((f) => f.endsWith('.css'))) checkCss(file, read(file));
114
+
115
+ // scripts: a quoted root-absolute path to a file in the build always breaks, while a remote URL
116
+ // may only be text, so it is worth a look rather than a failure
117
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
118
+ const absolute = new RegExp(`["'\`(]/(${files.map(escape).join('|')})(?=["'\`)?#])`, 'g');
119
+ for (const file of files.filter((f) => /\.m?js$/.test(f))) {
120
+ const source = read(file);
121
+ for (const target of new Set(Array.from(source.matchAll(absolute), (m) => m[1]))) {
122
+ errors.push(`${file}: refers to /${target} by an absolute path. Use ./${target}`);
123
+ }
124
+
125
+ const hosts = new Set();
126
+ for (const [url] of source.matchAll(/\b(?:https?|wss?):\/\/[^\s"'`)\\]+/g)) {
127
+ const host = URL.canParse(url) ? new URL(url).host : url;
128
+ if (!TEXT_HOSTS.includes(host)) hosts.add(host);
129
+ }
130
+ if (hosts.size) {
131
+ warnings.push(
132
+ `${file}: mentions ${[...hosts].join(', ')}. A Lens can't reach other hosts, so check none of them is loaded at runtime (npm run start blocks them the way Snapchat does)`
133
+ );
134
+ }
135
+ }
136
+
137
+ // a plain zip: deflated entries with forward-slash names and index.html at the root
138
+ const zipEntries = (entries) => {
139
+ const local = [];
140
+ const central = [];
141
+ let offset = 0;
142
+
143
+ for (const entry of entries) {
144
+ const file = path.join(buildDir, entry);
145
+ const data = fs.readFileSync(file);
146
+ const deflated = zlib.deflateRawSync(data, { level: 9 });
147
+ const stored = deflated.length >= data.length;
148
+ const body = stored ? data : deflated;
149
+ const entryName = Buffer.from(entry);
150
+ const crc = zlib.crc32(data);
151
+
152
+ const mtime = fs.statSync(file).mtime;
153
+ const time = (mtime.getHours() << 11) | (mtime.getMinutes() << 5) | (mtime.getSeconds() >> 1);
154
+ const date = ((mtime.getFullYear() - 1980) << 9) | ((mtime.getMonth() + 1) << 5) | mtime.getDate();
155
+
156
+ const header = Buffer.alloc(30);
157
+ header.writeUInt32LE(0x04034b50, 0);
158
+ header.writeUInt16LE(20, 4);
159
+ header.writeUInt16LE(0x0800, 6); // UTF-8 names
160
+ header.writeUInt16LE(stored ? 0 : 8, 8);
161
+ header.writeUInt16LE(time, 10);
162
+ header.writeUInt16LE(date, 12);
163
+ header.writeUInt32LE(crc, 14);
164
+ header.writeUInt32LE(body.length, 18);
165
+ header.writeUInt32LE(data.length, 22);
166
+ header.writeUInt16LE(entryName.length, 26);
167
+
168
+ const record = Buffer.alloc(46);
169
+ record.writeUInt32LE(0x02014b50, 0);
170
+ record.writeUInt16LE((3 << 8) | 20, 4); // made on Unix, so the permissions below apply
171
+ record.writeUInt16LE(20, 6);
172
+ record.writeUInt16LE(0x0800, 8);
173
+ record.writeUInt16LE(stored ? 0 : 8, 10);
174
+ record.writeUInt16LE(time, 12);
175
+ record.writeUInt16LE(date, 14);
176
+ record.writeUInt32LE(crc, 16);
177
+ record.writeUInt32LE(body.length, 20);
178
+ record.writeUInt32LE(data.length, 24);
179
+ record.writeUInt16LE(entryName.length, 28);
180
+ record.writeUInt32LE((0o100644 << 16) >>> 0, 38); // a regular file, rw-r--r--
181
+ record.writeUInt32LE(offset, 42);
182
+
183
+ local.push(header, entryName, body);
184
+ central.push(record, entryName);
185
+ offset += header.length + entryName.length + body.length;
186
+ }
187
+
188
+ const directory = Buffer.concat(central);
189
+ const end = Buffer.alloc(22);
190
+ end.writeUInt32LE(0x06054b50, 0);
191
+ end.writeUInt16LE(entries.length, 8);
192
+ end.writeUInt16LE(entries.length, 10);
193
+ end.writeUInt32LE(directory.length, 12);
194
+ end.writeUInt32LE(offset, 16);
195
+
196
+ return Buffer.concat([...local, directory, end]);
197
+ };
198
+
199
+ const zip = zipEntries(files);
200
+ if (zip.length > MAX_ZIP) {
201
+ errors.push(
202
+ `The zip is ${formatSize(zip.length)}, over the 8 MB limit. Compress textures and audio, and drop unused assets`
203
+ );
204
+ }
205
+
206
+ for (const warning of warnings) console.warn(`warning: ${warning}`);
207
+
208
+ if (errors.length) {
209
+ for (const error of errors) console.error(`error: ${error}`);
210
+ fs.rmSync(zipFile, { force: true });
211
+ console.error(
212
+ `\nNo zip written. Fix the ${errors.length === 1 ? 'problem' : 'problems'} above and run npm run package again.`
213
+ );
214
+ process.exit(1);
215
+ }
216
+
217
+ fs.writeFileSync(zipFile, zip);
218
+
219
+ const largest = files
220
+ .map((file) => ({ file, size: fs.statSync(path.join(buildDir, file)).size }))
221
+ .sort((a, b) => b.size - a.size)
222
+ .slice(0, 5);
223
+ const width = Math.max(...largest.map(({ file }) => file.length));
224
+
225
+ console.log(`\nPackaged ${path.relative('.', zipFile)} for Snapchat Web Lenses`);
226
+ console.log(` zip ${formatSize(zip.length)} of 8 MB (${Math.ceil((zip.length / MAX_ZIP) * 100)}%)`);
227
+ console.log(` index.html ${formatSize(indexSize)} of 1 MB`);
228
+ console.log(` files ${files.length}`);
229
+ console.log('\nLargest files, before compression:');
230
+ for (const { file, size } of largest) console.log(` ${file.padEnd(width)} ${formatSize(size)}`);
231
+ console.log('\nUpload the zip at https://my-lenses.snapchat.com/');
@@ -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
+ }