create-spud 0.1.11 → 0.1.12
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 → examples/minimal}/index.html +0 -2
- package/{example → examples/minimal}/package.json +4 -4
- package/examples/minimal/public/favicon.svg +1 -0
- package/{example → examples/minimal}/readme.md +3 -3
- package/examples/minimal/src/gameplay.ts +16 -0
- package/examples/pong/index.html +31 -0
- package/examples/pong/package.json +21 -0
- package/examples/pong/public/favicon.svg +1 -0
- package/examples/pong/readme.md +50 -0
- package/examples/pong/src/assets/audio/goal.wav +0 -0
- package/examples/pong/src/assets/audio/paddle-hit.wav +0 -0
- package/examples/pong/src/assets/audio/wall-hit.wav +0 -0
- package/examples/pong/src/audio.ts +20 -0
- package/examples/pong/src/camera.ts +50 -0
- package/examples/pong/src/canvas.ts +36 -0
- package/examples/pong/src/gameLoop.ts +46 -0
- package/examples/pong/src/gameplay.ts +165 -0
- package/examples/pong/src/main.ts +23 -0
- package/examples/pong/src/state.ts +38 -0
- package/examples/pong/tsconfig.json +37 -0
- package/examples/pong/vite.config.ts +15 -0
- package/index.js +153 -0
- package/package.json +10 -10
- package/readme.md +19 -18
- package/example/bun.lock +0 -212
- package/example/public/favicon.svg +0 -1
- package/example/public/poster.webp +0 -0
- package/example/src/assets/audio/menu-music.mp3 +0 -0
- package/example/src/assets/audio/shoot.wav +0 -0
- package/example/src/assets/images/sprite.png +0 -0
- package/example/src/audio.ts +0 -31
- package/example/src/gameplay.ts +0 -53
- package/example/src/sprite.ts +0 -53
- package/index.ts +0 -199
- /package/{example → examples/minimal}/src/canvas.ts +0 -0
- /package/{example → examples/minimal}/src/gameLoop.ts +0 -0
- /package/{example → examples/minimal}/src/main.ts +0 -0
- /package/{example → examples/minimal}/src/state.ts +0 -0
- /package/{example → examples/minimal}/tsconfig.json +0 -0
- /package/{example → examples/minimal}/vite.config.ts +0 -0
- /package/{example → examples/pong}/src/assets/fonts/atari.ttf +0 -0
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>game_name</title>
|
|
8
8
|
<style>
|
|
9
|
-
/* fonts_face_css */
|
|
10
9
|
canvas {
|
|
11
10
|
position: fixed;
|
|
12
11
|
inset: 0;
|
|
13
12
|
width: 100%;
|
|
14
13
|
height: 100%;
|
|
15
|
-
/* pixel_art_canvas_css */
|
|
16
14
|
}
|
|
17
15
|
</style>
|
|
18
16
|
</head>
|
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
7
|
+
"dev": "dev_command",
|
|
8
8
|
"build": "tsc && vite build",
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@spud.gg/api": "0.0.
|
|
12
|
+
"@spud.gg/api": "0.0.30"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"sharp": "0.
|
|
15
|
+
"sharp": "0.35.2",
|
|
16
16
|
"svgo": "4.0.1",
|
|
17
17
|
"typescript": "^5",
|
|
18
|
-
"vite": "8.0
|
|
18
|
+
"vite": "8.1.0",
|
|
19
19
|
"vite-plugin-image-optimizer": "2.0.3"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><style>.cls-1{fill:#2f0007;}.cls-2{fill:#fff;}</style></defs><rect class="cls-1" y="7.8" width="32" height="16.36" rx="8.2"/><rect class="cls-2" x="1.7" y="9.5" width="28.6" height="13.03" rx="6.5"/><path class="cls-1" d="M7.4,11.6h0A1.4,1.4,0,0,1,8.8,13v5.7a1.4,1.4,0,0,1-1.4,1.4h0a1.3,1.3,0,0,1-1.3-1.4V13A1.3,1.3,0,0,1,7.4,11.6Z"/><path class="cls-1" d="M11.8,15.8h0a1.4,1.4,0,0,1-1.4,1.4H4.7a1.4,1.4,0,0,1-1.4-1.4h0a1.3,1.3,0,0,1,1.4-1.3h5.7A1.3,1.3,0,0,1,11.8,15.8Z"/><circle class="cls-1" cx="20.7" cy="16" r="1.7"/><circle class="cls-1" cx="23.8" cy="12.8" r="1.7"/><circle class="cls-1" cx="23.8" cy="19.2" r="1.7"/><circle class="cls-1" cx="27.1" cy="16" r="1.7"/><ellipse class="cls-1" cx="14" cy="17.9" rx="1" ry="0.9"/><ellipse class="cls-1" cx="17" cy="17.9" rx="1" ry="0.9"/></svg>
|
|
@@ -6,17 +6,17 @@ Useful scripts:
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# (re)install dependencies
|
|
9
|
-
|
|
9
|
+
install_command
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
# start the local development server
|
|
14
|
-
|
|
14
|
+
dev_command_readme
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
# prepare a production build
|
|
19
|
-
|
|
19
|
+
build_command
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Resources:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { State } from "./state";
|
|
2
|
+
|
|
3
|
+
export function fixedUpdate(state: State, dt: number) {
|
|
4
|
+
// your gameplay logic here
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function update(state: State, dt: number) {
|
|
8
|
+
// your gameplay logic here
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function render(state: State, ctx: CanvasRenderingContext2D) {
|
|
12
|
+
const { width, height } = state.bounds;
|
|
13
|
+
ctx.clearRect(0, 0, width, height);
|
|
14
|
+
|
|
15
|
+
// your draw code here
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
@font-face {
|
|
10
|
+
font-family: "Atari";
|
|
11
|
+
font-display: block;
|
|
12
|
+
src: url("/src/assets/fonts/atari.ttf") format("truetype");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
background: black;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
canvas {
|
|
21
|
+
position: fixed;
|
|
22
|
+
inset: 0;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
}
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<script type="module" src="/src/main.ts"></script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "game_slug",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "dev_command",
|
|
8
|
+
"build": "tsc && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@spud.gg/api": "0.0.30"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"sharp": "0.35.2",
|
|
16
|
+
"svgo": "4.0.1",
|
|
17
|
+
"typescript": "^5",
|
|
18
|
+
"vite": "8.1.0",
|
|
19
|
+
"vite-plugin-image-optimizer": "2.0.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><style>.cls-1{fill:#2f0007;}.cls-2{fill:#fff;}</style></defs><rect class="cls-1" y="7.8" width="32" height="16.36" rx="8.2"/><rect class="cls-2" x="1.7" y="9.5" width="28.6" height="13.03" rx="6.5"/><path class="cls-1" d="M7.4,11.6h0A1.4,1.4,0,0,1,8.8,13v5.7a1.4,1.4,0,0,1-1.4,1.4h0a1.3,1.3,0,0,1-1.3-1.4V13A1.3,1.3,0,0,1,7.4,11.6Z"/><path class="cls-1" d="M11.8,15.8h0a1.4,1.4,0,0,1-1.4,1.4H4.7a1.4,1.4,0,0,1-1.4-1.4h0a1.3,1.3,0,0,1,1.4-1.3h5.7A1.3,1.3,0,0,1,11.8,15.8Z"/><circle class="cls-1" cx="20.7" cy="16" r="1.7"/><circle class="cls-1" cx="23.8" cy="12.8" r="1.7"/><circle class="cls-1" cx="23.8" cy="19.2" r="1.7"/><circle class="cls-1" cx="27.1" cy="16" r="1.7"/><ellipse class="cls-1" cx="14" cy="17.9" rx="1" ry="0.9"/><ellipse class="cls-1" cx="17" cy="17.9" rx="1" ry="0.9"/></svg>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# game_name
|
|
2
|
+
|
|
3
|
+
## Getting started
|
|
4
|
+
|
|
5
|
+
Useful scripts:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# (re)install dependencies
|
|
9
|
+
install_command
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# start the local development server
|
|
14
|
+
dev_command_readme
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# prepare a production build
|
|
19
|
+
build_command
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Resources:
|
|
23
|
+
|
|
24
|
+
- **Game assets**
|
|
25
|
+
- [itch.io game assets](https://itch.io/game-assets)
|
|
26
|
+
|
|
27
|
+
- **Audio**
|
|
28
|
+
- [sfxr.me sfx generator](https://pro.sfxr.me/) and [bfxr](https://www.bfxr.net/)
|
|
29
|
+
- [OpenGameArt cc0 sound effects](https://opengameart.org/art-search-advanced?keys=&title=&field_art_tags_tid_op=or&field_art_tags_tid=&name=&field_art_type_tid%5B%5D=13&field_art_licenses_tid%5B%5D=4)
|
|
30
|
+
- [Free sfx](https://www.freesfx.co.uk/)
|
|
31
|
+
|
|
32
|
+
- **Animation**
|
|
33
|
+
- [easings.net](https://easings.net/)
|
|
34
|
+
|
|
35
|
+
- **Sprites & pixel art**
|
|
36
|
+
- [Aseprite](https://www.aseprite.org/)
|
|
37
|
+
- [Pixel planet generator (itch.io)](https://deep-fold.itch.io/pixel-planet-generator)
|
|
38
|
+
- [Paint of Persia](https://dunin.itch.io/ptop)
|
|
39
|
+
|
|
40
|
+
- **Fonts**
|
|
41
|
+
- [Google Fonts](https://fonts.google.com/)
|
|
42
|
+
- [Font Squirrel](https://www.fontsquirrel.com/)
|
|
43
|
+
- [Font Library](https://fontlibrary.org/)
|
|
44
|
+
|
|
45
|
+
- **Documentation for dev tools & APIs used in this project**
|
|
46
|
+
- [Vite docs](https://vite.dev/)
|
|
47
|
+
- [Bun docs](https://bun.com/docs)
|
|
48
|
+
- [TypeScript handbook](https://www.typescriptlang.org/docs/handbook/intro.html)
|
|
49
|
+
- [MDN Canvas API tutorial](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial)
|
|
50
|
+
- [@spud.gg/api (npm)](https://www.npmjs.com/package/@spud.gg/api)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { audio } from "@spud.gg/api";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
when you import binary assets using vite, you'll get a url string.
|
|
5
|
+
read more at https://vite.dev/guide/assets#importing-asset-as-url
|
|
6
|
+
*/
|
|
7
|
+
import goalUrl from "./assets/audio/goal.wav";
|
|
8
|
+
import paddleHitUrl from "./assets/audio/paddle-hit.wav";
|
|
9
|
+
import wallHitUrl from "./assets/audio/wall-hit.wav";
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
use createSounds for short, snappy SFX that you want pre-buffered
|
|
13
|
+
for low-latency playback. we recommend creating your own effects
|
|
14
|
+
using a tool like https://pro.sfxr.me/
|
|
15
|
+
*/
|
|
16
|
+
export const sounds = audio.createSounds({
|
|
17
|
+
goal: { url: goalUrl },
|
|
18
|
+
paddleHit: { url: paddleHitUrl },
|
|
19
|
+
wallHit: { url: wallHitUrl },
|
|
20
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { game, State } from "./state";
|
|
2
|
+
|
|
3
|
+
export function shakeCamera(state: State, offset: { x: number; y: number }) {
|
|
4
|
+
state.camera.offset.x += offset.x;
|
|
5
|
+
state.camera.offset.y += offset.y;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function easeCameraOffsetToZero(state: State, dt: number) {
|
|
9
|
+
const ease = 1 - Math.exp(-14 * dt);
|
|
10
|
+
state.camera.offset.x += (0 - state.camera.offset.x) * ease;
|
|
11
|
+
state.camera.offset.y += (0 - state.camera.offset.y) * ease;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function gameArea(state: State) {
|
|
15
|
+
const margin = 25;
|
|
16
|
+
const width = Math.max(0, state.bounds.width - margin * 2);
|
|
17
|
+
const height = Math.max(0, state.bounds.height - margin * 2);
|
|
18
|
+
const ratio = width / height;
|
|
19
|
+
const targetRatio = game.width / game.height;
|
|
20
|
+
const drawWidth = ratio > targetRatio ? height * targetRatio : width;
|
|
21
|
+
const drawHeight = ratio > targetRatio ? height : width / targetRatio;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
x: state.bounds.centerX - drawWidth / 2,
|
|
25
|
+
y: state.bounds.centerY - drawHeight / 2,
|
|
26
|
+
width: drawWidth,
|
|
27
|
+
height: drawHeight,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function drawInGameArea(
|
|
32
|
+
state: State,
|
|
33
|
+
ctx: CanvasRenderingContext2D,
|
|
34
|
+
draw: (withScreenShake: (drawWorld: () => void) => void) => void,
|
|
35
|
+
) {
|
|
36
|
+
const area = gameArea(state);
|
|
37
|
+
const scale = Math.min(area.width / game.width, area.height / game.height) * state.camera.zoom;
|
|
38
|
+
|
|
39
|
+
ctx.save();
|
|
40
|
+
ctx.translate(area.x + area.width / 2, area.y + area.height / 2);
|
|
41
|
+
ctx.scale(scale, scale);
|
|
42
|
+
ctx.translate(-state.camera.x, -state.camera.y);
|
|
43
|
+
draw((drawWorld) => {
|
|
44
|
+
ctx.save();
|
|
45
|
+
ctx.translate(state.camera.offset.x, state.camera.offset.y);
|
|
46
|
+
drawWorld();
|
|
47
|
+
ctx.restore();
|
|
48
|
+
});
|
|
49
|
+
ctx.restore();
|
|
50
|
+
}
|
|
@@ -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,46 @@
|
|
|
1
|
+
type GameLoopConfig<State> = {
|
|
2
|
+
ctx: CanvasRenderingContext2D;
|
|
3
|
+
state: State;
|
|
4
|
+
update?: (state: State, dt: number) => void;
|
|
5
|
+
fixedUpdate?: (state: State, dt: number) => void;
|
|
6
|
+
render: (state: State, ctx: CanvasRenderingContext2D) => void;
|
|
7
|
+
fixedDeltaTime?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function gameLoop<State>({
|
|
11
|
+
ctx,
|
|
12
|
+
state,
|
|
13
|
+
update,
|
|
14
|
+
fixedUpdate,
|
|
15
|
+
render,
|
|
16
|
+
fixedDeltaTime = 8 / 1000,
|
|
17
|
+
}: GameLoopConfig<State>) {
|
|
18
|
+
let lastFrameTime: number | null = null;
|
|
19
|
+
let accumulator = 0;
|
|
20
|
+
|
|
21
|
+
function tick(now: number) {
|
|
22
|
+
if (lastFrameTime === null) {
|
|
23
|
+
lastFrameTime = now;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const elapsed = now - lastFrameTime;
|
|
27
|
+
const dt = Math.min(elapsed / 1000, 0.1);
|
|
28
|
+
lastFrameTime = now;
|
|
29
|
+
|
|
30
|
+
if (fixedUpdate) {
|
|
31
|
+
accumulator += dt;
|
|
32
|
+
while (accumulator >= fixedDeltaTime) {
|
|
33
|
+
fixedUpdate(state, fixedDeltaTime);
|
|
34
|
+
accumulator -= fixedDeltaTime;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update?.(state, dt);
|
|
39
|
+
|
|
40
|
+
render(state, ctx);
|
|
41
|
+
requestAnimationFrame(tick);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
render(state, ctx);
|
|
45
|
+
requestAnimationFrame(tick);
|
|
46
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Button, clamp, gamepads, HapticIntensity, keyboard, Player } from "@spud.gg/api";
|
|
2
|
+
import { sounds } from "./audio";
|
|
3
|
+
import { drawInGameArea, easeCameraOffsetToZero, shakeCamera } from "./camera";
|
|
4
|
+
import {
|
|
5
|
+
ballSize,
|
|
6
|
+
game,
|
|
7
|
+
paddleHeight,
|
|
8
|
+
paddleWidth,
|
|
9
|
+
paddleX,
|
|
10
|
+
State,
|
|
11
|
+
wallWidth,
|
|
12
|
+
} from "./state";
|
|
13
|
+
|
|
14
|
+
const ballHalf = ballSize / 2;
|
|
15
|
+
const wallX = game.width - wallWidth;
|
|
16
|
+
const paddleSpeed = 260;
|
|
17
|
+
const startSpeed = 240;
|
|
18
|
+
const maxSpeed = 620;
|
|
19
|
+
const speedIncreaseFactor = 0.035;
|
|
20
|
+
|
|
21
|
+
function serve(state: State, direction = -1) {
|
|
22
|
+
state.ball.x = game.width / 2;
|
|
23
|
+
state.ball.y = game.height / 2;
|
|
24
|
+
state.ball.dx = direction * Math.cos(Math.PI / 4);
|
|
25
|
+
state.ball.dy = (Math.random() < 0.5 ? -1 : 1) * Math.sin(Math.PI / 4);
|
|
26
|
+
state.ball.speed = startSpeed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function movePaddle(state: State, dt: number) {
|
|
30
|
+
const p1 = gamepads.players.list[Player.P1];
|
|
31
|
+
const gamepadDirection =
|
|
32
|
+
p1.leftStick.y || (p1.isDown(Button.DpadDown) ? 1 : p1.isDown(Button.DpadUp) ? -1 : 0);
|
|
33
|
+
const keyboardDirection =
|
|
34
|
+
(keyboard.keysDown.has("ArrowDown") ? 1 : 0) + (keyboard.keysDown.has("ArrowUp") ? -1 : 0);
|
|
35
|
+
const direction = gamepadDirection || keyboardDirection;
|
|
36
|
+
|
|
37
|
+
state.paddle.y += direction * paddleSpeed * dt;
|
|
38
|
+
state.paddle.y = clamp(state.paddle.y, 0, game.height - paddleHeight);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function increaseBallSpeed(state: State) {
|
|
42
|
+
state.ball.speed += (maxSpeed - state.ball.speed) * speedIncreaseFactor;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hitPaddle(state: State) {
|
|
46
|
+
const centerDist = state.ball.y - (state.paddle.y + paddleHeight / 2);
|
|
47
|
+
const normalized = clamp(centerDist / (paddleHeight / 2), -1, 1);
|
|
48
|
+
const angle = (Math.PI / 4) * normalized;
|
|
49
|
+
|
|
50
|
+
state.ball.dx = Math.cos(angle);
|
|
51
|
+
state.ball.dy = Math.sin(angle);
|
|
52
|
+
state.ball.x = paddleX + paddleWidth + ballHalf;
|
|
53
|
+
state.hits++;
|
|
54
|
+
|
|
55
|
+
increaseBallSpeed(state);
|
|
56
|
+
shakeCamera(state, { x: -4 - state.ball.speed / 90, y: 0 });
|
|
57
|
+
sounds("paddleHit").play();
|
|
58
|
+
gamepads.players.list[Player.P1].rumble(35, HapticIntensity.Balanced);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function updateBall(state: State, dt: number) {
|
|
62
|
+
state.ball.x += state.ball.dx * state.ball.speed * dt;
|
|
63
|
+
state.ball.y += state.ball.dy * state.ball.speed * dt;
|
|
64
|
+
|
|
65
|
+
if (state.ball.y - ballHalf <= 0) {
|
|
66
|
+
state.ball.y = ballHalf;
|
|
67
|
+
state.ball.dy = Math.abs(state.ball.dy);
|
|
68
|
+
shakeCamera(state, { x: 0, y: -2 - state.ball.speed / 120 });
|
|
69
|
+
sounds("wallHit").play();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state.ball.y + ballHalf >= game.height) {
|
|
73
|
+
state.ball.y = game.height - ballHalf;
|
|
74
|
+
state.ball.dy = -Math.abs(state.ball.dy);
|
|
75
|
+
shakeCamera(state, { x: 0, y: 2 + state.ball.speed / 120 });
|
|
76
|
+
sounds("wallHit").play();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (state.ball.x + ballHalf >= wallX) {
|
|
80
|
+
state.ball.x = wallX - ballHalf;
|
|
81
|
+
state.ball.dx = -Math.abs(state.ball.dx);
|
|
82
|
+
state.hits++;
|
|
83
|
+
increaseBallSpeed(state);
|
|
84
|
+
shakeCamera(state, { x: 3 + state.ball.speed / 100, y: 0 });
|
|
85
|
+
sounds("wallHit").play();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ball = {
|
|
89
|
+
left: state.ball.x - ballHalf,
|
|
90
|
+
right: state.ball.x + ballHalf,
|
|
91
|
+
top: state.ball.y - ballHalf,
|
|
92
|
+
bottom: state.ball.y + ballHalf,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const paddle = {
|
|
96
|
+
left: paddleX,
|
|
97
|
+
right: paddleX + paddleWidth,
|
|
98
|
+
top: state.paddle.y - ballSize,
|
|
99
|
+
bottom: state.paddle.y + paddleHeight + ballSize,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
state.ball.dx < 0 &&
|
|
104
|
+
ball.left <= paddle.right &&
|
|
105
|
+
ball.right >= paddle.left &&
|
|
106
|
+
ball.top <= paddle.bottom &&
|
|
107
|
+
ball.bottom >= paddle.top
|
|
108
|
+
) {
|
|
109
|
+
hitPaddle(state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (state.ball.x + ballHalf < 0) {
|
|
113
|
+
state.hits = 0;
|
|
114
|
+
shakeCamera(state, { x: -8, y: 0 });
|
|
115
|
+
sounds("goal").play();
|
|
116
|
+
serve(state, -1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function fixedUpdate(state: State, dt: number) {
|
|
121
|
+
movePaddle(state, dt);
|
|
122
|
+
updateBall(state, dt);
|
|
123
|
+
easeCameraOffsetToZero(state, dt);
|
|
124
|
+
gamepads.clearInputs();
|
|
125
|
+
keyboard.clearInputs();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function drawGrid(ctx: CanvasRenderingContext2D) {
|
|
129
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.08)";
|
|
130
|
+
for (let y = 7; y < game.height; y += 14) {
|
|
131
|
+
for (let x = 7; x < game.width; x += 14) {
|
|
132
|
+
ctx.beginPath();
|
|
133
|
+
ctx.arc(x, y, 1, 0, Math.PI * 2);
|
|
134
|
+
ctx.fill();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function drawScore(state: State, ctx: CanvasRenderingContext2D) {
|
|
140
|
+
ctx.fillStyle = "white";
|
|
141
|
+
ctx.font = "34px Atari";
|
|
142
|
+
ctx.textAlign = "center";
|
|
143
|
+
ctx.textBaseline = "middle";
|
|
144
|
+
ctx.fillText(`${state.hits}`, game.width / 2, 40);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function render(state: State, ctx: CanvasRenderingContext2D) {
|
|
148
|
+
ctx.fillStyle = "black";
|
|
149
|
+
ctx.fillRect(0, 0, state.bounds.width, state.bounds.height);
|
|
150
|
+
|
|
151
|
+
drawInGameArea(state, ctx, (withScreenShake) => {
|
|
152
|
+
withScreenShake(() => {
|
|
153
|
+
ctx.fillStyle = "#151515";
|
|
154
|
+
ctx.fillRect(0, 0, game.width, game.height);
|
|
155
|
+
drawGrid(ctx);
|
|
156
|
+
|
|
157
|
+
ctx.fillStyle = "white";
|
|
158
|
+
ctx.fillRect(paddleX, state.paddle.y, paddleWidth, paddleHeight);
|
|
159
|
+
ctx.fillRect(wallX, 0, wallWidth, game.height);
|
|
160
|
+
ctx.fillRect(state.ball.x - ballHalf, state.ball.y - ballHalf, ballSize, ballSize);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
drawScore(state, ctx);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
main.ts is the main entry point for your game.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { keyboard, spud } from "@spud.gg/api";
|
|
6
|
+
import { scaleAndObserveCanvasSize } from "./canvas";
|
|
7
|
+
import { gameLoop } from "./gameLoop";
|
|
8
|
+
import { render, fixedUpdate } from "./gameplay";
|
|
9
|
+
import { state } from "./state";
|
|
10
|
+
|
|
11
|
+
if (!spud.isListening) {
|
|
12
|
+
spud.listen();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
keyboard.listen();
|
|
16
|
+
|
|
17
|
+
const canvas = document.createElement("canvas");
|
|
18
|
+
document.body.appendChild(canvas);
|
|
19
|
+
|
|
20
|
+
const ctx = canvas.getContext("2d", { alpha: false })!;
|
|
21
|
+
scaleAndObserveCanvasSize(ctx, state.bounds);
|
|
22
|
+
|
|
23
|
+
gameLoop({ ctx, state, fixedUpdate, render, fixedDeltaTime: 1 / 500 });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createBounds } from "./canvas";
|
|
2
|
+
|
|
3
|
+
export const game = {
|
|
4
|
+
width: 640,
|
|
5
|
+
height: 360,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const ballSize = 6;
|
|
9
|
+
export const paddleWidth = 6;
|
|
10
|
+
export const paddleHeight = 54;
|
|
11
|
+
export const paddleX = 14;
|
|
12
|
+
export const wallWidth = 6;
|
|
13
|
+
|
|
14
|
+
export const state = {
|
|
15
|
+
bounds: createBounds(),
|
|
16
|
+
hits: 0,
|
|
17
|
+
paddle: {
|
|
18
|
+
y: game.height / 2 - paddleHeight / 2,
|
|
19
|
+
},
|
|
20
|
+
ball: {
|
|
21
|
+
x: game.width / 2,
|
|
22
|
+
y: game.height / 2,
|
|
23
|
+
dx: -Math.cos(Math.PI / 4),
|
|
24
|
+
dy: Math.sin(Math.PI / 4),
|
|
25
|
+
speed: 240,
|
|
26
|
+
},
|
|
27
|
+
camera: {
|
|
28
|
+
x: game.width / 2,
|
|
29
|
+
y: game.height / 2,
|
|
30
|
+
zoom: 1,
|
|
31
|
+
offset: {
|
|
32
|
+
x: 0,
|
|
33
|
+
y: 0,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type State = typeof state;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": [
|
|
6
|
+
"ESNext",
|
|
7
|
+
"DOM",
|
|
8
|
+
"DOM.Iterable"
|
|
9
|
+
],
|
|
10
|
+
"types": [
|
|
11
|
+
"vite/client"
|
|
12
|
+
],
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
/* Bundler mode */
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"allowImportingTsExtensions": true,
|
|
17
|
+
"moduleDetection": "force",
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
/* Linting */
|
|
20
|
+
"strict": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
23
|
+
"noImplicitOverride": true,
|
|
24
|
+
"erasableSyntaxOnly": true,
|
|
25
|
+
"noFallthroughCasesInSwitch": true,
|
|
26
|
+
"noUncheckedSideEffectImports": true,
|
|
27
|
+
/* Aliases */
|
|
28
|
+
"paths": {
|
|
29
|
+
"~/*": [
|
|
30
|
+
"./src/*"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"include": [
|
|
35
|
+
"src"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
build: {
|
|
6
|
+
sourcemap: true,
|
|
7
|
+
modulePreload: {
|
|
8
|
+
polyfill: false,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
plugins: [ViteImageOptimizer()],
|
|
12
|
+
resolve: {
|
|
13
|
+
tsconfigPaths: true,
|
|
14
|
+
},
|
|
15
|
+
});
|