create-spud 0.1.4 → 0.1.6
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/example/index.html +1 -3
- package/example/package.json +1 -2
- package/example/readme.md +0 -5
- package/example/src/audio.ts +2 -7
- package/example/src/canvas.ts +42 -0
- package/example/src/gameLoop.ts +42 -0
- package/example/src/gameplay.ts +12 -12
- package/example/src/main.ts +16 -0
- package/example/src/state.ts +3 -0
- package/example/vite.config.ts +1 -10
- package/index.ts +9 -3
- package/package.json +1 -1
- package/readme.md +1 -2
- package/example/demo.html +0 -22
- package/example/src/demo.ts +0 -39
- package/example/src/helpers.ts +0 -14
- package/example/src/index.ts +0 -45
package/example/index.html
CHANGED
|
@@ -10,13 +10,11 @@
|
|
|
10
10
|
canvas {
|
|
11
11
|
position: fixed;
|
|
12
12
|
inset: 0;
|
|
13
|
-
height: 100vh;
|
|
14
|
-
width: 100vw;
|
|
15
13
|
/* pixel_art_canvas_css */
|
|
16
14
|
}
|
|
17
15
|
</style>
|
|
18
16
|
</head>
|
|
19
17
|
<body>
|
|
20
|
-
<script type="module" src="/src/
|
|
18
|
+
<script type="module" src="/src/main.ts"></script>
|
|
21
19
|
</body>
|
|
22
20
|
</html>
|
package/example/package.json
CHANGED
package/example/readme.md
CHANGED
package/example/src/audio.ts
CHANGED
|
@@ -19,8 +19,8 @@ export const sfx = audio.createSounds({
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
/*
|
|
22
|
-
createMusic is for longer tracks that can
|
|
23
|
-
|
|
22
|
+
createMusic is for longer tracks that can stream and don't need
|
|
23
|
+
frame-perfect timing.
|
|
24
24
|
*/
|
|
25
25
|
export const menuMusic = audio.createMusic({
|
|
26
26
|
url: menuMusicUrl,
|
|
@@ -28,9 +28,4 @@ export const menuMusic = audio.createMusic({
|
|
|
28
28
|
volume: 0.4,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
/*
|
|
32
|
-
in spud demo mode (spud.isDemoMode === true), audio methods are no-ops.
|
|
33
|
-
that means you can always call sfx("...").play() or music.play() without
|
|
34
|
-
adding conditional checks; the demo stays silent automatically.
|
|
35
|
-
*/
|
|
36
31
|
menuMusic.play();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function createBounds() {
|
|
2
|
+
return { x: 0, y: 0, width: 0, height: 0, centerX: 0, centerY: 0 };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
type Bounds = ReturnType<typeof createBounds>;
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
by default, canvas rendering is blurry on high-dpi screens.
|
|
9
|
+
this fixes it by scaling the pixel buffer by the devicePixelRatio
|
|
10
|
+
and then setting a matching transform so that the drawing coords
|
|
11
|
+
can stay in CSS pixels.
|
|
12
|
+
|
|
13
|
+
`bounds` is mutated in place and kept in sync with the canvas via a
|
|
14
|
+
ResizeObserver, so it's cheap to read bounds.width/height from your
|
|
15
|
+
update and draw functions.
|
|
16
|
+
|
|
17
|
+
see also: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
|
|
18
|
+
*/
|
|
19
|
+
export function scaleAndObserveCanvasSize(ctx: CanvasRenderingContext2D, bounds: Bounds) {
|
|
20
|
+
const { canvas } = ctx;
|
|
21
|
+
|
|
22
|
+
function updateCanvasSize() {
|
|
23
|
+
const { x, y, width, height } = canvas.getBoundingClientRect();
|
|
24
|
+
const dpr = window.devicePixelRatio;
|
|
25
|
+
|
|
26
|
+
canvas.width = width * dpr;
|
|
27
|
+
canvas.height = height * dpr;
|
|
28
|
+
canvas.style.width = `${width}px`;
|
|
29
|
+
canvas.style.height = `${height}px`;
|
|
30
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
31
|
+
|
|
32
|
+
bounds.x = x;
|
|
33
|
+
bounds.y = y;
|
|
34
|
+
bounds.width = width;
|
|
35
|
+
bounds.height = height;
|
|
36
|
+
bounds.centerX = width / 2;
|
|
37
|
+
bounds.centerY = height / 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
updateCanvasSize();
|
|
41
|
+
new ResizeObserver(updateCanvasSize).observe(canvas);
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type GameLoopConfig<State> = {
|
|
2
|
+
ctx: CanvasRenderingContext2D;
|
|
3
|
+
state: State;
|
|
4
|
+
update: (state: State, dt: number) => void;
|
|
5
|
+
render: (state: State, ctx: CanvasRenderingContext2D) => void;
|
|
6
|
+
fixedDeltaTime?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function gameLoop<State>({
|
|
10
|
+
ctx,
|
|
11
|
+
state,
|
|
12
|
+
update,
|
|
13
|
+
render,
|
|
14
|
+
fixedDeltaTime = 8 / 1000,
|
|
15
|
+
}: GameLoopConfig<State>) {
|
|
16
|
+
let lastFrameTime: number | null = null;
|
|
17
|
+
let accumulator = 0;
|
|
18
|
+
|
|
19
|
+
function tick(now: number) {
|
|
20
|
+
if (lastFrameTime === null) {
|
|
21
|
+
lastFrameTime = now;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const elapsed = now - lastFrameTime;
|
|
25
|
+
const dt = Math.min(elapsed / 1000, 0.1);
|
|
26
|
+
lastFrameTime = now;
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
accumulator += dt;
|
|
30
|
+
while (accumulator >= fixedDeltaTime) {
|
|
31
|
+
update(state, fixedDeltaTime);
|
|
32
|
+
accumulator -= fixedDeltaTime;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
render(state, ctx);
|
|
37
|
+
requestAnimationFrame(tick);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render(state, ctx);
|
|
41
|
+
requestAnimationFrame(tick);
|
|
42
|
+
}
|
package/example/src/gameplay.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { Button, gamepads } from "@spud.gg/api";
|
|
2
|
-
import { State } from "./state";
|
|
2
|
+
import type { State } from "./state";
|
|
3
3
|
/* moon_sprite_import */
|
|
4
4
|
/* audio_import */
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
the update loop runs in a fixed time-step and should be used for any
|
|
8
|
-
state updates. `dt` is the
|
|
8
|
+
state updates. `dt` is the fixed simulation step in seconds (see gameLoop.ts).
|
|
9
|
+
read state.bounds here if you need to clamp or wrap positions to the play area.
|
|
9
10
|
*/
|
|
10
11
|
export function update(state: State, dt: number) {
|
|
11
|
-
state.elapsedSeconds += dt
|
|
12
|
+
state.elapsedSeconds += dt;
|
|
12
13
|
if (gamepads.anyPlayer.buttonJustPressed(Button.RightTrigger)) {
|
|
13
14
|
/* audio_shoot_sfx */
|
|
14
15
|
}
|
|
16
|
+
|
|
17
|
+
// handle any post-update cleanup below:
|
|
18
|
+
gamepads.clearInputs();
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
export function
|
|
18
|
-
const { width, height } =
|
|
19
|
-
const center = {
|
|
20
|
-
x: width / 2,
|
|
21
|
-
y: height / 2,
|
|
22
|
-
};
|
|
21
|
+
export function render(state: State, ctx: CanvasRenderingContext2D) {
|
|
22
|
+
const { width, height, centerX, centerY } = state.bounds;
|
|
23
23
|
|
|
24
24
|
/* pixel_art_image_smoothing */
|
|
25
25
|
|
|
@@ -32,10 +32,10 @@ export function draw(state: State, ctx: CanvasRenderingContext2D) {
|
|
|
32
32
|
radius: 30,
|
|
33
33
|
orbitRadius: 100,
|
|
34
34
|
get x() {
|
|
35
|
-
return
|
|
35
|
+
return centerX + Math.cos(state.elapsedSeconds) * moon.orbitRadius;
|
|
36
36
|
},
|
|
37
37
|
get y() {
|
|
38
|
-
return
|
|
38
|
+
return centerY + Math.sin(state.elapsedSeconds) * moon.orbitRadius;
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
/* moon_sprite_draw */
|
|
@@ -45,5 +45,5 @@ export function draw(state: State, ctx: CanvasRenderingContext2D) {
|
|
|
45
45
|
ctx.textAlign = "center";
|
|
46
46
|
ctx.textBaseline = "middle";
|
|
47
47
|
/* fonts_ctx_font */
|
|
48
|
-
ctx.fillText("hello, gamer",
|
|
48
|
+
ctx.fillText("hello, gamer", centerX, centerY);
|
|
49
49
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
main.ts is the main entry point for your game.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { scaleAndObserveCanvasSize } from "./canvas";
|
|
6
|
+
import { gameLoop } from "./gameLoop";
|
|
7
|
+
import { render, update } from "./gameplay";
|
|
8
|
+
import { state } from "./state";
|
|
9
|
+
|
|
10
|
+
const canvas = document.createElement("canvas");
|
|
11
|
+
document.body.appendChild(canvas);
|
|
12
|
+
|
|
13
|
+
const ctx = canvas.getContext("2d", { alpha: false })!;
|
|
14
|
+
scaleAndObserveCanvasSize(ctx, state.bounds);
|
|
15
|
+
|
|
16
|
+
gameLoop({ ctx, state, update, render });
|
package/example/src/state.ts
CHANGED
package/example/vite.config.ts
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { defineConfig } from "vite";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
2
|
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
|
|
5
3
|
|
|
6
|
-
const dir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
|
|
8
4
|
export default defineConfig({
|
|
9
5
|
build: {
|
|
6
|
+
sourcemap: true,
|
|
10
7
|
modulePreload: {
|
|
11
8
|
polyfill: false,
|
|
12
9
|
},
|
|
13
|
-
rolldownOptions: {
|
|
14
|
-
input: {
|
|
15
|
-
main: resolve(dir, "index.html"),
|
|
16
|
-
demo: resolve(dir, "demo.html"),
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
10
|
},
|
|
20
11
|
plugins: [ViteImageOptimizer()],
|
|
21
12
|
resolve: {
|
package/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ const { rawGameName, features, shouldContinue } = await group(
|
|
|
40
40
|
}),
|
|
41
41
|
shouldContinue: ({ results }) =>
|
|
42
42
|
confirm({
|
|
43
|
-
message: `
|
|
43
|
+
message: `Ready to write the files to "./${kebabcase((results.rawGameName ?? "").trim())}". Continue?`,
|
|
44
44
|
}),
|
|
45
45
|
},
|
|
46
46
|
{
|
|
@@ -66,7 +66,11 @@ await tasks([
|
|
|
66
66
|
title: `Scaffolding project in ./${slug}/`,
|
|
67
67
|
async task() {
|
|
68
68
|
await mkdir(targetDir, { recursive: true });
|
|
69
|
-
await copyDir(templateDir, targetDir, {
|
|
69
|
+
await copyDir(templateDir, targetDir, {
|
|
70
|
+
gameName,
|
|
71
|
+
slug,
|
|
72
|
+
features,
|
|
73
|
+
});
|
|
70
74
|
return "Initialized project from spud starter template";
|
|
71
75
|
},
|
|
72
76
|
},
|
|
@@ -95,7 +99,8 @@ async function copyDir(
|
|
|
95
99
|
|
|
96
100
|
for (const entry of entries) {
|
|
97
101
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
98
|
-
const
|
|
102
|
+
const outputName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
|
|
103
|
+
const destPath = path.join(destDir, outputName);
|
|
99
104
|
|
|
100
105
|
if (entry.isDirectory()) {
|
|
101
106
|
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
@@ -188,6 +193,7 @@ async function copyDir(
|
|
|
188
193
|
? 'ctx.font = "48px Atari";'
|
|
189
194
|
: 'ctx.font = "48px sans-serif";',
|
|
190
195
|
);
|
|
196
|
+
|
|
191
197
|
await Bun.write(destPath, replaced);
|
|
192
198
|
}
|
|
193
199
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -21,8 +21,6 @@ bun create spud
|
|
|
21
21
|
- Canvas setup with [DPI-aware scaling](https://web.dev/articles/canvas-hidipi)
|
|
22
22
|
- Fixed‑timestep update loop + variable-rate render loop ready to edit
|
|
23
23
|
- Static asset import handling through [Vite](https://vite.dev/guide/assets#importing-asset-as-url) and [image optimization](https://github.com/FatehAK/vite-plugin-image-optimizer)
|
|
24
|
-
- `index.html` and `demo.html` entry points with scripts ready to run
|
|
25
|
-
- _(Demo mode is a non-interactive gameplay example, providing your potential players a preview into your actual game. Spud favors realistic self-playing demos over pre-recorded trailers.)_
|
|
26
24
|
- Gamepad integration with the [spud API (npm)](https://www.npmjs.com/package/@spud.gg/api)
|
|
27
25
|
- Optional:
|
|
28
26
|
- Audio examples (low-latency [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_Web_Audio_API) sfx and [streaming background music](https://developer.mozilla.org/en-US/docs/Web/API/HTMLAudioElement)) with [autoplay best practices](https://developer.chrome.com/blog/web-audio-autoplay) for web games.
|
|
@@ -52,4 +50,5 @@ curl -fsSL https://bun.sh/install | bash
|
|
|
52
50
|
<!--
|
|
53
51
|
TODO
|
|
54
52
|
- add hmr hints for Vite to cleanup / reinit the raf loops and keep game state reloadable.
|
|
53
|
+
- handle resize
|
|
55
54
|
-->
|
package/example/demo.html
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
-
<title>game_name</title>
|
|
8
|
-
<style>
|
|
9
|
-
/* fonts_face_css */
|
|
10
|
-
canvas {
|
|
11
|
-
position: fixed;
|
|
12
|
-
inset: 0;
|
|
13
|
-
height: 100vh;
|
|
14
|
-
width: 100vw;
|
|
15
|
-
/* pixel_art_canvas_css */
|
|
16
|
-
}
|
|
17
|
-
</style>
|
|
18
|
-
</head>
|
|
19
|
-
<body>
|
|
20
|
-
<script type="module" src="/src/demo.ts"></script>
|
|
21
|
-
</body>
|
|
22
|
-
</html>
|
package/example/src/demo.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
demo.ts is the entry point for the demo.html file, which displays a
|
|
3
|
-
non-interactive demo of your game. it should be a minimal preview
|
|
4
|
-
version of your game that starts automatically and does not play any
|
|
5
|
-
sounds, use local storage, or handle inputs.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { draw, update } from "./gameplay";
|
|
9
|
-
import { state } from "./state";
|
|
10
|
-
import { resizeCanvasForDpi } from "./helpers";
|
|
11
|
-
|
|
12
|
-
const canvas = document.createElement("canvas");
|
|
13
|
-
document.body.appendChild(canvas);
|
|
14
|
-
|
|
15
|
-
const ctx = canvas.getContext("2d", { alpha: false })!;
|
|
16
|
-
|
|
17
|
-
let lastFrameTime = 0;
|
|
18
|
-
let timeToProcessPhysics = 0;
|
|
19
|
-
|
|
20
|
-
function gameLoop(now: number) {
|
|
21
|
-
const dt = Math.min(now - lastFrameTime, 100); // clamp time delta to 100ms in case the user switched tabs
|
|
22
|
-
lastFrameTime = now;
|
|
23
|
-
|
|
24
|
-
resizeCanvasForDpi(ctx);
|
|
25
|
-
|
|
26
|
-
{
|
|
27
|
-
timeToProcessPhysics += dt;
|
|
28
|
-
const physicsTickMs = 1000 / 120; // process physics updates at a fixed 120hz time step
|
|
29
|
-
while (timeToProcessPhysics > physicsTickMs) {
|
|
30
|
-
timeToProcessPhysics -= physicsTickMs;
|
|
31
|
-
update(state, physicsTickMs);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
draw(state, ctx);
|
|
36
|
-
requestAnimationFrame(gameLoop); // queue up the next tick
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
requestAnimationFrame(gameLoop);
|
package/example/src/helpers.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
by default, canvas rendering is blurry on high-dpi screens.
|
|
3
|
-
this fixes it by scaling the pixel buffer by the devicePixelRatio
|
|
4
|
-
and then setting a matching transform so that the drawing coords
|
|
5
|
-
can stay in CSS pixels.
|
|
6
|
-
|
|
7
|
-
see also: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
|
|
8
|
-
*/
|
|
9
|
-
export function resizeCanvasForDpi(ctx: CanvasRenderingContext2D) {
|
|
10
|
-
const { width, height } = ctx.canvas.getBoundingClientRect();
|
|
11
|
-
ctx.canvas.width = width * window.devicePixelRatio;
|
|
12
|
-
ctx.canvas.height = height * window.devicePixelRatio;
|
|
13
|
-
ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
|
|
14
|
-
}
|
package/example/src/index.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
index.ts is the main entry point for your game. it's responsible for
|
|
3
|
-
setting up the canvas and the two main game loops: a 120hz fixed time
|
|
4
|
-
step physics/update loop, and a variable framerate draw cycle.
|
|
5
|
-
see also:
|
|
6
|
-
- https://gafferongames.com/post/fix_your_timestep/
|
|
7
|
-
- https://gist.github.com/HipHopHuman/3e9b4a94b30ac9387d9a99ef2d29eb1a
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { spud, gamepads } from "@spud.gg/api";
|
|
11
|
-
import { draw, update } from "./gameplay";
|
|
12
|
-
import { state } from "./state";
|
|
13
|
-
import { resizeCanvasForDpi } from "./helpers";
|
|
14
|
-
|
|
15
|
-
const canvas = document.createElement("canvas");
|
|
16
|
-
document.body.appendChild(canvas);
|
|
17
|
-
|
|
18
|
-
const ctx = canvas.getContext("2d", { alpha: false })!;
|
|
19
|
-
|
|
20
|
-
let lastFrameTime = 0;
|
|
21
|
-
let timeToProcessPhysics = 0;
|
|
22
|
-
|
|
23
|
-
function gameLoop(now: number) {
|
|
24
|
-
const dt = Math.min(now - lastFrameTime, 100); // clamp time delta to 100ms in case the user switched tabs
|
|
25
|
-
lastFrameTime = now;
|
|
26
|
-
|
|
27
|
-
resizeCanvasForDpi(ctx);
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
timeToProcessPhysics += dt;
|
|
31
|
-
const physicsTickMs = 1000 / 120; // process physics updates at a fixed 120hz time step
|
|
32
|
-
while (timeToProcessPhysics > physicsTickMs) {
|
|
33
|
-
if (!spud.isPaused) {
|
|
34
|
-
timeToProcessPhysics -= physicsTickMs;
|
|
35
|
-
update(state, physicsTickMs);
|
|
36
|
-
}
|
|
37
|
-
gamepads.clearInputs();
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
draw(state, ctx);
|
|
42
|
-
requestAnimationFrame(gameLoop); // queue up the next tick
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
requestAnimationFrame(gameLoop);
|