├── .gitattributes
├── .gitignore
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── biome.json
├── index.html
├── package.json
├── public
└── i.png
├── screenshot.png
├── src
├── constants.ts
├── entity.ts
├── index.ts
├── input.ts
├── keys.ts
├── mouse.ts
├── music.ts
├── sounds.ts
├── tilemap.ts
├── zzfx.ts
└── zzfxm.ts
├── styles.css
├── tsconfig.json
└── vite.config.ts
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | package-lock.json -diff
3 | package-lock.json linguist-generated=true
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | package-lock.json
4 | .idea
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["biomejs.biome"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "biomejs.biome"
5 | },
6 | "[typescript]": {
7 | "editor.defaultFormatter": "biomejs.biome"
8 | },
9 | "[json]": {
10 | "editor.defaultFormatter": "biomejs.biome"
11 | },
12 | "[jsonc]": {
13 | "editor.defaultFormatter": "biomejs.biome"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Cody Ebberson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JS13K Starter
2 |
3 | A starter template for the [js13kgames competition](https://js13kgames.com/).
4 |
5 | 
6 |
7 | Play live demo:
8 |
9 | This starter project uses [js13k-vite-plugins](https://github.com/codyebberson/js13k-vite-plugins) for js13k optimized tooling. See [js13k-vite-plugins](https://github.com/codyebberson/js13k-vite-plugins) for more information about tooling and configuration.
10 |
11 | This demo currently builds to about 4 kB. Don't let this initial size overly concern you! The magic of the js13k compression process means that the final zipped size doesn't simply grow linearly with every line of code you add – the results can be quite unpredictable. The most important thing is to focus on creating your game. Rest assured, this starter equips you with the same powerful optimization toolchain that many js13k veterans rely on.
12 |
13 | ## Usage
14 |
15 | ### Install
16 |
17 | Clone and install dependencies:
18 |
19 | ```bash
20 | git clone git@github.com:codyebberson/js13k-starter.git
21 | cd js13k-starter
22 | npm i
23 | ```
24 |
25 | ### Dev server
26 |
27 | Start the dev server with hot reload:
28 |
29 | ```bash
30 | npm run dev
31 | ```
32 |
33 | Open your web browser to
34 |
35 | ### Production build
36 |
37 | Create a final production build:
38 |
39 | ```bash
40 | npm run build
41 | ```
42 |
43 | ### Preview production build
44 |
45 | After building, you can preview the production build with Vite's built-in server:
46 |
47 | ```bash
48 | npm run preview
49 | ```
50 |
51 | ## Acknowledgements
52 |
53 | [Frank Force](https://twitter.com/KilledByAPixel) for [ZzFX](https://github.com/KilledByAPixel/ZzFX)
54 |
55 | [Keith Clark](https://twitter.com/keithclarkcouk) and [Frank Force](https://twitter.com/KilledByAPixel) for [ZzFXM](https://keithclark.github.io/ZzFXM/)
56 |
57 | [Kang Seonghoon](https://mearie.org/) for [Roadroller](https://lifthrasiir.github.io/roadroller/)
58 |
59 | [Rob Louie](https://github.com/roblouie) for Roadroller configuration recommendations
60 |
61 | [Salvatore Previti](https://github.com/SalvatorePreviti) for Terser configuration recommendations
62 |
63 | [Kenney](https://kenney.nl/) for [Pixel Platformer](https://kenney.nl/assets/pixel-platformer) graphics
64 |
65 | [Andrzej Mazur](https://end3r.com/) for organizing js13k
66 |
67 | ## License
68 |
69 | Code: MIT
70 |
71 | Graphics: Creative Commons CC0 1.0 Universal
72 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
3 | "assist": { "actions": { "source": { "organizeImports": "on" } } },
4 | "formatter": {
5 | "enabled": true,
6 | "indentStyle": "space",
7 | "indentWidth": 2,
8 | "lineWidth": 120
9 | },
10 | "linter": {
11 | "enabled": true,
12 | "rules": {
13 | "recommended": true,
14 | "complexity": {
15 | "noCommaOperator": "off"
16 | },
17 | "style": {
18 | "noParameterAssign": "off",
19 | "useImportType": "off",
20 | "useTemplate": "off"
21 | },
22 | "suspicious": {
23 | "noAssignInExpressions": "off",
24 | "noExplicitAny": "off",
25 | "noSparseArray": "off"
26 | }
27 | }
28 | },
29 | "javascript": {
30 | "formatter": {
31 | "enabled": true,
32 | "quoteStyle": "single"
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | JS13K Demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "js13k-starter",
3 | "type": "module",
4 | "version": "0.0.2",
5 | "license": "MIT",
6 | "main": "src/index.ts",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "tsc && vite build",
10 | "fix": "biome check --write --unsafe .",
11 | "preview": "vite preview",
12 | "lint": "biome lint ."
13 | },
14 | "devDependencies": {
15 | "@biomejs/biome": "2.1.3",
16 | "js13k-vite-plugins": "0.4.0",
17 | "terser": "5.43.1",
18 | "typescript": "5.9.2",
19 | "vite": "7.0.6"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/public/i.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codyebberson/js13k-starter/4f7c3aa5ca6c534513b41ecc0e0e64f3f1fd23e2/public/i.png
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codyebberson/js13k-starter/4f7c3aa5ca6c534513b41ecc0e0e64f3f1fd23e2/screenshot.png
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const WIDTH = 480;
2 | export const HEIGHT = 256;
3 | export const CENTER_X = WIDTH / 2;
4 | export const CENTER_Y = HEIGHT / 2;
5 | export const PLAYER_ACCELERATION = 0.0015;
6 | export const PLAYER_DECCELERATION = 0.002;
7 | export const PLAYER_MAX_SPEED = 0.12;
8 | export const PLAYER_JUMP_POWER = 0.18;
9 | export const GRAVITY = 0.0008;
10 | export const FLOATY_GRAVITY = 0.0004;
11 | export const TILEMAP_WIDTH = 128;
12 | export const TILEMAP_HEIGHT = 16;
13 | export const TILE_SIZE = 16;
14 | export const HALF_TILE_SIZE = TILE_SIZE / 2;
15 |
--------------------------------------------------------------------------------
/src/entity.ts:
--------------------------------------------------------------------------------
1 | export const ENTITY_TYPE_PLAYER = 0;
2 | export const ENTITY_TYPE_COIN = 1;
3 | export const ENTITY_TYPE_JUMPPAD = 2;
4 | export const ENTITY_TYPE_WALKING_ENEMY = 3;
5 |
6 | export class Entity {
7 | entityType: number;
8 | x: number;
9 | y: number;
10 | dx: number;
11 | dy: number;
12 | direction: number;
13 | grounded: boolean;
14 | frame: number;
15 | health: number;
16 | cooldown: number;
17 |
18 | constructor(entityType: number, x: number, y: number, dx = 0, dy = 0) {
19 | this.entityType = entityType;
20 | this.x = x;
21 | this.y = y;
22 | this.dx = dx;
23 | this.dy = dy;
24 | this.direction = 1;
25 | this.grounded = false;
26 | this.frame = 0;
27 | this.health = 100;
28 | this.cooldown = 0;
29 | entities.push(this);
30 | }
31 |
32 | distance(other: Entity): number {
33 | return Math.hypot(this.x - other.x, this.y - other.y);
34 | }
35 | }
36 |
37 | export const entities: Entity[] = [];
38 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FLOATY_GRAVITY,
3 | GRAVITY,
4 | HALF_TILE_SIZE,
5 | HEIGHT,
6 | PLAYER_ACCELERATION,
7 | PLAYER_JUMP_POWER,
8 | PLAYER_MAX_SPEED,
9 | TILE_SIZE,
10 | TILEMAP_HEIGHT,
11 | TILEMAP_WIDTH,
12 | WIDTH,
13 | } from './constants';
14 | import {
15 | ENTITY_TYPE_COIN,
16 | ENTITY_TYPE_JUMPPAD,
17 | ENTITY_TYPE_PLAYER,
18 | ENTITY_TYPE_WALKING_ENEMY,
19 | entities,
20 | } from './entity';
21 | import { initKeys, KEY_A, KEY_D, KEY_LEFT, KEY_RIGHT, KEY_Z, keys, updateKeys } from './keys';
22 | import { initMouse, updateMouse } from './mouse';
23 | import { coinSound, hurtSound, jumpPadSound, jumpSound } from './sounds';
24 | import { collisionDetectionEntityToTile, getTile, initTileMap } from './tilemap';
25 | import { zzfx } from './zzfx';
26 |
27 | const canvas = document.querySelector('#c') as HTMLCanvasElement;
28 | const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
29 |
30 | const skyGradient = ctx.createLinearGradient(0, 0, 0, HEIGHT);
31 | skyGradient.addColorStop(0, '#dff6f5');
32 | skyGradient.addColorStop(1, '#a4c6f1');
33 |
34 | const image = new Image();
35 | image.src = 'i.png';
36 |
37 | const player = initTileMap();
38 |
39 | let windowTime = 0;
40 | let dt = 0;
41 | let gameTime = 0;
42 | let score = 0;
43 | let viewportX = 0;
44 |
45 | initKeys(canvas);
46 | initMouse(canvas);
47 |
48 | function gameLoop(newTime: number): void {
49 | requestAnimationFrame(gameLoop);
50 |
51 | if (player.health > 0) {
52 | dt = Math.min(newTime - windowTime, 1000 / 30);
53 | gameTime += dt;
54 |
55 | updateKeys();
56 | updateMouse();
57 | handleInput();
58 | updateEntities();
59 | collisionDetection();
60 | updateCamera();
61 | }
62 |
63 | render();
64 | windowTime = newTime;
65 | dt = 0;
66 | }
67 |
68 | function handleInput(): void {
69 | if (keys[KEY_LEFT].down || keys[KEY_A].down) {
70 | player.dx -= dt * PLAYER_ACCELERATION;
71 | player.direction = -1;
72 | } else if (keys[KEY_RIGHT].down || keys[KEY_D].down) {
73 | player.dx += dt * PLAYER_ACCELERATION;
74 | player.direction = 1;
75 | } else {
76 | player.dx = 0;
77 | }
78 |
79 | if (keys[KEY_Z].downCount === 1 && player.grounded) {
80 | player.dy = -PLAYER_JUMP_POWER;
81 | zzfx(...jumpSound);
82 | }
83 |
84 | if (player.dx > PLAYER_MAX_SPEED) {
85 | player.dx = PLAYER_MAX_SPEED;
86 | } else if (player.dx < -PLAYER_MAX_SPEED) {
87 | player.dx = -PLAYER_MAX_SPEED;
88 | }
89 | }
90 |
91 | function updateEntities(): void {
92 | for (let i = entities.length - 1; i >= 0; i--) {
93 | const entity = entities[i];
94 |
95 | if (entity === player && keys[KEY_Z].down) {
96 | player.dy += dt * FLOATY_GRAVITY;
97 | } else if (entity.entityType !== ENTITY_TYPE_COIN) {
98 | entity.dy += dt * GRAVITY;
99 | }
100 |
101 | if (entity.entityType === ENTITY_TYPE_WALKING_ENEMY) {
102 | entity.dx = entity.direction * 0.03;
103 | }
104 |
105 | entity.x += dt * entity.dx;
106 | entity.y += dt * entity.dy;
107 | entity.cooldown--;
108 |
109 | // Clear out dead entities
110 | if (entity.health <= 0) {
111 | entities.splice(i, 1);
112 | }
113 | }
114 | }
115 |
116 | function collisionDetection(): void {
117 | collisionDetectionEntityToTile();
118 | collisionDetectionEntityToEntity();
119 | }
120 |
121 | function collisionDetectionEntityToEntity(): void {
122 | for (const entity of entities) {
123 | for (const other of entities) {
124 | if (entity !== other && entity.distance(other) < TILE_SIZE) {
125 | if (entity === player && other.entityType === ENTITY_TYPE_COIN) {
126 | score += 100;
127 | other.health = 0;
128 | zzfx(...coinSound);
129 | }
130 | if (entity === player && other.entityType === ENTITY_TYPE_JUMPPAD) {
131 | player.y = Math.min(player.y, other.y - 8);
132 | player.dx = 0;
133 | player.dy = -PLAYER_JUMP_POWER * 2;
134 | zzfx(...jumpPadSound);
135 | }
136 | if (entity === player && other.entityType === ENTITY_TYPE_WALKING_ENEMY) {
137 | if (player.y + HALF_TILE_SIZE < other.y) {
138 | // If the player is at least half a tile above the enemy, kill the enemy
139 | other.health -= 100;
140 | player.dy = -PLAYER_JUMP_POWER;
141 | zzfx(...jumpSound);
142 | } else {
143 | // Otherwise hurt the player
144 | player.health -= 10;
145 | player.dy = -0.25 * PLAYER_JUMP_POWER;
146 |
147 | // Push the player away from the enemy
148 | if (player.x < other.x) {
149 | player.x = other.x - TILE_SIZE - HALF_TILE_SIZE;
150 | } else {
151 | player.x = other.x + TILE_SIZE + HALF_TILE_SIZE;
152 | }
153 | zzfx(...hurtSound);
154 | }
155 | }
156 | }
157 | }
158 | }
159 | }
160 |
161 | function updateCamera(): void {
162 | if (player.x - viewportX > 300) {
163 | viewportX = player.x - 300;
164 | } else if (viewportX + WIDTH - player.x > 300) {
165 | viewportX = player.x + 300 - WIDTH;
166 | }
167 |
168 | if (viewportX < 0) {
169 | viewportX = 0;
170 | }
171 |
172 | if (viewportX + WIDTH > TILEMAP_WIDTH * TILE_SIZE) {
173 | viewportX = TILEMAP_WIDTH * TILE_SIZE - WIDTH;
174 | }
175 | }
176 |
177 | function render(): void {
178 | clearScreen();
179 | drawTileMap();
180 | drawEntities();
181 | drawOverlay();
182 | }
183 |
184 | function clearScreen(): void {
185 | ctx.fillStyle = skyGradient;
186 | ctx.fillRect(0, 0, WIDTH, HEIGHT);
187 | }
188 |
189 | function drawTileMap(): void {
190 | for (let y = 0; y < TILEMAP_HEIGHT; y++) {
191 | for (let x = 0; x < TILEMAP_WIDTH; x++) {
192 | const tile = getTile(x, y);
193 | if (tile > 0) {
194 | const tx = (tile - 1) * TILE_SIZE;
195 | const ty = 24;
196 | ctx.drawImage(
197 | image,
198 | tx,
199 | ty,
200 | TILE_SIZE,
201 | TILE_SIZE,
202 | Math.floor(x * TILE_SIZE - viewportX),
203 | y * TILE_SIZE,
204 | TILE_SIZE,
205 | TILE_SIZE,
206 | );
207 | }
208 | }
209 | }
210 | }
211 |
212 | function drawEntities(): void {
213 | for (const entity of entities) {
214 | ctx.save();
215 | ctx.translate(Math.floor(entity.x - viewportX + HALF_TILE_SIZE), Math.floor(entity.y + HALF_TILE_SIZE));
216 | ctx.scale(entity.direction, 1);
217 | let sx = 0;
218 | if (entity.entityType === ENTITY_TYPE_PLAYER) {
219 | const walking = Math.abs(entity.dx) > 0.01;
220 | sx = !entity.grounded ? 48 : walking ? 16 + (entity.frame | 0) * TILE_SIZE : 0;
221 | } else if (entity.entityType === ENTITY_TYPE_COIN) {
222 | sx = 64 + (entity.frame | 0) * TILE_SIZE;
223 | } else if (entity.entityType === ENTITY_TYPE_JUMPPAD) {
224 | sx = 96;
225 | } else if (entity.entityType === ENTITY_TYPE_WALKING_ENEMY) {
226 | sx = 112 + (entity.frame | 0) * TILE_SIZE;
227 | }
228 | ctx.drawImage(image, sx, 8, TILE_SIZE, TILE_SIZE, -HALF_TILE_SIZE, -HALF_TILE_SIZE, TILE_SIZE, TILE_SIZE);
229 | ctx.restore();
230 | entity.frame += dt * 0.005;
231 | if (entity.frame >= 2) {
232 | entity.frame = 0;
233 | }
234 | }
235 | }
236 |
237 | function drawOverlay(): void {
238 | ctx.fillStyle = '#000';
239 | ctx.fillRect(0, 0, WIDTH, 16);
240 |
241 | drawString('HEALTH ' + player.health, 4, 6);
242 | drawString('SCORE ' + score, 150, 6);
243 | drawString('TIME ' + ((gameTime / 1000) | 0), 300, 6);
244 | }
245 |
246 | function drawString(str: string, x: number, y: number): void {
247 | for (let i = 0; i < str.length; i++) {
248 | const charCode = str.charCodeAt(i);
249 | const charIndex = charCode < 65 ? charCode - 48 : charCode - 55;
250 | ctx.drawImage(image, charIndex * 6, 0, 6, 6, x, y, 6, 6);
251 | x += 6;
252 | }
253 | }
254 |
255 | requestAnimationFrame(gameLoop);
256 |
--------------------------------------------------------------------------------
/src/input.ts:
--------------------------------------------------------------------------------
1 | export interface Input {
2 | down: boolean;
3 | downCount: number;
4 | upCount: number;
5 | }
6 |
7 | /**
8 | * Creates a new input.
9 | */
10 | export const newInput = (): Input => ({ down: false, downCount: 0, upCount: 2 });
11 |
12 | /**
13 | * Updates the up/down counts for an input.
14 | * @param input
15 | */
16 | export function updateInput(input: Input): void {
17 | if (input.down) {
18 | input.downCount++;
19 | input.upCount = 0;
20 | } else {
21 | input.downCount = 0;
22 | input.upCount++;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/keys.ts:
--------------------------------------------------------------------------------
1 | import { type Input, newInput, updateInput } from './input';
2 |
3 | export const KEY_LEFT = 37;
4 | export const KEY_UP = 38;
5 | export const KEY_RIGHT = 39;
6 | export const KEY_DOWN = 40;
7 | export const KEY_A = 65;
8 | export const KEY_D = 68;
9 | export const KEY_S = 83;
10 | export const KEY_W = 87;
11 | export const KEY_Z = 90;
12 |
13 | const KEY_COUNT = 256;
14 |
15 | /**
16 | * Array of keyboard keys.
17 | */
18 | export const keys: Input[] = new Array(KEY_COUNT);
19 |
20 | /**
21 | * Initializes the keyboard.
22 | * @param el The HTML element to listen on.
23 | */
24 | export function initKeys(el: HTMLElement): void {
25 | for (let i = 0; i < KEY_COUNT; i++) {
26 | keys[i] = newInput();
27 | }
28 |
29 | el.addEventListener('click', handleClick);
30 | el.addEventListener('keydown', (e) => setKey(e, true));
31 | el.addEventListener('keyup', (e) => setKey(e, false));
32 | }
33 |
34 | function handleClick(e: MouseEvent): void {
35 | e.preventDefault();
36 | e.stopPropagation();
37 | (e.currentTarget as HTMLElement).focus();
38 | }
39 |
40 | function setKey(e: KeyboardEvent, state: boolean): void {
41 | e.preventDefault();
42 | e.stopPropagation();
43 | keys[e.keyCode].down = state;
44 | }
45 |
46 | export function updateKeys(): void {
47 | keys.forEach(updateInput);
48 | }
49 |
--------------------------------------------------------------------------------
/src/mouse.ts:
--------------------------------------------------------------------------------
1 | import { newInput, updateInput } from './input';
2 |
3 | export const mouse = {
4 | /**
5 | * Mouse x coordinate.
6 | */
7 | x: 0,
8 |
9 | /**
10 | * Mouse y coordinate.
11 | */
12 | y: 0,
13 |
14 | /**
15 | * Mouse buttons
16 | */
17 | buttons: [newInput(), newInput(), newInput()],
18 | };
19 |
20 | /**
21 | * Initializes the keyboard.
22 | * @param el The HTML element to listen on.
23 | */
24 | export function initMouse(el: HTMLElement): void {
25 | el.addEventListener('mousedown', (e) => {
26 | mouse.buttons[e.button].down = true;
27 | });
28 | el.addEventListener('mouseup', (e) => {
29 | mouse.buttons[e.button].down = false;
30 | });
31 | el.addEventListener('mousemove', (e) => {
32 | mouse.x = e.pageX - el.offsetLeft;
33 | mouse.y = e.pageY - el.offsetTop;
34 | });
35 | }
36 |
37 | /**
38 | * Updates all mouse button states.
39 | */
40 | export function updateMouse(): void {
41 | mouse.buttons.forEach(updateInput);
42 | }
43 |
--------------------------------------------------------------------------------
/src/music.ts:
--------------------------------------------------------------------------------
1 | import { zzfxM } from './zzfxm';
2 |
3 | export const music = zzfxM(
4 | [
5 | [0.4, 0, , 0.1, 0.25, 0.25, 2, 0.2, , , , , , 0.1, , , 0.06, , 0.1, 0.21],
6 | [, 0, 440, , , 0.15, 2, 0.2, -0.1, , 9, 0.02, , 0.1, 0.12, , 0.06],
7 | [0.4, 0, , 0.1, 1, 0.25, 2, 0.2, , , , , , 0.1, , , 0.06, , 0.1, 0.21],
8 | ],
9 | [
10 | [
11 | [
12 | ,
13 | ,
14 | 8,
15 | ,
16 | ,
17 | ,
18 | ,
19 | ,
20 | ,
21 | ,
22 | 11,
23 | ,
24 | ,
25 | ,
26 | 15,
27 | ,
28 | ,
29 | ,
30 | 14,
31 | ,
32 | ,
33 | ,
34 | 7,
35 | ,
36 | ,
37 | ,
38 | ,
39 | ,
40 | ,
41 | ,
42 | ,
43 | ,
44 | ,
45 | ,
46 | 6,
47 | ,
48 | ,
49 | ,
50 | ,
51 | ,
52 | ,
53 | ,
54 | 6,
55 | ,
56 | ,
57 | ,
58 | 9,
59 | ,
60 | 14,
61 | ,
62 | 13,
63 | ,
64 | ,
65 | ,
66 | 5,
67 | ,
68 | ,
69 | ,
70 | ,
71 | ,
72 | ,
73 | ,
74 | ,
75 | ,
76 | ,
77 | ],
78 | [
79 | 1,
80 | ,
81 | 8,
82 | 11,
83 | 15,
84 | 16,
85 | 8,
86 | 11,
87 | 15,
88 | 16,
89 | 8,
90 | 11,
91 | 15,
92 | 16,
93 | 8,
94 | 11,
95 | 15,
96 | 16,
97 | 7,
98 | 10,
99 | 15,
100 | 16,
101 | 7,
102 | 10,
103 | 15,
104 | 16,
105 | 10,
106 | 15,
107 | 16,
108 | 7,
109 | 10,
110 | 15,
111 | 16,
112 | 6,
113 | 9,
114 | 15,
115 | 16,
116 | 6,
117 | 9,
118 | 15,
119 | 16,
120 | 6,
121 | 9,
122 | 15,
123 | 16,
124 | 6,
125 | 9,
126 | 15,
127 | 16,
128 | 5,
129 | 8,
130 | 15,
131 | 16,
132 | 5,
133 | 8,
134 | 15,
135 | 16,
136 | 5,
137 | 8,
138 | 15,
139 | 16,
140 | 5,
141 | 8,
142 | 15,
143 | 16,
144 | ],
145 | ],
146 | [
147 | [2, , 4, , , , , , , , , , , , , , , , 4, , , , , , , , , , , , , , , , 3, , , , , , , , , , , , , , , , , , , ,],
148 | [
149 | 1,
150 | ,
151 | 4,
152 | 8,
153 | 13,
154 | 15,
155 | 4,
156 | 8,
157 | 13,
158 | 15,
159 | 4,
160 | 8,
161 | 13,
162 | 15,
163 | 4,
164 | 8,
165 | 13,
166 | 15,
167 | 3,
168 | 8,
169 | 13,
170 | 15,
171 | 3,
172 | 8,
173 | 13,
174 | 15,
175 | 3,
176 | 8,
177 | 13,
178 | 15,
179 | 3,
180 | 8,
181 | 13,
182 | 15,
183 | 1,
184 | 7,
185 | 10,
186 | 13,
187 | 7,
188 | 10,
189 | 13,
190 | 16,
191 | 10,
192 | 13,
193 | 16,
194 | 13,
195 | 16,
196 | 19,
197 | 16,
198 | 19,
199 | 22,
200 | 19,
201 | 22,
202 | 25,
203 | ],
204 | ],
205 | ],
206 | [0, 1],
207 | 90,
208 | );
209 |
--------------------------------------------------------------------------------
/src/sounds.ts:
--------------------------------------------------------------------------------
1 | // Sounds generated with ZzFX: https://killedbyapixel.github.io/ZzFX/
2 | // Find a sound you like, then copy the parameters into the sound array.
3 |
4 | export const coinSound = [, 0, 1267, 0.01, 0.09, , 1, 1.5, , , 400, 0.08, , , , , , 0.3, 0.02];
5 |
6 | export const jumpSound = [, 0, 200, 0.02, 0.01, 0.03, , 1.73, 5, , , , , , , , , 0.5, 0.09];
7 |
8 | export const jumpPadSound = [, 0, 175, , 0.1, , , 0.28, 10, 2, , , , , , , , 0.59, 0.05];
9 |
10 | export const hurtSound = [, 0, 466, , 0.08, 0.05, , 0.27, -2.5, , , , , , , , , 0.69, 0.09];
11 |
--------------------------------------------------------------------------------
/src/tilemap.ts:
--------------------------------------------------------------------------------
1 | import { HEIGHT, TILE_SIZE, TILEMAP_HEIGHT, TILEMAP_WIDTH } from './constants';
2 | import {
3 | ENTITY_TYPE_COIN,
4 | ENTITY_TYPE_JUMPPAD,
5 | ENTITY_TYPE_PLAYER,
6 | ENTITY_TYPE_WALKING_ENEMY,
7 | Entity,
8 | entities,
9 | } from './entity';
10 |
11 | // Simple tile map
12 | //
13 | // In this example, the tile map is a 2D array of numbers
14 | // P = player
15 | // C = coin
16 | // J = jump pad
17 | // W = walking enemy
18 | // 1-9 = different tiles
19 | //
20 | // You can use whatever convention you like for creating the map
21 | //
22 | // Some other ideas:
23 | // - Use a tool like Tiled to create maps
24 | // - Load the map from image data, to take advantage of PNG compression
25 | // - Procedurally generate the map
26 | const tileMapSource: string[] = [
27 | ' 454556555556 ',
28 | ' 454556555556 ',
29 | ' 454556555556 ',
30 | ' 457889555559 ',
31 | ' 45555555889 ',
32 | ' 78885556 1223 ',
33 | ' 7889 ',
34 | ' P C CC CC ',
35 | ' C C J 123 ',
36 | ' 1223 122223 C 1223 456 W W W W W W W W ',
37 | ' 45553 W 122222555556 455223 1223 1223 1223 1223 1223 1223 1223 12',
38 | ' 12222455553 15555555555553 155555522225555222255552222555522225555222255552222555522225555222255',
39 | ' 455554555553 CC 1555555123555553 455555555555555555555555555555555555555555555555555555555555555555555',
40 | ' 12455554555556 C C 1555555545122355523 1223 J 1512355555555555555555555555555555555555555555555555555555555555555555',
41 | ' 122355554555556J 1555555554545565555522255552225545655555555555555555555555555555555555555555555555555555555555555555',
42 | '22222222555545555562222222225555555554545565555555551223555545655555555555555555555555555555555555555555555555555555555555555555',
43 | ];
44 |
45 | const tileMap: number[][] = [];
46 |
47 | export function initTileMap(): Entity {
48 | let player: Entity | undefined;
49 |
50 | for (let y = 0; y < TILEMAP_HEIGHT; y++) {
51 | const row = [];
52 | for (let x = 0; x < TILEMAP_WIDTH; x++) {
53 | let tile = 0;
54 | let c = '';
55 |
56 | switch ((c = tileMapSource[y].charAt(x))) {
57 | case 'P':
58 | player = new Entity(ENTITY_TYPE_PLAYER, x * TILE_SIZE, y * TILE_SIZE);
59 | break;
60 | case 'C':
61 | new Entity(ENTITY_TYPE_COIN, x * TILE_SIZE, y * TILE_SIZE);
62 | break;
63 | case 'J':
64 | new Entity(ENTITY_TYPE_JUMPPAD, x * TILE_SIZE, y * TILE_SIZE);
65 | break;
66 | case 'W':
67 | new Entity(ENTITY_TYPE_WALKING_ENEMY, x * TILE_SIZE, y * TILE_SIZE);
68 | break;
69 | default:
70 | // Convert ASCII to tile index
71 | // 48 is the ASCII code for '0'
72 | // See ASCII chart for more details: https://en.wikipedia.org/wiki/ASCII
73 | tile = Math.max(0, c.charCodeAt(0) - 48);
74 | break;
75 | }
76 | row.push(tile);
77 | }
78 | tileMap.push(row);
79 | }
80 |
81 | return player as Entity;
82 | }
83 |
84 | export function getTile(x: number, y: number): number {
85 | if (x < 0 || x >= TILEMAP_WIDTH || y < 0 || y >= TILEMAP_HEIGHT) {
86 | return 1;
87 | }
88 | return tileMap[y | 0][x | 0];
89 | }
90 |
91 | export function collisionDetectionEntityToTile(): void {
92 | for (const entity of entities) {
93 | entity.grounded = false;
94 |
95 | if (entity.dy < 0) {
96 | if (getTile((entity.x + 8) / TILE_SIZE, entity.y / TILE_SIZE)) {
97 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE + TILE_SIZE;
98 | entity.dy = 0;
99 | }
100 | }
101 |
102 | if (entity.dy > 0) {
103 | if (getTile((entity.x + 4) / TILE_SIZE, entity.y / TILE_SIZE + 1)) {
104 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE;
105 | entity.dy = 0;
106 | entity.grounded = true;
107 | }
108 | if (getTile((entity.x + 12) / TILE_SIZE, entity.y / TILE_SIZE + 1)) {
109 | entity.y = Math.floor(entity.y / TILE_SIZE) * TILE_SIZE;
110 | entity.dy = 0;
111 | entity.grounded = true;
112 | }
113 | }
114 |
115 | if (getTile(entity.x / TILE_SIZE, (entity.y + 8) / TILE_SIZE)) {
116 | entity.x = Math.floor(entity.x / TILE_SIZE) * TILE_SIZE + TILE_SIZE;
117 | entity.dx = 0;
118 | if (entity.entityType !== ENTITY_TYPE_PLAYER) {
119 | entity.direction = 1;
120 | }
121 | }
122 |
123 | if (getTile(entity.x / TILE_SIZE + 1, (entity.y + 8) / TILE_SIZE)) {
124 | entity.x = Math.floor(entity.x / TILE_SIZE) * TILE_SIZE;
125 | entity.dx = 0;
126 | if (entity.entityType !== ENTITY_TYPE_PLAYER) {
127 | entity.direction = -1;
128 | }
129 | }
130 |
131 | // Prevent entities from falling through the floor
132 | // This can be removed if you have confidence that your maps always have a floor
133 | if (entity.y > HEIGHT - 32) {
134 | entity.y = HEIGHT - 32;
135 | entity.dy = 0;
136 | entity.grounded = true;
137 | }
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/zzfx.ts:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | ZzFX - Zuper Zmall Zound Zynth v1.1.8
4 | By Frank Force 2019
5 | https://github.com/KilledByAPixel/ZzFX
6 |
7 | ZzFX Features
8 |
9 | - Tiny synth engine with 20 controllable parameters.
10 | - Play sounds via code, no need for sound assed files!
11 | - Compatible with most modern web browsers.
12 | - Small code footprint, the micro version is under 1 kilobyte.
13 | - Can produce a huge variety of sound effect types.
14 | - Sounds can be played with a short call. zzfx(...[,,,,.1,,,,9])
15 | - A small bit of randomness appied to sounds when played.
16 | - Use ZZFX.GetNote to get frequencies on a standard diatonic scale.
17 | - Sounds can be saved out as wav files for offline playback.
18 | - No additional libraries or dependencies are required.
19 |
20 | */
21 | /*
22 |
23 | ZzFX MIT License
24 |
25 | Copyright (c) 2019 - Frank Force
26 |
27 | Permission is hereby granted, free of charge, to any person obtaining a copy
28 | of this software and associated documentation files (the "Software"), to deal
29 | in the Software without restriction, including without limitation the rights
30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31 | copies of the Software, and to permit persons to whom the Software is
32 | furnished to do so, subject to the following conditions:
33 |
34 | The above copyright notice and this permission notice shall be included in all
35 | copies or substantial portions of the Software.
36 |
37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43 | SOFTWARE.
44 |
45 | */
46 |
47 | /**
48 | * Master volume scale.
49 | */
50 | export const zzfxV = 0.3;
51 |
52 | /**
53 | * Sample rate for audio.
54 | */
55 | export const zzfxR = 44100;
56 |
57 | /**
58 | * Create shared audio context.
59 | */
60 | export const zzfxX = new AudioContext();
61 |
62 | /**
63 | * Play a sound from zzfx paramerters.
64 | */
65 | export function zzfx(...parameters: (number | undefined)[]): AudioBufferSourceNode {
66 | return zzfxP(zzfxG(...parameters));
67 | }
68 |
69 | /**
70 | * Play an array of samples.
71 | */
72 | export function zzfxP(...samples: number[][]): AudioBufferSourceNode {
73 | const buffer = zzfxX.createBuffer(samples.length, samples[0].length, zzfxR);
74 | const source = zzfxX.createBufferSource();
75 |
76 | samples.map((d, i) => buffer.getChannelData(i).set(d));
77 | source.buffer = buffer;
78 | source.connect((zzfxX as AudioContext).destination);
79 | source.start();
80 | return source;
81 | }
82 |
83 | /**
84 | * Build an array of samples.
85 | */
86 | export function zzfxG(
87 | volume = 1,
88 | randomness = 0.05,
89 | frequency = 220,
90 | attack = 0,
91 | sustain = 0,
92 | release = 0.1,
93 | shape = 0,
94 | shapeCurve = 1,
95 | slide = 0,
96 | deltaSlide = 0,
97 | pitchJump = 0,
98 | pitchJumpTime = 0,
99 | repeatTime = 0,
100 | noise = 0,
101 | modulation = 0,
102 | bitCrush = 0,
103 | delay = 0,
104 | sustainVolume = 1,
105 | decay = 0,
106 | tremolo = 0,
107 | ): number[] {
108 | // init parameters
109 | const PI2 = Math.PI * 2;
110 | const sampleRate = zzfxR;
111 | const sign = (v: number): number => (v > 0 ? 1 : -1);
112 | const startSlide = (slide *= (500 * PI2) / sampleRate / sampleRate);
113 | const b = [];
114 |
115 | let startFrequency = (frequency *= ((1 + randomness * 2 * Math.random() - randomness) * PI2) / sampleRate);
116 | let t = 0;
117 | let tm = 0;
118 | let i = 0;
119 | let j = 1;
120 | let r = 0;
121 | let c = 0;
122 | let s = 0;
123 | let f: number;
124 | let length: number;
125 |
126 | // scale by sample rate
127 | attack = attack * sampleRate + 9; // minimum attack to prevent pop
128 | decay *= sampleRate;
129 | sustain *= sampleRate;
130 | release *= sampleRate;
131 | delay *= sampleRate;
132 | deltaSlide *= (500 * PI2) / sampleRate ** 3;
133 | modulation *= PI2 / sampleRate;
134 | pitchJump *= PI2 / sampleRate;
135 | pitchJumpTime *= sampleRate;
136 | repeatTime = (repeatTime * sampleRate) | 0;
137 |
138 | // generate waveform
139 | for (length = (attack + decay + sustain + release + delay) | 0; i < length; b[i++] = s) {
140 | if (!(++c % ((bitCrush * 100) | 0))) {
141 | // bit crush
142 | s = shape
143 | ? shape > 1
144 | ? shape > 2
145 | ? shape > 3 // wave shape
146 | ? Math.sin((t % PI2) ** 3) // 4 noise
147 | : Math.max(Math.min(Math.tan(t), 1), -1) // 3 tan
148 | : 1 - (((((2 * t) / PI2) % 2) + 2) % 2) // 2 saw
149 | : 1 - 4 * Math.abs(Math.round(t / PI2) - t / PI2) // 1 triangle
150 | : Math.sin(t); // 0 sin
151 |
152 | s =
153 | (repeatTime
154 | ? 1 - tremolo + tremolo * Math.sin((PI2 * i) / repeatTime) // tremolo
155 | : 1) *
156 | sign(s) *
157 | Math.abs(s) ** shapeCurve * // curve 0=square, 2=pointy
158 | volume *
159 | zzfxV * // envelope
160 | (i < attack
161 | ? i / attack // attack
162 | : i < attack + decay // decay
163 | ? 1 - ((i - attack) / decay) * (1 - sustainVolume) // decay falloff
164 | : i < attack + decay + sustain // sustain
165 | ? sustainVolume // sustain volume
166 | : i < length - delay // release
167 | ? ((length - i - delay) / release) * // release falloff
168 | sustainVolume // release volume
169 | : 0); // post release
170 |
171 | s = delay
172 | ? s / 2 +
173 | (delay > i
174 | ? 0 // delay
175 | : ((i < length - delay ? 1 : (length - i) / delay) * // release delay
176 | b[(i - delay) | 0]) /
177 | 2)
178 | : s; // sample delay
179 | }
180 |
181 | f =
182 | (frequency += slide += deltaSlide) * // frequency
183 | Math.cos(modulation * tm++); // modulation
184 | t += f - f * noise * (1 - (((Math.sin(i) + 1) * 1e9) % 2)); // noise
185 |
186 | if (j && ++j > pitchJumpTime) {
187 | // pitch jump
188 | frequency += pitchJump; // apply pitch jump
189 | startFrequency += pitchJump; // also apply to start
190 | j = 0; // stop pitch jump time
191 | }
192 |
193 | if (repeatTime && !(++r % repeatTime)) {
194 | // repeat
195 | frequency = startFrequency; // reset frequency
196 | slide = startSlide; // reset slide
197 | j = j || 1; // reset pitch jump time
198 | }
199 | }
200 |
201 | return b;
202 | }
203 |
--------------------------------------------------------------------------------
/src/zzfxm.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force
3 | */
4 |
5 | import { zzfxG, zzfxR } from './zzfx';
6 |
7 | /**
8 | * @typedef Channel
9 | * @type {Array.}
10 | * @property {Number} 0 - Channel instrument
11 | * @property {Number} 1 - Channel panning (-1 to +1)
12 | * @property {Number} 2 - Note
13 | */
14 | type Channel = (number | undefined)[]; //[number, number, number];
15 |
16 | /**
17 | * @typedef Pattern
18 | * @type {Array.}
19 | */
20 | type Pattern = Channel[];
21 |
22 | /**
23 | * @typedef Instrument
24 | * @type {Array.} ZzFX sound parameters
25 | */
26 | type Instrument = (number | undefined)[];
27 |
28 | /**
29 | * Generate a song
30 | *
31 | * @param instruments - Array of ZzFX sound paramaters.
32 | * @param patterns - Array of pattern data.
33 | * @param sequence - Array of pattern indexes.
34 | * @param [speed=125] - Playback speed of the song (in BPM).
35 | * @returns Left and right channel sample data.
36 | */
37 | export const zzfxM = (instruments: Instrument[], patterns: Pattern[], sequence: number[], BPM = 125): number[][] => {
38 | let instrumentParameters: Instrument;
39 | let i: number;
40 | let j: number;
41 | let k: number;
42 | let note: number | undefined;
43 | let sample: number;
44 | let patternChannel: Channel;
45 | let notFirstBeat: number | undefined;
46 | let stop: number;
47 | let instrument = 0;
48 | let attenuation = 0;
49 | let outSampleOffset = 0;
50 | let isSequenceEnd: number;
51 | let sampleOffset = 0;
52 | let nextSampleOffset: number;
53 | let sampleBuffer: number[] = [];
54 | const leftChannelBuffer: number[] = [];
55 | const rightChannelBuffer: number[] = [];
56 | let channelIndex = 0;
57 | let panning = 0;
58 | let hasMore = 1;
59 | const sampleCache: Record = {};
60 | const beatLength = ((zzfxR / BPM) * 60) >> 2;
61 |
62 | // for each channel in order until there are no more
63 | for (; hasMore; channelIndex++) {
64 | // reset current values
65 | sampleBuffer = [(hasMore = notFirstBeat = outSampleOffset = 0)];
66 |
67 | // for each pattern in sequence
68 | sequence.map((patternIndex, sequenceIndex) => {
69 | // get pattern for current channel, use empty 1 note pattern if none found
70 | patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
71 |
72 | // check if there are more channels
73 | hasMore |= !!patterns[patternIndex][channelIndex] as unknown as number;
74 |
75 | // get next offset, use the length of first channel
76 | nextSampleOffset =
77 | outSampleOffset + (patterns[patternIndex][0].length - 2 - (!notFirstBeat as unknown as number)) * beatLength;
78 | // for each beat in pattern, plus one extra if end of sequence
79 | isSequenceEnd = (sequenceIndex === sequence.length - 1) as unknown as number;
80 | for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) {
81 | //
82 | note = patternChannel[i];
83 |
84 | // stop if end, different instrument or new note
85 | stop =
86 | (i === patternChannel.length + isSequenceEnd - 1 && isSequenceEnd) ||
87 | ((instrument !== (patternChannel[0] || 0)) as unknown as number) | (note as number) | 0;
88 |
89 | // fill buffer with samples for previous beat, most cpu intensive part
90 | for (
91 | j = 0;
92 | j < beatLength && notFirstBeat;
93 | // fade off attenuation at end of beat if stopping note, prevents clicking
94 | j++ > beatLength - 99 && stop && (attenuation += ((attenuation < 1) as unknown as number) / 99)
95 | ) {
96 | // copy sample to stereo buffers with panning
97 | sample = ((1 - attenuation) * sampleBuffer[sampleOffset++]) / 2 || 0;
98 | leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
99 | rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
100 | }
101 |
102 | // set up for next note
103 | if (note) {
104 | // set attenuation
105 | attenuation = note % 1;
106 | panning = patternChannel[1] || 0;
107 | if ((note |= 0)) {
108 | // get cached sample
109 | sampleBuffer = sampleCache[`i${(instrument = patternChannel[(sampleOffset = 0)] || 0)}n${note}`] =
110 | sampleCache[`i${instrument}n${note}`] ||
111 | // add sample to cache
112 | ((instrumentParameters = [...instruments[instrument]]),
113 | ((instrumentParameters[2] as number) *= 2 ** ((note - 12) / 12)),
114 | // allow negative values to stop notes
115 | note > 0 ? zzfxG(...instrumentParameters) : []);
116 | }
117 | }
118 | }
119 |
120 | // update the sample offset
121 | outSampleOffset = nextSampleOffset;
122 | });
123 | }
124 |
125 | return [leftChannelBuffer, rightChannelBuffer];
126 | };
127 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | #c {
2 | width: 100vw;
3 | outline: 0;
4 | image-rendering: pixelated; /* You definitely want this if you are doing pixel art */
5 | }
6 |
7 | /* Get rid of any margins, set background black, use flexbox to center */
8 | html,
9 | body {
10 | margin: 0;
11 | background-color: black;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | height: 100%;
16 | }
17 |
18 | /* Input whatever aspect ratio you use here */
19 | @media (min-aspect-ratio: 16 / 9) {
20 | #c {
21 | height: 100vh;
22 | width: auto;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2019",
4 | "module": "esnext",
5 | "lib": ["esnext", "DOM"],
6 | "moduleResolution": "Node",
7 | "strict": true,
8 | "resolveJsonModule": true,
9 | "isolatedModules": true,
10 | "esModuleInterop": true,
11 | "noEmit": true,
12 | "noUnusedLocals": true,
13 | "noUnusedParameters": true,
14 | "noImplicitReturns": true,
15 | "skipLibCheck": true
16 | },
17 | "include": ["src"]
18 | }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { js13kViteConfig } from 'js13k-vite-plugins';
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig(js13kViteConfig());
5 |
--------------------------------------------------------------------------------