create-playcanvas 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -16
- package/dist/index.mjs +20 -20
- package/package.json +2 -2
- package/templates/_shared/{ar-placement → ar}/README.md +1 -1
- package/templates/_shared/blank/README.md +7 -0
- package/templates/_shared/first-person-game/AGENTS.md +11 -0
- package/templates/_shared/first-person-game/README.md +21 -0
- package/templates/_shared/first-person-game/public/level.glb +0 -0
- package/templates/_shared/first-person-game/src/game.css +47 -0
- package/templates/_shared/first-person-game/src/game.ts +232 -0
- package/templates/_shared/{sprite-game → platformer}/README.md +1 -1
- package/templates/_shared/{sprite-game/src/sprite-game.ts → platformer/src/platformer.ts} +3 -3
- package/templates/_shared/{third-person-controller → third-person-game}/README.md +1 -1
- package/templates/_shared/{vr-starter → vr}/README.md +1 -1
- package/templates/engine/ar/index.html +20 -0
- package/templates/engine/{ar-placement → ar}/src/main.ts +22 -12
- package/templates/engine/base/package.json +2 -2
- package/templates/engine/first-person-game/index.html +26 -0
- package/templates/engine/first-person-game/src/main.ts +167 -0
- package/templates/engine/platformer/index.html +19 -0
- package/templates/engine/{sprite-game → platformer}/src/main.ts +21 -13
- package/templates/engine/product-configurator/index.html +30 -0
- package/templates/engine/product-configurator/src/main.ts +33 -32
- package/templates/engine/third-person-game/index.html +19 -0
- package/templates/engine/{third-person-controller → third-person-game}/src/main.ts +16 -11
- package/templates/engine/vr/index.html +20 -0
- package/templates/engine/{vr-starter → vr}/src/main.ts +22 -12
- package/templates/index.ts +14 -14
- package/templates/react/{ar-placement → ar}/src/App.tsx +1 -1
- package/templates/react/base/package.json +2 -2
- package/templates/react/first-person-game/src/App.tsx +140 -0
- package/templates/react/{sprite-game → platformer}/src/App.tsx +3 -3
- package/templates/react/{third-person-controller → third-person-game}/src/App.tsx +1 -1
- package/templates/react/{vr-starter → vr}/src/App.tsx +1 -1
- package/templates/web-components/{ar-placement → ar}/index.html +2 -2
- package/templates/web-components/base/package.json +2 -2
- package/templates/web-components/first-person-game/index.html +80 -0
- package/templates/web-components/first-person-game/src/main.ts +74 -0
- package/templates/web-components/{sprite-game → platformer}/index.html +2 -2
- package/templates/web-components/{sprite-game → platformer}/src/main.ts +2 -2
- package/templates/web-components/{third-person-controller → third-person-game}/index.html +2 -2
- package/templates/web-components/{vr-starter → vr}/index.html +2 -2
- package/templates/_shared/first-person-controller/README.md +0 -7
- package/templates/_shared/first-person-controller/src/controller.ts +0 -18
- package/templates/_shared/spinning-cube/README.md +0 -7
- package/templates/engine/first-person-controller/src/main.ts +0 -138
- package/templates/react/first-person-controller/src/App.tsx +0 -115
- package/templates/web-components/first-person-controller/index.html +0 -163
- package/templates/web-components/first-person-controller/src/main.ts +0 -15
- /package/templates/_shared/{ar-placement → ar}/src/ar-placement.ts +0 -0
- /package/templates/_shared/{first-person-controller → first-person-game}/src/scripts.d.ts +0 -0
- /package/templates/_shared/{third-person-controller → third-person-game}/src/controller.ts +0 -0
- /package/templates/_shared/{third-person-controller → third-person-game}/src/scripts.d.ts +0 -0
- /package/templates/_shared/{vr-starter → vr}/src/vr.ts +0 -0
- /package/templates/_shared/{vr-starter → vr}/src/xr.d.ts +0 -0
- /package/templates/engine/{spinning-cube → blank}/src/main.ts +0 -0
- /package/templates/react/{spinning-cube → blank}/src/App.tsx +0 -0
- /package/templates/web-components/{ar-placement → ar}/src/main.ts +0 -0
- /package/templates/web-components/{spinning-cube → blank}/index.html +0 -0
- /package/templates/web-components/{spinning-cube → blank}/src/main.ts +0 -0
- /package/templates/web-components/{third-person-controller → third-person-game}/src/main.ts +0 -0
- /package/templates/web-components/{vr-starter → vr}/src/main.ts +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
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 `Game` and drive the HUD from the `game:state`
|
|
3
|
+
// app event and `CARDS`
|
|
4
|
+
import type { Asset, Entity, RigidBodyComponentSystem } from 'playcanvas';
|
|
5
|
+
import { Script, Vec3 } from 'playcanvas';
|
|
6
|
+
import { FirstPersonController } from 'playcanvas/scripts/esm/first-person-controller.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where a run is: waiting for the first click, in play, held, held because the browser wouldn't
|
|
10
|
+
* lock the mouse, or finished either way.
|
|
11
|
+
*/
|
|
12
|
+
export type GameState = 'ready' | 'playing' | 'paused' | 'blocked' | 'won' | 'lost';
|
|
13
|
+
|
|
14
|
+
/** The app events the HUD follows. */
|
|
15
|
+
export type GameEvents = {
|
|
16
|
+
/** Fired by the game whenever its state, the orbs found or the whole seconds left change. */
|
|
17
|
+
'game:state': (state: GameState, found: number, total: number, seconds: number) => void;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fired by the controller as a thumb on the left of the screen steers: where it went down and
|
|
21
|
+
* where it is now, in page pixels, and -1 for all four once it lifts.
|
|
22
|
+
*/
|
|
23
|
+
'joystick:left': (baseX: number, baseY: number, knobX: number, knobY: number) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** What the HUD card says in each state. `null` hides the card. */
|
|
27
|
+
export const CARDS: Record<GameState, { title: string; hint: string } | null> = {
|
|
28
|
+
ready: { title: 'Orb Run', hint: 'Collect every orb before the clock runs out. Click or tap to play' },
|
|
29
|
+
playing: null,
|
|
30
|
+
paused: { title: 'Paused', hint: 'Click or tap to resume' },
|
|
31
|
+
blocked: {
|
|
32
|
+
title: 'Mouse not locked',
|
|
33
|
+
hint: "The browser didn't lock the mouse to the game, and the keyboard and mouse can't steer without it. Click to try again, or open the game in its own browser tab if this keeps happening"
|
|
34
|
+
},
|
|
35
|
+
won: { title: 'You got them all', hint: 'Click or tap to play again' },
|
|
36
|
+
lost: { title: 'Out of time', hint: 'Click or tap to play again' }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// seconds a run lasts
|
|
40
|
+
const TIME_LIMIT = 90;
|
|
41
|
+
|
|
42
|
+
// how close the middle of the player must come to an orb to collect it, in metres
|
|
43
|
+
const REACH = 1.2;
|
|
44
|
+
|
|
45
|
+
// the height of the player's middle above the ground it stands on: half the capsule's height
|
|
46
|
+
const PLAYER_HEIGHT = 0.9;
|
|
47
|
+
|
|
48
|
+
// milliseconds after a run ends before a click starts another, so a touch that was steering when
|
|
49
|
+
// the clock ran out doesn't restart it
|
|
50
|
+
const RESTART_DELAY = 800;
|
|
51
|
+
|
|
52
|
+
// stronger than Earth's 9.8, so a jump is quick and lands firmly
|
|
53
|
+
const GRAVITY = -18;
|
|
54
|
+
|
|
55
|
+
// degrees an orb turns each second
|
|
56
|
+
const ORB_SPIN = 90;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A first-person game: collect every orb in the level before the clock runs out. It builds the level
|
|
60
|
+
* from the `level` asset, gives `player` a first-person controller and talks to the page only
|
|
61
|
+
* through the `game:state` app event.
|
|
62
|
+
*
|
|
63
|
+
* The level is a GLB whose nodes are found by name: a mesh called `level` to look at, one called
|
|
64
|
+
* `collision` to stand on, any number called `orb-…` to collect, and one called `spawn` where the
|
|
65
|
+
* player starts.
|
|
66
|
+
*/
|
|
67
|
+
class Game extends Script {
|
|
68
|
+
static scriptName = 'game';
|
|
69
|
+
|
|
70
|
+
/** The level's GLB, as a loaded container asset. */
|
|
71
|
+
level!: Asset<'container'>;
|
|
72
|
+
|
|
73
|
+
/** The player: an entity with a capsule collision and a dynamic rigid body. */
|
|
74
|
+
player!: Entity;
|
|
75
|
+
|
|
76
|
+
/** The camera, a child of the player at eye height. */
|
|
77
|
+
camera!: Entity;
|
|
78
|
+
|
|
79
|
+
state: GameState = 'ready';
|
|
80
|
+
found = 0;
|
|
81
|
+
timeLeft = TIME_LIMIT;
|
|
82
|
+
endedAt = 0;
|
|
83
|
+
|
|
84
|
+
// whether the canvas holds the mouse
|
|
85
|
+
locked = false;
|
|
86
|
+
|
|
87
|
+
orbs: Entity[] = [];
|
|
88
|
+
spawn = new Vec3();
|
|
89
|
+
|
|
90
|
+
initialize() {
|
|
91
|
+
const canvas = this.app.graphicsDevice.canvas;
|
|
92
|
+
|
|
93
|
+
const root = this.level.resource!.instantiateRenderEntity();
|
|
94
|
+
this.entity.addChild(root);
|
|
95
|
+
|
|
96
|
+
// the player stands on the level's collision mesh, which is simpler than the one they see: a
|
|
97
|
+
// flight of stairs is a ramp in it, so the capsule glides up. It is never drawn. A level
|
|
98
|
+
// without one is stood on as it is seen
|
|
99
|
+
const solid = (root.findByName('collision') ?? root.findByName('level')) as Entity;
|
|
100
|
+
solid.addComponent('collision', { type: 'mesh', renderAsset: solid.render!.asset });
|
|
101
|
+
solid.addComponent('rigidbody', { type: 'static' });
|
|
102
|
+
if (solid.name === 'collision') solid.render!.enabled = false;
|
|
103
|
+
|
|
104
|
+
this.orbs = root.find((node) => node.name.startsWith('orb-')) as Entity[];
|
|
105
|
+
this.spawn.copy(root.findByName('spawn')!.getPosition()).add(new Vec3(0, PLAYER_HEIGHT, 0));
|
|
106
|
+
|
|
107
|
+
(this.app.systems.rigidbody as RigidBodyComponentSystem).gravity.set(0, GRAVITY, 0);
|
|
108
|
+
if (!this.player.script) this.player.addComponent('script');
|
|
109
|
+
|
|
110
|
+
canvas.addEventListener('pointerdown', this.onPointerDown);
|
|
111
|
+
document.addEventListener('pointerlockchange', this.onPointerLockChange);
|
|
112
|
+
document.addEventListener('pointerlockerror', this.onPointerLockError);
|
|
113
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
114
|
+
this.on('destroy', () => {
|
|
115
|
+
canvas.removeEventListener('pointerdown', this.onPointerDown);
|
|
116
|
+
document.removeEventListener('pointerlockchange', this.onPointerLockChange);
|
|
117
|
+
document.removeEventListener('pointerlockerror', this.onPointerLockError);
|
|
118
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
119
|
+
this.player.script?.destroy('firstPersonController');
|
|
120
|
+
root.destroy();
|
|
121
|
+
this.app.timeScale = 1;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.reset();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Puts everything back where a run starts. The first run goes through here too. */
|
|
128
|
+
reset() {
|
|
129
|
+
this.found = 0;
|
|
130
|
+
this.timeLeft = TIME_LIMIT;
|
|
131
|
+
for (const orb of this.orbs) orb.enabled = true;
|
|
132
|
+
|
|
133
|
+
this.player.rigidbody!.teleport(this.spawn);
|
|
134
|
+
this.player.rigidbody!.linearVelocity = Vec3.ZERO;
|
|
135
|
+
|
|
136
|
+
// a new controller each run, so the view starts level and facing into the yard again
|
|
137
|
+
this.player.script!.destroy('firstPersonController');
|
|
138
|
+
this.camera.setLocalEulerAngles(0, 0, 0);
|
|
139
|
+
this.player.script!.create(FirstPersonController, {
|
|
140
|
+
properties: {
|
|
141
|
+
camera: this.camera,
|
|
142
|
+
// tuned for the player's 80 kg under this gravity: a jump rises about 1.8 m, enough
|
|
143
|
+
// for every step and gap in the yard, and the ground speed is a brisk run
|
|
144
|
+
jumpForce: 650,
|
|
145
|
+
speedGround: 55
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
this.setState('ready');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setState(state: GameState) {
|
|
153
|
+
this.state = state;
|
|
154
|
+
|
|
155
|
+
// stop the clock outside play: physics, the controller and this script's own update all
|
|
156
|
+
// run on scaled time, so nothing moves and no time is lost
|
|
157
|
+
this.app.timeScale = state === 'playing' ? 1 : 0;
|
|
158
|
+
this.report();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
report() {
|
|
162
|
+
this.app.fire('game:state', this.state, this.found, this.orbs.length, Math.ceil(this.timeLeft));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
end(state: 'won' | 'lost') {
|
|
166
|
+
this.endedAt = performance.now();
|
|
167
|
+
this.setState(state);
|
|
168
|
+
|
|
169
|
+
// hand the mouse back, after the state has changed, so losing the lock isn't read as a pause
|
|
170
|
+
if (document.pointerLockElement) document.exitPointerLock();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
onPointerDown = (event: PointerEvent) => {
|
|
174
|
+
if (this.state === 'playing') return;
|
|
175
|
+
|
|
176
|
+
if (this.state === 'won' || this.state === 'lost') {
|
|
177
|
+
if (performance.now() - this.endedAt < RESTART_DELAY) return;
|
|
178
|
+
this.reset();
|
|
179
|
+
|
|
180
|
+
// the controller asks for the mouse on every click, but the one this reset made joined
|
|
181
|
+
// too late to hear this click, so ask for it here. Only here: asking twice for one
|
|
182
|
+
// click makes the browser report a lock that was lost
|
|
183
|
+
if (event.pointerType === 'mouse') this.app.graphicsDevice.canvas.requestPointerLock();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.setState('playing');
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Escape gives the mouse back, which holds the game. Only a lock that was held can be lost: a
|
|
190
|
+
// browser that refuses the lock also reports a change, and touch never has one
|
|
191
|
+
onPointerLockChange = () => {
|
|
192
|
+
const locked = document.pointerLockElement === this.app.graphicsDevice.canvas;
|
|
193
|
+
if (this.locked && !locked && this.state === 'playing') this.setState('paused');
|
|
194
|
+
this.locked = locked;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// The controller ignores the keyboard and the mouse until the canvas holds the lock. A browser
|
|
198
|
+
// can refuse it, as a preview pane embedded in an editor does, and Chrome does for a second after
|
|
199
|
+
// Escape. Say so, since otherwise the run starts and nothing steers it
|
|
200
|
+
onPointerLockError = () => {
|
|
201
|
+
if (this.state === 'playing') this.setState('blocked');
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
onVisibilityChange = () => {
|
|
205
|
+
if (document.hidden && this.state === 'playing') this.setState('paused');
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
update(dt: number) {
|
|
209
|
+
if (this.state !== 'playing') return;
|
|
210
|
+
|
|
211
|
+
const position = this.player.getPosition();
|
|
212
|
+
|
|
213
|
+
const seconds = Math.ceil(this.timeLeft);
|
|
214
|
+
this.timeLeft = Math.max(this.timeLeft - dt, 0);
|
|
215
|
+
|
|
216
|
+
const found = this.found;
|
|
217
|
+
for (const orb of this.orbs) {
|
|
218
|
+
if (!orb.enabled) continue;
|
|
219
|
+
orb.rotate(0, ORB_SPIN * dt, 0);
|
|
220
|
+
if (orb.getPosition().distance(position) < REACH) {
|
|
221
|
+
orb.enabled = false;
|
|
222
|
+
this.found++;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.found === this.orbs.length) this.end('won');
|
|
227
|
+
else if (this.timeLeft === 0) this.end('lost');
|
|
228
|
+
else if (this.found !== found || Math.ceil(this.timeLeft) !== seconds) this.report();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { Game };
|
|
@@ -28,8 +28,8 @@ const PLATFORMS = [
|
|
|
28
28
|
{ x: 3.6, y: 1.1, width: 2.8 }
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
-
class
|
|
32
|
-
static scriptName = '
|
|
31
|
+
class Platformer extends Script {
|
|
32
|
+
static scriptName = 'platformer';
|
|
33
33
|
|
|
34
34
|
keys = new Set<string>();
|
|
35
35
|
player!: Entity;
|
|
@@ -194,4 +194,4 @@ class SpriteGame extends Script {
|
|
|
194
194
|
|
|
195
195
|
const mathClamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
196
196
|
|
|
197
|
-
export {
|
|
197
|
+
export { Platformer };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/png" href="/src/assets/playcanvas.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Augmented Reality</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="application-canvas"></canvas>
|
|
11
|
+
<div class="hud">
|
|
12
|
+
<section class="panel">
|
|
13
|
+
<h1>Augmented Reality</h1>
|
|
14
|
+
<p id="xr-status">Checking AR support…</p>
|
|
15
|
+
<div class="controls"><button id="xr-button" disabled>Start AR</button></div>
|
|
16
|
+
</section>
|
|
17
|
+
</div>
|
|
18
|
+
<script type="module" src="/src/main.ts"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -17,24 +17,36 @@ import {
|
|
|
17
17
|
import { ArPlacement } from './ar-placement';
|
|
18
18
|
import './starter.css';
|
|
19
19
|
|
|
20
|
-
document.body.insertAdjacentHTML(
|
|
21
|
-
'beforeend',
|
|
22
|
-
'<div class="hud"><section class="panel"><h1>AR Placement</h1><p id="xr-status">Checking AR support…</p><div class="controls"><button id="xr-button" disabled>Start AR</button></div></section></div>'
|
|
23
|
-
);
|
|
24
|
-
|
|
25
20
|
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
|
|
21
|
+
|
|
26
22
|
const device = await createGraphicsDevice(canvas);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
|
|
24
|
+
|
|
25
|
+
const createOptions = new AppOptions();
|
|
26
|
+
createOptions.graphicsDevice = device;
|
|
27
|
+
createOptions.componentSystems = [
|
|
28
|
+
RenderComponentSystem,
|
|
29
|
+
CameraComponentSystem,
|
|
30
|
+
LightComponentSystem,
|
|
31
|
+
ScriptComponentSystem
|
|
32
|
+
];
|
|
33
|
+
createOptions.xr = XrManager;
|
|
31
34
|
|
|
32
35
|
const app = new AppBase(canvas);
|
|
33
|
-
app.init(
|
|
36
|
+
app.init(createOptions);
|
|
34
37
|
app.start();
|
|
38
|
+
|
|
39
|
+
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
|
|
35
40
|
app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
|
|
36
41
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
|
37
42
|
|
|
43
|
+
// Ensure canvas is resized when window changes size
|
|
44
|
+
const resize = () => app.resizeCanvas();
|
|
45
|
+
window.addEventListener('resize', resize);
|
|
46
|
+
app.on('destroy', () => {
|
|
47
|
+
window.removeEventListener('resize', resize);
|
|
48
|
+
});
|
|
49
|
+
|
|
38
50
|
const camera = new Entity('camera');
|
|
39
51
|
camera.addComponent('camera', { clearColor: new Color(0.05, 0.08, 0.12, 1) });
|
|
40
52
|
camera.setPosition(3, 2.4, 4);
|
|
@@ -73,5 +85,3 @@ const placement = new Entity('placement');
|
|
|
73
85
|
placement.addComponent('script');
|
|
74
86
|
placement.script!.create(ArPlacement, { properties: { camera, preview } });
|
|
75
87
|
app.root.addChild(placement);
|
|
76
|
-
|
|
77
|
-
window.addEventListener('resize', () => app.resizeCanvas());
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"playcanvas": "^2.24.
|
|
18
|
+
"playcanvas": "^2.24.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@playcanvas/eslint-config": "3.0.0",
|
|
22
22
|
"@playcanvas/inspector": "^0.5.0",
|
|
23
23
|
"eslint": "^9.39.5",
|
|
24
|
-
"prettier": "^3.10.
|
|
24
|
+
"prettier": "^3.10.1",
|
|
25
25
|
"typescript": "~6.0.3",
|
|
26
26
|
"vite": "^8.3.8"
|
|
27
27
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/png" href="/src/assets/playcanvas.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>First-Person Game</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="application-canvas"></canvas>
|
|
11
|
+
<div class="hud">
|
|
12
|
+
<header class="status">
|
|
13
|
+
<span id="orbs"></span>
|
|
14
|
+
<span id="time"></span>
|
|
15
|
+
</header>
|
|
16
|
+
<section class="panel card" id="card">
|
|
17
|
+
<h1 id="card-title"></h1>
|
|
18
|
+
<p id="card-hint"></p>
|
|
19
|
+
</section>
|
|
20
|
+
<div class="crosshair"></div>
|
|
21
|
+
<div class="joystick" id="joystick" hidden></div>
|
|
22
|
+
<div class="joystick-knob" id="joystick-knob" hidden></div>
|
|
23
|
+
</div>
|
|
24
|
+
<script type="module" src="/src/main.ts"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AppBase,
|
|
3
|
+
AppOptions,
|
|
4
|
+
Asset,
|
|
5
|
+
AssetListLoader,
|
|
6
|
+
CameraComponentSystem,
|
|
7
|
+
CollisionComponentSystem,
|
|
8
|
+
Color,
|
|
9
|
+
ContainerHandler,
|
|
10
|
+
Entity,
|
|
11
|
+
FILLMODE_FILL_WINDOW,
|
|
12
|
+
LightComponentSystem,
|
|
13
|
+
RenderComponentSystem,
|
|
14
|
+
RESOLUTION_AUTO,
|
|
15
|
+
RigidBodyComponentSystem,
|
|
16
|
+
ScriptComponentSystem,
|
|
17
|
+
SHADOW_PCF5_32F,
|
|
18
|
+
TextureHandler,
|
|
19
|
+
TONEMAP_ACES2,
|
|
20
|
+
Vec3,
|
|
21
|
+
WasmModule,
|
|
22
|
+
createGraphicsDevice
|
|
23
|
+
} from 'playcanvas';
|
|
24
|
+
import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
|
|
25
|
+
|
|
26
|
+
import { CARDS, Game } from './game';
|
|
27
|
+
import type { GameEvents } from './game';
|
|
28
|
+
import './game.css';
|
|
29
|
+
import './starter.css';
|
|
30
|
+
|
|
31
|
+
// Swap this for your own level - files in public/ are served next to index.html, so use a ./ path.
|
|
32
|
+
// The game finds its nodes by name: a mesh called `level` to look at, one called `collision` to
|
|
33
|
+
// stand on, any number called `orb-…` to collect, and one called `spawn` where the player starts
|
|
34
|
+
const LEVEL_URL = './level.glb';
|
|
35
|
+
|
|
36
|
+
// Physics runs in Ammo, a WebAssembly module, which has to be ready before the app is created
|
|
37
|
+
WasmModule.setConfig('Ammo', {
|
|
38
|
+
glueUrl: './ammo/ammo.wasm.js',
|
|
39
|
+
wasmUrl: './ammo/ammo.wasm.wasm'
|
|
40
|
+
});
|
|
41
|
+
await new Promise<void>((resolve) => {
|
|
42
|
+
WasmModule.getInstance('Ammo', () => resolve());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
|
|
46
|
+
|
|
47
|
+
const device = await createGraphicsDevice(canvas);
|
|
48
|
+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
|
|
49
|
+
|
|
50
|
+
const createOptions = new AppOptions();
|
|
51
|
+
createOptions.graphicsDevice = device;
|
|
52
|
+
createOptions.componentSystems = [
|
|
53
|
+
RenderComponentSystem,
|
|
54
|
+
CameraComponentSystem,
|
|
55
|
+
LightComponentSystem,
|
|
56
|
+
ScriptComponentSystem,
|
|
57
|
+
CollisionComponentSystem,
|
|
58
|
+
RigidBodyComponentSystem
|
|
59
|
+
];
|
|
60
|
+
createOptions.resourceHandlers = [TextureHandler, ContainerHandler];
|
|
61
|
+
|
|
62
|
+
const app = new AppBase(canvas);
|
|
63
|
+
app.init(createOptions);
|
|
64
|
+
|
|
65
|
+
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
|
|
66
|
+
app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
|
|
67
|
+
app.setCanvasResolution(RESOLUTION_AUTO);
|
|
68
|
+
|
|
69
|
+
// Ensure canvas is resized when window changes size
|
|
70
|
+
const resize = () => app.resizeCanvas();
|
|
71
|
+
window.addEventListener('resize', resize);
|
|
72
|
+
app.on('destroy', () => {
|
|
73
|
+
window.removeEventListener('resize', resize);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Load the level before starting, so the first frame already has something in it
|
|
77
|
+
const level = new Asset('level', 'container', { url: LEVEL_URL });
|
|
78
|
+
|
|
79
|
+
await new Promise<void>((resolve) => {
|
|
80
|
+
new AssetListLoader([level], app.assets).load(() => resolve());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.start();
|
|
84
|
+
|
|
85
|
+
// The HUD follows the game's state
|
|
86
|
+
const hud = {
|
|
87
|
+
orbs: document.getElementById('orbs')!,
|
|
88
|
+
time: document.getElementById('time')!,
|
|
89
|
+
card: document.getElementById('card')!,
|
|
90
|
+
title: document.getElementById('card-title')!,
|
|
91
|
+
hint: document.getElementById('card-hint')!,
|
|
92
|
+
joystick: document.getElementById('joystick')!,
|
|
93
|
+
knob: document.getElementById('joystick-knob')!
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onState: GameEvents['game:state'] = (state, found, total, seconds) => {
|
|
97
|
+
const card = CARDS[state];
|
|
98
|
+
hud.orbs.textContent = `${found} / ${total}`;
|
|
99
|
+
hud.time.textContent = `${seconds}s`;
|
|
100
|
+
hud.card.hidden = !card;
|
|
101
|
+
if (card) {
|
|
102
|
+
hud.title.textContent = card.title;
|
|
103
|
+
hud.hint.textContent = card.hint;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
app.on('game:state', onState);
|
|
107
|
+
|
|
108
|
+
// A thumb on the left of the screen steers, and the circle and knob show where it is
|
|
109
|
+
const onJoystick: GameEvents['joystick:left'] = (baseX, baseY, knobX, knobY) => {
|
|
110
|
+
hud.joystick.hidden = hud.knob.hidden = baseX < 0;
|
|
111
|
+
hud.joystick.style.left = `${baseX}px`;
|
|
112
|
+
hud.joystick.style.top = `${baseY}px`;
|
|
113
|
+
hud.knob.style.left = `${knobX}px`;
|
|
114
|
+
hud.knob.style.top = `${knobY}px`;
|
|
115
|
+
};
|
|
116
|
+
app.on('joystick:left', onJoystick);
|
|
117
|
+
|
|
118
|
+
// The sun is a directional light that the sky keeps pointing the way its own sun does
|
|
119
|
+
const sun = new Entity('sun');
|
|
120
|
+
sun.addComponent('light', {
|
|
121
|
+
type: 'directional',
|
|
122
|
+
intensity: 2,
|
|
123
|
+
castShadows: true,
|
|
124
|
+
// far enough to reach across the yard from any corner
|
|
125
|
+
shadowDistance: 45,
|
|
126
|
+
// one shadow map over that distance is too coarse up close, so split it into cascades
|
|
127
|
+
numCascades: 4,
|
|
128
|
+
shadowResolution: 2048,
|
|
129
|
+
shadowType: SHADOW_PCF5_32F,
|
|
130
|
+
shadowBias: 0.2,
|
|
131
|
+
normalOffsetBias: 0.05
|
|
132
|
+
});
|
|
133
|
+
app.root.addChild(sun);
|
|
134
|
+
|
|
135
|
+
// The procedural sky is both the background and the ambient light. Its default luminance is
|
|
136
|
+
// scene-scale bright, so turn it down to keep the yard out of clipping
|
|
137
|
+
const sky = new Entity('sky');
|
|
138
|
+
sky.addComponent('script');
|
|
139
|
+
sky.script!.create(ProceduralSky, {
|
|
140
|
+
properties: { luminance: 0.3, azimuth: 150, elevation: 45, sunLight: sun }
|
|
141
|
+
});
|
|
142
|
+
app.root.addChild(sky);
|
|
143
|
+
|
|
144
|
+
// The sky lights the shade blue. A warm light from the other side, with no shadows of its own,
|
|
145
|
+
// stands in for the sunlight the ground bounces back, so the shade stays soft and the sand warm
|
|
146
|
+
const fill = new Entity('fill');
|
|
147
|
+
fill.addComponent('light', { type: 'directional', intensity: 0.7, color: new Color().fromString('#ffdbb3') });
|
|
148
|
+
fill.setEulerAngles(60, -30, 0);
|
|
149
|
+
app.root.addChild(fill);
|
|
150
|
+
|
|
151
|
+
// The player is a capsule the physics moves, with the camera at eye height. The game places it
|
|
152
|
+
// at the level's spawn point and gives it a first-person controller
|
|
153
|
+
const player = new Entity('player');
|
|
154
|
+
player.addComponent('collision', { type: 'capsule', radius: 0.45, height: 1.8 });
|
|
155
|
+
player.addComponent('rigidbody', { type: 'dynamic', mass: 80, angularFactor: Vec3.ZERO });
|
|
156
|
+
app.root.addChild(player);
|
|
157
|
+
|
|
158
|
+
// The sky is HDR, so the camera needs tone mapping to bring it back into displayable range
|
|
159
|
+
const camera = new Entity('camera');
|
|
160
|
+
camera.addComponent('camera', { fov: 80, toneMapping: TONEMAP_ACES2 });
|
|
161
|
+
camera.setLocalPosition(0, 0.65, 0);
|
|
162
|
+
player.addChild(camera);
|
|
163
|
+
|
|
164
|
+
const game = new Entity('game');
|
|
165
|
+
game.addComponent('script');
|
|
166
|
+
game.script!.create(Game, { properties: { level, player, camera } });
|
|
167
|
+
app.root.addChild(game);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/png" href="/src/assets/playcanvas.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Platformer</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="application-canvas"></canvas>
|
|
11
|
+
<div class="hud">
|
|
12
|
+
<section class="panel">
|
|
13
|
+
<h1>Platformer</h1>
|
|
14
|
+
<p>Use A/D or the arrow keys to move and Space to jump.</p>
|
|
15
|
+
</section>
|
|
16
|
+
</div>
|
|
17
|
+
<script type="module" src="/src/main.ts"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -13,28 +13,38 @@ import {
|
|
|
13
13
|
createGraphicsDevice
|
|
14
14
|
} from 'playcanvas';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { Platformer } from './platformer';
|
|
17
17
|
import './starter.css';
|
|
18
18
|
|
|
19
|
-
document.body.insertAdjacentHTML(
|
|
20
|
-
'beforeend',
|
|
21
|
-
'<div class="hud"><section class="panel"><h1>Sprite Game</h1><p>Use A/D or the arrow keys to move and Space to jump.</p></section></div>'
|
|
22
|
-
);
|
|
23
|
-
|
|
24
19
|
const canvas = document.getElementById('application-canvas') as HTMLCanvasElement;
|
|
20
|
+
|
|
25
21
|
const device = await createGraphicsDevice(canvas);
|
|
26
22
|
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
|
|
27
23
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
const createOptions = new AppOptions();
|
|
25
|
+
createOptions.graphicsDevice = device;
|
|
26
|
+
createOptions.componentSystems = [
|
|
27
|
+
RenderComponentSystem,
|
|
28
|
+
CameraComponentSystem,
|
|
29
|
+
SpriteComponentSystem,
|
|
30
|
+
ScriptComponentSystem
|
|
31
|
+
];
|
|
31
32
|
|
|
32
33
|
const app = new AppBase(canvas);
|
|
33
|
-
app.init(
|
|
34
|
+
app.init(createOptions);
|
|
34
35
|
app.start();
|
|
36
|
+
|
|
37
|
+
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
|
|
35
38
|
app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
|
|
36
39
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
|
37
40
|
|
|
41
|
+
// Ensure canvas is resized when window changes size
|
|
42
|
+
const resize = () => app.resizeCanvas();
|
|
43
|
+
window.addEventListener('resize', resize);
|
|
44
|
+
app.on('destroy', () => {
|
|
45
|
+
window.removeEventListener('resize', resize);
|
|
46
|
+
});
|
|
47
|
+
|
|
38
48
|
const camera = new Entity('camera');
|
|
39
49
|
camera.addComponent('camera', {
|
|
40
50
|
projection: 1,
|
|
@@ -66,7 +76,5 @@ app.root.addChild(grass);
|
|
|
66
76
|
const game = new Entity('game');
|
|
67
77
|
game.setPosition(0, -2.15, 0);
|
|
68
78
|
game.addComponent('script');
|
|
69
|
-
game.script!.create(
|
|
79
|
+
game.script!.create(Platformer);
|
|
70
80
|
app.root.addChild(game);
|
|
71
|
-
|
|
72
|
-
window.addEventListener('resize', () => app.resizeCanvas());
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/png" href="/src/assets/playcanvas.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Product Configurator</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="application-canvas"></canvas>
|
|
11
|
+
<div class="hud">
|
|
12
|
+
<section class="panel">
|
|
13
|
+
<h1>Product Configurator</h1>
|
|
14
|
+
<p>Choose a paint finish, then drag to orbit.</p>
|
|
15
|
+
<div class="controls variants" id="paints">
|
|
16
|
+
<button aria-pressed="true" data-color="#d6293e" data-shade="#9b1731">
|
|
17
|
+
<span class="finish" style="--color: #d6293e"></span>Crimson
|
|
18
|
+
</button>
|
|
19
|
+
<button data-color="#d8e4e9" data-shade="#8fa8b5">
|
|
20
|
+
<span class="finish" style="--color: #d8e4e9"></span>Arctic
|
|
21
|
+
</button>
|
|
22
|
+
<button data-color="#9acb34" data-shade="#3c742d">
|
|
23
|
+
<span class="finish" style="--color: #9acb34"></span>Volt
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
</div>
|
|
28
|
+
<script type="module" src="/src/main.ts"></script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|