create-spud 0.1.3 → 0.1.5

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.
@@ -0,0 +1,24 @@
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?
@@ -10,8 +10,6 @@
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>
@@ -5,35 +5,46 @@
5
5
  sounds, use local storage, or handle inputs.
6
6
  */
7
7
 
8
- import { draw, update } from "./gameplay";
8
+ import { render, update } from "./gameplay";
9
9
  import { state } from "./state";
10
- import { resizeCanvasForDpi } from "./helpers";
10
+ import { handleCanvasDpi } from "./helpers";
11
11
 
12
12
  const canvas = document.createElement("canvas");
13
13
  document.body.appendChild(canvas);
14
14
 
15
15
  const ctx = canvas.getContext("2d", { alpha: false })!;
16
16
 
17
- let lastFrameTime = 0;
18
- let timeToProcessPhysics = 0;
17
+ let lastFrameTime: number | null = null;
18
+ let physicsAccumulatorMs = 0;
19
19
 
20
20
  function gameLoop(now: number) {
21
- const dt = Math.min(now - lastFrameTime, 100); // clamp time delta to 100ms in case the user switched tabs
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);
22
28
  lastFrameTime = now;
23
29
 
24
- resizeCanvasForDpi(ctx);
30
+ const rect = handleCanvasDpi(ctx);
25
31
 
26
32
  {
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);
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);
32
40
  }
33
41
  }
34
42
 
35
- draw(state, ctx);
43
+ render({ state, ctx, rect });
36
44
  requestAnimationFrame(gameLoop); // queue up the next tick
37
45
  }
38
46
 
47
+ // render immediately, then start the game loop
48
+ const rect = handleCanvasDpi(ctx);
49
+ render({ state, ctx, rect });
39
50
  requestAnimationFrame(gameLoop);
@@ -1,25 +1,30 @@
1
1
  import { Button, gamepads } from "@spud.gg/api";
2
2
  import { State } from "./state";
3
+ import { CanvasFrameRect } from "./helpers";
3
4
  /* moon_sprite_import */
4
5
  /* audio_import */
5
6
 
6
7
  /*
7
8
  the update loop runs in a fixed time-step and should be used for any
8
- state updates. `dt` is the time since the last update call.
9
+ state updates. `deltaTime` is the time since the last update call.
9
10
  */
10
- export function update(state: State, dt: number) {
11
- state.elapsedSeconds += dt / 1000;
11
+ export function update(state: State, deltaTime: number) {
12
+ state.elapsedSeconds += deltaTime / 1000;
12
13
  if (gamepads.anyPlayer.buttonJustPressed(Button.RightTrigger)) {
13
14
  /* audio_shoot_sfx */
14
15
  }
15
16
  }
16
17
 
17
- export function draw(state: State, ctx: CanvasRenderingContext2D) {
18
- const { width, height } = ctx.canvas.getBoundingClientRect();
19
- const center = {
20
- x: width / 2,
21
- y: height / 2,
22
- };
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;
23
28
 
24
29
  /* pixel_art_image_smoothing */
25
30
 
@@ -32,10 +37,10 @@ export function draw(state: State, ctx: CanvasRenderingContext2D) {
32
37
  radius: 30,
33
38
  orbitRadius: 100,
34
39
  get x() {
35
- return center.x + Math.cos(state.elapsedSeconds) * moon.orbitRadius;
40
+ return centerX + Math.cos(state.elapsedSeconds) * moon.orbitRadius;
36
41
  },
37
42
  get y() {
38
- return center.y + Math.sin(state.elapsedSeconds) * moon.orbitRadius;
43
+ return centerY + Math.sin(state.elapsedSeconds) * moon.orbitRadius;
39
44
  },
40
45
  };
41
46
  /* moon_sprite_draw */
@@ -45,5 +50,5 @@ export function draw(state: State, ctx: CanvasRenderingContext2D) {
45
50
  ctx.textAlign = "center";
46
51
  ctx.textBaseline = "middle";
47
52
  /* fonts_ctx_font */
48
- ctx.fillText("hello, gamer", center.x, center.y);
53
+ ctx.fillText("hello, gamer", centerX, centerY);
49
54
  }
@@ -6,9 +6,19 @@
6
6
 
7
7
  see also: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
8
8
  */
9
- export function resizeCanvasForDpi(ctx: CanvasRenderingContext2D) {
9
+ export function handleCanvasDpi(ctx: CanvasRenderingContext2D) {
10
10
  const { width, height } = ctx.canvas.getBoundingClientRect();
11
- ctx.canvas.width = width * window.devicePixelRatio;
12
- ctx.canvas.height = height * window.devicePixelRatio;
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;
13
20
  ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
21
+ return rect;
14
22
  }
23
+
24
+ export type CanvasFrameRect = ReturnType<typeof handleCanvasDpi>;
@@ -4,42 +4,61 @@
4
4
  step physics/update loop, and a variable framerate draw cycle.
5
5
  see also:
6
6
  - https://gafferongames.com/post/fix_your_timestep/
7
+ - https://www.youtube.com/watch?v=yGhfUcPjXuE
7
8
  - https://gist.github.com/HipHopHuman/3e9b4a94b30ac9387d9a99ef2d29eb1a
8
9
  */
9
10
 
10
11
  import { spud, gamepads } from "@spud.gg/api";
11
- import { draw, update } from "./gameplay";
12
+ import { render, update } from "./gameplay";
12
13
  import { state } from "./state";
13
- import { resizeCanvasForDpi } from "./helpers";
14
+ import { handleCanvasDpi } from "./helpers";
14
15
 
15
16
  const canvas = document.createElement("canvas");
16
17
  document.body.appendChild(canvas);
17
18
 
18
19
  const ctx = canvas.getContext("2d", { alpha: false })!;
19
20
 
20
- let lastFrameTime = 0;
21
- let timeToProcessPhysics = 0;
21
+ let lastFrameTime: number | null = null;
22
+ let physicsAccumulatorMs = 0;
23
+ let animationFrame = -1;
22
24
 
23
25
  function gameLoop(now: number) {
24
- const dt = Math.min(now - lastFrameTime, 100); // clamp time delta to 100ms in case the user switched tabs
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);
25
33
  lastFrameTime = now;
26
34
 
27
- resizeCanvasForDpi(ctx);
35
+ const rect = handleCanvasDpi(ctx);
28
36
 
29
37
  {
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
- }
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);
37
45
  gamepads.clearInputs();
38
46
  }
39
47
  }
40
48
 
41
- draw(state, ctx);
42
- requestAnimationFrame(gameLoop); // queue up the next tick
49
+ render({ state, ctx, rect });
50
+ animationFrame = requestAnimationFrame(gameLoop); // queue up the next tick
43
51
  }
44
52
 
45
- requestAnimationFrame(gameLoop);
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
+ }
@@ -7,6 +7,7 @@ const dir = dirname(fileURLToPath(import.meta.url));
7
7
 
8
8
  export default defineConfig({
9
9
  build: {
10
+ sourcemap: true,
10
11
  modulePreload: {
11
12
  polyfill: false,
12
13
  },
package/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { mkdir, readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { intro, outro, cancel, text, confirm, tasks, group, multiselect } from "@clack/prompts";
5
+ import { intro, note, cancel, text, confirm, tasks, group, multiselect } from "@clack/prompts";
6
6
  import kebabcase from "lodash.kebabcase";
7
7
 
8
8
  intro("It's spud time.");
@@ -20,14 +20,16 @@ const { rawGameName, features, shouldContinue } = await group(
20
20
  rawGameName: () =>
21
21
  text({
22
22
  message: "Game name",
23
- placeholder: "eg. Space Invaders",
23
+ placeholder: "e.g. Space Invaders",
24
24
  validate(value) {
25
- if (!value?.trim()) return "Game name is required. Esc to cancel.";
25
+ const trimmed = value?.trim();
26
+ if (!trimmed) return "Game name is required. Esc to cancel.";
27
+ if (!kebabcase(trimmed)) return "Game name must include letters or numbers.";
26
28
  },
27
29
  }),
28
30
  features: () =>
29
31
  multiselect({
30
- message: "Features",
32
+ message: "Select features",
31
33
  initialValues: [],
32
34
  required: false,
33
35
  options: [
@@ -38,7 +40,7 @@ const { rawGameName, features, shouldContinue } = await group(
38
40
  }),
39
41
  shouldContinue: ({ results }) =>
40
42
  confirm({
41
- message: `We'll create a folder for "${kebabcase((results.rawGameName ?? "").trim())}" in the current directory. Do you want to continue?`,
43
+ message: `Create a folder for "${kebabcase((results.rawGameName ?? "").trim())}" in the current directory?`,
42
44
  }),
43
45
  },
44
46
  {
@@ -50,7 +52,7 @@ const { rawGameName, features, shouldContinue } = await group(
50
52
  );
51
53
 
52
54
  if (!shouldContinue) {
53
- cancel("Operation cancelled.");
55
+ cancel("Cancelled.");
54
56
  process.exit(0);
55
57
  }
56
58
 
@@ -71,16 +73,17 @@ await tasks([
71
73
  {
72
74
  title: "Installing dependencies with Bun",
73
75
  async task() {
74
- const proc = Bun.spawn(["bun", "install"], {
75
- cwd: targetDir,
76
- });
77
- await proc.exited;
76
+ const proc = Bun.spawn(["bun", "install"], { cwd: targetDir });
77
+ const exitCode = await proc.exited;
78
+ if (exitCode !== 0) {
79
+ throw new Error(`bun install failed with exit code ${exitCode}`);
80
+ }
78
81
  return "Installed via bun";
79
82
  },
80
83
  },
81
84
  ]);
82
85
 
83
- outro(`You're all set! Now: cd ${slug} && bun dev`);
86
+ note(`cd ${slug}\nbun dev`, "Next steps");
84
87
 
85
88
  async function copyDir(
86
89
  sourceDir: string,
@@ -92,7 +95,8 @@ async function copyDir(
92
95
 
93
96
  for (const entry of entries) {
94
97
  const sourcePath = path.join(sourceDir, entry.name);
95
- const destPath = path.join(destDir, entry.name);
98
+ const outputName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
99
+ const destPath = path.join(destDir, outputName);
96
100
 
97
101
  if (entry.isDirectory()) {
98
102
  if (entry.name === "node_modules" || entry.name === "dist") continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-spud",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "bin": {
5
5
  "create-spud": "./index.ts"
6
6
  },