create-spud 0.1.5 → 0.1.7

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.
@@ -10,11 +10,13 @@
10
10
  canvas {
11
11
  position: fixed;
12
12
  inset: 0;
13
+ width: 100%;
14
+ height: 100%;
13
15
  /* pixel_art_canvas_css */
14
16
  }
15
17
  </style>
16
18
  </head>
17
19
  <body>
18
- <script type="module" src="/src/index.ts"></script>
20
+ <script type="module" src="/src/main.ts"></script>
19
21
  </body>
20
22
  </html>
@@ -4,8 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite",
8
- "dev:demo": "vite --open /demo.html",
7
+ "dev": "bunx --bun vite",
9
8
  "build": "tsc && vite build",
10
9
  "preview": "vite preview"
11
10
  },
package/example/readme.md CHANGED
@@ -14,11 +14,6 @@ bun i
14
14
  bun dev
15
15
  ```
16
16
 
17
- ```bash
18
- # start the local development server and open the demo.html directly
19
- bun run dev:demo
20
- ```
21
-
22
17
  ```bash
23
18
  # prepare a production build
24
19
  bun run build
@@ -19,8 +19,8 @@ export const sfx = audio.createSounds({
19
19
  });
20
20
 
21
21
  /*
22
- createMusic is for longer tracks that can
23
- stream and don't need frame-perfect timing.
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,36 @@
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
+ see also: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
14
+ */
15
+ export function scaleAndObserveCanvasSize(ctx: CanvasRenderingContext2D, bounds: Bounds) {
16
+ const { canvas } = ctx;
17
+
18
+ function updateCanvasSize() {
19
+ const { x, y, width, height } = canvas.getBoundingClientRect();
20
+ const dpr = window.devicePixelRatio;
21
+
22
+ canvas.width = width * dpr;
23
+ canvas.height = height * dpr;
24
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
25
+
26
+ bounds.x = x;
27
+ bounds.y = y;
28
+ bounds.width = width;
29
+ bounds.height = height;
30
+ bounds.centerX = width / 2;
31
+ bounds.centerY = height / 2;
32
+ }
33
+
34
+ updateCanvasSize();
35
+ new ResizeObserver(updateCanvasSize).observe(canvas);
36
+ }
@@ -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
+ }
@@ -1,36 +1,26 @@
1
1
  import { Button, gamepads } from "@spud.gg/api";
2
- import { State } from "./state";
3
- import { CanvasFrameRect } from "./helpers";
2
+ import type { State } from "./state";
4
3
  /* moon_sprite_import */
5
4
  /* audio_import */
6
5
 
7
- /*
8
- the update loop runs in a fixed time-step and should be used for any
9
- state updates. `deltaTime` is the time since the last update call.
10
- */
11
- export function update(state: State, deltaTime: number) {
12
- state.elapsedSeconds += deltaTime / 1000;
13
- if (gamepads.anyPlayer.buttonJustPressed(Button.RightTrigger)) {
14
- /* audio_shoot_sfx */
15
- }
6
+ export function update(state: State, dt: number) {
7
+ // handle inputs and update state here
8
+ state.elapsedSeconds += dt;
9
+ /* audio_shoot_sfx */
10
+
11
+ // handle any post-update cleanup below
12
+ gamepads.clearInputs();
16
13
  }
17
14
 
18
- export function render({
19
- state,
20
- ctx,
21
- rect,
22
- }: {
23
- state: State;
24
- ctx: CanvasRenderingContext2D;
25
- rect: CanvasFrameRect;
26
- }) {
27
- const { width, height, centerX, centerY } = rect;
15
+ export function render(state: State, ctx: CanvasRenderingContext2D) {
16
+ const { width, height, centerX, centerY } = state.bounds;
28
17
 
29
18
  /* pixel_art_image_smoothing */
30
19
 
31
20
  // clear out the background
32
21
  ctx.fillStyle = "#0b0d1a";
33
22
  ctx.fillRect(0, 0, width, height);
23
+ ctx.fillStyle = "white";
34
24
 
35
25
  // draw an orbiting circle
36
26
  const moon = {
@@ -43,10 +33,10 @@ export function render({
43
33
  return centerY + Math.sin(state.elapsedSeconds) * moon.orbitRadius;
44
34
  },
45
35
  };
36
+
46
37
  /* moon_sprite_draw */
47
38
 
48
39
  // write some text
49
- ctx.fillStyle = "white";
50
40
  ctx.textAlign = "center";
51
41
  ctx.textBaseline = "middle";
52
42
  /* fonts_ctx_font */
@@ -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 });
@@ -1,5 +1,8 @@
1
+ import { createBounds } from "./canvas";
2
+
1
3
  export const state = {
2
4
  elapsedSeconds: 0,
5
+ bounds: createBounds(),
3
6
  };
4
7
 
5
8
  export type State = typeof state;
@@ -1,22 +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: {
10
6
  sourcemap: true,
11
7
  modulePreload: {
12
8
  polyfill: false,
13
9
  },
14
- rolldownOptions: {
15
- input: {
16
- main: resolve(dir, "index.html"),
17
- demo: resolve(dir, "demo.html"),
18
- },
19
- },
20
10
  },
21
11
  plugins: [ViteImageOptimizer()],
22
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: `Create a folder for "${kebabcase((results.rawGameName ?? "").trim())}" in the current directory?`,
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, { gameName, slug, features });
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
  },
@@ -176,10 +180,13 @@ async function copyDir(
176
180
  .replaceAll(
177
181
  " /* audio_shoot_sfx */\n",
178
182
  replacements.features.includes(Feature.Audio)
179
- ? ` sfx("shoot").play({
183
+ }
184
+ ? ` if (gamepads.anyPlayer.buttonJustPressed(Button.RightTrigger)) {
185
+ sfx("shoot").play({
180
186
  detune: -1000 + Math.random() * 2000,
181
187
  playbackRate: 0.5 + Math.random(),
182
188
  });
189
+ }
183
190
  `
184
191
  : " /* handle inputs and update state */\n",
185
192
  )
@@ -189,6 +196,7 @@ async function copyDir(
189
196
  ? 'ctx.font = "48px Atari";'
190
197
  : 'ctx.font = "48px sans-serif";',
191
198
  );
199
+
192
200
  await Bun.write(destPath, replaced);
193
201
  }
194
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-spud",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "bin": {
5
5
  "create-spud": "./index.ts"
6
6
  },
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
  -->
@@ -1,24 +0,0 @@
1
- # Logs
2
- logs
3
- *.log
4
- npm-debug.log*
5
- yarn-debug.log*
6
- yarn-error.log*
7
- pnpm-debug.log*
8
- lerna-debug.log*
9
-
10
- node_modules
11
- dist
12
- dist-ssr
13
- *.local
14
-
15
- # Editor directories and files
16
- .vscode/*
17
- !.vscode/extensions.json
18
- .idea
19
- .DS_Store
20
- *.suo
21
- *.ntvs*
22
- *.njsproj
23
- *.sln
24
- *.sw?
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>
@@ -1,50 +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 { render, update } from "./gameplay";
9
- import { state } from "./state";
10
- import { handleCanvasDpi } 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: number | null = null;
18
- let physicsAccumulatorMs = 0;
19
-
20
- function gameLoop(now: number) {
21
- // initialize lastFrameTime on the first tick
22
- if (lastFrameTime === null) {
23
- lastFrameTime = now;
24
- }
25
-
26
- // cap extreme frame deltas so one frame doesn't do a huge catch-up after tab restore
27
- const deltaTime = Math.min(now - lastFrameTime, 100);
28
- lastFrameTime = now;
29
-
30
- const rect = handleCanvasDpi(ctx);
31
-
32
- {
33
- // process physics updates at a fixed 120hz time step
34
- physicsAccumulatorMs += deltaTime;
35
- const physicsTicksPerSecond = 120;
36
- const fixedDeltaMs = 1000 / physicsTicksPerSecond;
37
- while (physicsAccumulatorMs >= fixedDeltaMs) {
38
- physicsAccumulatorMs -= fixedDeltaMs;
39
- update(state, fixedDeltaMs);
40
- }
41
- }
42
-
43
- render({ state, ctx, rect });
44
- requestAnimationFrame(gameLoop); // queue up the next tick
45
- }
46
-
47
- // render immediately, then start the game loop
48
- const rect = handleCanvasDpi(ctx);
49
- render({ state, ctx, rect });
50
- requestAnimationFrame(gameLoop);
@@ -1,24 +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 handleCanvasDpi(ctx: CanvasRenderingContext2D) {
10
- const { width, height } = ctx.canvas.getBoundingClientRect();
11
- const targetWidth = width * window.devicePixelRatio;
12
- const targetHeight = height * window.devicePixelRatio;
13
- const rect = { width, height, centerX: width / 2, centerY: height / 2 };
14
-
15
- // optimization: since setting canvas.width and .height reset the canvas state, skip if the size didn't change
16
- if (ctx.canvas.width === targetWidth && ctx.canvas.height === targetHeight) return rect;
17
-
18
- ctx.canvas.width = targetWidth;
19
- ctx.canvas.height = targetHeight;
20
- ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
21
- return rect;
22
- }
23
-
24
- export type CanvasFrameRect = ReturnType<typeof handleCanvasDpi>;
@@ -1,64 +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://www.youtube.com/watch?v=yGhfUcPjXuE
8
- - https://gist.github.com/HipHopHuman/3e9b4a94b30ac9387d9a99ef2d29eb1a
9
- */
10
-
11
- import { spud, gamepads } from "@spud.gg/api";
12
- import { render, update } from "./gameplay";
13
- import { state } from "./state";
14
- import { handleCanvasDpi } from "./helpers";
15
-
16
- const canvas = document.createElement("canvas");
17
- document.body.appendChild(canvas);
18
-
19
- const ctx = canvas.getContext("2d", { alpha: false })!;
20
-
21
- let lastFrameTime: number | null = null;
22
- let physicsAccumulatorMs = 0;
23
- let animationFrame = -1;
24
-
25
- function gameLoop(now: number) {
26
- // initialize lastFrameTime on the first tick
27
- if (lastFrameTime === null) {
28
- lastFrameTime = now;
29
- }
30
-
31
- // cap extreme frame deltas after tab restore so one frame doesn't do a huge catch-up
32
- const deltaTime = Math.min(now - lastFrameTime, 100);
33
- lastFrameTime = now;
34
-
35
- const rect = handleCanvasDpi(ctx);
36
-
37
- {
38
- // process physics updates at a fixed 120hz time step
39
- physicsAccumulatorMs = spud.isPaused ? 0 : physicsAccumulatorMs + deltaTime;
40
- const physicsTicksPerSecond = 120;
41
- const fixedDeltaMs = 1000 / physicsTicksPerSecond;
42
- while (physicsAccumulatorMs >= fixedDeltaMs) {
43
- physicsAccumulatorMs -= fixedDeltaMs;
44
- update(state, fixedDeltaMs);
45
- gamepads.clearInputs();
46
- }
47
- }
48
-
49
- render({ state, ctx, rect });
50
- animationFrame = requestAnimationFrame(gameLoop); // queue up the next tick
51
- }
52
-
53
- // render immediately, then start the game loop
54
- const rect = handleCanvasDpi(ctx);
55
- render({ state, ctx, rect });
56
- animationFrame = requestAnimationFrame(gameLoop);
57
-
58
- // enable hot reloading in dev mode, but ensure the canvas is properly cleaned up
59
- if (import.meta.hot) {
60
- import.meta.hot.accept(() => {
61
- cancelAnimationFrame(animationFrame);
62
- canvas.remove();
63
- });
64
- }