├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── banner.png ├── banner_new.png ├── dist ├── index.d.ts ├── index.js ├── index.min.d.ts ├── index.min.js └── index.min.js.gz ├── package.json └── src ├── camera.js ├── canvas.js ├── collision.js ├── device.js ├── dom.js ├── emitter.js ├── game.js ├── index.js ├── keyboard.js ├── loader.js ├── math.js ├── sound.js ├── sprite.js ├── utils.js └── vec2.js /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | releaseTag: 7 | description: 'Release Tag' 8 | required: true 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '20.x' 19 | registry-url: 'https://registry.npmjs.org' 20 | - name: Install dependencies and build 21 | run: | 22 | npm install 23 | npm install -g rollup 24 | npm install -g terser 25 | npm run build 26 | - name: Publish package on NPM 📦 27 | run: npm publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | - name: Create Github release 31 | id: release 32 | uses: softprops/action-gh-release@v1 33 | with: 34 | token: ${{ secrets.GITHUB_TOKEN }} 35 | tag_name: ${{ github.event.inputs.releaseTag }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .packages 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 rwbeast 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 | # bottlecap.js - 2D Game Framework 2 | 3 | ![bottlecap.js](https://github.com/harshsinghdev/bottlecap/raw/main/banner_new.png) 4 | 5 | ## Table of Contents 6 | - [About](#about) 7 | - [Installation and Setup](#installation-and-setup) 8 | - [NPM](#npm) 9 | - [CDN](#cdn) 10 | - [Docs](#docs) 11 | - [Example](#example) 12 | 13 | ## About 14 | 15 | **bottlecap.js** is a lightweight 2D game framework written in ES6. It offers a set of modular components, referred to as **bottlecaps**, that can be easily combined to create engaging 2D games. 16 | 17 | ## Installation and Setup 18 | 19 | ### NPM 20 | 21 | ```shell 22 | npm create vite@latest my-bottlecap-game -- --template vanilla # vanilla-ts for TypeScript 23 | cd my-bottlecap-game 24 | npm i bottlecap 25 | ``` 26 | 27 | **src/main.js:** 28 | 29 | ```javascript 30 | import * as Bottlecap from 'bottlecap'; 31 | 32 | // your code 33 | ``` 34 | 35 | ### CDN 36 | 37 | **src/main.js:** 38 | 39 | ```javascript 40 | import * as Bottlecap from 'https://unpkg.com/bottlecap@latest'; 41 | 42 | // your code 43 | ``` 44 | 45 | ## Docs 46 | 47 | Explore the comprehensive documentation in the [wiki](https://github.com/harshsinghdev/bottlecap/wiki) to get started with **bottlecap.js**. 48 | 49 | ## Example 50 | 51 | Check out the live [demo](https://harshdoesdev.github.io/bottlecap-demo/). 52 | 53 | [Source code](https://github.com/harshdoesdev/bottlecap-demo/) 54 | 55 | ![Demo](https://github.com/harshsinghdev/bottlecap/raw/gh-pages/images/demo-screenshot.png) 56 | 57 | ## Games made using bottlecap 58 | 59 | - [Hydrogen](https://hypervoid.itch.io/hydrogen) 60 | - [Play Or Die](https://hypervoid.itch.io/play-or-die) 61 | - [Sneaky Tails](https://hypervoid.itch.io/sneaky-tails) 62 | - [SlideToShoot](https://hypervoid.itch.io/slide-to-shoot) 63 | 64 | Feel inspired by these creations? Start your own journey with **bottlecap.js** and join the growing community of game developers. Share your masterpiece and let the world experience your unique vision! If you have a game developed using **bottlecap.js** that you'd like to showcase, consider adding it to the list by opening a pull request. Happy gaming! 65 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshdoesdev/bottlecap/61b9294ba66ed699b5fac7957804c4ed0d5e0f1d/banner.png -------------------------------------------------------------------------------- /banner_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshdoesdev/bottlecap/61b9294ba66ed699b5fac7957804c4ed0d5e0f1d/banner_new.png -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export type direction = { 2 | x: number; 3 | y: number; 4 | }; 5 | export namespace ASSET_TYPES { 6 | let IMAGE: string; 7 | let SOUND: string; 8 | let JSON: string; 9 | } 10 | export class AnimatedSprite { 11 | /** 12 | * @param {CanvasRenderingContext2D} ctx 13 | * @param {image} spritesheet 14 | * @param {number} numCol number of columns 15 | * @param {number} numRow number of rows 16 | * @param {number} x x position 17 | * @param {number} y y position 18 | * @param {number} width 19 | * @param {number} height 20 | */ 21 | constructor(ctx: CanvasRenderingContext2D, spritesheet: image, numCol: number, numRow: number, x: number, y: number, width: number, height: number); 22 | ctx: CanvasRenderingContext2D; 23 | spritesheet: image; 24 | numCol: number; 25 | numRow: number; 26 | frameWidth: number; 27 | frameHeight: number; 28 | position: Vec2; 29 | size: Vec2; 30 | maxFrames: number; 31 | currentFrame: number; 32 | flipX: boolean; 33 | flipY: boolean; 34 | rotation: number; 35 | scale: number; 36 | animations: any; 37 | currentAnimation: any; 38 | playing: boolean; 39 | time: number; 40 | /** 41 | * add animation 42 | * @param {string} animationName name of animation 43 | * @param {number} frameStart frame to begin from 44 | * @param {number} frameEnd frame to end at 45 | * @param {number} delay delay between each frame 46 | */ 47 | addAnimation(animationName: string, frameStart: number, frameEnd: number, delay: number): this; 48 | /** 49 | * play animation 50 | * @param {string} animationName name of animation to play 51 | */ 52 | play(animationName: string): void; 53 | /** 54 | * stop the sprite animation 55 | */ 56 | stop(): void; 57 | /** 58 | * Called on each frame to update sprite states 59 | * @param {number} dt delta time 60 | * @returns 61 | */ 62 | update(dt: number): void; 63 | render(): void; 64 | } 65 | export namespace COLLISION_SIDE { 66 | let TOP: string; 67 | let BOTTOM: string; 68 | let LEFT: string; 69 | let RIGHT: string; 70 | } 71 | /** 72 | * Camera - Basic PointLocked Camera 73 | */ 74 | export class Camera { 75 | /** 76 | * @param {CanvasRenderingContext2D} ctx - current canvas context 77 | * @param {number} x - initial point to look at (x) 78 | * @param {number} y - initial point to look at (y) 79 | * @param {number} dx - offset from screen center (x) 80 | * @param {number} dy - offset from screen center (y) 81 | */ 82 | constructor(ctx: CanvasRenderingContext2D, x?: number, y?: number, dx?: number, dy?: number); 83 | ctx: CanvasRenderingContext2D; 84 | pos: Vec2; 85 | target: Vec2; 86 | cx: number; 87 | cy: number; 88 | /** 89 | * Start rendering through this camera 90 | */ 91 | attach(): void; 92 | /** 93 | * Stop rendering through this camera 94 | */ 95 | detach(): void; 96 | /** 97 | * update the camera 98 | * @param {*} dt 99 | */ 100 | update(dt: any): void; 101 | /** 102 | * Move the focus point of the camera 103 | * @param {number} x - where to look 104 | * @param {number} y - where to look 105 | */ 106 | lookAt(x: number, y: number): void; 107 | } 108 | /** 109 | * Collision - Basic Collision Handling 110 | */ 111 | export class Collision { 112 | /** 113 | * circle in circle collision detection 114 | * @param {number} x1 - center of first circle 115 | * @param {number} y1 - center of first circle 116 | * @param {number} r1 - radius of first circle 117 | * @param {number} x2 - center of second circle 118 | * @param {number} y2 - center of second circle 119 | * @param {number} r2 - radius of second circle 120 | * @return {boolean} true if circles are overlapping 121 | */ 122 | static circleInCircle(x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): boolean; 123 | /** 124 | * point in circle collision check 125 | * @param {number} px - point 126 | * @param {number} py - point 127 | * @param {number} cx - center of circle 128 | * @param {number} cy - center of circle 129 | * @param {number} cr - radius of circle 130 | * @return {boolean} true if point is inside circle 131 | */ 132 | static pointInCircle(px: number, py: number, cx: number, cy: number, cr: number): boolean; 133 | /** 134 | * checks if a point is in a rectangle 135 | * AABB rectangle in rectangle collision detection 136 | * @param {number} x1 - left side of first rectangle 137 | * @param {number} y1 - top side of first rectangle 138 | * @param {number} w1 - width of first rectangle 139 | * @param {number} h1 - height of first rectangle 140 | * @param {number} x2 - left side of second rectangle 141 | * @param {number} y2 - top side of second rectangle 142 | * @param {number} w2 - width of second rectangle 143 | * @param {number} h2 - height of second rectangle 144 | */ 145 | static rectInRect(x1: number, y1: number, w1: number, h1: number, x2: number, y2: number, w2: number, h2: number): boolean; 146 | /** 147 | * point in rectangle check detection 148 | * @param {number} px - point 149 | * @param {number} py - point 150 | * @param {number} rx - left side of rectangle 151 | * @param {number} ry - top side of rectangle 152 | * @param {number} rw - width of rectangle 153 | * @param {number} rh - height of rectangle 154 | */ 155 | static pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean; 156 | /** 157 | * Circle in Rectangle collision detection 158 | * @param {number} cx center of circle 159 | * @param {number} cy center of circle 160 | * @param {number} r radius of the circle 161 | * @param {number} rx left side of rectangle 162 | * @param {number} ry top side of rectangle 163 | * @param {number} w width of rectangle 164 | * @param {number} h height of rectangle 165 | * @returns {boolean} 166 | */ 167 | static circleInRect(cx: number, cy: number, r: number, rx: number, ry: number, w: number, h: number): boolean; 168 | /** 169 | * Resolve Collision Between Two Rects 170 | * @typedef {postition: Vec2, size: Vec2} Rect 171 | * @param {Rect} A 172 | * @param {Rect} B 173 | * @returns 174 | */ 175 | static resolveCollision(A: Rect, B: Rect): string; 176 | } 177 | declare var dom: Readonly<{ 178 | __proto__: any; 179 | attr: (element: any, attributeName: any, value: any) => any; 180 | el: (selector: any) => any; 181 | frag: () => DocumentFragment; 182 | off: (element: any, type: any, handler: any) => any; 183 | on: (element: any, type: any, handler: any) => any; 184 | qs: (selectors: any, ctx?: Document) => any; 185 | qsa: (selectors: any, ctx?: Document) => NodeListOf; 186 | ready: (app: any) => void; 187 | setStyle: (element: any, styleObj: any) => any; 188 | svg: (selector: any) => any; 189 | text: (data?: string) => Text; 190 | }>; 191 | /** @module Device */ 192 | /** 193 | * Basic Device Info 194 | */ 195 | export class Device { 196 | /** 197 | * @return {boolean} true if the device have touch screen capabilities 198 | */ 199 | static isTouchscreen(): boolean; 200 | /** 201 | * @return {boolean} true if the browser supports gamepads 202 | */ 203 | static gamepadAvailable(): boolean; 204 | } 205 | /** @module Emitter */ 206 | export class Emitter { 207 | topics: {}; 208 | emit(id: any, ...data: any[]): void; 209 | hasTopic(id: any): any; 210 | on(id: any, listener: any): () => void; 211 | once(id: any, listener: any): () => void; 212 | off(id: any, listener: any): void; 213 | destroy(): void; 214 | } 215 | /** @module Game */ 216 | /** 217 | * Main class, representing the current Game state 218 | */ 219 | export class Game { 220 | /** 221 | * Kickstart the game 222 | */ 223 | run(): void; 224 | running: boolean; 225 | _lastStep: number; 226 | _frameRequest: number; 227 | stop(): void; 228 | /** 229 | * @ignore 230 | * Internal function called on each frame. 231 | */ 232 | step(): void; 233 | /** 234 | * Called at start to initialize game states. 235 | */ 236 | init(): void; 237 | /** 238 | * Called on each frame to update game states. 239 | * @param {number} dt time since last update 240 | */ 241 | update(dt: number): void; 242 | /** 243 | * Called on each frame to render the game. 244 | */ 245 | render(): void; 246 | } 247 | declare var math: Readonly<{ 248 | __proto__: any; 249 | HALF_PI: number; 250 | PI: number; 251 | TWO_PI: number; 252 | clamp: (num: any, min: any, max: any) => number; 253 | pointDistance: (x1: number, y1: number, x2: number, y2: number) => number; 254 | pointToAngle: (x: number, y: any) => number; 255 | }>; 256 | export class Keyboard { 257 | /** 258 | * check if a key is down 259 | * @param {string} key 260 | */ 261 | static keyDown(key: string): boolean; 262 | /** 263 | * get direction for movement of player 264 | * @return {direction} 265 | * @example 266 | * update(dt) { 267 | * const direction = Keyboard.getDirection(); 268 | * player.x += direction.x * player.speed; 269 | * player.y += direction.y * player.speed; 270 | * } 271 | */ 272 | static getDirection(): direction; 273 | static KEYS: { 274 | LEFT: string; 275 | RIGHT: string; 276 | UP: string; 277 | DOWN: string; 278 | SPACEBAR: string; 279 | ESCAPE: string; 280 | ENTER: string; 281 | CTRL: string; 282 | TAB: string; 283 | ALT: string; 284 | W: string; 285 | A: string; 286 | S: string; 287 | D: string; 288 | E: string; 289 | X: string; 290 | Z: string; 291 | }; 292 | } 293 | /** 294 | * A Basic Asset Loader 295 | */ 296 | export class Loader extends Emitter { 297 | queue: any[]; 298 | loading: boolean; 299 | /** 300 | * enqueue an asset 301 | * @param {string} name - name of asset 302 | * @param {string} src - src of asset 303 | * @param {string} type - type of asset 304 | */ 305 | enqueue(name: string, src: string, type: string): void; 306 | /** 307 | * add image to the queue 308 | * @param {string} name - name of image 309 | * @param {string} src - source of image 310 | */ 311 | addImage(name: string, src: string): this; 312 | /** 313 | * add sound to queue 314 | * @param {string} name - name of sound 315 | * @param {string} src - source of sound 316 | */ 317 | addSound(name: string, src: string): this; 318 | /** 319 | * add json file to queue 320 | * @param {string} name - name of json file 321 | * @param {string} src - source of json file 322 | */ 323 | addJSON(name: string, src: string): this; 324 | /** 325 | * clears the queue 326 | */ 327 | clearQueue(): void; 328 | /** 329 | * reset the loader 330 | */ 331 | reset(): void; 332 | /** 333 | * Start Loading 334 | */ 335 | load(): Promise; 336 | } 337 | /** 338 | * Resource Loader 339 | */ 340 | export class ResourceLoader { 341 | /** 342 | * Asynchronously load an image from URL 343 | * @param {string} name - ressource id 344 | * @param {string} src - ressource URL 345 | */ 346 | static Image(name: string, src: string): any; 347 | /** 348 | * Asynchronously load a sound file from URL 349 | * @param {string} name - ressource id 350 | * @param {string} src - ressource URL 351 | */ 352 | static Sound(name: string, src: string): Promise<{ 353 | type: string; 354 | name: string; 355 | value: any; 356 | }>; 357 | /** 358 | * Asynchronously load a sound file from URL 359 | * @param {string} name - ressource id 360 | * @param {string} src - ressource URL 361 | */ 362 | static JSON(name: string, src: string): Promise<{ 363 | type: string; 364 | name: string; 365 | value: any; 366 | }>; 367 | /** 368 | * Load Multiple Assets at Once 369 | * @param {array} - Array of load Promises 370 | * @return {object} - Loaded assets are mapped to this object categorically 371 | */ 372 | static loadAll(loadPromises: any): object; 373 | } 374 | /** 375 | * Sound Player 376 | */ 377 | export class Sound { 378 | /** 379 | * play sound 380 | * @param {AudioBuffer} audioBuffer - sound data 381 | * @param {number} time - length to play, or 0 to play to the end 382 | * @param {boolean} loop - play the sound in loop if true 383 | * @param {GainNode} gainNode - output mixer 384 | * @example 385 | * import Sound from './sound.js'; 386 | * Sound.play(jumpSound); 387 | */ 388 | static play(audioBuffer: AudioBuffer, time?: number, loop?: boolean, gainNode?: GainNode): any; 389 | static stop(source: any, time?: number): void; 390 | /** 391 | * set the output volume 392 | * @param {number} v - volume 393 | * @param {GainNode} gainNode - output mixer 394 | * @example 395 | * setVolume(.5); 396 | */ 397 | static setVolume(v: number, gainNode?: GainNode): void; 398 | } 399 | /** @module Sprite */ 400 | export class Sprite { 401 | /** 402 | * @param {CanvasRenderingContext2D} ctx 403 | * @param {image} image sprite image 404 | * @param {number} sx source x 405 | * @param {number} sy source y 406 | * @param {number} sw source width 407 | * @param {number} sh source height 408 | * @param {number} dx destination x 409 | * @param {number} dy destination y 410 | * @param {number} width destination width 411 | * @param {number} height destination height 412 | */ 413 | constructor(ctx: CanvasRenderingContext2D, image: image, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, width: number, height: number); 414 | ctx: CanvasRenderingContext2D; 415 | image: image; 416 | sourceX: number; 417 | sourceY: number; 418 | sourceWidth: any; 419 | sourceHeight: any; 420 | position: Vec2; 421 | size: Vec2; 422 | rotation: number; 423 | flipX: boolean; 424 | flipY: boolean; 425 | render(): void; 426 | } 427 | export class SpriteAnimation { 428 | /** 429 | * 430 | * @param {AnimatedSprite} sprite 431 | * @param {number} frameStart 432 | * @param {number} frameEnd 433 | * @param {number} delay 434 | */ 435 | constructor(sprite: AnimatedSprite, frameStart: number, frameEnd: number, delay?: number); 436 | sprite: AnimatedSprite; 437 | frameStart: number; 438 | frameEnd: number; 439 | delay: number; 440 | frames: number[][]; 441 | /** 442 | * 443 | * @param {number} frame 444 | * @returns {array} [col, row] 445 | */ 446 | getFrame(frame: number): any[]; 447 | } 448 | declare var utils: Readonly<{ 449 | __proto__: any; 450 | chunk: (arr: any, chunkSize: any) => any[]; 451 | getMousePos: (canvas: any, evt: any) => Vec2; 452 | random: (min?: number, max?: number) => number; 453 | randomInt: (min?: number, max?: number) => number; 454 | shuffle: (arr: any) => any; 455 | unique: (arr: any) => any[]; 456 | }>; 457 | /** @module Vec2 */ 458 | /** 459 | * Vec2 - Create Vector and Perform Basic Vector Math 460 | */ 461 | export class Vec2 { 462 | static zero(): Vec2; 463 | static create(x: any, y: any): Vec2; 464 | static clone({ x, y }: { 465 | x: any; 466 | y: any; 467 | }): Vec2; 468 | static copy(v: any, v2: any): any; 469 | static set(v: any, x: any, y: any): any; 470 | static add(v: any, { x, y }: { 471 | x: any; 472 | y: any; 473 | }): any; 474 | static sub(v: any, { x, y }: { 475 | x: any; 476 | y: any; 477 | }): any; 478 | static mul(v: any, { x, y }: { 479 | x: any; 480 | y: any; 481 | }): any; 482 | static div(v: any, { x, y }: { 483 | x: any; 484 | y: any; 485 | }): any; 486 | static addScalar(v: any, s: any): any; 487 | static subScalar(v: any, s: any): any; 488 | static mulScalar(v: any, s: any): any; 489 | static divScalar(v: any, s: any): any; 490 | static angle(v: any): any; 491 | static calcLength(v: any): number; 492 | static equals(v: any, v2: any): boolean; 493 | static dot(v: any, v2: any): number; 494 | static cross(v: any, v2: any): number; 495 | static lerp(v: any, { x, y }: { 496 | x: any; 497 | y: any; 498 | }, alpha: any): any; 499 | static normalize(v: any): any; 500 | static distance(v: any, v2: any): number; 501 | constructor(x?: number, y?: number); 502 | x: number; 503 | y: number; 504 | } 505 | /** @module Canvas */ 506 | /** 507 | * Initialize a new Canvas 508 | * @param {number} width 509 | * @param {number} height 510 | * @param {string} background - background color 511 | */ 512 | export function createCanvas(width: number, height: number, background: string): HTMLCanvasElement; 513 | export function getAudioCtx(): any; 514 | export function getSoundMixer(): any; 515 | export { dom as DOM, math as GameMath, utils as Utils }; 516 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | /** @module Game */ 2 | 3 | /** 4 | * Main class, representing the current Game state 5 | */ 6 | class Game { 7 | 8 | /** 9 | * Kickstart the game 10 | */ 11 | run() { 12 | if(this.running) { 13 | return; 14 | } 15 | 16 | this.running = true; 17 | 18 | console.log( 19 | "%c%s", 20 | "color: #1abc9c; font-weight: bold", 21 | "Made with bottlecap.js" 22 | ); 23 | 24 | this.init(); 25 | 26 | this._lastStep = performance.now(); 27 | 28 | const loop = () => { 29 | this.step(); 30 | this._lastStep = performance.now(); 31 | this._frameRequest = requestAnimationFrame(loop); 32 | }; 33 | 34 | requestAnimationFrame(loop); 35 | } 36 | 37 | stop() { 38 | if(this._frameRequest) { 39 | cancelAnimationFrame(this._frameRequest); 40 | } 41 | 42 | this._frameRequest = null; 43 | this.running = false; 44 | } 45 | 46 | /** 47 | * @ignore 48 | * Internal function called on each frame. 49 | */ 50 | step() { 51 | const now = performance.now(); 52 | const dt = (now - this._lastStep) / 1000; 53 | this._lastStep = now; 54 | this.update(dt); 55 | this.render(); 56 | } 57 | 58 | /** 59 | * Called at start to initialize game states. 60 | */ 61 | init() { 62 | console.log('Game Initialized'); 63 | } 64 | 65 | /** 66 | * Called on each frame to update game states. 67 | * @param {number} dt time since last update 68 | */ 69 | update(dt) {} 70 | 71 | /** 72 | * Called on each frame to render the game. 73 | */ 74 | render() {} 75 | 76 | } 77 | 78 | /** @module GameMath */ 79 | 80 | // Math constants 81 | 82 | const PI$1 = Math.PI; 83 | 84 | const TWO_PI = PI$1 * 2; 85 | 86 | const HALF_PI = PI$1 / 2; 87 | 88 | // measures distance between two points 89 | 90 | /** 91 | * Euclidean distance 92 | * @param {number} x1 93 | * @param {number} y1 94 | * @param {number} x2 95 | * @param {number} y2 96 | */ 97 | const pointDistance = (x1, y1, x2, y2) => { 98 | 99 | const dx = x1 - x2, 100 | 101 | dy = y1 - y2; 102 | 103 | return Math.sqrt(dx * dx + dy * dy); 104 | 105 | }; 106 | 107 | // converts point to angle 108 | 109 | /** 110 | * Angle from the (1, 0) direction in radians 111 | * @param {number} x 112 | * @param {number} z 113 | */ 114 | const pointToAngle = (x, y) => -Math.atan2(-y, x); 115 | 116 | /** 117 | * clamp num between min and max 118 | */ 119 | const clamp = (num, min, max) => Math.max(min, Math.min(num, max)); 120 | 121 | var math = /*#__PURE__*/Object.freeze({ 122 | __proto__: null, 123 | HALF_PI: HALF_PI, 124 | PI: PI$1, 125 | TWO_PI: TWO_PI, 126 | clamp: clamp, 127 | pointDistance: pointDistance, 128 | pointToAngle: pointToAngle 129 | }); 130 | 131 | /** @module Vec2 */ 132 | 133 | 134 | /** 135 | * Vec2 - Create Vector and Perform Basic Vector Math 136 | */ 137 | class Vec2 { 138 | constructor(x = 0, y = 0) { 139 | this.x = x; 140 | this.y = y; 141 | } 142 | 143 | static zero() { 144 | return new Vec2(); 145 | } 146 | 147 | static create(x, y) { 148 | return new Vec2(x, y); 149 | } 150 | 151 | static clone({ x, y }) { 152 | return Vec2.create(x, y); 153 | } 154 | 155 | static copy(v, v2) { 156 | return Object.assign(v, v2); 157 | } 158 | 159 | static set(v, x, y) { 160 | v.x = x != null ? x : v.x; 161 | v.y = y != null ? y : v.y; 162 | 163 | return v; 164 | } 165 | 166 | static add(v, { x, y }) { 167 | v.x += x; 168 | v.y += y; 169 | 170 | return v; 171 | } 172 | 173 | static sub(v, { x, y }) { 174 | v.x -= x; 175 | v.y -= y; 176 | 177 | return v; 178 | } 179 | 180 | static mul(v, { x, y }) { 181 | v.x *= x; 182 | v.y *= y; 183 | 184 | return v; 185 | } 186 | 187 | static div(v, { x, y }) { 188 | v.x /= x; 189 | v.y /= y; 190 | 191 | return v; 192 | } 193 | 194 | static addScalar(v, s) { 195 | v.x += s; 196 | v.y += s; 197 | 198 | return v; 199 | } 200 | 201 | static subScalar(v, s) { 202 | v.x -= s; 203 | v.y -= s; 204 | 205 | return v; 206 | } 207 | 208 | static mulScalar(v, s) { 209 | v.x *= s; 210 | v.y *= s; 211 | 212 | return v; 213 | } 214 | 215 | static divScalar(v, s) { 216 | v.x /= s; 217 | v.y /= s; 218 | 219 | return v; 220 | } 221 | 222 | static angle(v) { 223 | return Math.atan2(-v.y, -v.x) + PI; 224 | } 225 | 226 | static calcLength(v) { 227 | return Math.sqrt(v.x * v.x + v.y * v.y); 228 | } 229 | 230 | static equals(v, v2) { 231 | return ((v.x === v2.x) && (v.y === v2.y)); 232 | } 233 | 234 | static dot(v, v2) { 235 | return v.x * v2.x + v.y * v2.y; 236 | } 237 | 238 | static cross(v, v2) { 239 | return v.x * v2.y - v.y * v2.x; 240 | } 241 | 242 | static lerp(v, { x, y }, alpha) { 243 | v.x += (x - v.x) * alpha; 244 | v.y += (y - v.y) * alpha; 245 | 246 | return v; 247 | } 248 | 249 | static normalize(v) { 250 | Vec2.divScalar(v, Vec2.calcLength(v) || 1); 251 | 252 | return v; 253 | } 254 | 255 | static distance(v, v2) { 256 | return pointDistance(v.x, v.y, v2.x, v2.y); 257 | } 258 | } 259 | 260 | /** @module Camera */ 261 | 262 | 263 | const round = Math.round; 264 | 265 | /** 266 | * Camera - Basic PointLocked Camera 267 | */ 268 | class Camera { 269 | 270 | /** 271 | * @param {CanvasRenderingContext2D} ctx - current canvas context 272 | * @param {number} x - initial point to look at (x) 273 | * @param {number} y - initial point to look at (y) 274 | * @param {number} dx - offset from screen center (x) 275 | * @param {number} dy - offset from screen center (y) 276 | */ 277 | constructor(ctx, x = 0, y = 0, dx = 0, dy = 0) { 278 | 279 | this.ctx = ctx; 280 | 281 | this.pos = Vec2.create(x, y); 282 | 283 | this.target = Vec2.zero(); 284 | 285 | this.cx = round(ctx.canvas.width / 2) - dx; 286 | this.cy = round(ctx.canvas.height / 2) - dy; 287 | 288 | } 289 | 290 | 291 | /** 292 | * Start rendering through this camera 293 | */ 294 | attach() { 295 | 296 | this.ctx.save(); 297 | 298 | this.ctx.translate(this.pos.x, this.pos.y); 299 | 300 | } 301 | 302 | /** 303 | * Stop rendering through this camera 304 | */ 305 | detach() { 306 | 307 | this.ctx.restore(); 308 | 309 | } 310 | 311 | /** 312 | * update the camera 313 | * @param {*} dt 314 | */ 315 | update(dt) { 316 | Vec2.set(this.pos, this.cx - this.target.x, this.cy - this.target.y); 317 | } 318 | 319 | /** 320 | * Move the focus point of the camera 321 | * @param {number} x - where to look 322 | * @param {number} y - where to look 323 | */ 324 | lookAt(x, y) { 325 | Vec2.set(this.target, x, y); 326 | } 327 | 328 | } 329 | 330 | /** @module Emitter */ 331 | 332 | class Emitter { 333 | 334 | constructor() { 335 | this.topics = {}; 336 | } 337 | 338 | emit(id, ...data) { 339 | const listeners = this.topics[id]; 340 | 341 | if(!listeners || listeners.size < 0) { 342 | return; 343 | } 344 | 345 | listeners.forEach(listener => listener(...data)); 346 | } 347 | 348 | hasTopic(id) { 349 | return Reflect.has(this.topics, id); 350 | } 351 | 352 | on(id, listener) { 353 | if(!this.hasTopic(id)) { 354 | this.topics[id] = new Set(); 355 | } 356 | 357 | this.topics[id].add(listener); 358 | 359 | return () => this.off(id, listener); 360 | } 361 | 362 | once(id, listener) { 363 | const proxy = (...data) => { 364 | this.off(id, proxy); 365 | 366 | listener(...data); 367 | }; 368 | 369 | return this.on(id, proxy); 370 | } 371 | 372 | off(id, listener) { 373 | if(this.hasTopic(id)) { 374 | this.topics[id].delete(listener); 375 | } 376 | } 377 | 378 | destroy() { 379 | this.topics = {}; 380 | } 381 | 382 | } 383 | 384 | /** @module DOM */ 385 | 386 | /* Tejas | Tejas Contributors | MIT License */ 387 | 388 | const doc = document; 389 | const selectorRegex = /([.#])/; 390 | const ns = 'http://www.w3.org/2000/svg'; 391 | 392 | const parseSelector = selector => { 393 | const tokens = selector.split(selectorRegex); 394 | let id = '', className = ''; 395 | 396 | for (let i = 1; i < tokens.length; i += 2) { 397 | switch (tokens[i]) { 398 | case '.': 399 | className += ` ${tokens[i + 1]}`; 400 | break; 401 | case '#': 402 | id = tokens[i + 1]; 403 | } 404 | } 405 | 406 | return { 407 | tag: tokens[0] || 'div', 408 | className: className.trim(), 409 | id 410 | }; 411 | }; 412 | 413 | const el = selector => { 414 | const { tag, id, className } = parseSelector(selector); 415 | const element = doc.createElement(tag); 416 | 417 | if (id) 418 | element.id = id; 419 | 420 | if (className) 421 | element.className = className; 422 | 423 | return element; 424 | }; 425 | 426 | const svg = selector => { 427 | const { tag, id, className } = parseSelector(selector); 428 | const element = doc.createElementNS(ns, tag); 429 | 430 | if (id) 431 | element.id = id; 432 | 433 | if (className) 434 | attr(element, 'class', className); 435 | 436 | return element; 437 | }; 438 | 439 | const frag = () => doc.createDocumentFragment(); 440 | 441 | const text = (data = '') => doc.createTextNode(data); 442 | 443 | const qs = (selectors, ctx = doc) => ctx.querySelector(selectors); 444 | 445 | const qsa = (selectors, ctx = doc) => ctx.querySelectorAll(selectors); 446 | 447 | const setStyle = (element, styleObj) => Object.assign(element.style, styleObj); 448 | 449 | const attr = (element, attributeName, value) => { 450 | if (value === undefined) 451 | return element.getAttribute(attributeName); 452 | 453 | if (value === false) { 454 | element.removeAttribute(attributeName); 455 | } else { 456 | element.setAttribute(attributeName, value); 457 | } 458 | }; 459 | 460 | const on = (element, type, handler) => element.addEventListener(type, handler, false); 461 | 462 | const off = (element, type, handler) => element.removeEventListener(type, handler, false); 463 | 464 | const ready = app => { 465 | if (/complete|loaded|interactive/.test(doc.readyState) && doc.body) { 466 | setTimeout(app, 1); 467 | } else { 468 | on(doc, 'DOMContentLoaded', app); 469 | } 470 | }; 471 | 472 | var dom = /*#__PURE__*/Object.freeze({ 473 | __proto__: null, 474 | attr: attr, 475 | el: el, 476 | frag: frag, 477 | off: off, 478 | on: on, 479 | qs: qs, 480 | qsa: qsa, 481 | ready: ready, 482 | setStyle: setStyle, 483 | svg: svg, 484 | text: text 485 | }); 486 | 487 | /** @module Keyboard */ 488 | 489 | 490 | /** 491 | * @typedef {{x: number, y: number}} direction 492 | */ 493 | 494 | const DIRECTION = Vec2.zero(); 495 | 496 | const KEYSTATE = {}; 497 | 498 | class Keyboard { 499 | 500 | /** 501 | * check if a key is down 502 | * @param {string} key 503 | */ 504 | static keyDown(key) { 505 | return !!KEYSTATE[key]; 506 | } 507 | 508 | /** 509 | * get direction for movement of player 510 | * @return {direction} 511 | * @example 512 | * update(dt) { 513 | * const direction = Keyboard.getDirection(); 514 | * player.x += direction.x * player.speed; 515 | * player.y += direction.y * player.speed; 516 | * } 517 | */ 518 | static getDirection() { 519 | const keyDown = Keyboard.keyDown, KEYS = Keyboard.KEYS; 520 | 521 | const x = keyDown(KEYS.LEFT) ? -1 : keyDown(KEYS.RIGHT) ? 1 : 0; 522 | const y = keyDown(KEYS.UP) ? -1 : keyDown(KEYS.DOWN) ? 1 : 0; 523 | 524 | Vec2.set(DIRECTION, x, y); 525 | 526 | return DIRECTION; 527 | } 528 | 529 | static KEYS = { 530 | LEFT: 'ArrowLeft', 531 | RIGHT: 'ArrowRight', 532 | UP: 'ArrowUp', 533 | DOWN: 'ArrowDown', 534 | SPACEBAR: ' ', 535 | ESCAPE: 'Escape', 536 | ENTER: 'Enter', 537 | CTRL: 'Control', 538 | TAB: 'Tab', 539 | ALT: 'Alt', 540 | W: 'w', 541 | A: 'a', 542 | S: 's', 543 | D: 'd', 544 | E: 'e', 545 | X: 'x', 546 | Z: 'z' 547 | } 548 | 549 | } 550 | 551 | const handleKeyDown = e => { 552 | if(e.defaultPrevented) { 553 | return; 554 | } 555 | 556 | KEYSTATE[e.key] = true; 557 | 558 | e.preventDefault(); 559 | }; 560 | 561 | const handleKeyUp = e => { 562 | return KEYSTATE[e.key] = false; 563 | }; 564 | 565 | on(window, 'keydown', handleKeyDown); 566 | on(window, 'keyup', handleKeyUp); 567 | 568 | /** @module Device */ 569 | 570 | /** 571 | * Basic Device Info 572 | */ 573 | class Device { 574 | 575 | /** 576 | * @return {boolean} true if the device have touch screen capabilities 577 | */ 578 | static isTouchscreen() { 579 | return !!('ontouchstart' in document.documentElement); 580 | } 581 | 582 | /** 583 | * @return {boolean} true if the browser supports gamepads 584 | */ 585 | static gamepadAvailable() { 586 | return !!(navigator && navigator.getGamepads); 587 | } 588 | 589 | } 590 | 591 | /** @module Collision */ 592 | 593 | 594 | const abs = Math.abs; 595 | 596 | const COLLISION_SIDE = { 597 | TOP: 'top', 598 | BOTTOM: 'bottom', 599 | LEFT: 'left', 600 | RIGHT: 'right' 601 | }; 602 | 603 | /** 604 | * Collision - Basic Collision Handling 605 | */ 606 | class Collision { 607 | 608 | /** 609 | * circle in circle collision detection 610 | * @param {number} x1 - center of first circle 611 | * @param {number} y1 - center of first circle 612 | * @param {number} r1 - radius of first circle 613 | * @param {number} x2 - center of second circle 614 | * @param {number} y2 - center of second circle 615 | * @param {number} r2 - radius of second circle 616 | * @return {boolean} true if circles are overlapping 617 | */ 618 | static circleInCircle(x1, y1, r1, x2, y2, r2) { 619 | return pointDistance( x1, y1, x2, y2 ) <= r1 + r2; 620 | } 621 | 622 | /** 623 | * point in circle collision check 624 | * @param {number} px - point 625 | * @param {number} py - point 626 | * @param {number} cx - center of circle 627 | * @param {number} cy - center of circle 628 | * @param {number} cr - radius of circle 629 | * @return {boolean} true if point is inside circle 630 | */ 631 | static pointInCircle(px, py, cx, cy, cr) { 632 | return pointDistance( px, py, cx, cy ) <= cr; 633 | } 634 | 635 | /** 636 | * checks if a point is in a rectangle 637 | * AABB rectangle in rectangle collision detection 638 | * @param {number} x1 - left side of first rectangle 639 | * @param {number} y1 - top side of first rectangle 640 | * @param {number} w1 - width of first rectangle 641 | * @param {number} h1 - height of first rectangle 642 | * @param {number} x2 - left side of second rectangle 643 | * @param {number} y2 - top side of second rectangle 644 | * @param {number} w2 - width of second rectangle 645 | * @param {number} h2 - height of second rectangle 646 | */ 647 | static rectInRect(x1, y1, w1, h1, x2, y2, w2, h2) { 648 | if( x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2 ) { 649 | return true; 650 | } 651 | 652 | return false; 653 | } 654 | 655 | /** 656 | * point in rectangle check detection 657 | * @param {number} px - point 658 | * @param {number} py - point 659 | * @param {number} rx - left side of rectangle 660 | * @param {number} ry - top side of rectangle 661 | * @param {number} rw - width of rectangle 662 | * @param {number} rh - height of rectangle 663 | */ 664 | static pointInRect(px, py, rx, ry, rw, rh) { 665 | if(rx <= px && px <= rx + rw && ry <= py && py <= ry + rh) { 666 | return true; 667 | } 668 | 669 | return false; 670 | } 671 | 672 | /** 673 | * Circle in Rectangle collision detection 674 | * @param {number} cx center of circle 675 | * @param {number} cy center of circle 676 | * @param {number} r radius of the circle 677 | * @param {number} rx left side of rectangle 678 | * @param {number} ry top side of rectangle 679 | * @param {number} w width of rectangle 680 | * @param {number} h height of rectangle 681 | * @returns {boolean} 682 | */ 683 | static circleInRect(cx, cy, r, rx, ry, w, h) { 684 | const halfWidth = w / 2; 685 | const halfHeight = h / 2; 686 | 687 | const distX = abs(cx - rx - halfWidth); 688 | const distY = abs(cy - ry - halfHeight); 689 | 690 | if (distX > (halfWidth + r)) { return false; } 691 | if (distY > (halfHeight + r)) { return false; } 692 | 693 | if (distX <= halfWidth) { return true; } 694 | if (distY <= halfHeight) { return true; } 695 | 696 | const dx = distX - halfWidth; 697 | const dy = distY - halfHeight; 698 | 699 | return (dx * dx + dy * dy <= (r * r)); 700 | } 701 | 702 | /** 703 | * Resolve Collision Between Two Rects 704 | * @typedef {postition: Vec2, size: Vec2} Rect 705 | * @param {Rect} A 706 | * @param {Rect} B 707 | * @returns 708 | */ 709 | static resolveCollision(A, B) { 710 | const vX = (A.position.x + (A.size.x / 2)) - (B.position.x + (B.size.x / 2)); 711 | const vY = (A.position.y + (A.size.y / 2)) - (B.position.y + (B.size.y / 2)); 712 | const ww2 = (A.size.x / 2) + (B.size.x / 2); 713 | const hh2 = (A.size.y / 2) + (B.size.y / 2); 714 | 715 | let colDir = ''; 716 | 717 | if(abs(vX) < ww2 && abs(vY) < hh2) { 718 | const oX = ww2 - abs(vX), oY = hh2 - abs(vY); 719 | 720 | if(oX >= oY) { 721 | if(vY > 0) { 722 | colDir = COLLISION_SIDE.TOP; 723 | 724 | A.position.y += oY; 725 | } else { 726 | colDir = COLLISION_SIDE.BOTTOM; 727 | 728 | A.position.y -= oY; 729 | } 730 | } else { 731 | if(vX > 0) { 732 | colDir = COLLISION_SIDE.LEFT; 733 | 734 | A.position.x += oX; 735 | } else { 736 | colDir = COLLISION_SIDE.RIGHT; 737 | 738 | A.position.x -= oX; 739 | } 740 | } 741 | } 742 | 743 | return colDir; 744 | } 745 | 746 | } 747 | 748 | /** @module Sound */ 749 | 750 | 751 | /** 752 | * WebAudio context 753 | */ 754 | let _audioCtx = null; 755 | 756 | const getAudioCtx = () => { 757 | if(!_audioCtx) { 758 | _audioCtx = new AudioContext(); 759 | } 760 | 761 | return _audioCtx; 762 | }; 763 | 764 | /** 765 | * output mixer 766 | */ 767 | let _soundMixer = null; 768 | 769 | const getSoundMixer = () => { 770 | if(!_soundMixer) { 771 | const audioCtx = getAudioCtx(); 772 | 773 | _soundMixer = audioCtx.createGain(); 774 | 775 | _soundMixer.connect(audioCtx.destination); 776 | } 777 | 778 | return _soundMixer; 779 | }; 780 | 781 | /** 782 | * Sound Player 783 | */ 784 | class Sound { 785 | 786 | /** 787 | * play sound 788 | * @param {AudioBuffer} audioBuffer - sound data 789 | * @param {number} time - length to play, or 0 to play to the end 790 | * @param {boolean} loop - play the sound in loop if true 791 | * @param {GainNode} gainNode - output mixer 792 | * @example 793 | * import Sound from './sound.js'; 794 | * Sound.play(jumpSound); 795 | */ 796 | static play(audioBuffer, time = 0, loop = false, gainNode = getSoundMixer()) { 797 | const audioCtx = getAudioCtx(); 798 | 799 | const source = audioCtx.createBufferSource(); 800 | source.buffer = audioBuffer; 801 | source.connect(gainNode); 802 | source.loop = loop; 803 | source.start(time); 804 | 805 | return source; 806 | } 807 | 808 | static stop(source, time = 0) { 809 | source.stop(time); 810 | } 811 | 812 | /** 813 | * set the output volume 814 | * @param {number} v - volume 815 | * @param {GainNode} gainNode - output mixer 816 | * @example 817 | * setVolume(.5); 818 | */ 819 | static setVolume(v, gainNode = getSoundMixer()) { 820 | gainNode.gain.value = v; 821 | } 822 | 823 | } 824 | 825 | // hack to resume the audio ctx 826 | 827 | const resumeAudioCtx = () => { 828 | const audioCtx = getAudioCtx(); 829 | 830 | if(/interrupted|suspended/.test(audioCtx.state)) { 831 | audioCtx.resume(); 832 | } 833 | 834 | off(window, 'click', resumeAudioCtx); 835 | }; 836 | 837 | on(window, 'click', resumeAudioCtx); 838 | 839 | /** @module Loader */ 840 | 841 | 842 | const ASSET_TYPES = { 843 | IMAGE: 'image', 844 | SOUND: 'sound', 845 | JSON: 'json' 846 | }; 847 | 848 | /** 849 | * Asset Reducer 850 | */ 851 | const reduceAssets = (assets, { name, type, value }) => { 852 | if(!assets[type]) { 853 | assets[type] = {}; 854 | } 855 | 856 | assets[type][name] = value; 857 | 858 | return assets; 859 | }; 860 | 861 | /** 862 | * Resource Loader 863 | */ 864 | class ResourceLoader { 865 | 866 | /** 867 | * Asynchronously load an image from URL 868 | * @param {string} name - ressource id 869 | * @param {string} src - ressource URL 870 | */ 871 | static Image(name, src) { 872 | return new Promise((resolve, reject) => { 873 | 874 | const img = new Image(); 875 | 876 | img.crossOrigin = 'Anonymous'; 877 | 878 | img.onload = () => resolve({ type: ASSET_TYPES.IMAGE, name, value: img }); 879 | 880 | img.onerror = () => reject(new Error(`Couldn't load Image: ${src}`)); 881 | 882 | img.src = src; 883 | 884 | }); 885 | } 886 | 887 | /** 888 | * Asynchronously load a sound file from URL 889 | * @param {string} name - ressource id 890 | * @param {string} src - ressource URL 891 | */ 892 | static async Sound(name, src) { 893 | const audioCtx = getAudioCtx(); 894 | 895 | const res = await fetch(src); 896 | 897 | if (!res.ok) { 898 | throw new Error(`Couldn't Load Sound: ${src}`); 899 | } 900 | 901 | const arrayBuffer = await res.arrayBuffer(); 902 | 903 | const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); 904 | 905 | return { type: ASSET_TYPES.SOUND, name, value: audioBuffer }; 906 | } 907 | 908 | /** 909 | * Asynchronously load a sound file from URL 910 | * @param {string} name - ressource id 911 | * @param {string} src - ressource URL 912 | */ 913 | static async JSON(name, src) { 914 | const res = await fetch(src); 915 | 916 | if (!res.ok) { 917 | throw new Error(`Couldn't load the JSON file: ${src}`); 918 | } 919 | 920 | const json = await res.json(); 921 | 922 | return { type: ASSET_TYPES.JSON, name, value: json }; 923 | } 924 | 925 | /** 926 | * Load Multiple Assets at Once 927 | * @param {array} - Array of load Promises 928 | * @return {object} - Loaded assets are mapped to this object categorically 929 | */ 930 | static async loadAll(loadPromises) { 931 | const loadedAssets = await Promise.all(loadPromises); 932 | 933 | const reducedAssets = loadedAssets.reduce(reduceAssets, {}); 934 | 935 | return reducedAssets; 936 | } 937 | 938 | } 939 | 940 | const createLoadPromise = ({ name, type, src }) => { 941 | switch(type) { 942 | case ASSET_TYPES.IMAGE: 943 | return ResourceLoader.Image(name, src); 944 | case ASSET_TYPES.SOUND: 945 | return ResourceLoader.Sound(name, src); 946 | case ASSET_TYPES.JSON: 947 | return ResourceLoader.JSON(name, src); 948 | default: 949 | throw new Error(`Unknown Asset Type: "${type}"`); 950 | } 951 | }; 952 | 953 | /** 954 | * A Basic Asset Loader 955 | */ 956 | class Loader extends Emitter { 957 | 958 | constructor() { 959 | super(); 960 | this.queue = []; 961 | this.loading = false; 962 | } 963 | 964 | /** 965 | * enqueue an asset 966 | * @param {string} name - name of asset 967 | * @param {string} src - src of asset 968 | * @param {string} type - type of asset 969 | */ 970 | enqueue(name, src, type) { 971 | if(this.loading) { 972 | throw new Error(`Can't Enqueue Assets While The Loader is Loading.`); 973 | } 974 | 975 | this.queue.push({ name, type, src }); 976 | } 977 | 978 | /** 979 | * add image to the queue 980 | * @param {string} name - name of image 981 | * @param {string} src - source of image 982 | */ 983 | addImage(name, src) { 984 | this.enqueue(name, src, ASSET_TYPES.IMAGE); 985 | 986 | return this; 987 | } 988 | 989 | /** 990 | * add sound to queue 991 | * @param {string} name - name of sound 992 | * @param {string} src - source of sound 993 | */ 994 | addSound(name, src) { 995 | this.enqueue(name, src, ASSET_TYPES.SOUND); 996 | 997 | return this; 998 | } 999 | 1000 | /** 1001 | * add json file to queue 1002 | * @param {string} name - name of json file 1003 | * @param {string} src - source of json file 1004 | */ 1005 | addJSON(name, src) { 1006 | this.enqueue(name, src, ASSET_TYPES.JSON); 1007 | 1008 | return this; 1009 | } 1010 | 1011 | /** 1012 | * clears the queue 1013 | */ 1014 | clearQueue() { 1015 | while(this.queue.length) { 1016 | this.queue.pop(); 1017 | } 1018 | } 1019 | 1020 | /** 1021 | * reset the loader 1022 | */ 1023 | reset() { 1024 | this.clearQueue(); 1025 | this.loading = false; 1026 | } 1027 | 1028 | /** 1029 | * Start Loading 1030 | */ 1031 | async load() { 1032 | if(this.loading) { 1033 | console.error('Loader is already loading.'); 1034 | 1035 | return; 1036 | } 1037 | 1038 | this.loading = true; 1039 | 1040 | const loadPromises = this.queue.map(createLoadPromise); 1041 | 1042 | try { 1043 | const assets = await ResourceLoader.loadAll(loadPromises); 1044 | 1045 | this.emit('load', assets); 1046 | } catch(e) { 1047 | this.emit('error', e); 1048 | } finally { 1049 | this.reset(); 1050 | } 1051 | } 1052 | 1053 | } 1054 | 1055 | /** @module Sprite */ 1056 | 1057 | 1058 | class Sprite { 1059 | 1060 | /** 1061 | * @param {CanvasRenderingContext2D} ctx 1062 | * @param {image} image sprite image 1063 | * @param {number} sx source x 1064 | * @param {number} sy source y 1065 | * @param {number} sw source width 1066 | * @param {number} sh source height 1067 | * @param {number} dx destination x 1068 | * @param {number} dy destination y 1069 | * @param {number} width destination width 1070 | * @param {number} height destination height 1071 | */ 1072 | constructor(ctx, image, sx = 0, sy = 0, sw, sh, dx, dy, width, height) { 1073 | this.ctx = ctx; 1074 | this.image = image; 1075 | this.sourceX = sx; 1076 | this.sourceY = sy; 1077 | this.sourceWidth = sw || this.image.width; 1078 | this.sourceHeight = sh || this.image.height; 1079 | this.position = Vec2.create(dx, dy); 1080 | this.size = Vec2.create( 1081 | width || this.image.width, 1082 | height || this.image.height 1083 | ); 1084 | 1085 | this.rotation = 0; 1086 | 1087 | this.flipX = false; 1088 | this.flipY = false; 1089 | } 1090 | 1091 | render() { 1092 | this.ctx.save(); 1093 | 1094 | this.ctx.translate(this.position.x + this.size.x / 2, this.position.y + this.size.x / 2); 1095 | 1096 | this.ctx.scale(this.scale, this.scale); 1097 | 1098 | this.ctx.rotate(this.rotation); 1099 | 1100 | this.ctx.scale(this.flipX ? -1 : 1, this.flipY ? -1 : 1); 1101 | 1102 | this.ctx.translate(-(this.position.x + this.size.x / 2), -(this.position.y + this.size.x / 2)); 1103 | 1104 | this.ctx.drawImage( 1105 | this.image, 1106 | this.sourceX, 1107 | this.sourceY, 1108 | this.sourceWidth, 1109 | this.sourceHeight, 1110 | this.position.x, 1111 | this.position.y, 1112 | this.size.x, 1113 | this.size.y 1114 | ); 1115 | 1116 | this.ctx.restore(); 1117 | } 1118 | 1119 | } 1120 | 1121 | class AnimatedSprite { 1122 | 1123 | /** 1124 | * @param {CanvasRenderingContext2D} ctx 1125 | * @param {image} spritesheet 1126 | * @param {number} numCol number of columns 1127 | * @param {number} numRow number of rows 1128 | * @param {number} x x position 1129 | * @param {number} y y position 1130 | * @param {number} width 1131 | * @param {number} height 1132 | */ 1133 | constructor(ctx, spritesheet, numCol, numRow, x, y, width, height) { 1134 | this.ctx = ctx; 1135 | this.spritesheet = spritesheet; 1136 | 1137 | this.numCol = numCol; 1138 | this.numRow = numRow; 1139 | 1140 | this.frameWidth = spritesheet.width / numCol; 1141 | this.frameHeight = spritesheet.height / numRow; 1142 | 1143 | this.position = Vec2.create(x, y); 1144 | 1145 | this.size = Vec2.create( 1146 | width || this.frameWidth, 1147 | height || this.frameHeight 1148 | ); 1149 | 1150 | this.maxFrames = numCol * numRow - 1; 1151 | this.currentFrame = 0; 1152 | 1153 | this.flipX = false; 1154 | this.flipY = false; 1155 | this.rotation = 0; 1156 | this.scale = 1; 1157 | 1158 | this.animations = new Map(); 1159 | 1160 | this.currentAnimation = null; 1161 | this.playing = false; 1162 | 1163 | this.time = 0; 1164 | } 1165 | 1166 | /** 1167 | * add animation 1168 | * @param {string} animationName name of animation 1169 | * @param {number} frameStart frame to begin from 1170 | * @param {number} frameEnd frame to end at 1171 | * @param {number} delay delay between each frame 1172 | */ 1173 | addAnimation(animationName, frameStart, frameEnd, delay) { 1174 | if(this.animations.has(animationName)) { 1175 | throw new Error(`Animation with name "${animationName}" already exists.`); 1176 | } 1177 | 1178 | const animation = new SpriteAnimation(this, frameStart, frameEnd, delay); 1179 | 1180 | this.animations.set(animationName, animation); 1181 | 1182 | return this; 1183 | } 1184 | 1185 | /** 1186 | * play animation 1187 | * @param {string} animationName name of animation to play 1188 | */ 1189 | play(animationName) { 1190 | if(!this.animations.has(animationName)) { 1191 | throw new Error(`Animation with name "${animationName}" does not exists.`); 1192 | } 1193 | 1194 | this.currentAnimation = this.animations.get(animationName); 1195 | this.currentFrame = this.currentAnimation.frameStart; 1196 | this.playing = true; 1197 | this.time = 0; 1198 | } 1199 | 1200 | /** 1201 | * stop the sprite animation 1202 | */ 1203 | stop() { 1204 | this.playing = false; 1205 | } 1206 | 1207 | /** 1208 | * Called on each frame to update sprite states 1209 | * @param {number} dt delta time 1210 | * @returns 1211 | */ 1212 | update(dt) { 1213 | if(!this.playing) { 1214 | return; 1215 | } 1216 | 1217 | const { frameStart, frameEnd, delay } = this.currentAnimation; 1218 | 1219 | if(this.time >= delay) { 1220 | 1221 | this.currentFrame++; 1222 | 1223 | if(this.currentFrame > frameEnd) { 1224 | 1225 | this.currentFrame = frameStart; 1226 | 1227 | } 1228 | 1229 | this.time = 0; 1230 | 1231 | } 1232 | 1233 | this.time += dt; 1234 | 1235 | } 1236 | 1237 | render() { 1238 | if(!this.currentAnimation) { 1239 | throw new Error("Can't Render AnimatedSprite. No current animation has been set."); 1240 | } 1241 | 1242 | const [ col, row ] = this.currentAnimation.getFrame(this.currentFrame); 1243 | 1244 | this.ctx.save(); 1245 | 1246 | this.ctx.translate(this.position.x + this.size.x / 2, this.position.y + this.size.x / 2); 1247 | 1248 | this.ctx.scale(this.scale, this.scale); 1249 | 1250 | this.ctx.rotate(this.rotation); 1251 | 1252 | this.ctx.scale(this.flipX ? -1 : 1, this.flipY ? -1 : 1); 1253 | 1254 | this.ctx.translate(-(this.position.x + this.size.x / 2), -(this.position.y + this.size.x / 2)); 1255 | 1256 | this.ctx.drawImage( 1257 | this.spritesheet, 1258 | col * this.frameWidth, 1259 | row * this.frameHeight, 1260 | this.frameWidth, 1261 | this.frameHeight, 1262 | this.position.x, 1263 | this.position.y, 1264 | this.size.x, 1265 | this.size.y 1266 | ); 1267 | 1268 | this.ctx.restore(); 1269 | } 1270 | 1271 | } 1272 | 1273 | class SpriteAnimation { 1274 | 1275 | /** 1276 | * 1277 | * @param {AnimatedSprite} sprite 1278 | * @param {number} frameStart 1279 | * @param {number} frameEnd 1280 | * @param {number} delay 1281 | */ 1282 | constructor(sprite, frameStart, frameEnd, delay = 100) { 1283 | this.sprite = sprite; 1284 | 1285 | this.frameStart = frameStart; 1286 | this.frameEnd = frameEnd || this.sprite.maxFrames; 1287 | this.delay = delay / 1000; 1288 | this.frames = []; 1289 | 1290 | for(let frame = this.frameStart; frame <= this.frameEnd; frame++) { 1291 | this.frames[frame] = [ 1292 | frame % this.sprite.numCol, // col 1293 | Math.floor(frame / this.sprite.numCol) // row 1294 | ]; 1295 | } 1296 | } 1297 | 1298 | /** 1299 | * 1300 | * @param {number} frame 1301 | * @returns {array} [col, row] 1302 | */ 1303 | getFrame(frame) { 1304 | return this.frames[frame]; 1305 | } 1306 | 1307 | } 1308 | 1309 | /** @module Utils */ 1310 | 1311 | 1312 | const MOUSE = Vec2.create(); 1313 | 1314 | // get exact mouse position 1315 | 1316 | const getMousePos = (canvas, evt) => { 1317 | const rect = canvas.getBoundingClientRect(); 1318 | const scaleX = canvas.width / rect.width; // relationship bitmap vs. element for X 1319 | const scaleY = canvas.height / rect.height; // relationship bitmap vs. element for Y 1320 | 1321 | Vec2.set(MOUSE, (evt.clientX - rect.left) * scaleX, (evt.clientY - rect.top) * scaleY); 1322 | 1323 | return MOUSE; 1324 | }; 1325 | 1326 | // random stuff 1327 | 1328 | /** 1329 | * return a random float between min and max 1330 | */ 1331 | const random = (min = 0, max = 1) => Math.random() * (max - min) + min; 1332 | 1333 | /** 1334 | * return a random integer between min and max 1335 | */ 1336 | const randomInt = (min = 0, max = 1) => { 1337 | 1338 | min = Math.ceil(min); 1339 | 1340 | max = Math.floor(max); 1341 | 1342 | return Math.floor(Math.random() * (max - min + 1)) + min; 1343 | 1344 | }; 1345 | 1346 | // array stuff 1347 | 1348 | /** 1349 | * Copy the array without duplicates 1350 | */ 1351 | const unique = arr => [...new Set(arr)]; 1352 | 1353 | /** 1354 | * Randomly shufflet elements of an array in place. 1355 | */ 1356 | const shuffle = arr => arr.sort(() => Math.random() - 0.5); 1357 | 1358 | /** 1359 | * Split an array into chunks of constant sizes. 1360 | */ 1361 | const chunk = (arr, chunkSize) => { 1362 | 1363 | const chunks = []; 1364 | 1365 | for (let i = 0; i < arr.length; i += chunkSize) { 1366 | 1367 | chunks.push(arr.slice(i, i + chunkSize)); 1368 | 1369 | } 1370 | 1371 | return chunks; 1372 | 1373 | }; 1374 | 1375 | var utils = /*#__PURE__*/Object.freeze({ 1376 | __proto__: null, 1377 | chunk: chunk, 1378 | getMousePos: getMousePos, 1379 | random: random, 1380 | randomInt: randomInt, 1381 | shuffle: shuffle, 1382 | unique: unique 1383 | }); 1384 | 1385 | /** @module Canvas */ 1386 | 1387 | /** 1388 | * Initialize a new Canvas 1389 | * @param {number} width 1390 | * @param {number} height 1391 | * @param {string} background - background color 1392 | */ 1393 | const createCanvas = (width = 400, height = 400, background) => { 1394 | 1395 | const canvas = document.createElement('canvas'); // canvas 1396 | 1397 | canvas.width = width; // set width 1398 | 1399 | canvas.height = height; // set height 1400 | 1401 | if(background) { 1402 | canvas.style.background = background; // change background 1403 | } 1404 | 1405 | return canvas; 1406 | 1407 | }; 1408 | 1409 | export { ASSET_TYPES, AnimatedSprite, COLLISION_SIDE, Camera, Collision, dom as DOM, Device, Emitter, Game, math as GameMath, Keyboard, Loader, ResourceLoader, Sound, Sprite, SpriteAnimation, utils as Utils, Vec2, createCanvas, getAudioCtx, getSoundMixer }; 1410 | -------------------------------------------------------------------------------- /dist/index.min.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace b { 2 | let IMAGE: string; 3 | let SOUND: string; 4 | let JSON: string; 5 | } 6 | declare class D { 7 | constructor(t: any, e: any, s: any, i: any, r: any, a: any, o: any, c: any); 8 | ctx: any; 9 | spritesheet: any; 10 | numCol: any; 11 | numRow: any; 12 | frameWidth: number; 13 | frameHeight: number; 14 | position: n; 15 | size: n; 16 | maxFrames: number; 17 | currentFrame: number; 18 | flipX: boolean; 19 | flipY: boolean; 20 | rotation: number; 21 | scale: number; 22 | animations: any; 23 | currentAnimation: any; 24 | playing: boolean; 25 | time: number; 26 | addAnimation(t: any, e: any, s: any, i: any): this; 27 | play(t: any): void; 28 | stop(): void; 29 | update(t: any): void; 30 | render(): void; 31 | } 32 | declare namespace E { 33 | let TOP: string; 34 | let BOTTOM: string; 35 | let LEFT: string; 36 | let RIGHT: string; 37 | } 38 | declare class c { 39 | constructor(t: any, e?: number, s?: number, i?: number, r?: number); 40 | ctx: any; 41 | pos: n; 42 | target: n; 43 | cx: number; 44 | cy: number; 45 | attach(): void; 46 | detach(): void; 47 | update(t: any): void; 48 | lookAt(t: any, e: any): void; 49 | } 50 | declare class v { 51 | static circleInCircle(t: any, e: any, s: any, i: any, a: any, n: any): boolean; 52 | static pointInCircle(t: any, e: any, s: any, i: any, a: any): boolean; 53 | static rectInRect(t: any, e: any, s: any, i: any, r: any, a: any, n: any, o: any): boolean; 54 | static pointInRect(t: any, e: any, s: any, i: any, r: any, a: any): boolean; 55 | static circleInRect(t: any, e: any, s: any, i: any, r: any, a: any, n: any): boolean; 56 | static resolveCollision(t: any, e: any): string; 57 | } 58 | declare var y: Readonly<{ 59 | __proto__: any; 60 | attr: (t: any, e: any, s: any) => any; 61 | el: (t: any) => any; 62 | frag: () => DocumentFragment; 63 | off: (t: any, e: any, s: any) => any; 64 | on: (t: any, e: any, s: any) => any; 65 | qs: (t: any, e?: Document) => any; 66 | qsa: (t: any, e?: Document) => NodeListOf; 67 | ready: (t: any) => void; 68 | setStyle: (t: any, e: any) => any; 69 | svg: (t: any) => any; 70 | text: (t?: string) => Text; 71 | }>; 72 | declare class A { 73 | static isTouchscreen(): boolean; 74 | static gamepadAvailable(): boolean; 75 | } 76 | declare class h { 77 | topics: {}; 78 | emit(t: any, ...e: any[]): void; 79 | hasTopic(t: any): any; 80 | on(t: any, e: any): () => void; 81 | once(t: any, e: any): () => void; 82 | off(t: any, e: any): void; 83 | destroy(): void; 84 | } 85 | declare class t { 86 | run(): void; 87 | running: boolean; 88 | _lastStep: number; 89 | _frameRequest: number; 90 | stop(): void; 91 | step(): void; 92 | init(): void; 93 | update(t: any): void; 94 | render(): void; 95 | } 96 | declare var a: Readonly<{ 97 | __proto__: any; 98 | HALF_PI: number; 99 | PI: number; 100 | TWO_PI: number; 101 | clamp: (t: any, e: any, s: any) => number; 102 | pointDistance: (t: any, e: any, s: any, i: any) => number; 103 | pointToAngle: (t: any, e: any) => number; 104 | }>; 105 | declare class w { 106 | static keyDown(t: any): boolean; 107 | static getDirection(): n; 108 | static KEYS: { 109 | LEFT: string; 110 | RIGHT: string; 111 | UP: string; 112 | DOWN: string; 113 | SPACEBAR: string; 114 | ESCAPE: string; 115 | ENTER: string; 116 | CTRL: string; 117 | TAB: string; 118 | ALT: string; 119 | W: string; 120 | A: string; 121 | S: string; 122 | D: string; 123 | E: string; 124 | X: string; 125 | Z: string; 126 | }; 127 | } 128 | declare class R extends h { 129 | queue: any[]; 130 | loading: boolean; 131 | enqueue(t: any, e: any, s: any): void; 132 | addImage(t: any, e: any): this; 133 | addSound(t: any, e: any): this; 134 | addJSON(t: any, e: any): this; 135 | clearQueue(): void; 136 | reset(): void; 137 | load(): Promise; 138 | } 139 | declare class _ { 140 | static Image(t: any, e: any): any; 141 | static Sound(t: any, e: any): Promise<{ 142 | type: string; 143 | name: any; 144 | value: any; 145 | }>; 146 | static JSON(t: any, e: any): Promise<{ 147 | type: string; 148 | name: any; 149 | value: any; 150 | }>; 151 | static loadAll(t: any): Promise; 152 | } 153 | declare class I { 154 | static play(t: any, e?: number, s?: boolean, i?: any): any; 155 | static stop(t: any, e?: number): void; 156 | static setVolume(t: any, e?: any): void; 157 | } 158 | declare class C { 159 | constructor(t: any, e: any, s: number, i: number, r: any, a: any, o: any, c: any, h: any, u: any); 160 | ctx: any; 161 | image: any; 162 | sourceX: number; 163 | sourceY: number; 164 | sourceWidth: any; 165 | sourceHeight: any; 166 | position: n; 167 | size: n; 168 | rotation: number; 169 | flipX: boolean; 170 | flipY: boolean; 171 | render(): void; 172 | } 173 | declare class L { 174 | constructor(t: any, e: any, s: any, i?: number); 175 | sprite: any; 176 | frameStart: any; 177 | frameEnd: any; 178 | delay: number; 179 | frames: number[][]; 180 | getFrame(t: any): number[]; 181 | } 182 | declare var P: Readonly<{ 183 | __proto__: any; 184 | chunk: (t: any, e: any) => any[]; 185 | getMousePos: (t: any, e: any) => n; 186 | random: (t?: number, e?: number) => number; 187 | randomInt: (t?: number, e?: number) => number; 188 | shuffle: (t: any) => any; 189 | unique: (t: any) => any[]; 190 | }>; 191 | declare class n { 192 | static zero(): n; 193 | static create(t: any, e: any): n; 194 | static clone({ x: t, y: e }: { 195 | x: any; 196 | y: any; 197 | }): n; 198 | static copy(t: any, e: any): any; 199 | static set(t: any, e: any, s: any): any; 200 | static add(t: any, { x: e, y: s }: { 201 | x: any; 202 | y: any; 203 | }): any; 204 | static sub(t: any, { x: e, y: s }: { 205 | x: any; 206 | y: any; 207 | }): any; 208 | static mul(t: any, { x: e, y: s }: { 209 | x: any; 210 | y: any; 211 | }): any; 212 | static div(t: any, { x: e, y: s }: { 213 | x: any; 214 | y: any; 215 | }): any; 216 | static addScalar(t: any, e: any): any; 217 | static subScalar(t: any, e: any): any; 218 | static mulScalar(t: any, e: any): any; 219 | static divScalar(t: any, e: any): any; 220 | static angle(t: any): any; 221 | static calcLength(t: any): number; 222 | static equals(t: any, e: any): boolean; 223 | static dot(t: any, e: any): number; 224 | static cross(t: any, e: any): number; 225 | static lerp(t: any, { x: e, y: s }: { 226 | x: any; 227 | y: any; 228 | }, i: any): any; 229 | static normalize(t: any): any; 230 | static distance(t: any, e: any): number; 231 | constructor(t?: number, e?: number); 232 | x: number; 233 | y: number; 234 | } 235 | declare function G(t: number, e: number, s: any): HTMLCanvasElement; 236 | declare function O(): any; 237 | declare function M(): any; 238 | export { b as ASSET_TYPES, D as AnimatedSprite, E as COLLISION_SIDE, c as Camera, v as Collision, y as DOM, A as Device, h as Emitter, t as Game, a as GameMath, w as Keyboard, R as Loader, _ as ResourceLoader, I as Sound, C as Sprite, L as SpriteAnimation, P as Utils, n as Vec2, G as createCanvas, O as getAudioCtx, M as getSoundMixer }; 239 | -------------------------------------------------------------------------------- /dist/index.min.js: -------------------------------------------------------------------------------- 1 | class t{run(){if(this.running)return;this.running=!0,console.log("%c%s","color: #1abc9c; font-weight: bold","Made with bottlecap.js"),this.init(),this._lastStep=performance.now();const t=()=>{this.step(),this._lastStep=performance.now(),this._frameRequest=requestAnimationFrame(t)};requestAnimationFrame(t)}stop(){this._frameRequest&&cancelAnimationFrame(this._frameRequest),this._frameRequest=null,this.running=!1}step(){const t=performance.now(),e=(t-this._lastStep)/1e3;this._lastStep=t,this.update(e),this.render()}init(){console.log("Game Initialized")}update(t){}render(){}}const e=Math.PI,s=2*e,i=e/2,r=(t,e,s,i)=>{const r=t-s,a=e-i;return Math.sqrt(r*r+a*a)};var a=Object.freeze({__proto__:null,HALF_PI:i,PI:e,TWO_PI:s,clamp:(t,e,s)=>Math.max(e,Math.min(t,s)),pointDistance:r,pointToAngle:(t,e)=>-Math.atan2(-e,t)});class n{constructor(t=0,e=0){this.x=t,this.y=e}static zero(){return new n}static create(t,e){return new n(t,e)}static clone({x:t,y:e}){return n.create(t,e)}static copy(t,e){return Object.assign(t,e)}static set(t,e,s){return t.x=null!=e?e:t.x,t.y=null!=s?s:t.y,t}static add(t,{x:e,y:s}){return t.x+=e,t.y+=s,t}static sub(t,{x:e,y:s}){return t.x-=e,t.y-=s,t}static mul(t,{x:e,y:s}){return t.x*=e,t.y*=s,t}static div(t,{x:e,y:s}){return t.x/=e,t.y/=s,t}static addScalar(t,e){return t.x+=e,t.y+=e,t}static subScalar(t,e){return t.x-=e,t.y-=e,t}static mulScalar(t,e){return t.x*=e,t.y*=e,t}static divScalar(t,e){return t.x/=e,t.y/=e,t}static angle(t){return Math.atan2(-t.y,-t.x)+PI}static calcLength(t){return Math.sqrt(t.x*t.x+t.y*t.y)}static equals(t,e){return t.x===e.x&&t.y===e.y}static dot(t,e){return t.x*e.x+t.y*e.y}static cross(t,e){return t.x*e.y-t.y*e.x}static lerp(t,{x:e,y:s},i){return t.x+=(e-t.x)*i,t.y+=(s-t.y)*i,t}static normalize(t){return n.divScalar(t,n.calcLength(t)||1),t}static distance(t,e){return r(t.x,t.y,e.x,e.y)}}const o=Math.round;class c{constructor(t,e=0,s=0,i=0,r=0){this.ctx=t,this.pos=n.create(e,s),this.target=n.zero(),this.cx=o(t.canvas.width/2)-i,this.cy=o(t.canvas.height/2)-r}attach(){this.ctx.save(),this.ctx.translate(this.pos.x,this.pos.y)}detach(){this.ctx.restore()}update(t){n.set(this.pos,this.cx-this.target.x,this.cy-this.target.y)}lookAt(t,e){n.set(this.target,t,e)}}class h{constructor(){this.topics={}}emit(t,...e){const s=this.topics[t];!s||s.size<0||s.forEach((t=>t(...e)))}hasTopic(t){return Reflect.has(this.topics,t)}on(t,e){return this.hasTopic(t)||(this.topics[t]=new Set),this.topics[t].add(e),()=>this.off(t,e)}once(t,e){const s=(...i)=>{this.off(t,s),e(...i)};return this.on(t,s)}off(t,e){this.hasTopic(t)&&this.topics[t].delete(e)}destroy(){this.topics={}}}const u=document,l=/([.#])/,d=t=>{const e=t.split(l);let s="",i="";for(let t=1;t{if(void 0===s)return t.getAttribute(e);!1===s?t.removeAttribute(e):t.setAttribute(e,s)},p=(t,e,s)=>t.addEventListener(e,s,!1),x=(t,e,s)=>t.removeEventListener(e,s,!1);var y=Object.freeze({__proto__:null,attr:m,el:t=>{const{tag:e,id:s,className:i}=d(t),r=u.createElement(e);return s&&(r.id=s),i&&(r.className=i),r},frag:()=>u.createDocumentFragment(),off:x,on:p,qs:(t,e=u)=>e.querySelector(t),qsa:(t,e=u)=>e.querySelectorAll(t),ready:t=>{/complete|loaded|interactive/.test(u.readyState)&&u.body?setTimeout(t,1):p(u,"DOMContentLoaded",t)},setStyle:(t,e)=>Object.assign(t.style,e),svg:t=>{const{tag:e,id:s,className:i}=d(t),r=u.createElementNS("http://www.w3.org/2000/svg",e);return s&&(r.id=s),i&&m(r,"class",i),r},text:(t="")=>u.createTextNode(t)});const f=n.zero(),g={};class w{static keyDown(t){return!!g[t]}static getDirection(){const t=w.keyDown,e=w.KEYS,s=t(e.LEFT)?-1:t(e.RIGHT)?1:0,i=t(e.UP)?-1:t(e.DOWN)?1:0;return n.set(f,s,i),f}static KEYS={LEFT:"ArrowLeft",RIGHT:"ArrowRight",UP:"ArrowUp",DOWN:"ArrowDown",SPACEBAR:" ",ESCAPE:"Escape",ENTER:"Enter",CTRL:"Control",TAB:"Tab",ALT:"Alt",W:"w",A:"a",S:"s",D:"d",E:"e",X:"x",Z:"z"}}p(window,"keydown",(t=>{t.defaultPrevented||(g[t.key]=!0,t.preventDefault())})),p(window,"keyup",(t=>g[t.key]=!1));class A{static isTouchscreen(){return!!("ontouchstart"in document.documentElement)}static gamepadAvailable(){return!(!navigator||!navigator.getGamepads)}}const S=Math.abs,E={TOP:"top",BOTTOM:"bottom",LEFT:"left",RIGHT:"right"};class v{static circleInCircle(t,e,s,i,a,n){return r(t,e,i,a)<=s+n}static pointInCircle(t,e,s,i,a){return r(t,e,s,i)<=a}static rectInRect(t,e,s,i,r,a,n,o){return tr&&ea}static pointInRect(t,e,s,i,r,a){return s<=t&&t<=s+r&&i<=e&&e<=i+a}static circleInRect(t,e,s,i,r,a,n){const o=a/2,c=n/2,h=S(t-i-o),u=S(e-r-c);if(h>o+s)return!1;if(u>c+s)return!1;if(h<=o)return!0;if(u<=c)return!0;const l=h-o,d=u-c;return l*l+d*d<=s*s}static resolveCollision(t,e){const s=t.position.x+t.size.x/2-(e.position.x+e.size.x/2),i=t.position.y+t.size.y/2-(e.position.y+e.size.y/2),r=t.size.x/2+e.size.x/2,a=t.size.y/2+e.size.y/2;let n="";if(S(s)=o?i>0?(n=E.TOP,t.position.y+=o):(n=E.BOTTOM,t.position.y-=o):s>0?(n=E.LEFT,t.position.x+=e):(n=E.RIGHT,t.position.x-=e)}return n}}let z=null;const O=()=>(z||(z=new AudioContext),z);let T=null;const M=()=>{if(!T){const t=O();T=t.createGain(),T.connect(t.destination)}return T};class I{static play(t,e=0,s=!1,i=M()){const r=O().createBufferSource();return r.buffer=t,r.connect(i),r.loop=s,r.start(e),r}static stop(t,e=0){t.stop(e)}static setVolume(t,e=M()){e.gain.value=t}}const q=()=>{const t=O();/interrupted|suspended/.test(t.state)&&t.resume(),x(window,"click",q)};p(window,"click",q);const b={IMAGE:"image",SOUND:"sound",JSON:"json"},N=(t,{name:e,type:s,value:i})=>(t[s]||(t[s]={}),t[s][e]=i,t);class _{static Image(t,e){return new Promise(((s,i)=>{const r=new Image;r.crossOrigin="Anonymous",r.onload=()=>s({type:b.IMAGE,name:t,value:r}),r.onerror=()=>i(new Error(`Couldn't load Image: ${e}`)),r.src=e}))}static async Sound(t,e){const s=O(),i=await fetch(e);if(!i.ok)throw new Error(`Couldn't Load Sound: ${e}`);const r=await i.arrayBuffer(),a=await s.decodeAudioData(r);return{type:b.SOUND,name:t,value:a}}static async JSON(t,e){const s=await fetch(e);if(!s.ok)throw new Error(`Couldn't load the JSON file: ${e}`);const i=await s.json();return{type:b.JSON,name:t,value:i}}static async loadAll(t){return(await Promise.all(t)).reduce(N,{})}}const F=({name:t,type:e,src:s})=>{switch(e){case b.IMAGE:return _.Image(t,s);case b.SOUND:return _.Sound(t,s);case b.JSON:return _.JSON(t,s);default:throw new Error(`Unknown Asset Type: "${e}"`)}};class R extends h{constructor(){super(),this.queue=[],this.loading=!1}enqueue(t,e,s){if(this.loading)throw new Error("Can't Enqueue Assets While The Loader is Loading.");this.queue.push({name:t,type:s,src:e})}addImage(t,e){return this.enqueue(t,e,b.IMAGE),this}addSound(t,e){return this.enqueue(t,e,b.SOUND),this}addJSON(t,e){return this.enqueue(t,e,b.JSON),this}clearQueue(){for(;this.queue.length;)this.queue.pop()}reset(){this.clearQueue(),this.loading=!1}async load(){if(this.loading)return void console.error("Loader is already loading.");this.loading=!0;const t=this.queue.map(F);try{const e=await _.loadAll(t);this.emit("load",e)}catch(t){this.emit("error",t)}finally{this.reset()}}}class C{constructor(t,e,s=0,i=0,r,a,o,c,h,u){this.ctx=t,this.image=e,this.sourceX=s,this.sourceY=i,this.sourceWidth=r||this.image.width,this.sourceHeight=a||this.image.height,this.position=n.create(o,c),this.size=n.create(h||this.image.width,u||this.image.height),this.rotation=0,this.flipX=!1,this.flipY=!1}render(){this.ctx.save(),this.ctx.translate(this.position.x+this.size.x/2,this.position.y+this.size.x/2),this.ctx.scale(this.scale,this.scale),this.ctx.rotate(this.rotation),this.ctx.scale(this.flipX?-1:1,this.flipY?-1:1),this.ctx.translate(-(this.position.x+this.size.x/2),-(this.position.y+this.size.x/2)),this.ctx.drawImage(this.image,this.sourceX,this.sourceY,this.sourceWidth,this.sourceHeight,this.position.x,this.position.y,this.size.x,this.size.y),this.ctx.restore()}}class D{constructor(t,e,s,i,r,a,o,c){this.ctx=t,this.spritesheet=e,this.numCol=s,this.numRow=i,this.frameWidth=e.width/s,this.frameHeight=e.height/i,this.position=n.create(r,a),this.size=n.create(o||this.frameWidth,c||this.frameHeight),this.maxFrames=s*i-1,this.currentFrame=0,this.flipX=!1,this.flipY=!1,this.rotation=0,this.scale=1,this.animations=new Map,this.currentAnimation=null,this.playing=!1,this.time=0}addAnimation(t,e,s,i){if(this.animations.has(t))throw new Error(`Animation with name "${t}" already exists.`);const r=new L(this,e,s,i);return this.animations.set(t,r),this}play(t){if(!this.animations.has(t))throw new Error(`Animation with name "${t}" does not exists.`);this.currentAnimation=this.animations.get(t),this.currentFrame=this.currentAnimation.frameStart,this.playing=!0,this.time=0}stop(){this.playing=!1}update(t){if(!this.playing)return;const{frameStart:e,frameEnd:s,delay:i}=this.currentAnimation;this.time>=i&&(this.currentFrame++,this.currentFrame>s&&(this.currentFrame=e),this.time=0),this.time+=t}render(){if(!this.currentAnimation)throw new Error("Can't Render AnimatedSprite. No current animation has been set.");const[t,e]=this.currentAnimation.getFrame(this.currentFrame);this.ctx.save(),this.ctx.translate(this.position.x+this.size.x/2,this.position.y+this.size.x/2),this.ctx.scale(this.scale,this.scale),this.ctx.rotate(this.rotation),this.ctx.scale(this.flipX?-1:1,this.flipY?-1:1),this.ctx.translate(-(this.position.x+this.size.x/2),-(this.position.y+this.size.x/2)),this.ctx.drawImage(this.spritesheet,t*this.frameWidth,e*this.frameHeight,this.frameWidth,this.frameHeight,this.position.x,this.position.y,this.size.x,this.size.y),this.ctx.restore()}}class L{constructor(t,e,s,i=100){this.sprite=t,this.frameStart=e,this.frameEnd=s||this.sprite.maxFrames,this.delay=i/1e3,this.frames=[];for(let t=this.frameStart;t<=this.frameEnd;t++)this.frames[t]=[t%this.sprite.numCol,Math.floor(t/this.sprite.numCol)]}getFrame(t){return this.frames[t]}}const k=n.create();var P=Object.freeze({__proto__:null,chunk:(t,e)=>{const s=[];for(let i=0;i{const s=t.getBoundingClientRect(),i=t.width/s.width,r=t.height/s.height;return n.set(k,(e.clientX-s.left)*i,(e.clientY-s.top)*r),k},random:(t=0,e=1)=>Math.random()*(e-t)+t,randomInt:(t=0,e=1)=>(t=Math.ceil(t),e=Math.floor(e),Math.floor(Math.random()*(e-t+1))+t),shuffle:t=>t.sort((()=>Math.random()-.5)),unique:t=>[...new Set(t)]});const G=(t=400,e=400,s)=>{const i=document.createElement("canvas");return i.width=t,i.height=e,s&&(i.style.background=s),i};export{b as ASSET_TYPES,D as AnimatedSprite,E as COLLISION_SIDE,c as Camera,v as Collision,y as DOM,A as Device,h as Emitter,t as Game,a as GameMath,w as Keyboard,R as Loader,_ as ResourceLoader,I as Sound,C as Sprite,L as SpriteAnimation,P as Utils,n as Vec2,G as createCanvas,O as getAudioCtx,M as getSoundMixer}; -------------------------------------------------------------------------------- /dist/index.min.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshdoesdev/bottlecap/61b9294ba66ed699b5fac7957804c4ed0d5e0f1d/dist/index.min.js.gz -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bottlecap", 3 | "version": "1.0.8", 4 | "description": "A 2D GAME FRAMEWORK FOR HYPERCASUAL GAMES", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "clean": "rm -fr dist", 8 | "build": "npm run clean && npm run bundle:esm && npm run bundle:esm:min && tsc --allowJs --declaration --emitDeclarationOnly ./dist/index.js ./dist/index.min.js", 9 | "bundle:esm": "rollup src/index.js --file dist/index.js --format esm", 10 | "bundle:esm:min": "terser --ecma 6 --compress --mangle --module -o dist/index.min.js -- dist/index.js && gzip -9 -c dist/index.min.js > dist/index.min.js.gz" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/rare-earth/bottlecap.git" 15 | }, 16 | "keywords": [ 17 | "2d", 18 | "game", 19 | "framework", 20 | "game", 21 | "engine", 22 | "hypercasual" 23 | ], 24 | "author": "Harsh Singh", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/rare-earth/bottlecap/issues" 28 | }, 29 | "homepage": "https://github.com/rare-earth/bottlecap#readme" 30 | } 31 | -------------------------------------------------------------------------------- /src/camera.js: -------------------------------------------------------------------------------- 1 | /** @module Camera */ 2 | 3 | import Vec2 from './vec2.js'; 4 | 5 | const round = Math.round; 6 | 7 | /** 8 | * Camera - Basic PointLocked Camera 9 | */ 10 | export default class Camera { 11 | 12 | /** 13 | * @param {CanvasRenderingContext2D} ctx - current canvas context 14 | * @param {number} x - initial point to look at (x) 15 | * @param {number} y - initial point to look at (y) 16 | * @param {number} dx - offset from screen center (x) 17 | * @param {number} dy - offset from screen center (y) 18 | */ 19 | constructor(ctx, x = 0, y = 0, dx = 0, dy = 0) { 20 | 21 | this.ctx = ctx; 22 | 23 | this.pos = Vec2.create(x, y); 24 | 25 | this.target = Vec2.zero(); 26 | 27 | this.cx = round(ctx.canvas.width / 2) - dx; 28 | this.cy = round(ctx.canvas.height / 2) - dy; 29 | 30 | } 31 | 32 | 33 | /** 34 | * Start rendering through this camera 35 | */ 36 | attach() { 37 | 38 | this.ctx.save(); 39 | 40 | this.ctx.translate(this.pos.x, this.pos.y); 41 | 42 | } 43 | 44 | /** 45 | * Stop rendering through this camera 46 | */ 47 | detach() { 48 | 49 | this.ctx.restore(); 50 | 51 | } 52 | 53 | /** 54 | * update the camera 55 | * @param {*} dt 56 | */ 57 | update(dt) { 58 | Vec2.set(this.pos, this.cx - this.target.x, this.cy - this.target.y); 59 | } 60 | 61 | /** 62 | * Move the focus point of the camera 63 | * @param {number} x - where to look 64 | * @param {number} y - where to look 65 | */ 66 | lookAt(x, y) { 67 | Vec2.set(this.target, x, y); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/canvas.js: -------------------------------------------------------------------------------- 1 | /** @module Canvas */ 2 | 3 | /** 4 | * Initialize a new Canvas 5 | * @param {number} width 6 | * @param {number} height 7 | * @param {string} background - background color 8 | */ 9 | export const createCanvas = (width = 400, height = 400, background) => { 10 | 11 | const canvas = document.createElement('canvas'); // canvas 12 | 13 | canvas.width = width; // set width 14 | 15 | canvas.height = height; // set height 16 | 17 | if(background) { 18 | canvas.style.background = background; // change background 19 | } 20 | 21 | return canvas; 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /src/collision.js: -------------------------------------------------------------------------------- 1 | /** @module Collision */ 2 | 3 | import { pointDistance } from './math.js'; 4 | 5 | const abs = Math.abs; 6 | 7 | export const COLLISION_SIDE = { 8 | TOP: 'top', 9 | BOTTOM: 'bottom', 10 | LEFT: 'left', 11 | RIGHT: 'right' 12 | }; 13 | 14 | /** 15 | * Collision - Basic Collision Handling 16 | */ 17 | export default class Collision { 18 | 19 | /** 20 | * circle in circle collision detection 21 | * @param {number} x1 - center of first circle 22 | * @param {number} y1 - center of first circle 23 | * @param {number} r1 - radius of first circle 24 | * @param {number} x2 - center of second circle 25 | * @param {number} y2 - center of second circle 26 | * @param {number} r2 - radius of second circle 27 | * @return {boolean} true if circles are overlapping 28 | */ 29 | static circleInCircle(x1, y1, r1, x2, y2, r2) { 30 | return pointDistance( x1, y1, x2, y2 ) <= r1 + r2; 31 | } 32 | 33 | /** 34 | * point in circle collision check 35 | * @param {number} px - point 36 | * @param {number} py - point 37 | * @param {number} cx - center of circle 38 | * @param {number} cy - center of circle 39 | * @param {number} cr - radius of circle 40 | * @return {boolean} true if point is inside circle 41 | */ 42 | static pointInCircle(px, py, cx, cy, cr) { 43 | return pointDistance( px, py, cx, cy ) <= cr; 44 | } 45 | 46 | /** 47 | * checks if a point is in a rectangle 48 | * AABB rectangle in rectangle collision detection 49 | * @param {number} x1 - left side of first rectangle 50 | * @param {number} y1 - top side of first rectangle 51 | * @param {number} w1 - width of first rectangle 52 | * @param {number} h1 - height of first rectangle 53 | * @param {number} x2 - left side of second rectangle 54 | * @param {number} y2 - top side of second rectangle 55 | * @param {number} w2 - width of second rectangle 56 | * @param {number} h2 - height of second rectangle 57 | */ 58 | static rectInRect(x1, y1, w1, h1, x2, y2, w2, h2) { 59 | if( x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2 ) { 60 | return true; 61 | } 62 | 63 | return false; 64 | } 65 | 66 | /** 67 | * point in rectangle check detection 68 | * @param {number} px - point 69 | * @param {number} py - point 70 | * @param {number} rx - left side of rectangle 71 | * @param {number} ry - top side of rectangle 72 | * @param {number} rw - width of rectangle 73 | * @param {number} rh - height of rectangle 74 | */ 75 | static pointInRect(px, py, rx, ry, rw, rh) { 76 | if(rx <= px && px <= rx + rw && ry <= py && py <= ry + rh) { 77 | return true; 78 | } 79 | 80 | return false; 81 | } 82 | 83 | /** 84 | * Circle in Rectangle collision detection 85 | * @param {number} cx center of circle 86 | * @param {number} cy center of circle 87 | * @param {number} r radius of the circle 88 | * @param {number} rx left side of rectangle 89 | * @param {number} ry top side of rectangle 90 | * @param {number} w width of rectangle 91 | * @param {number} h height of rectangle 92 | * @returns {boolean} 93 | */ 94 | static circleInRect(cx, cy, r, rx, ry, w, h) { 95 | const halfWidth = w / 2; 96 | const halfHeight = h / 2; 97 | 98 | const distX = abs(cx - rx - halfWidth); 99 | const distY = abs(cy - ry - halfHeight); 100 | 101 | if (distX > (halfWidth + r)) { return false; } 102 | if (distY > (halfHeight + r)) { return false; } 103 | 104 | if (distX <= halfWidth) { return true; } 105 | if (distY <= halfHeight) { return true; } 106 | 107 | const dx = distX - halfWidth; 108 | const dy = distY - halfHeight; 109 | 110 | return (dx * dx + dy * dy <= (r * r)); 111 | } 112 | 113 | /** 114 | * Resolve Collision Between Two Rects 115 | * @typedef {postition: Vec2, size: Vec2} Rect 116 | * @param {Rect} A 117 | * @param {Rect} B 118 | * @returns 119 | */ 120 | static resolveCollision(A, B) { 121 | const vX = (A.position.x + (A.size.x / 2)) - (B.position.x + (B.size.x / 2)); 122 | const vY = (A.position.y + (A.size.y / 2)) - (B.position.y + (B.size.y / 2)); 123 | const ww2 = (A.size.x / 2) + (B.size.x / 2); 124 | const hh2 = (A.size.y / 2) + (B.size.y / 2); 125 | 126 | let colDir = ''; 127 | 128 | if(abs(vX) < ww2 && abs(vY) < hh2) { 129 | const oX = ww2 - abs(vX), oY = hh2 - abs(vY); 130 | 131 | if(oX >= oY) { 132 | if(vY > 0) { 133 | colDir = COLLISION_SIDE.TOP; 134 | 135 | A.position.y += oY; 136 | } else { 137 | colDir = COLLISION_SIDE.BOTTOM; 138 | 139 | A.position.y -= oY; 140 | } 141 | } else { 142 | if(vX > 0) { 143 | colDir = COLLISION_SIDE.LEFT; 144 | 145 | A.position.x += oX; 146 | } else { 147 | colDir = COLLISION_SIDE.RIGHT; 148 | 149 | A.position.x -= oX; 150 | } 151 | } 152 | } 153 | 154 | return colDir; 155 | } 156 | 157 | } -------------------------------------------------------------------------------- /src/device.js: -------------------------------------------------------------------------------- 1 | /** @module Device */ 2 | 3 | /** 4 | * Basic Device Info 5 | */ 6 | export default class Device { 7 | 8 | /** 9 | * @return {boolean} true if the device have touch screen capabilities 10 | */ 11 | static isTouchscreen() { 12 | return !!('ontouchstart' in document.documentElement); 13 | } 14 | 15 | /** 16 | * @return {boolean} true if the browser supports gamepads 17 | */ 18 | static gamepadAvailable() { 19 | return !!(navigator && navigator.getGamepads); 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /src/dom.js: -------------------------------------------------------------------------------- 1 | /** @module DOM */ 2 | 3 | /* Tejas | Tejas Contributors | MIT License */ 4 | 5 | const doc = document; 6 | const selectorRegex = /([.#])/; 7 | const ns = 'http://www.w3.org/2000/svg'; 8 | 9 | const parseSelector = selector => { 10 | const tokens = selector.split(selectorRegex); 11 | let id = '', className = ''; 12 | 13 | for (let i = 1; i < tokens.length; i += 2) { 14 | switch (tokens[i]) { 15 | case '.': 16 | className += ` ${tokens[i + 1]}`; 17 | break; 18 | case '#': 19 | id = tokens[i + 1]; 20 | } 21 | } 22 | 23 | return { 24 | tag: tokens[0] || 'div', 25 | className: className.trim(), 26 | id 27 | }; 28 | }; 29 | 30 | export const el = selector => { 31 | const { tag, id, className } = parseSelector(selector); 32 | const element = doc.createElement(tag); 33 | 34 | if (id) 35 | element.id = id; 36 | 37 | if (className) 38 | element.className = className; 39 | 40 | return element; 41 | }; 42 | 43 | export const svg = selector => { 44 | const { tag, id, className } = parseSelector(selector); 45 | const element = doc.createElementNS(ns, tag); 46 | 47 | if (id) 48 | element.id = id; 49 | 50 | if (className) 51 | attr(element, 'class', className); 52 | 53 | return element; 54 | }; 55 | 56 | export const frag = () => doc.createDocumentFragment(); 57 | 58 | export const text = (data = '') => doc.createTextNode(data); 59 | 60 | export const qs = (selectors, ctx = doc) => ctx.querySelector(selectors); 61 | 62 | export const qsa = (selectors, ctx = doc) => ctx.querySelectorAll(selectors); 63 | 64 | export const setStyle = (element, styleObj) => Object.assign(element.style, styleObj); 65 | 66 | export const attr = (element, attributeName, value) => { 67 | if (value === undefined) 68 | return element.getAttribute(attributeName); 69 | 70 | if (value === false) { 71 | element.removeAttribute(attributeName); 72 | } else { 73 | element.setAttribute(attributeName, value); 74 | } 75 | }; 76 | 77 | export const on = (element, type, handler) => element.addEventListener(type, handler, false); 78 | 79 | export const off = (element, type, handler) => element.removeEventListener(type, handler, false); 80 | 81 | export const ready = app => { 82 | if (/complete|loaded|interactive/.test(doc.readyState) && doc.body) { 83 | setTimeout(app, 1); 84 | } else { 85 | on(doc, 'DOMContentLoaded', app); 86 | } 87 | }; -------------------------------------------------------------------------------- /src/emitter.js: -------------------------------------------------------------------------------- 1 | /** @module Emitter */ 2 | 3 | export default class Emitter { 4 | 5 | constructor() { 6 | this.topics = {}; 7 | } 8 | 9 | emit(id, ...data) { 10 | const listeners = this.topics[id]; 11 | 12 | if(!listeners || listeners.size < 0) { 13 | return; 14 | } 15 | 16 | listeners.forEach(listener => listener(...data)); 17 | } 18 | 19 | hasTopic(id) { 20 | return Reflect.has(this.topics, id); 21 | } 22 | 23 | on(id, listener) { 24 | if(!this.hasTopic(id)) { 25 | this.topics[id] = new Set(); 26 | } 27 | 28 | this.topics[id].add(listener); 29 | 30 | return () => this.off(id, listener); 31 | } 32 | 33 | once(id, listener) { 34 | const proxy = (...data) => { 35 | this.off(id, proxy); 36 | 37 | listener(...data); 38 | }; 39 | 40 | return this.on(id, proxy); 41 | } 42 | 43 | off(id, listener) { 44 | if(this.hasTopic(id)) { 45 | this.topics[id].delete(listener); 46 | } 47 | } 48 | 49 | destroy() { 50 | this.topics = {}; 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /src/game.js: -------------------------------------------------------------------------------- 1 | /** @module Game */ 2 | 3 | /** 4 | * Main class, representing the current Game state 5 | */ 6 | export default class Game { 7 | 8 | /** 9 | * Kickstart the game 10 | */ 11 | run() { 12 | if(this.running) { 13 | return; 14 | } 15 | 16 | this.running = true; 17 | 18 | console.log( 19 | "%c%s", 20 | "color: #1abc9c; font-weight: bold", 21 | "Made with bottlecap.js" 22 | ); 23 | 24 | this.init(); 25 | 26 | this._lastStep = performance.now(); 27 | 28 | const loop = () => { 29 | this.step(); 30 | this._lastStep = performance.now(); 31 | this._frameRequest = requestAnimationFrame(loop); 32 | } 33 | 34 | requestAnimationFrame(loop); 35 | } 36 | 37 | stop() { 38 | if(this._frameRequest) { 39 | cancelAnimationFrame(this._frameRequest); 40 | } 41 | 42 | this._frameRequest = null; 43 | this.running = false; 44 | } 45 | 46 | /** 47 | * @ignore 48 | * Internal function called on each frame. 49 | */ 50 | step() { 51 | const now = performance.now(); 52 | const dt = (now - this._lastStep) / 1000; 53 | this._lastStep = now; 54 | this.update(dt); 55 | this.render(); 56 | } 57 | 58 | /** 59 | * Called at start to initialize game states. 60 | */ 61 | init() { 62 | console.log('Game Initialized'); 63 | } 64 | 65 | /** 66 | * Called on each frame to update game states. 67 | * @param {number} dt time since last update 68 | */ 69 | update(dt) {} 70 | 71 | /** 72 | * Called on each frame to render the game. 73 | */ 74 | render() {} 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Game } from './game.js'; 2 | export { default as Camera } from './camera.js'; 3 | export { default as Emitter } from './emitter.js'; 4 | export { default as Keyboard } from './keyboard.js'; 5 | export { default as Device } from './device.js'; 6 | export { COLLISION_SIDE, default as Collision } from './collision.js'; 7 | export { ResourceLoader, ASSET_TYPES, default as Loader } from './loader.js'; 8 | export { default as Sound, getSoundMixer, getAudioCtx } from './sound.js'; 9 | export * from './sprite.js'; 10 | export * as Utils from './utils.js'; 11 | export * as GameMath from './math.js'; 12 | export * as DOM from './dom.js'; 13 | export * from './canvas.js'; 14 | export { default as Vec2 } from './vec2.js'; -------------------------------------------------------------------------------- /src/keyboard.js: -------------------------------------------------------------------------------- 1 | /** @module Keyboard */ 2 | 3 | import { on } from './dom.js'; 4 | import Vec2 from './vec2.js'; 5 | 6 | /** 7 | * @typedef {{x: number, y: number}} direction 8 | */ 9 | 10 | const DIRECTION = Vec2.zero(); 11 | 12 | const KEYSTATE = {}; 13 | 14 | export default class Keyboard { 15 | 16 | /** 17 | * check if a key is down 18 | * @param {string} key 19 | */ 20 | static keyDown(key) { 21 | return !!KEYSTATE[key]; 22 | } 23 | 24 | /** 25 | * get direction for movement of player 26 | * @return {direction} 27 | * @example 28 | * update(dt) { 29 | * const direction = Keyboard.getDirection(); 30 | * player.x += direction.x * player.speed; 31 | * player.y += direction.y * player.speed; 32 | * } 33 | */ 34 | static getDirection() { 35 | const keyDown = Keyboard.keyDown, KEYS = Keyboard.KEYS; 36 | 37 | const x = keyDown(KEYS.LEFT) ? -1 : keyDown(KEYS.RIGHT) ? 1 : 0; 38 | const y = keyDown(KEYS.UP) ? -1 : keyDown(KEYS.DOWN) ? 1 : 0; 39 | 40 | Vec2.set(DIRECTION, x, y); 41 | 42 | return DIRECTION; 43 | } 44 | 45 | static KEYS = { 46 | LEFT: 'ArrowLeft', 47 | RIGHT: 'ArrowRight', 48 | UP: 'ArrowUp', 49 | DOWN: 'ArrowDown', 50 | SPACEBAR: ' ', 51 | ESCAPE: 'Escape', 52 | ENTER: 'Enter', 53 | CTRL: 'Control', 54 | TAB: 'Tab', 55 | ALT: 'Alt', 56 | W: 'w', 57 | A: 'a', 58 | S: 's', 59 | D: 'd', 60 | E: 'e', 61 | X: 'x', 62 | Z: 'z' 63 | } 64 | 65 | } 66 | 67 | const handleKeyDown = e => { 68 | if(e.defaultPrevented) { 69 | return; 70 | } 71 | 72 | KEYSTATE[e.key] = true; 73 | 74 | e.preventDefault(); 75 | }; 76 | 77 | const handleKeyUp = e => { 78 | return KEYSTATE[e.key] = false; 79 | }; 80 | 81 | on(window, 'keydown', handleKeyDown); 82 | on(window, 'keyup', handleKeyUp); -------------------------------------------------------------------------------- /src/loader.js: -------------------------------------------------------------------------------- 1 | /** @module Loader */ 2 | 3 | import Emitter from './emitter.js'; 4 | import { getAudioCtx } from './sound.js'; 5 | 6 | export const ASSET_TYPES = { 7 | IMAGE: 'image', 8 | SOUND: 'sound', 9 | JSON: 'json' 10 | }; 11 | 12 | /** 13 | * Asset Reducer 14 | */ 15 | const reduceAssets = (assets, { name, type, value }) => { 16 | if(!assets[type]) { 17 | assets[type] = {}; 18 | } 19 | 20 | assets[type][name] = value; 21 | 22 | return assets; 23 | }; 24 | 25 | /** 26 | * Resource Loader 27 | */ 28 | export class ResourceLoader { 29 | 30 | /** 31 | * Asynchronously load an image from URL 32 | * @param {string} name - ressource id 33 | * @param {string} src - ressource URL 34 | */ 35 | static Image(name, src) { 36 | return new Promise((resolve, reject) => { 37 | 38 | const img = new Image(); 39 | 40 | img.crossOrigin = 'Anonymous'; 41 | 42 | img.onload = () => resolve({ type: ASSET_TYPES.IMAGE, name, value: img }); 43 | 44 | img.onerror = () => reject(new Error(`Couldn't load Image: ${src}`)); 45 | 46 | img.src = src; 47 | 48 | }); 49 | } 50 | 51 | /** 52 | * Asynchronously load a sound file from URL 53 | * @param {string} name - ressource id 54 | * @param {string} src - ressource URL 55 | */ 56 | static async Sound(name, src) { 57 | const audioCtx = getAudioCtx(); 58 | 59 | const res = await fetch(src); 60 | 61 | if (!res.ok) { 62 | throw new Error(`Couldn't Load Sound: ${src}`); 63 | } 64 | 65 | const arrayBuffer = await res.arrayBuffer(); 66 | 67 | const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); 68 | 69 | return { type: ASSET_TYPES.SOUND, name, value: audioBuffer }; 70 | } 71 | 72 | /** 73 | * Asynchronously load a sound file from URL 74 | * @param {string} name - ressource id 75 | * @param {string} src - ressource URL 76 | */ 77 | static async JSON(name, src) { 78 | const res = await fetch(src); 79 | 80 | if (!res.ok) { 81 | throw new Error(`Couldn't load the JSON file: ${src}`); 82 | } 83 | 84 | const json = await res.json(); 85 | 86 | return { type: ASSET_TYPES.JSON, name, value: json }; 87 | } 88 | 89 | /** 90 | * Load Multiple Assets at Once 91 | * @param {array} - Array of load Promises 92 | * @return {object} - Loaded assets are mapped to this object categorically 93 | */ 94 | static async loadAll(loadPromises) { 95 | const loadedAssets = await Promise.all(loadPromises); 96 | 97 | const reducedAssets = loadedAssets.reduce(reduceAssets, {}); 98 | 99 | return reducedAssets; 100 | } 101 | 102 | } 103 | 104 | const createLoadPromise = ({ name, type, src }) => { 105 | switch(type) { 106 | case ASSET_TYPES.IMAGE: 107 | return ResourceLoader.Image(name, src); 108 | case ASSET_TYPES.SOUND: 109 | return ResourceLoader.Sound(name, src); 110 | case ASSET_TYPES.JSON: 111 | return ResourceLoader.JSON(name, src); 112 | default: 113 | throw new Error(`Unknown Asset Type: "${type}"`); 114 | } 115 | }; 116 | 117 | /** 118 | * A Basic Asset Loader 119 | */ 120 | export default class Loader extends Emitter { 121 | 122 | constructor() { 123 | super(); 124 | this.queue = []; 125 | this.loading = false; 126 | } 127 | 128 | /** 129 | * enqueue an asset 130 | * @param {string} name - name of asset 131 | * @param {string} src - src of asset 132 | * @param {string} type - type of asset 133 | */ 134 | enqueue(name, src, type) { 135 | if(this.loading) { 136 | throw new Error(`Can't Enqueue Assets While The Loader is Loading.`); 137 | } 138 | 139 | this.queue.push({ name, type, src }); 140 | } 141 | 142 | /** 143 | * add image to the queue 144 | * @param {string} name - name of image 145 | * @param {string} src - source of image 146 | */ 147 | addImage(name, src) { 148 | this.enqueue(name, src, ASSET_TYPES.IMAGE); 149 | 150 | return this; 151 | } 152 | 153 | /** 154 | * add sound to queue 155 | * @param {string} name - name of sound 156 | * @param {string} src - source of sound 157 | */ 158 | addSound(name, src) { 159 | this.enqueue(name, src, ASSET_TYPES.SOUND); 160 | 161 | return this; 162 | } 163 | 164 | /** 165 | * add json file to queue 166 | * @param {string} name - name of json file 167 | * @param {string} src - source of json file 168 | */ 169 | addJSON(name, src) { 170 | this.enqueue(name, src, ASSET_TYPES.JSON); 171 | 172 | return this; 173 | } 174 | 175 | /** 176 | * clears the queue 177 | */ 178 | clearQueue() { 179 | while(this.queue.length) { 180 | this.queue.pop(); 181 | } 182 | } 183 | 184 | /** 185 | * reset the loader 186 | */ 187 | reset() { 188 | this.clearQueue(); 189 | this.loading = false; 190 | } 191 | 192 | /** 193 | * Start Loading 194 | */ 195 | async load() { 196 | if(this.loading) { 197 | console.error('Loader is already loading.'); 198 | 199 | return; 200 | } 201 | 202 | this.loading = true; 203 | 204 | const loadPromises = this.queue.map(createLoadPromise); 205 | 206 | try { 207 | const assets = await ResourceLoader.loadAll(loadPromises); 208 | 209 | this.emit('load', assets); 210 | } catch(e) { 211 | this.emit('error', e); 212 | } finally { 213 | this.reset(); 214 | } 215 | } 216 | 217 | } -------------------------------------------------------------------------------- /src/math.js: -------------------------------------------------------------------------------- 1 | /** @module GameMath */ 2 | 3 | // Math constants 4 | 5 | export const PI = Math.PI; 6 | 7 | export const TWO_PI = PI * 2; 8 | 9 | export const HALF_PI = PI / 2; 10 | 11 | // measures distance between two points 12 | 13 | /** 14 | * Euclidean distance 15 | * @param {number} x1 16 | * @param {number} y1 17 | * @param {number} x2 18 | * @param {number} y2 19 | */ 20 | export const pointDistance = (x1, y1, x2, y2) => { 21 | 22 | const dx = x1 - x2, 23 | 24 | dy = y1 - y2; 25 | 26 | return Math.sqrt(dx * dx + dy * dy); 27 | 28 | }; 29 | 30 | // converts point to angle 31 | 32 | /** 33 | * Angle from the (1, 0) direction in radians 34 | * @param {number} x 35 | * @param {number} z 36 | */ 37 | export const pointToAngle = (x, y) => -Math.atan2(-y, x); 38 | 39 | /** 40 | * clamp num between min and max 41 | */ 42 | export const clamp = (num, min, max) => Math.max(min, Math.min(num, max)); -------------------------------------------------------------------------------- /src/sound.js: -------------------------------------------------------------------------------- 1 | /** @module Sound */ 2 | 3 | import { off, on } from "./dom.js"; 4 | 5 | /** 6 | * WebAudio context 7 | */ 8 | let _audioCtx = null; 9 | 10 | export const getAudioCtx = () => { 11 | if(!_audioCtx) { 12 | _audioCtx = new AudioContext(); 13 | } 14 | 15 | return _audioCtx; 16 | }; 17 | 18 | /** 19 | * output mixer 20 | */ 21 | let _soundMixer = null; 22 | 23 | export const getSoundMixer = () => { 24 | if(!_soundMixer) { 25 | const audioCtx = getAudioCtx(); 26 | 27 | _soundMixer = audioCtx.createGain(); 28 | 29 | _soundMixer.connect(audioCtx.destination); 30 | } 31 | 32 | return _soundMixer; 33 | }; 34 | 35 | /** 36 | * Sound Player 37 | */ 38 | export default class Sound { 39 | 40 | /** 41 | * play sound 42 | * @param {AudioBuffer} audioBuffer - sound data 43 | * @param {number} time - length to play, or 0 to play to the end 44 | * @param {boolean} loop - play the sound in loop if true 45 | * @param {GainNode} gainNode - output mixer 46 | * @example 47 | * import Sound from './sound.js'; 48 | * Sound.play(jumpSound); 49 | */ 50 | static play(audioBuffer, time = 0, loop = false, gainNode = getSoundMixer()) { 51 | const audioCtx = getAudioCtx(); 52 | 53 | const source = audioCtx.createBufferSource(); 54 | source.buffer = audioBuffer; 55 | source.connect(gainNode); 56 | source.loop = loop; 57 | source.start(time); 58 | 59 | return source; 60 | } 61 | 62 | static stop(source, time = 0) { 63 | source.stop(time); 64 | } 65 | 66 | /** 67 | * set the output volume 68 | * @param {number} v - volume 69 | * @param {GainNode} gainNode - output mixer 70 | * @example 71 | * setVolume(.5); 72 | */ 73 | static setVolume(v, gainNode = getSoundMixer()) { 74 | gainNode.gain.value = v; 75 | } 76 | 77 | } 78 | 79 | // hack to resume the audio ctx 80 | 81 | const resumeAudioCtx = () => { 82 | const audioCtx = getAudioCtx(); 83 | 84 | if(/interrupted|suspended/.test(audioCtx.state)) { 85 | audioCtx.resume(); 86 | } 87 | 88 | off(window, 'click', resumeAudioCtx); 89 | }; 90 | 91 | on(window, 'click', resumeAudioCtx); 92 | -------------------------------------------------------------------------------- /src/sprite.js: -------------------------------------------------------------------------------- 1 | /** @module Sprite */ 2 | 3 | import Vec2 from "./vec2.js"; 4 | 5 | export class Sprite { 6 | 7 | /** 8 | * @param {CanvasRenderingContext2D} ctx 9 | * @param {image} image sprite image 10 | * @param {number} sx source x 11 | * @param {number} sy source y 12 | * @param {number} sw source width 13 | * @param {number} sh source height 14 | * @param {number} dx destination x 15 | * @param {number} dy destination y 16 | * @param {number} width destination width 17 | * @param {number} height destination height 18 | */ 19 | constructor(ctx, image, sx = 0, sy = 0, sw, sh, dx, dy, width, height) { 20 | this.ctx = ctx; 21 | this.image = image; 22 | this.sourceX = sx; 23 | this.sourceY = sy; 24 | this.sourceWidth = sw || this.image.width; 25 | this.sourceHeight = sh || this.image.height; 26 | this.position = Vec2.create(dx, dy); 27 | this.size = Vec2.create( 28 | width || this.image.width, 29 | height || this.image.height 30 | ); 31 | 32 | this.rotation = 0; 33 | 34 | this.flipX = false; 35 | this.flipY = false; 36 | } 37 | 38 | render() { 39 | this.ctx.save(); 40 | 41 | this.ctx.translate(this.position.x + this.size.x / 2, this.position.y + this.size.x / 2); 42 | 43 | this.ctx.scale(this.scale, this.scale); 44 | 45 | this.ctx.rotate(this.rotation); 46 | 47 | this.ctx.scale(this.flipX ? -1 : 1, this.flipY ? -1 : 1); 48 | 49 | this.ctx.translate(-(this.position.x + this.size.x / 2), -(this.position.y + this.size.x / 2)); 50 | 51 | this.ctx.drawImage( 52 | this.image, 53 | this.sourceX, 54 | this.sourceY, 55 | this.sourceWidth, 56 | this.sourceHeight, 57 | this.position.x, 58 | this.position.y, 59 | this.size.x, 60 | this.size.y 61 | ); 62 | 63 | this.ctx.restore(); 64 | } 65 | 66 | } 67 | 68 | export class AnimatedSprite { 69 | 70 | /** 71 | * @param {CanvasRenderingContext2D} ctx 72 | * @param {image} spritesheet 73 | * @param {number} numCol number of columns 74 | * @param {number} numRow number of rows 75 | * @param {number} x x position 76 | * @param {number} y y position 77 | * @param {number} width 78 | * @param {number} height 79 | */ 80 | constructor(ctx, spritesheet, numCol, numRow, x, y, width, height) { 81 | this.ctx = ctx; 82 | this.spritesheet = spritesheet; 83 | 84 | this.numCol = numCol; 85 | this.numRow = numRow; 86 | 87 | this.frameWidth = spritesheet.width / numCol; 88 | this.frameHeight = spritesheet.height / numRow; 89 | 90 | this.position = Vec2.create(x, y); 91 | 92 | this.size = Vec2.create( 93 | width || this.frameWidth, 94 | height || this.frameHeight 95 | ); 96 | 97 | this.maxFrames = numCol * numRow - 1; 98 | this.currentFrame = 0; 99 | 100 | this.flipX = false; 101 | this.flipY = false; 102 | this.rotation = 0; 103 | this.scale = 1; 104 | 105 | this.animations = new Map(); 106 | 107 | this.currentAnimation = null; 108 | this.playing = false; 109 | 110 | this.time = 0; 111 | } 112 | 113 | /** 114 | * add animation 115 | * @param {string} animationName name of animation 116 | * @param {number} frameStart frame to begin from 117 | * @param {number} frameEnd frame to end at 118 | * @param {number} delay delay between each frame 119 | */ 120 | addAnimation(animationName, frameStart, frameEnd, delay) { 121 | if(this.animations.has(animationName)) { 122 | throw new Error(`Animation with name "${animationName}" already exists.`); 123 | } 124 | 125 | const animation = new SpriteAnimation(this, frameStart, frameEnd, delay); 126 | 127 | this.animations.set(animationName, animation); 128 | 129 | return this; 130 | } 131 | 132 | /** 133 | * play animation 134 | * @param {string} animationName name of animation to play 135 | */ 136 | play(animationName) { 137 | if(!this.animations.has(animationName)) { 138 | throw new Error(`Animation with name "${animationName}" does not exists.`); 139 | } 140 | 141 | this.currentAnimation = this.animations.get(animationName); 142 | this.currentFrame = this.currentAnimation.frameStart; 143 | this.playing = true; 144 | this.time = 0; 145 | } 146 | 147 | /** 148 | * stop the sprite animation 149 | */ 150 | stop() { 151 | this.playing = false; 152 | } 153 | 154 | /** 155 | * Called on each frame to update sprite states 156 | * @param {number} dt delta time 157 | * @returns 158 | */ 159 | update(dt) { 160 | if(!this.playing) { 161 | return; 162 | } 163 | 164 | const { frameStart, frameEnd, delay } = this.currentAnimation; 165 | 166 | if(this.time >= delay) { 167 | 168 | this.currentFrame++; 169 | 170 | if(this.currentFrame > frameEnd) { 171 | 172 | this.currentFrame = frameStart; 173 | 174 | } 175 | 176 | this.time = 0; 177 | 178 | } 179 | 180 | this.time += dt; 181 | 182 | } 183 | 184 | render() { 185 | if(!this.currentAnimation) { 186 | throw new Error("Can't Render AnimatedSprite. No current animation has been set."); 187 | } 188 | 189 | const [ col, row ] = this.currentAnimation.getFrame(this.currentFrame); 190 | 191 | this.ctx.save(); 192 | 193 | this.ctx.translate(this.position.x + this.size.x / 2, this.position.y + this.size.x / 2); 194 | 195 | this.ctx.scale(this.scale, this.scale); 196 | 197 | this.ctx.rotate(this.rotation); 198 | 199 | this.ctx.scale(this.flipX ? -1 : 1, this.flipY ? -1 : 1); 200 | 201 | this.ctx.translate(-(this.position.x + this.size.x / 2), -(this.position.y + this.size.x / 2)); 202 | 203 | this.ctx.drawImage( 204 | this.spritesheet, 205 | col * this.frameWidth, 206 | row * this.frameHeight, 207 | this.frameWidth, 208 | this.frameHeight, 209 | this.position.x, 210 | this.position.y, 211 | this.size.x, 212 | this.size.y 213 | ); 214 | 215 | this.ctx.restore(); 216 | } 217 | 218 | } 219 | 220 | export class SpriteAnimation { 221 | 222 | /** 223 | * 224 | * @param {AnimatedSprite} sprite 225 | * @param {number} frameStart 226 | * @param {number} frameEnd 227 | * @param {number} delay 228 | */ 229 | constructor(sprite, frameStart, frameEnd, delay = 100) { 230 | this.sprite = sprite; 231 | 232 | this.frameStart = frameStart; 233 | this.frameEnd = frameEnd || this.sprite.maxFrames; 234 | this.delay = delay / 1000; 235 | this.frames = []; 236 | 237 | for(let frame = this.frameStart; frame <= this.frameEnd; frame++) { 238 | this.frames[frame] = [ 239 | frame % this.sprite.numCol, // col 240 | Math.floor(frame / this.sprite.numCol) // row 241 | ]; 242 | } 243 | } 244 | 245 | /** 246 | * 247 | * @param {number} frame 248 | * @returns {array} [col, row] 249 | */ 250 | getFrame(frame) { 251 | return this.frames[frame]; 252 | } 253 | 254 | } 255 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** @module Utils */ 2 | 3 | import Vec2 from "./vec2.js"; 4 | 5 | const MOUSE = Vec2.create(); 6 | 7 | // get exact mouse position 8 | 9 | export const getMousePos = (canvas, evt) => { 10 | const rect = canvas.getBoundingClientRect(); 11 | const scaleX = canvas.width / rect.width; // relationship bitmap vs. element for X 12 | const scaleY = canvas.height / rect.height; // relationship bitmap vs. element for Y 13 | 14 | Vec2.set(MOUSE, (evt.clientX - rect.left) * scaleX, (evt.clientY - rect.top) * scaleY); 15 | 16 | return MOUSE; 17 | }; 18 | 19 | // random stuff 20 | 21 | /** 22 | * return a random float between min and max 23 | */ 24 | export const random = (min = 0, max = 1) => Math.random() * (max - min) + min; 25 | 26 | /** 27 | * return a random integer between min and max 28 | */ 29 | export const randomInt = (min = 0, max = 1) => { 30 | 31 | min = Math.ceil(min); 32 | 33 | max = Math.floor(max); 34 | 35 | return Math.floor(Math.random() * (max - min + 1)) + min; 36 | 37 | }; 38 | 39 | // array stuff 40 | 41 | /** 42 | * Copy the array without duplicates 43 | */ 44 | export const unique = arr => [...new Set(arr)]; 45 | 46 | /** 47 | * Randomly shufflet elements of an array in place. 48 | */ 49 | export const shuffle = arr => arr.sort(() => Math.random() - 0.5); 50 | 51 | /** 52 | * Split an array into chunks of constant sizes. 53 | */ 54 | export const chunk = (arr, chunkSize) => { 55 | 56 | const chunks = []; 57 | 58 | for (let i = 0; i < arr.length; i += chunkSize) { 59 | 60 | chunks.push(arr.slice(i, i + chunkSize)); 61 | 62 | } 63 | 64 | return chunks; 65 | 66 | }; 67 | -------------------------------------------------------------------------------- /src/vec2.js: -------------------------------------------------------------------------------- 1 | /** @module Vec2 */ 2 | 3 | import { pointDistance } from "./math.js"; 4 | 5 | /** 6 | * Vec2 - Create Vector and Perform Basic Vector Math 7 | */ 8 | export default class Vec2 { 9 | constructor(x = 0, y = 0) { 10 | this.x = x; 11 | this.y = y; 12 | } 13 | 14 | static zero() { 15 | return new Vec2(); 16 | } 17 | 18 | static create(x, y) { 19 | return new Vec2(x, y); 20 | } 21 | 22 | static clone({ x, y }) { 23 | return Vec2.create(x, y); 24 | } 25 | 26 | static copy(v, v2) { 27 | return Object.assign(v, v2); 28 | } 29 | 30 | static set(v, x, y) { 31 | v.x = x != null ? x : v.x; 32 | v.y = y != null ? y : v.y; 33 | 34 | return v; 35 | } 36 | 37 | static add(v, { x, y }) { 38 | v.x += x; 39 | v.y += y; 40 | 41 | return v; 42 | } 43 | 44 | static sub(v, { x, y }) { 45 | v.x -= x; 46 | v.y -= y; 47 | 48 | return v; 49 | } 50 | 51 | static mul(v, { x, y }) { 52 | v.x *= x; 53 | v.y *= y; 54 | 55 | return v; 56 | } 57 | 58 | static div(v, { x, y }) { 59 | v.x /= x; 60 | v.y /= y; 61 | 62 | return v; 63 | } 64 | 65 | static addScalar(v, s) { 66 | v.x += s; 67 | v.y += s; 68 | 69 | return v; 70 | } 71 | 72 | static subScalar(v, s) { 73 | v.x -= s; 74 | v.y -= s; 75 | 76 | return v; 77 | } 78 | 79 | static mulScalar(v, s) { 80 | v.x *= s; 81 | v.y *= s; 82 | 83 | return v; 84 | } 85 | 86 | static divScalar(v, s) { 87 | v.x /= s; 88 | v.y /= s; 89 | 90 | return v; 91 | } 92 | 93 | static angle(v) { 94 | return Math.atan2(-v.y, -v.x) + PI; 95 | } 96 | 97 | static calcLength(v) { 98 | return Math.sqrt(v.x * v.x + v.y * v.y); 99 | } 100 | 101 | static equals(v, v2) { 102 | return ((v.x === v2.x) && (v.y === v2.y)); 103 | } 104 | 105 | static dot(v, v2) { 106 | return v.x * v2.x + v.y * v2.y; 107 | } 108 | 109 | static cross(v, v2) { 110 | return v.x * v2.y - v.y * v2.x; 111 | } 112 | 113 | static lerp(v, { x, y }, alpha) { 114 | v.x += (x - v.x) * alpha; 115 | v.y += (y - v.y) * alpha; 116 | 117 | return v; 118 | } 119 | 120 | static normalize(v) { 121 | Vec2.divScalar(v, Vec2.calcLength(v) || 1); 122 | 123 | return v; 124 | } 125 | 126 | static distance(v, v2) { 127 | return pointDistance(v.x, v.y, v2.x, v2.y); 128 | } 129 | } --------------------------------------------------------------------------------