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.
- package/README.md +26 -3
- package/dist/index.mjs +41 -38
- package/package.json +6 -3
- package/templates/_features/physics/assets/public/ammo/SOURCE.md +4 -1
- package/templates/{react/interactive-sphere/src/App.css → _shared/interactive-sphere/src/counter.css} +0 -6
- package/templates/_shared/web-lens/README.md +42 -0
- package/templates/_shared/web-lens/package.json +9 -0
- package/templates/_shared/web-lens/scripts/package-lens.mjs +231 -0
- package/templates/_shared/web-lens/src/dodge-game.ts +271 -0
- package/templates/_shared/web-lens/src/lens.css +155 -0
- package/templates/_shared/web-lens/src/lens.ts +91 -0
- package/templates/engine/base/README.md +1 -1
- package/templates/engine/base/package.json +4 -4
- package/templates/engine/base/vite.config.ts +3 -0
- package/templates/engine/first-person-controller/src/main.ts +2 -3
- package/templates/engine/interactive-sphere/src/App.ts +1 -1
- package/templates/engine/interactive-sphere/src/main.ts +3 -5
- package/templates/engine/model-viewer/src/main.ts +3 -3
- package/templates/engine/physics-playground/src/main.ts +2 -3
- package/templates/engine/spinning-cube/src/main.ts +1 -1
- package/templates/engine/splat-viewer/src/main.ts +1 -1
- package/templates/engine/third-person-controller/src/main.ts +2 -3
- package/templates/engine/web-lens/index.html +26 -0
- package/templates/engine/web-lens/src/main.ts +89 -0
- package/templates/engine/web-lens/vite.config.ts +44 -0
- package/templates/index.ts +6 -0
- package/templates/react/base/README.md +1 -1
- package/templates/react/base/package.json +6 -6
- package/templates/react/base/vite.config.ts +3 -0
- package/templates/react/interactive-sphere/src/App.tsx +5 -6
- package/templates/react/interactive-sphere/src/Scene.tsx +1 -1
- package/templates/react/model-viewer/src/App.tsx +4 -2
- package/templates/react/spinning-cube/src/App.tsx +2 -0
- package/templates/react/splat-viewer/src/App.tsx +2 -0
- package/templates/react/{splat-viewer → web-lens}/index.html +2 -3
- package/templates/react/web-lens/src/App.tsx +85 -0
- package/templates/react/web-lens/vite.config.ts +46 -0
- package/templates/web-components/base/README.md +1 -1
- package/templates/web-components/base/package.json +4 -4
- package/templates/web-components/base/vite.config.ts +3 -0
- package/templates/web-components/first-person-controller/index.html +1 -6
- package/templates/web-components/interactive-sphere/src/main.ts +2 -2
- package/templates/web-components/model-viewer/index.html +2 -2
- package/templates/web-components/model-viewer/src/main.ts +0 -1
- package/templates/web-components/physics-playground/index.html +1 -6
- package/templates/web-components/spinning-cube/src/main.ts +0 -1
- package/templates/web-components/splat-viewer/src/main.ts +0 -1
- package/templates/web-components/third-person-controller/index.html +1 -6
- package/templates/web-components/web-lens/index.html +36 -0
- package/templates/web-components/web-lens/src/main.ts +47 -0
- package/templates/web-components/web-lens/vite.config.ts +44 -0
- package/templates/_features/physics/assets/public/ammo/ammo.js +0 -973
- package/templates/engine/interactive-sphere/index.html +0 -13
- package/templates/engine/interactive-sphere/src/style.css +0 -28
- package/templates/engine/model-viewer/index.html +0 -13
- package/templates/engine/model-viewer/src/style.css +0 -8
- package/templates/engine/spinning-cube/index.html +0 -13
- package/templates/engine/spinning-cube/src/style.css +0 -8
- package/templates/engine/splat-viewer/index.html +0 -13
- package/templates/engine/splat-viewer/src/style.css +0 -8
- package/templates/react/base/.prettierignore +0 -3
- package/templates/react/base/_gitignore +0 -24
- package/templates/react/base/_npmrc +0 -1
- package/templates/react/base/prettier.config.mjs +0 -1
- package/templates/react/base/src/assets/playcanvas.png +0 -0
- package/templates/react/base/src/inspector.css +0 -33
- package/templates/react/base/src/inspector.ts +0 -38
- package/templates/react/base/src/starter.css +0 -104
- package/templates/react/base/src/vite-env.d.ts +0 -19
- package/templates/react/interactive-sphere/index.html +0 -13
- package/templates/react/interactive-sphere/src/index.css +0 -4
- package/templates/react/interactive-sphere/src/main.tsx +0 -11
- package/templates/react/model-viewer/index.html +0 -13
- package/templates/react/model-viewer/src/index.css +0 -9
- package/templates/react/model-viewer/src/main.tsx +0 -11
- package/templates/react/spinning-cube/index.html +0 -13
- package/templates/react/spinning-cube/src/index.css +0 -9
- package/templates/react/spinning-cube/src/main.tsx +0 -11
- package/templates/react/splat-viewer/src/index.css +0 -9
- package/templates/react/splat-viewer/src/main.tsx +0 -11
- package/templates/web-components/base/.prettierignore +0 -3
- package/templates/web-components/base/_gitignore +0 -24
- package/templates/web-components/base/_npmrc +0 -1
- package/templates/web-components/base/prettier.config.mjs +0 -1
- package/templates/web-components/base/src/assets/playcanvas.png +0 -0
- package/templates/web-components/base/src/inspector.css +0 -33
- package/templates/web-components/base/src/inspector.ts +0 -38
- package/templates/web-components/base/src/starter.css +0 -104
- package/templates/web-components/base/src/vite-env.d.ts +0 -19
- package/templates/web-components/interactive-sphere/src/style.css +0 -53
- package/templates/web-components/model-viewer/src/style.css +0 -8
- package/templates/web-components/spinning-cube/src/style.css +0 -8
- package/templates/web-components/splat-viewer/src/style.css +0 -8
- /package/templates/{engine/base → _common}/.prettierignore +0 -0
- /package/templates/{engine/base → _common}/_gitignore +0 -0
- /package/templates/{engine/base → _common}/_npmrc +0 -0
- /package/templates/{engine/base → _common}/prettier.config.mjs +0 -0
- /package/templates/{engine/base → _common}/src/assets/playcanvas.png +0 -0
- /package/templates/{engine/base → _common}/src/inspector.css +0 -0
- /package/templates/{engine/base → _common}/src/inspector.ts +0 -0
- /package/templates/{engine/base → _common}/src/starter.css +0 -0
- /package/templates/{engine/base → _common}/src/vite-env.d.ts +0 -0
|
@@ -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
|
+
}));
|
|
@@ -32,7 +32,7 @@ Open <http://localhost:5173>. Edit the files under `src/` and save to see the sc
|
|
|
32
32
|
| `npm run fmt` | Check formatting |
|
|
33
33
|
| `npm run typecheck` | Run TypeScript checks |
|
|
34
34
|
|
|
35
|
-
Run `npm run build` to generate a deployable static site in `dist/`.
|
|
35
|
+
Run `npm run build` to generate a deployable static site in `dist/`. Its paths are relative, so it runs from any folder, such as a GitHub Pages project site or an itch.io upload.
|
|
36
36
|
|
|
37
37
|
## Agent skills
|
|
38
38
|
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"playcanvas": "^2.23.
|
|
18
|
+
"playcanvas": "^2.23.3"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@playcanvas/eslint-config": "3.0.0-beta.8",
|
|
22
|
-
"@playcanvas/inspector": "^0.
|
|
22
|
+
"@playcanvas/inspector": "^0.3.0",
|
|
23
23
|
"eslint": "^9.39.5",
|
|
24
|
-
"prettier": "^3.9.
|
|
24
|
+
"prettier": "^3.9.10",
|
|
25
25
|
"typescript": "~6.0.3",
|
|
26
|
-
"vite": "^8.3.
|
|
26
|
+
"vite": "^8.3.5"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=22.23.2"
|
|
@@ -2,6 +2,9 @@ import { defineConfig } from 'vite';
|
|
|
2
2
|
|
|
3
3
|
// https://vite.dev/config/
|
|
4
4
|
export default defineConfig({
|
|
5
|
+
// Relative URLs, so the build runs wherever it's served from: a domain root, a subpath such as
|
|
6
|
+
// GitHub Pages or itch.io, or a Snapchat Web Lens
|
|
7
|
+
base: './',
|
|
5
8
|
plugins: [
|
|
6
9
|
{
|
|
7
10
|
// Dev only: install the PlayCanvas Inspector before the app starts (press ` to toggle)
|
|
@@ -21,9 +21,8 @@ import { addFirstPersonController } from './controller';
|
|
|
21
21
|
import './starter.css';
|
|
22
22
|
|
|
23
23
|
WasmModule.setConfig('Ammo', {
|
|
24
|
-
glueUrl: '
|
|
25
|
-
wasmUrl: '
|
|
26
|
-
fallbackUrl: '/ammo/ammo.js'
|
|
24
|
+
glueUrl: './ammo/ammo.wasm.js',
|
|
25
|
+
wasmUrl: './ammo/ammo.wasm.wasm'
|
|
27
26
|
});
|
|
28
27
|
await new Promise<void>((resolve) => {
|
|
29
28
|
WasmModule.getInstance('Ammo', () => resolve());
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import './style.css';
|
|
2
1
|
import { setupApp } from './App';
|
|
2
|
+
import './counter.css';
|
|
3
|
+
import './starter.css';
|
|
3
4
|
|
|
4
5
|
let count = 0;
|
|
5
6
|
|
|
6
|
-
document.
|
|
7
|
-
<canvas id="application-canvas"></canvas>
|
|
8
|
-
<p id="counter" class="counter">Click count: ${count}</p>
|
|
9
|
-
`;
|
|
7
|
+
document.body.insertAdjacentHTML('beforeend', `<p id="counter" class="counter">Click count: ${count}</p>`);
|
|
10
8
|
|
|
11
9
|
const counterElement = document.getElementById('counter')!;
|
|
12
10
|
|
|
@@ -17,10 +17,10 @@ import {
|
|
|
17
17
|
import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
|
|
18
18
|
import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
|
|
19
19
|
|
|
20
|
-
import './
|
|
20
|
+
import './starter.css';
|
|
21
21
|
|
|
22
|
-
// Swap this for your own model -
|
|
23
|
-
const MODEL_URL = '
|
|
22
|
+
// Swap this for your own model - files in public/ are served next to index.html, so use a ./ path
|
|
23
|
+
const MODEL_URL = './playcanvas-cube.glb';
|
|
24
24
|
|
|
25
25
|
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
|
|
26
26
|
|
|
@@ -30,9 +30,8 @@ const SCALES: [number, number, number][] = [
|
|
|
30
30
|
];
|
|
31
31
|
|
|
32
32
|
WasmModule.setConfig('Ammo', {
|
|
33
|
-
glueUrl: '
|
|
34
|
-
wasmUrl: '
|
|
35
|
-
fallbackUrl: '/ammo/ammo.js'
|
|
33
|
+
glueUrl: './ammo/ammo.wasm.js',
|
|
34
|
+
wasmUrl: './ammo/ammo.wasm.wasm'
|
|
36
35
|
});
|
|
37
36
|
await new Promise<void>((resolve) => {
|
|
38
37
|
WasmModule.getInstance('Ammo', () => resolve());
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from 'playcanvas';
|
|
17
17
|
import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
|
|
18
18
|
|
|
19
|
-
import './
|
|
19
|
+
import './starter.css';
|
|
20
20
|
|
|
21
21
|
// Hosted so the template stays small. Capture or edit your own splat at https://superspl.at, then
|
|
22
22
|
// drop the .sog or .ply in public/ and point this at it instead
|
|
@@ -42,9 +42,8 @@ const PINE_LAYERS: [number, number, number][] = [
|
|
|
42
42
|
];
|
|
43
43
|
|
|
44
44
|
WasmModule.setConfig('Ammo', {
|
|
45
|
-
glueUrl: '
|
|
46
|
-
wasmUrl: '
|
|
47
|
-
fallbackUrl: '/ammo/ammo.js'
|
|
45
|
+
glueUrl: './ammo/ammo.wasm.js',
|
|
46
|
+
wasmUrl: './ammo/ammo.wasm.wasm'
|
|
48
47
|
});
|
|
49
48
|
await new Promise<void>((resolve) => {
|
|
50
49
|
WasmModule.getInstance('Ammo', () => resolve());
|
|
@@ -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);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
|
|
3
|
+
// Approximates the Web Lens sandbox, which allows resources from the Lens itself and nothing else
|
|
4
|
+
const LENS_SANDBOX = [
|
|
5
|
+
"default-src 'self' data: blob:",
|
|
6
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
|
7
|
+
"style-src 'self' 'unsafe-inline'",
|
|
8
|
+
"frame-src 'none'",
|
|
9
|
+
"object-src 'none'",
|
|
10
|
+
"base-uri 'none'",
|
|
11
|
+
"form-action 'none'"
|
|
12
|
+
].join('; ');
|
|
13
|
+
|
|
14
|
+
// https://vite.dev/config/
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
// Snapchat serves a Lens from its own origin, so every URL in the build must be relative
|
|
17
|
+
base: './',
|
|
18
|
+
build: {
|
|
19
|
+
// npm run package zips this folder and writes the zip beside it, inside the ignored dist/
|
|
20
|
+
outDir: 'dist/lens',
|
|
21
|
+
// source maps only add to the 8 MB upload
|
|
22
|
+
sourcemap: false
|
|
23
|
+
},
|
|
24
|
+
preview: {
|
|
25
|
+
// npm run start serves the build under the Lens rules, so a request for anything outside
|
|
26
|
+
// the zip fails in the browser console as it would in Snapchat
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Security-Policy': LENS_SANDBOX,
|
|
29
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
30
|
+
'Cross-Origin-Embedder-Policy': 'require-corp'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
plugins: [
|
|
34
|
+
{
|
|
35
|
+
// Dev only: install the PlayCanvas Inspector before the app starts (press ` to toggle)
|
|
36
|
+
name: 'playcanvas-inspector',
|
|
37
|
+
apply: 'serve',
|
|
38
|
+
config: () => ({ optimizeDeps: { include: ['@playcanvas/inspector'] } }),
|
|
39
|
+
transformIndexHtml: () => [
|
|
40
|
+
{ tag: 'script', attrs: { type: 'module', src: '/src/inspector.ts' }, injectTo: 'head-prepend' }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
});
|
package/templates/index.ts
CHANGED
|
@@ -90,6 +90,12 @@ export const STARTERS = [
|
|
|
90
90
|
category: 'Games',
|
|
91
91
|
description: 'an orthographic camera, sprite animation and input'
|
|
92
92
|
},
|
|
93
|
+
{
|
|
94
|
+
name: 'web-lens',
|
|
95
|
+
display: 'Snapchat Web Lens',
|
|
96
|
+
category: 'Games',
|
|
97
|
+
description: 'a portrait game with Snapchat leaderboards, packaged for upload'
|
|
98
|
+
},
|
|
93
99
|
{
|
|
94
100
|
name: 'scene-editor',
|
|
95
101
|
display: 'Scene Editor',
|
|
@@ -32,7 +32,7 @@ Open <http://localhost:5173>. Edit the files under `src/` and save to see the sc
|
|
|
32
32
|
| `npm run fmt` | Check formatting |
|
|
33
33
|
| `npm run typecheck` | Run TypeScript checks |
|
|
34
34
|
|
|
35
|
-
Run `npm run build` to generate a deployable static site in `dist/`.
|
|
35
|
+
Run `npm run build` to generate a deployable static site in `dist/`. Its paths are relative, so it runs from any folder, such as a GitHub Pages project site or an itch.io upload.
|
|
36
36
|
|
|
37
37
|
## Agent skills
|
|
38
38
|
|
|
@@ -16,20 +16,20 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@playcanvas/react": "^0.11.7",
|
|
19
|
-
"playcanvas": "^2.23.
|
|
20
|
-
"react": "^19.3.
|
|
21
|
-
"react-dom": "^19.3.
|
|
19
|
+
"playcanvas": "^2.23.3",
|
|
20
|
+
"react": "^19.3.1",
|
|
21
|
+
"react-dom": "^19.3.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@playcanvas/eslint-config": "3.0.0-beta.8",
|
|
25
|
-
"@playcanvas/inspector": "^0.
|
|
25
|
+
"@playcanvas/inspector": "^0.3.0",
|
|
26
26
|
"@types/react": "^19.3.0",
|
|
27
27
|
"@types/react-dom": "^19.3.0",
|
|
28
28
|
"@vitejs/plugin-react": "^6.1.2",
|
|
29
29
|
"eslint": "^9.39.5",
|
|
30
|
-
"prettier": "^3.9.
|
|
30
|
+
"prettier": "^3.9.10",
|
|
31
31
|
"typescript": "~6.0.3",
|
|
32
|
-
"vite": "^8.3.
|
|
32
|
+
"vite": "^8.3.5"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.23.2"
|
|
@@ -3,6 +3,9 @@ import { defineConfig } from 'vite';
|
|
|
3
3
|
|
|
4
4
|
// https://vite.dev/config/
|
|
5
5
|
export default defineConfig({
|
|
6
|
+
// Relative URLs, so the build runs wherever it's served from: a domain root, a subpath such as
|
|
7
|
+
// GitHub Pages or itch.io, or a Snapchat Web Lens
|
|
8
|
+
base: './',
|
|
6
9
|
plugins: [
|
|
7
10
|
react(),
|
|
8
11
|
{
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { Application } from '@playcanvas/react';
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
|
|
4
|
-
import './
|
|
4
|
+
import './counter.css';
|
|
5
5
|
import Scene from './Scene';
|
|
6
|
+
import './starter.css';
|
|
6
7
|
|
|
7
8
|
function App() {
|
|
8
9
|
const [count, setCount] = useState(0);
|
|
9
10
|
|
|
10
11
|
return (
|
|
11
12
|
<>
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
</Application>
|
|
16
|
-
</div>
|
|
13
|
+
<Application className="playcanvas-app">
|
|
14
|
+
<Scene onClick={() => setCount((count) => count + 1)} />
|
|
15
|
+
</Application>
|
|
17
16
|
<p className="counter">Click count: {count}</p>
|
|
18
17
|
</>
|
|
19
18
|
);
|
|
@@ -18,7 +18,7 @@ function Scene({ onClick }: SceneProps) {
|
|
|
18
18
|
const material = useMaterial({ diffuse });
|
|
19
19
|
|
|
20
20
|
// Load the environment map
|
|
21
|
-
const { asset: envMap } = useEnvAtlas('
|
|
21
|
+
const { asset: envMap } = useEnvAtlas('./environment-map.png');
|
|
22
22
|
|
|
23
23
|
// Change the mouse cursor based on the hover state
|
|
24
24
|
useEffect(() => {
|
|
@@ -5,8 +5,10 @@ import { TONEMAP_ACES2 } from 'playcanvas';
|
|
|
5
5
|
import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
|
|
6
6
|
import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import './starter.css';
|
|
9
|
+
|
|
10
|
+
// Swap this for your own model - files in public/ are served next to index.html, so use a ./ path
|
|
11
|
+
const MODEL_URL = './playcanvas-cube.glb';
|
|
10
12
|
|
|
11
13
|
function Model() {
|
|
12
14
|
const { asset } = useModel(MODEL_URL);
|
|
@@ -3,6 +3,8 @@ import { Camera, GSplat, Script } from '@playcanvas/react/components';
|
|
|
3
3
|
import { useSplat } from '@playcanvas/react/hooks';
|
|
4
4
|
import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
|
|
5
5
|
|
|
6
|
+
import './starter.css';
|
|
7
|
+
|
|
6
8
|
// Hosted so the template stays small. Capture or edit your own splat at https://superspl.at, then
|
|
7
9
|
// drop the .sog or .ply in public/ and point this at it instead
|
|
8
10
|
const SPLAT_URL = 'https://developer.playcanvas.com/assets/toy-cat.sog';
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<
|
|
6
|
-
<
|
|
7
|
-
<title>PlayCanvas + React</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<title>Dodge</title>
|
|
8
7
|
</head>
|
|
9
8
|
<body>
|
|
10
9
|
<div id="root"></div>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Application, Entity } from '@playcanvas/react';
|
|
2
|
+
import { Camera, Light, Script } from '@playcanvas/react/components';
|
|
3
|
+
import { useApp, useAppEvent } from '@playcanvas/react/hooks';
|
|
4
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { CARDS, DodgeGame } from './dodge-game';
|
|
7
|
+
import type { GameEvents, GameState } from './dodge-game';
|
|
8
|
+
import { hasLeaderboard, loadAvatar, loadBest, recordScore, showLeaderboard } from './lens';
|
|
9
|
+
import './lens.css';
|
|
10
|
+
|
|
11
|
+
function Scene({ onState }: { onState: GameEvents['game:state'] }) {
|
|
12
|
+
const app = useApp();
|
|
13
|
+
useAppEvent<GameEvents>('game:state', onState);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// render at up to twice the CSS resolution: sharp on a phone, without the cost of 3x
|
|
17
|
+
// eslint-disable-next-line react-hooks/immutability -- the device belongs to the engine, not React
|
|
18
|
+
app.graphicsDevice.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
|
|
19
|
+
}, [app]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{/* fix the field's width, so a taller phone shows more of the falling blocks */}
|
|
24
|
+
<Entity name="camera" position={[0, 0, 7]}>
|
|
25
|
+
<Camera clearColor="#0b1020" fov={45} horizontalFov />
|
|
26
|
+
</Entity>
|
|
27
|
+
<Entity name="light" rotation={[40, 30, 0]}>
|
|
28
|
+
<Light type="directional" intensity={1.5} />
|
|
29
|
+
</Entity>
|
|
30
|
+
<Entity name="game">
|
|
31
|
+
<Script script={DodgeGame} />
|
|
32
|
+
</Entity>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function App() {
|
|
38
|
+
const [state, setState] = useState<GameState>('ready');
|
|
39
|
+
const [score, setScore] = useState(0);
|
|
40
|
+
const [best, setBest] = useState(0);
|
|
41
|
+
const [avatar, setAvatar] = useState<string>();
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
loadBest().then(setBest);
|
|
45
|
+
loadAvatar().then(setAvatar);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// the HUD follows the game's state, and the Lens records each finished run
|
|
49
|
+
const onState = useCallback((next: GameState, value: number) => {
|
|
50
|
+
setState(next);
|
|
51
|
+
setScore(value);
|
|
52
|
+
if (next === 'over') recordScore(value).then(setBest);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const card = CARDS[state];
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<main className="lens">
|
|
59
|
+
{/* lens.css sizes the canvas, filling a phone screen and centering a 9:16 column anywhere wider */}
|
|
60
|
+
<Application>
|
|
61
|
+
<Scene onState={onState} />
|
|
62
|
+
</Application>
|
|
63
|
+
<div className="lens-hud">
|
|
64
|
+
<header className="lens-bar">
|
|
65
|
+
{avatar && <img className="lens-avatar" src={avatar} alt="Your Bitmoji" />}
|
|
66
|
+
<span className="lens-score">{score}</span>
|
|
67
|
+
<span className="lens-best">Best {best}</span>
|
|
68
|
+
</header>
|
|
69
|
+
{card && (
|
|
70
|
+
<section className="lens-card">
|
|
71
|
+
<h1 className="lens-title">{card.title}</h1>
|
|
72
|
+
{state === 'over' && hasLeaderboard() && (
|
|
73
|
+
<button className="lens-leaderboard" onClick={showLeaderboard}>
|
|
74
|
+
Leaderboard
|
|
75
|
+
</button>
|
|
76
|
+
)}
|
|
77
|
+
<p className="lens-hint">{card.hint}</p>
|
|
78
|
+
</section>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</main>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default App;
|