create-playcanvas 0.9.0 → 0.10.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 (37) hide show
  1. package/package.json +2 -2
  2. package/skills/apply-conventions/SKILL.md +3 -3
  3. package/skills/assemble-scene/SKILL.md +1 -1
  4. package/skills/build-app/SKILL.md +2 -0
  5. package/skills/configure-animation/SKILL.md +1 -1
  6. package/skills/inspect-runtime/SKILL.md +41 -0
  7. package/skills/inspect-runtime/scripts/devtools-hook.js +23 -0
  8. package/skills/override-shader-chunks/SKILL.md +1 -1
  9. package/skills/reduce-draw-calls/SKILL.md +4 -4
  10. package/skills/verify-pixels/SKILL.md +1 -1
  11. package/templates/_shared/third-person-controller/src/controller.ts +25 -3
  12. package/templates/engine/base/README.md +4 -0
  13. package/templates/engine/base/package.json +3 -2
  14. package/templates/engine/base/src/inspector.css +33 -0
  15. package/templates/engine/base/src/inspector.ts +38 -0
  16. package/templates/engine/base/tsconfig.json +1 -1
  17. package/templates/engine/base/vite.config.ts +16 -0
  18. package/templates/engine/interactive-sphere/src/App.ts +2 -2
  19. package/templates/engine/model-viewer/src/main.ts +1 -2
  20. package/templates/engine/product-configurator/src/main.ts +6 -7
  21. package/templates/engine/third-person-controller/src/main.ts +10 -5
  22. package/templates/react/base/README.md +4 -0
  23. package/templates/react/base/package.json +4 -3
  24. package/templates/react/base/src/inspector.css +33 -0
  25. package/templates/react/base/src/inspector.ts +38 -0
  26. package/templates/react/base/vite.config.ts +12 -1
  27. package/templates/react/product-configurator/src/App.tsx +6 -6
  28. package/templates/react/third-person-controller/src/App.tsx +5 -2
  29. package/templates/web-components/base/README.md +4 -0
  30. package/templates/web-components/base/package.json +4 -3
  31. package/templates/web-components/base/src/inspector.css +33 -0
  32. package/templates/web-components/base/src/inspector.ts +38 -0
  33. package/templates/web-components/base/tsconfig.json +1 -1
  34. package/templates/web-components/base/vite.config.ts +16 -0
  35. package/templates/web-components/interactive-sphere/src/main.ts +1 -2
  36. package/templates/web-components/product-configurator/src/main.ts +5 -6
  37. package/templates/web-components/third-person-controller/index.html +17 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-playcanvas",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Scaffold a PlayCanvas project",
5
5
  "keywords": [
6
6
  "playcanvas",
@@ -44,7 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@clack/prompts": "^1.8.1",
46
46
  "@playcanvas/eslint-config": "3.0.0-beta.8",
47
- "@types/node": "22.20.4",
47
+ "@types/node": "22.20.5",
48
48
  "eslint": "^9.39.5",
49
49
  "mri": "^1.2.0",
50
50
  "picocolors": "^1.1.1",
@@ -44,6 +44,6 @@ Check version-sensitive APIs against installed `playcanvas` declarations.
44
44
 
45
45
  ## Verify in the running app
46
46
 
47
- Offline values cannot prove facing, poses, or placement. Log values from a `Script` or update
48
- callback in the running app, and judge visuals only from screenshots you have inspected. A saved
49
- file path is not evidence.
47
+ Offline values cannot prove facing, poses, or placement. Read values from the running app with
48
+ `inspect-runtime`, and judge visuals only from screenshots you have inspected. A saved file path is
49
+ not evidence.
@@ -20,7 +20,7 @@ once; if the tuning record is missing, calibrate first.
20
20
  ## Check placement
21
21
 
22
22
  After a rendered frame, measure each support or attachment at the object's actual position, using
23
- a mount point, raycast, or geometry sample. A positive gap floats; a negative gap penetrates.
23
+ a mount point, raycast, or geometry sample read through `inspect-runtime`. A positive gap floats; a negative gap penetrates.
24
24
  Overlapping world AABBs between unrelated roots are a failure unless two different views prove the
25
25
  overlap is empty space.
26
26
 
@@ -19,6 +19,8 @@ reference: [direct Engine](references/direct-engine.md), [React](references/reac
19
19
  `canvas.width / canvas.clientWidth`.
20
20
  - Rigid bodies stay inert until Ammo loads through `WasmModule`. Copy the loader from the
21
21
  `physics/falling-shapes` example with `find-examples`.
22
+ - `devtools` defaults to `true`, so debugging tools can find the app (`inspect-runtime`). Set it to
23
+ `false` only to hide a production build.
22
24
 
23
25
  ## Update loop
24
26
 
@@ -20,7 +20,7 @@ Get clip and joint names from `inspect-glb`; never guess them.
20
20
  ## Prove playback
21
21
 
22
22
  An active state or advancing state time does not prove motion. Sample two non-root joints over
23
- several rendered frames at idle and at both ends of each blend tree. Zero joint motion means a
23
+ several rendered frames (`inspect-runtime`) at idle and at both ends of each blend tree. Zero joint motion means a
24
24
  binding, playback, or blend-weight failure.
25
25
 
26
26
  ## Diagnose
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: inspect-runtime
3
+ description: Use when reading state from a running PlayCanvas app, to reach its live Application through the Engine devtools hook without changing app code.
4
+ ---
5
+
6
+ # Inspect a running app
7
+
8
+ From Engine 2.23, every app announces itself to a hook at
9
+ `globalThis[Symbol.for('playcanvas.inspector')]`. Read live state through it instead of adding
10
+ `window.app` or debug code to the app.
11
+
12
+ ## Attach
13
+
14
+ - Resolve `scripts/devtools-hook.js` relative to this skill and inject it as an init script that
15
+ runs before any page script. A hook defined later misses existing apps; reload instead.
16
+ - Page code then reads `globalThis.__playcanvasApps`, a `Map` from each live app to
17
+ `{ version, revision, protocol }`. `app.init()` adds the entry and `app.destroy()` removes it.
18
+ - The script chains a hook defined before it, such as a devtools extension's.
19
+ - No entry means the Engine predates 2.23, the hook loaded late, or the app set
20
+ `devtools: false`. The Engine swallows errors thrown by the hook.
21
+ - In 2.23.0 only the debug build (`playcanvas/debug`, `build/playcanvas.dbg.*`) announces apps; the
22
+ default import never calls the hook. Later 2.23 patches fix every build.
23
+
24
+ ## Read state
25
+
26
+ - Read after `frameend`, so values reflect a rendered frame.
27
+ - Return plain numbers and strings to the caller, not Engine objects.
28
+ - Useful reads: `app.root.findByName()` and `findByTag()`, world positions and
29
+ `meshInstance.aabb`, `app.stats.drawCallCount` and `frameTime`, and
30
+ `entity.anim.baseLayer.activeState`.
31
+ - Pause with `app.timeScale = 0`; `update` still runs with a zero `dt`. Step one frame by restoring
32
+ the scale, then zeroing it again on the next `frameend`.
33
+ - Revert anything you change to take a measurement.
34
+
35
+ ## Inspector panel
36
+
37
+ `@playcanvas/inspector` adds a panel for people: `new Inspector(app)` shows the hierarchy, assets,
38
+ materials, shaders, textures, render targets, frame graph, and physics. It must import the app's
39
+ own `playcanvas` package, so add it only to development builds. It draws over the canvas and
40
+ applies view overrides, so destroy it before `verify-pixels` captures. Stepping through draws
41
+ needs the debug build.
@@ -0,0 +1,23 @@
1
+ // Records every PlayCanvas app (Engine 2.23+) in globalThis.__playcanvasApps, a Map from app to
2
+ // { version, revision, protocol }. Run it before any page script; apps created earlier are missed.
3
+ // Plain script with no imports or exports, so it runs as an init script, a <script>, or an import.
4
+ {
5
+ const key = Symbol.for('playcanvas.inspector');
6
+ const prev = globalThis[key];
7
+ const apps = (globalThis.__playcanvasApps ??= new Map());
8
+
9
+ // chain a hook defined earlier, such as a devtools extension's, and install only once
10
+ if (!prev?.playcanvasApps) {
11
+ globalThis[key] = {
12
+ playcanvasApps: apps,
13
+ register(app, info) {
14
+ apps.set(app, info);
15
+ prev?.register?.(app, info);
16
+ },
17
+ unregister(app) {
18
+ apps.delete(app);
19
+ prev?.unregister?.(app);
20
+ }
21
+ };
22
+ }
23
+ }
@@ -8,7 +8,7 @@ description: Use when a PlayCanvas StandardMaterial needs custom shading its pro
8
8
  Overriding a `StandardMaterial` chunk keeps fog, tone mapping, shadows, skinning, and instancing
9
9
  intact. Use a `ShaderMaterial` only when the look needs its own vertex and fragment pair.
10
10
 
11
- - Set `material.shaderChunksVersion` to the installed major.minor, such as `'2.22'`. Chunk names
11
+ - Set `material.shaderChunksVersion` to the installed major.minor, such as `'2.23'`. Chunk names
12
12
  and contracts change between versions, so read the installed chunk source, not memory.
13
13
  - Set chunks with `material.getShaderChunks(SHADERLANGUAGE_GLSL).set(name, source)` and the WGSL
14
14
  equivalent, then call `material.update()`. For every material on a device, use
@@ -5,10 +5,10 @@ description: Use when a PlayCanvas app has too many draw calls or is CPU-bound,
5
5
 
6
6
  # Cut draw calls
7
7
 
8
- Measure first with `app.stats.drawCalls.total` or `MiniStats`; the per-pass breakdown fills only in
9
- profiler builds. Also halve the pixel ratio and call `app.resizeCanvas()`: if frame time drops, the
10
- cost is fill rate, not draw calls. Stop at the first rung that meets the budget and report
11
- before-and-after counts.
8
+ Measure first with `app.stats.drawCallCount` or `MiniStats`; the per-pass `app.stats.drawCalls`
9
+ breakdown fills only in profiler builds. Also halve the pixel ratio and call `app.resizeCanvas()`:
10
+ if frame time drops, the cost is fill rate, not draw calls. Stop at the first rung that meets the
11
+ budget and report before-and-after counts.
12
12
 
13
13
  ## 1. Stop drawing hidden things
14
14
 
@@ -33,4 +33,4 @@ description: Use when a PlayCanvas rendering change is meant to leave the image
33
33
  - Report the count, for example "0 of 65536 pixels differ", or the count and where the differences
34
34
  are. "Looks the same" is not a result.
35
35
 
36
- Keep harness code out of the shipped bundle.
36
+ Keep harness code out of the shipped bundle; attach it through `inspect-runtime`.
@@ -1,7 +1,10 @@
1
1
  import type { Entity, RigidBodyComponentSystem } from 'playcanvas';
2
- import { Script } from 'playcanvas';
2
+ import { Script, Vec3 } from 'playcanvas';
3
3
  import { ThirdPersonController } from 'playcanvas/scripts/esm/third-person-controller.mjs';
4
4
 
5
+ /** Ground probes under the player's box collider (half width 0.4): centre, edges and corners */
6
+ const FEET = [-0.38, 0, 0.38].flatMap((x) => [-0.38, 0, 0.38].map((z) => [x, z]));
7
+
5
8
  class RobotAnimation extends Script {
6
9
  static scriptName = 'robotAnimation';
7
10
 
@@ -28,9 +31,10 @@ class RobotAnimation extends Script {
28
31
  }
29
32
 
30
33
  export const addThirdPersonController = (player: Entity, camera: Entity, model: Entity) => {
31
- (player.rigidbody!.system as RigidBodyComponentSystem).gravity.set(0, -18, 0);
34
+ const sys = player.rigidbody!.system as RigidBodyComponentSystem;
35
+ sys.gravity.set(0, -18, 0);
32
36
  if (!player.script) player.addComponent('script');
33
- player.script!.create(ThirdPersonController, {
37
+ const controller = player.script!.create(ThirdPersonController, {
34
38
  properties: {
35
39
  camera,
36
40
  characterModel: model,
@@ -41,6 +45,24 @@ export const addThirdPersonController = (player: Entity, camera: Entity, model:
41
45
  jumpForce: 520,
42
46
  speedGround: 60
43
47
  }
48
+ })!;
49
+
50
+ // The stock ground check is one ray from the centre, so a player standing on a ledge reads as
51
+ // airborne and can't jump. Count it grounded when any probe under the collider finds a solid
52
+ // surface; triggers (colliders without a rigid body) only stop the camera
53
+ const [from, to] = [new Vec3(), new Vec3()];
54
+ const solid = { filterCallback: (e: Entity) => !!e.rigidbody };
55
+ let grounded = false;
56
+ Object.defineProperty(controller, '_grounded', {
57
+ get: () => grounded,
58
+ set: () => {
59
+ const p = player.getPosition();
60
+ grounded = FEET.some(([x, z]) => {
61
+ from.set(p.x + x, p.y, p.z + z);
62
+ to.set(from.x, p.y - 1.1, from.z);
63
+ return !!sys.raycastFirst(from, to, solid);
64
+ });
65
+ }
44
66
  });
45
67
 
46
68
  if (!model.script) model.addComponent('script');
@@ -17,6 +17,10 @@ npm run dev
17
17
 
18
18
  Open <http://localhost:5173>. Edit the files under `src/` and save to see the scene update.
19
19
 
20
+ ## Inspector
21
+
22
+ `npm run dev` loads the [PlayCanvas Inspector](https://github.com/playcanvas/inspector), a debug panel for the hierarchy, assets, materials, textures, frame graph and physics. Press the backquote key (<kbd>&#96;</kbd>) to show or hide it, <kbd>F9</kbd> to pause and <kbd>F10</kbd> to step a frame. It is wired up in `vite.config.ts` and `src/inspector.ts` and is left out of production builds.
23
+
20
24
  ## Scripts
21
25
 
22
26
  | Command | Description |
@@ -15,14 +15,15 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "playcanvas": "^2.22.6"
18
+ "playcanvas": "^2.23.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@playcanvas/eslint-config": "3.0.0-beta.8",
22
+ "@playcanvas/inspector": "^0.2.0",
22
23
  "eslint": "^9.39.5",
23
24
  "prettier": "^3.9.9",
24
25
  "typescript": "~6.0.3",
25
- "vite": "^8.3.1"
26
+ "vite": "^8.3.4"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=22.23.2"
@@ -0,0 +1,33 @@
1
+ /* Inspector key hint, added on the dev server only by src/inspector.ts. Styled like the starter panels */
2
+ .inspector-hint {
3
+ position: fixed;
4
+ bottom: 20px;
5
+ left: 20px;
6
+ z-index: 10;
7
+ margin: 0;
8
+ padding: 8px 12px;
9
+ border: 1px solid #ffffff24;
10
+ border-radius: 10px;
11
+ background: #0b1019d9;
12
+ box-shadow: 0 12px 40px #0008;
13
+ color: #aeb8c8;
14
+ font:
15
+ 12px/1.6 Inter,
16
+ ui-sans-serif,
17
+ system-ui,
18
+ sans-serif;
19
+ backdrop-filter: blur(12px);
20
+ pointer-events: none;
21
+ }
22
+
23
+ .inspector-hint kbd {
24
+ padding: 1px 5px;
25
+ border-radius: 4px;
26
+ background: #263247;
27
+ color: #f8fafc;
28
+ font:
29
+ 11px ui-monospace,
30
+ SFMono-Regular,
31
+ Menlo,
32
+ monospace;
33
+ }
@@ -0,0 +1,38 @@
1
+ // Loaded by the dev server only (see vite.config.ts), so production builds never include it
2
+ import { Inspector } from '@playcanvas/inspector';
3
+ import type { AppBase } from 'playcanvas';
4
+
5
+ import './inspector.css';
6
+
7
+ type Hook = {
8
+ register?(app: AppBase, info: object): void;
9
+ unregister?(app: AppBase): void;
10
+ };
11
+
12
+ // Every app announces itself to this hook as it starts, whichever format created it
13
+ const KEY = Symbol.for('playcanvas.inspector');
14
+ const scope = globalThis as unknown as Record<symbol, Hook | undefined>;
15
+
16
+ // Keep a hook installed earlier, such as a devtools extension's
17
+ const prev = scope[KEY];
18
+
19
+ scope[KEY] = {
20
+ register(app, info) {
21
+ prev?.register?.(app, info);
22
+
23
+ // Skip headless apps, such as the null apps @playcanvas/react creates for validation. Each
24
+ // panel destroys itself with its app
25
+ if (!app.graphicsDevice.isNull) new Inspector(app, { visible: false });
26
+ },
27
+ unregister(app) {
28
+ prev?.unregister?.(app);
29
+ }
30
+ };
31
+
32
+ console.info('PlayCanvas Inspector: press ` to toggle');
33
+
34
+ // Key hint, a separate corner chip so the starter's own UI stays untouched
35
+ const hint = document.createElement('p');
36
+ hint.className = 'inspector-hint';
37
+ hint.innerHTML = '<kbd>`</kbd> inspector · <kbd>F9</kbd> pause · <kbd>F10</kbd> step';
38
+ document.body.append(hint);
@@ -21,5 +21,5 @@
21
21
  "noFallthroughCasesInSwitch": true,
22
22
  "noUncheckedSideEffectImports": true
23
23
  },
24
- "include": ["src"]
24
+ "include": ["src", "vite.config.ts"]
25
25
  }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ // https://vite.dev/config/
4
+ export default defineConfig({
5
+ plugins: [
6
+ {
7
+ // Dev only: install the PlayCanvas Inspector before the app starts (press ` to toggle)
8
+ name: 'playcanvas-inspector',
9
+ apply: 'serve',
10
+ config: () => ({ optimizeDeps: { include: ['@playcanvas/inspector'] } }),
11
+ transformIndexHtml: () => [
12
+ { tag: 'script', attrs: { type: 'module', src: '/src/inspector.ts' }, injectTo: 'head-prepend' }
13
+ ]
14
+ }
15
+ ]
16
+ });
@@ -1,4 +1,4 @@
1
- import type { Layer, Texture } from 'playcanvas';
1
+ import type { Layer } from 'playcanvas';
2
2
  import {
3
3
  createGraphicsDevice,
4
4
  AppBase,
@@ -89,7 +89,7 @@ const setupApp = async (canvas: HTMLCanvasElement, onClick: () => void) => {
89
89
  app.start();
90
90
 
91
91
  // Set up environment lighting (no skybox, just IBL)
92
- app.scene.envAtlas = assets.envAtlas.resource as Texture;
92
+ app.scene.envAtlas = assets.envAtlas.resource!;
93
93
  const skyboxLayer = app.scene.layers.getLayerByName('Skybox');
94
94
  if (skyboxLayer) {
95
95
  skyboxLayer.enabled = false;
@@ -1,4 +1,3 @@
1
- import type { ContainerResource } from 'playcanvas';
2
1
  import {
3
2
  AppBase,
4
3
  AppOptions,
@@ -57,7 +56,7 @@ await new Promise<void>((resolve) => {
57
56
  app.start();
58
57
 
59
58
  // A container asset holds the whole glTF scene, so instantiate it to get an entity hierarchy
60
- app.root.addChild((model.resource as ContainerResource).instantiateRenderEntity());
59
+ app.root.addChild(model.resource!.instantiateRenderEntity());
61
60
 
62
61
  // The procedural sky is both the background and the image-based lighting, so no light or
63
62
  // environment map asset is needed. Its default luminance is scene-scale bright, so turn it down to
@@ -1,4 +1,3 @@
1
- import type { ContainerResource, RenderComponent } from 'playcanvas';
2
1
  import {
3
2
  AppBase,
4
3
  AppOptions,
@@ -62,12 +61,12 @@ await new Promise<void>((resolve) => new AssetListLoader([product], app.assets).
62
61
 
63
62
  app.start();
64
63
 
65
- const car = (product.resource as ContainerResource).instantiateRenderEntity();
64
+ const car = product.resource!.instantiateRenderEntity();
66
65
  car.setLocalEulerAngles(-90, -25, 0);
67
66
  app.root.addChild(car);
68
67
  app.root.syncHierarchy();
69
68
 
70
- const meshes = (car.findComponents('render') as RenderComponent[]).flatMap((render) => render.meshInstances);
69
+ const meshes = car.findComponents('render').flatMap((render) => render.meshInstances);
71
70
  const bounds = meshes[0].aabb.clone();
72
71
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
73
72
  const scale = 5 / Math.max(bounds.halfExtents.x * 2, bounds.halfExtents.z * 2);
@@ -77,10 +76,10 @@ bounds.copy(meshes[0].aabb);
77
76
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
78
77
  car.setPosition(-bounds.center.x, 0.03 - bounds.getMin().y, -bounds.center.z);
79
78
 
80
- const body = meshes.filter((mesh) => mesh.material.name === 'material');
81
- const panels = meshes.filter((mesh) => mesh.material.name === 'material_1');
82
- const paint = body[0].material.clone() as StandardMaterial;
83
- const shade = panels[0].material.clone() as StandardMaterial;
79
+ const body = meshes.filter((mesh) => mesh.material?.name === 'material');
80
+ const panels = meshes.filter((mesh) => mesh.material?.name === 'material_1');
81
+ const paint = body[0].material!.clone() as StandardMaterial;
82
+ const shade = panels[0].material!.clone() as StandardMaterial;
84
83
  body.forEach((mesh) => (mesh.material = paint));
85
84
  panels.forEach((mesh) => (mesh.material = shade));
86
85
  paint.diffuse.fromString(PAINTS[0].color);
@@ -134,6 +134,9 @@ for (const [i, [x, z, size]] of TREES.entries()) {
134
134
  crown.setPosition(x, size * y, z);
135
135
  crown.setLocalScale(size * width, size * height, size * width);
136
136
  crown.addComponent('render', { type: 'cone', material: j === 1 ? tips : leaves });
137
+
138
+ // A trigger: it stops the camera's line of sight but the player walks through it
139
+ crown.addComponent('collision', { type: 'cone', radius: (size * width) / 2, height: size * height });
137
140
  app.root.addChild(crown);
138
141
  }
139
142
  }
@@ -142,11 +145,13 @@ prop('stump', [-1.8, 0.4, -2.2], [0.9, 0.8, 0.9], wood);
142
145
 
143
146
  const player = new Entity('player');
144
147
  player.setPosition(0, 1.1, 0);
148
+
149
+ // A box rather than a capsule, so the player stands on ledges instead of sliding off them. Its
150
+ // bottom sits 1 m below the body, inside the controller's 1.1 m ground probes
145
151
  player.addComponent('collision', {
146
- type: 'capsule',
147
- radius: 0.5,
148
- height: 2.4,
149
- linearOffset: new Vec3(0, 0.1, 0)
152
+ type: 'box',
153
+ halfExtents: new Vec3(0.4, 1.2, 0.4),
154
+ linearOffset: new Vec3(0, 0.2, 0)
150
155
  });
151
156
  player.addComponent('rigidbody', { type: 'dynamic', mass: 70, angularFactor: Vec3.ZERO });
152
157
  app.root.addChild(player);
@@ -162,7 +167,7 @@ pants.diffuse = new Color(0.18, 0.29, 0.52);
162
167
  pants.update();
163
168
 
164
169
  const model = new Entity('character');
165
- model.setLocalPosition(0, 0.2, 0);
170
+ model.setLocalPosition(0, 0.3, 0);
166
171
  const part = (
167
172
  parent: Entity,
168
173
  name: string,
@@ -17,6 +17,10 @@ npm run dev
17
17
 
18
18
  Open <http://localhost:5173>. Edit the files under `src/` and save to see the scene update.
19
19
 
20
+ ## Inspector
21
+
22
+ `npm run dev` loads the [PlayCanvas Inspector](https://github.com/playcanvas/inspector), a debug panel for the hierarchy, assets, materials, textures, frame graph and physics. Press the backquote key (<kbd>&#96;</kbd>) to show or hide it, <kbd>F9</kbd> to pause and <kbd>F10</kbd> to step a frame. It is wired up in `vite.config.ts` and `src/inspector.ts` and is left out of production builds.
23
+
20
24
  ## Scripts
21
25
 
22
26
  | Command | Description |
@@ -16,19 +16,20 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@playcanvas/react": "^0.11.7",
19
- "playcanvas": "^2.22.6",
19
+ "playcanvas": "^2.23.1",
20
20
  "react": "^19.3.0",
21
21
  "react-dom": "^19.3.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@playcanvas/eslint-config": "3.0.0-beta.8",
25
+ "@playcanvas/inspector": "^0.2.0",
25
26
  "@types/react": "^19.3.0",
26
27
  "@types/react-dom": "^19.3.0",
27
- "@vitejs/plugin-react": "^6.1.1",
28
+ "@vitejs/plugin-react": "^6.1.2",
28
29
  "eslint": "^9.39.5",
29
30
  "prettier": "^3.9.9",
30
31
  "typescript": "~6.0.3",
31
- "vite": "^8.3.1"
32
+ "vite": "^8.3.4"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=22.23.2"
@@ -0,0 +1,33 @@
1
+ /* Inspector key hint, added on the dev server only by src/inspector.ts. Styled like the starter panels */
2
+ .inspector-hint {
3
+ position: fixed;
4
+ bottom: 20px;
5
+ left: 20px;
6
+ z-index: 10;
7
+ margin: 0;
8
+ padding: 8px 12px;
9
+ border: 1px solid #ffffff24;
10
+ border-radius: 10px;
11
+ background: #0b1019d9;
12
+ box-shadow: 0 12px 40px #0008;
13
+ color: #aeb8c8;
14
+ font:
15
+ 12px/1.6 Inter,
16
+ ui-sans-serif,
17
+ system-ui,
18
+ sans-serif;
19
+ backdrop-filter: blur(12px);
20
+ pointer-events: none;
21
+ }
22
+
23
+ .inspector-hint kbd {
24
+ padding: 1px 5px;
25
+ border-radius: 4px;
26
+ background: #263247;
27
+ color: #f8fafc;
28
+ font:
29
+ 11px ui-monospace,
30
+ SFMono-Regular,
31
+ Menlo,
32
+ monospace;
33
+ }
@@ -0,0 +1,38 @@
1
+ // Loaded by the dev server only (see vite.config.ts), so production builds never include it
2
+ import { Inspector } from '@playcanvas/inspector';
3
+ import type { AppBase } from 'playcanvas';
4
+
5
+ import './inspector.css';
6
+
7
+ type Hook = {
8
+ register?(app: AppBase, info: object): void;
9
+ unregister?(app: AppBase): void;
10
+ };
11
+
12
+ // Every app announces itself to this hook as it starts, whichever format created it
13
+ const KEY = Symbol.for('playcanvas.inspector');
14
+ const scope = globalThis as unknown as Record<symbol, Hook | undefined>;
15
+
16
+ // Keep a hook installed earlier, such as a devtools extension's
17
+ const prev = scope[KEY];
18
+
19
+ scope[KEY] = {
20
+ register(app, info) {
21
+ prev?.register?.(app, info);
22
+
23
+ // Skip headless apps, such as the null apps @playcanvas/react creates for validation. Each
24
+ // panel destroys itself with its app
25
+ if (!app.graphicsDevice.isNull) new Inspector(app, { visible: false });
26
+ },
27
+ unregister(app) {
28
+ prev?.unregister?.(app);
29
+ }
30
+ };
31
+
32
+ console.info('PlayCanvas Inspector: press ` to toggle');
33
+
34
+ // Key hint, a separate corner chip so the starter's own UI stays untouched
35
+ const hint = document.createElement('p');
36
+ hint.className = 'inspector-hint';
37
+ hint.innerHTML = '<kbd>`</kbd> inspector · <kbd>F9</kbd> pause · <kbd>F10</kbd> step';
38
+ document.body.append(hint);
@@ -3,5 +3,16 @@ import { defineConfig } from 'vite';
3
3
 
4
4
  // https://vite.dev/config/
5
5
  export default defineConfig({
6
- plugins: [react()]
6
+ plugins: [
7
+ react(),
8
+ {
9
+ // Dev only: install the PlayCanvas Inspector before the app starts (press ` to toggle)
10
+ name: 'playcanvas-inspector',
11
+ apply: 'serve',
12
+ config: () => ({ optimizeDeps: { include: ['@playcanvas/inspector'] } }),
13
+ transformIndexHtml: () => [
14
+ { tag: 'script', attrs: { type: 'module', src: '/src/inspector.ts' }, injectTo: 'head-prepend' }
15
+ ]
16
+ }
17
+ ]
7
18
  });
@@ -1,7 +1,7 @@
1
1
  import { Application, Entity } from '@playcanvas/react';
2
2
  import { Camera, Light, Script } from '@playcanvas/react/components';
3
3
  import { useApp, useModel } from '@playcanvas/react/hooks';
4
- import type { ContainerResource, Entity as PcEntity, RenderComponent } from 'playcanvas';
4
+ import type { ContainerResource, Entity as PcEntity } from 'playcanvas';
5
5
  import { Color, CylinderGeometry, Mesh, MeshInstance, StandardMaterial, TONEMAP_ACES2, Vec2, Vec3 } from 'playcanvas';
6
6
  import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
7
7
  import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
@@ -32,7 +32,7 @@ function Product({ color, shade }: { color: string; shade: string }) {
32
32
  entity.setLocalEulerAngles(-90, -25, 0);
33
33
  root.current.addChild(entity);
34
34
 
35
- const meshes = (entity.findComponents('render') as RenderComponent[]).flatMap((render) => render.meshInstances);
35
+ const meshes = entity.findComponents('render').flatMap((render) => render.meshInstances);
36
36
  const bounds = meshes[0].aabb.clone();
37
37
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
38
38
  const scale = 5 / Math.max(bounds.halfExtents.x * 2, bounds.halfExtents.z * 2);
@@ -42,10 +42,10 @@ function Product({ color, shade }: { color: string; shade: string }) {
42
42
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
43
43
  entity.setPosition(-bounds.center.x, 0.03 - bounds.getMin().y, -bounds.center.z);
44
44
 
45
- const body = meshes.filter((mesh) => mesh.material.name === 'material');
46
- const panels = meshes.filter((mesh) => mesh.material.name === 'material_1');
47
- paint.current = body[0].material.clone() as StandardMaterial;
48
- tone.current = panels[0].material.clone() as StandardMaterial;
45
+ const body = meshes.filter((mesh) => mesh.material?.name === 'material');
46
+ const panels = meshes.filter((mesh) => mesh.material?.name === 'material_1');
47
+ paint.current = body[0].material!.clone() as StandardMaterial;
48
+ tone.current = panels[0].material!.clone() as StandardMaterial;
49
49
  body.forEach((mesh) => (mesh.material = paint.current!));
50
50
  panels.forEach((mesh) => (mesh.material = tone.current!));
51
51
  return () => {
@@ -79,6 +79,8 @@ function Pine({
79
79
  scale={[size * width, size * height, size * width]}
80
80
  >
81
81
  <Render type="cone" material={i === 1 ? tips : leaves} />
82
+ {/* A trigger: it stops the camera's line of sight but the player walks through it */}
83
+ <Collision type="cone" radius={(size * width) / 2} height={size * height} />
82
84
  </Entity>
83
85
  ))}
84
86
  </>
@@ -139,9 +141,10 @@ function Scene() {
139
141
  <Block name="fallen-log" position={[0, 0.35, 6]} scale={[3.8, 0.7, 0.7]} material={wood} />
140
142
  <Block name="stump" position={[-1.8, 0.4, -2.2]} scale={[0.9, 0.8, 0.9]} material={wood} />
141
143
  <Entity name="player" ref={player} position={[0, 1.1, 0]}>
142
- <Collision type="capsule" radius={0.5} height={2.4} linearOffset={[0, 0.1, 0]} />
144
+ {/* A box rather than a capsule, so the player stands on ledges instead of sliding off them */}
145
+ <Collision type="box" halfExtents={[0.4, 1.2, 0.4]} linearOffset={[0, 0.2, 0]} />
143
146
  <RigidBody type="dynamic" mass={70} angularFactor={[0, 0, 0]} />
144
- <Entity name="character" ref={model} position={[0, 0.2, 0]}>
147
+ <Entity name="character" ref={model} position={[0, 0.3, 0]}>
145
148
  <Entity name="body" scale={[0.56, 0.88, 0.38]}>
146
149
  <Render type="box" material={shirt} />
147
150
  </Entity>
@@ -18,6 +18,10 @@ npm run dev
18
18
 
19
19
  Open <http://localhost:5173>. Edit `index.html` to change the scene.
20
20
 
21
+ ## Inspector
22
+
23
+ `npm run dev` loads the [PlayCanvas Inspector](https://github.com/playcanvas/inspector), a debug panel for the hierarchy, assets, materials, textures, frame graph and physics. Press the backquote key (<kbd>&#96;</kbd>) to show or hide it, <kbd>F9</kbd> to pause and <kbd>F10</kbd> to step a frame. It is wired up in `vite.config.ts` and `src/inspector.ts` and is left out of production builds.
24
+
21
25
  ## Scripts
22
26
 
23
27
  | Command | Description |
@@ -15,15 +15,16 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@playcanvas/web-components": "^0.26.0",
19
- "playcanvas": "^2.22.6"
18
+ "@playcanvas/web-components": "^0.28.0",
19
+ "playcanvas": "^2.23.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@playcanvas/eslint-config": "3.0.0-beta.8",
23
+ "@playcanvas/inspector": "^0.2.0",
23
24
  "eslint": "^9.39.5",
24
25
  "prettier": "^3.9.9",
25
26
  "typescript": "~6.0.3",
26
- "vite": "^8.3.1"
27
+ "vite": "^8.3.4"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=22.23.2"
@@ -0,0 +1,33 @@
1
+ /* Inspector key hint, added on the dev server only by src/inspector.ts. Styled like the starter panels */
2
+ .inspector-hint {
3
+ position: fixed;
4
+ bottom: 20px;
5
+ left: 20px;
6
+ z-index: 10;
7
+ margin: 0;
8
+ padding: 8px 12px;
9
+ border: 1px solid #ffffff24;
10
+ border-radius: 10px;
11
+ background: #0b1019d9;
12
+ box-shadow: 0 12px 40px #0008;
13
+ color: #aeb8c8;
14
+ font:
15
+ 12px/1.6 Inter,
16
+ ui-sans-serif,
17
+ system-ui,
18
+ sans-serif;
19
+ backdrop-filter: blur(12px);
20
+ pointer-events: none;
21
+ }
22
+
23
+ .inspector-hint kbd {
24
+ padding: 1px 5px;
25
+ border-radius: 4px;
26
+ background: #263247;
27
+ color: #f8fafc;
28
+ font:
29
+ 11px ui-monospace,
30
+ SFMono-Regular,
31
+ Menlo,
32
+ monospace;
33
+ }
@@ -0,0 +1,38 @@
1
+ // Loaded by the dev server only (see vite.config.ts), so production builds never include it
2
+ import { Inspector } from '@playcanvas/inspector';
3
+ import type { AppBase } from 'playcanvas';
4
+
5
+ import './inspector.css';
6
+
7
+ type Hook = {
8
+ register?(app: AppBase, info: object): void;
9
+ unregister?(app: AppBase): void;
10
+ };
11
+
12
+ // Every app announces itself to this hook as it starts, whichever format created it
13
+ const KEY = Symbol.for('playcanvas.inspector');
14
+ const scope = globalThis as unknown as Record<symbol, Hook | undefined>;
15
+
16
+ // Keep a hook installed earlier, such as a devtools extension's
17
+ const prev = scope[KEY];
18
+
19
+ scope[KEY] = {
20
+ register(app, info) {
21
+ prev?.register?.(app, info);
22
+
23
+ // Skip headless apps, such as the null apps @playcanvas/react creates for validation. Each
24
+ // panel destroys itself with its app
25
+ if (!app.graphicsDevice.isNull) new Inspector(app, { visible: false });
26
+ },
27
+ unregister(app) {
28
+ prev?.unregister?.(app);
29
+ }
30
+ };
31
+
32
+ console.info('PlayCanvas Inspector: press ` to toggle');
33
+
34
+ // Key hint, a separate corner chip so the starter's own UI stays untouched
35
+ const hint = document.createElement('p');
36
+ hint.className = 'inspector-hint';
37
+ hint.innerHTML = '<kbd>`</kbd> inspector · <kbd>F9</kbd> pause · <kbd>F10</kbd> step';
38
+ document.body.append(hint);
@@ -21,5 +21,5 @@
21
21
  "noFallthroughCasesInSwitch": true,
22
22
  "noUncheckedSideEffectImports": true
23
23
  },
24
- "include": ["src"]
24
+ "include": ["src", "vite.config.ts"]
25
25
  }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ // https://vite.dev/config/
4
+ export default defineConfig({
5
+ plugins: [
6
+ {
7
+ // Dev only: install the PlayCanvas Inspector before the app starts (press ` to toggle)
8
+ name: 'playcanvas-inspector',
9
+ apply: 'serve',
10
+ config: () => ({ optimizeDeps: { include: ['@playcanvas/inspector'] } }),
11
+ transformIndexHtml: () => [
12
+ { tag: 'script', attrs: { type: 'module', src: '/src/inspector.ts' }, injectTo: 'head-prepend' }
13
+ ]
14
+ }
15
+ ]
16
+ });
@@ -1,6 +1,5 @@
1
1
  import type { EntityElement } from '@playcanvas/web-components';
2
2
  import { whenReady } from '@playcanvas/web-components';
3
- import type { Texture } from 'playcanvas';
4
3
  import { Asset, TEXTURETYPE_RGBP } from 'playcanvas';
5
4
  import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
6
5
  import { Grid } from 'playcanvas/scripts/esm/grid.mjs';
@@ -40,7 +39,7 @@ await new Promise<void>((resolve) => {
40
39
  });
41
40
 
42
41
  // Light the scene from the environment, with the skybox itself left hidden
43
- app.scene.envAtlas = envAtlas.resource as Texture;
42
+ app.scene.envAtlas = envAtlas.resource!;
44
43
  const skyboxLayer = app.scene.layers.getLayerByName('Skybox');
45
44
  if (skyboxLayer) {
46
45
  skyboxLayer.enabled = false;
@@ -1,6 +1,5 @@
1
1
  import type { AssetElement, EntityElement, ModelElement } from '@playcanvas/web-components';
2
2
  import { whenReady } from '@playcanvas/web-components';
3
- import type { RenderComponent } from 'playcanvas';
4
3
  import { Color, CylinderGeometry, Mesh, MeshInstance, StandardMaterial, Vec2, Vec3 } from 'playcanvas';
5
4
  import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
6
5
  import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
@@ -63,7 +62,7 @@ ground.addComponent('render', {
63
62
  const entity = model.entity!;
64
63
  entity.setLocalEulerAngles(0, -25, 0);
65
64
  entity.root.syncHierarchy();
66
- const meshes = (entity.findComponents('render') as RenderComponent[]).flatMap((render) => render.meshInstances);
65
+ const meshes = entity.findComponents('render').flatMap((render) => render.meshInstances);
67
66
  const bounds = meshes[0].aabb.clone();
68
67
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
69
68
  const scale = 5 / Math.max(bounds.halfExtents.x * 2, bounds.halfExtents.z * 2);
@@ -73,10 +72,10 @@ bounds.copy(meshes[0].aabb);
73
72
  meshes.slice(1).forEach((mesh) => bounds.add(mesh.aabb));
74
73
  entity.setPosition(-bounds.center.x, 0.03 - bounds.getMin().y, -bounds.center.z);
75
74
 
76
- const body = meshes.filter((mesh) => mesh.material.name === 'material');
77
- const panels = meshes.filter((mesh) => mesh.material.name === 'material_1');
78
- const paint = body[0].material.clone() as StandardMaterial;
79
- const shade = panels[0].material.clone() as StandardMaterial;
75
+ const body = meshes.filter((mesh) => mesh.material?.name === 'material');
76
+ const panels = meshes.filter((mesh) => mesh.material?.name === 'material_1');
77
+ const paint = body[0].material!.clone() as StandardMaterial;
78
+ const shade = panels[0].material!.clone() as StandardMaterial;
80
79
  body.forEach((mesh) => (mesh.material = paint));
81
80
  panels.forEach((mesh) => (mesh.material = shade));
82
81
  paint.diffuse.fromString('#d6293e');
@@ -65,12 +65,15 @@
65
65
  ></pc-entity>
66
66
  <pc-entity name="tree-0-crown-0" position="-7 2.09 -6" scale="2.31 1.98 2.31"
67
67
  ><pc-render type="cone" material="leaves-material"></pc-render
68
+ ><pc-collision type="cone" radius="1.155" height="1.98"></pc-collision
68
69
  ></pc-entity>
69
70
  <pc-entity name="tree-0-crown-1" position="-7 2.915 -6" scale="1.76 1.705 1.76"
70
71
  ><pc-render type="cone" material="tips-material"></pc-render
72
+ ><pc-collision type="cone" radius="0.88" height="1.705"></pc-collision
71
73
  ></pc-entity>
72
74
  <pc-entity name="tree-0-crown-2" position="-7 3.63 -6" scale="1.21 1.54 1.21"
73
75
  ><pc-render type="cone" material="leaves-material"></pc-render
76
+ ><pc-collision type="cone" radius="0.605" height="1.54"></pc-collision
74
77
  ></pc-entity>
75
78
  <pc-entity name="tree-1-trunk" position="6.5 1.125 -6.5" scale="0.475 2.25 0.475"
76
79
  ><pc-render type="box" material="wood-material"></pc-render
@@ -79,12 +82,15 @@
79
82
  ></pc-entity>
80
83
  <pc-entity name="tree-1-crown-0" position="6.5 2.375 -6.5" scale="2.625 2.25 2.625"
81
84
  ><pc-render type="cone" material="leaves-material"></pc-render
85
+ ><pc-collision type="cone" radius="1.3125" height="2.25"></pc-collision
82
86
  ></pc-entity>
83
87
  <pc-entity name="tree-1-crown-1" position="6.5 3.3125 -6.5" scale="2 1.9375 2"
84
88
  ><pc-render type="cone" material="tips-material"></pc-render
89
+ ><pc-collision type="cone" radius="1" height="1.9375"></pc-collision
85
90
  ></pc-entity>
86
91
  <pc-entity name="tree-1-crown-2" position="6.5 4.125 -6.5" scale="1.375 1.75 1.375"
87
92
  ><pc-render type="cone" material="leaves-material"></pc-render
93
+ ><pc-collision type="cone" radius="0.6875" height="1.75"></pc-collision
88
94
  ></pc-entity>
89
95
  <pc-entity name="tree-2-trunk" position="-7.5 0.81 5" scale="0.342 1.62 0.342"
90
96
  ><pc-render type="box" material="wood-material"></pc-render
@@ -93,12 +99,15 @@
93
99
  ></pc-entity>
94
100
  <pc-entity name="tree-2-crown-0" position="-7.5 1.71 5" scale="1.89 1.62 1.89"
95
101
  ><pc-render type="cone" material="leaves-material"></pc-render
102
+ ><pc-collision type="cone" radius="0.945" height="1.62"></pc-collision
96
103
  ></pc-entity>
97
104
  <pc-entity name="tree-2-crown-1" position="-7.5 2.385 5" scale="1.44 1.395 1.44"
98
105
  ><pc-render type="cone" material="tips-material"></pc-render
106
+ ><pc-collision type="cone" radius="0.72" height="1.395"></pc-collision
99
107
  ></pc-entity>
100
108
  <pc-entity name="tree-2-crown-2" position="-7.5 2.97 5" scale="0.99 1.26 0.99"
101
109
  ><pc-render type="cone" material="leaves-material"></pc-render
110
+ ><pc-collision type="cone" radius="0.495" height="1.26"></pc-collision
102
111
  ></pc-entity>
103
112
  <pc-entity name="tree-3-trunk" position="7 1.035 5.5" scale="0.437 2.07 0.437"
104
113
  ><pc-render type="box" material="wood-material"></pc-render
@@ -107,12 +116,15 @@
107
116
  ></pc-entity>
108
117
  <pc-entity name="tree-3-crown-0" position="7 2.185 5.5" scale="2.415 2.07 2.415"
109
118
  ><pc-render type="cone" material="leaves-material"></pc-render
119
+ ><pc-collision type="cone" radius="1.2075" height="2.07"></pc-collision
110
120
  ></pc-entity>
111
121
  <pc-entity name="tree-3-crown-1" position="7 3.0475 5.5" scale="1.84 1.7825 1.84"
112
122
  ><pc-render type="cone" material="tips-material"></pc-render
123
+ ><pc-collision type="cone" radius="0.92" height="1.7825"></pc-collision
113
124
  ></pc-entity>
114
125
  <pc-entity name="tree-3-crown-2" position="7 3.795 5.5" scale="1.265 1.61 1.265"
115
126
  ><pc-render type="cone" material="leaves-material"></pc-render
127
+ ><pc-collision type="cone" radius="0.6325" height="1.61"></pc-collision
116
128
  ></pc-entity>
117
129
  <pc-entity name="tree-4-trunk" position="-2.2 0.765 -8" scale="0.323 1.53 0.323"
118
130
  ><pc-render type="box" material="wood-material"></pc-render
@@ -121,12 +133,15 @@
121
133
  ></pc-entity>
122
134
  <pc-entity name="tree-4-crown-0" position="-2.2 1.615 -8" scale="1.785 1.53 1.785"
123
135
  ><pc-render type="cone" material="leaves-material"></pc-render
136
+ ><pc-collision type="cone" radius="0.8925" height="1.53"></pc-collision
124
137
  ></pc-entity>
125
138
  <pc-entity name="tree-4-crown-1" position="-2.2 2.2525 -8" scale="1.36 1.3175 1.36"
126
139
  ><pc-render type="cone" material="tips-material"></pc-render
140
+ ><pc-collision type="cone" radius="0.68" height="1.3175"></pc-collision
127
141
  ></pc-entity>
128
142
  <pc-entity name="tree-4-crown-2" position="-2.2 2.805 -8" scale="0.935 1.19 0.935"
129
143
  ><pc-render type="cone" material="leaves-material"></pc-render
144
+ ><pc-collision type="cone" radius="0.4675" height="1.19"></pc-collision
130
145
  ></pc-entity>
131
146
  <pc-entity name="fallen-log" position="0 0.35 6" scale="3.8 0.7 0.7"
132
147
  ><pc-render type="box" material="wood-material"></pc-render
@@ -139,9 +154,9 @@
139
154
  ><pc-rigid-body type="static"></pc-rigid-body
140
155
  ></pc-entity>
141
156
  <pc-entity name="player" position="0 1.1 0">
142
- <pc-collision type="capsule" radius="0.5" height="2.4" linear-offset="0 0.1 0"></pc-collision>
157
+ <pc-collision type="box" half-extents="0.4 1.2 0.4" linear-offset="0 0.2 0"></pc-collision>
143
158
  <pc-rigid-body type="dynamic" mass="70" angular-factor="0 0 0"></pc-rigid-body>
144
- <pc-entity name="character" position="0 0.2 0">
159
+ <pc-entity name="character" position="0 0.3 0">
145
160
  <pc-entity name="body" scale="0.56 0.88 0.38"
146
161
  ><pc-render type="box" material="shirt-material"></pc-render
147
162
  ></pc-entity>