A lightweight JavaScript game engine
git clone git://soophie.de/game-engine
1
import { Shape } from './shape.js';3
export function collideRect(a, b) {4
return a.x < b.x + b.shape.width && a.x + a.shape.width > b.x5
&& a.y < b.y + b.shape.height && a.y + a.shape.height > b.y;6
}8
export function collideCircle(a, b) {9
const dx = Math.abs(a.x - b.x);10
const dy = Math.abs(a.y - b.y);11
return Math.sqrt(dx ** 2 + dy ** 2) < a.shape.radius + b.shape.radius;12
}14
export function collideRectAndCircle(a, b) {15
const [rect, circle] = a instanceof Shape.Rect ? [a, b] : [b, a];16
let x = circle.x;17
let y = circle.y;18
if(circle.x < rect.x) {19
x = rect.x;20
}21
else if(circle.x > rect.x + rect.shape.width) {22
x = rect.x + rect.shape.width;23
}24
if(circle.y < rect.y) {25
y = rect.y;26
}27
else if(circle.y > rect.y + rect.shape.height) {28
y = rect.y + rect.shape.height;29
}30
const distance = Math.sqrt((circle.x - x) ** 2 + (circle.y - y) ** 2);31
return distance <= circle.shape.radius;32
}34
export function collidePoint(object, point) {35
if(object.shape instanceof Shape.Rect) {36
const {x, y, shape: {width, height}} = object;37
return x < point.x && x + width > point.x && y < point.y && y + height > point.y;38
}39
if(object.shape instanceof Shape.Circle) {40
const {x, y, shape: {radius}} = object;41
return Math.sqrt((x - point.x) ** 2 + (y - point.y) ** 2) < radius;42
}43
}
1
import { scaleCanvas } from './util.js';3
export class Game {4
constructor(width, height, { fps } = {}) {5
this.width = width || 900;6
this.height = height || 600;7
this.fps = 1000 / (fps || 60);8
this.then = Date.now();9
this.canvas = document.createElement('canvas');10
scaleCanvas(this.canvas, this.width, this.height);11
this.g = this.canvas.getContext('2d');12
this.events = [];13
}14
start() {15
requestAnimationFrame(this.loop.bind(this));16
this.update();17
this.render(this.g);18
}19
loop() {20
requestAnimationFrame(this.loop.bind(this));21
this.now = Date.now();22
this.elapsed = this.now - this.then;23
if(this.elapsed > this.fps) {24
this.then = this.now - (this.elapsed % this.fps);25
for(const event of this.events) {26
if(this['on' + event.type]) {27
this['on' + event.type](event);28
}29
}30
this.update();31
this.render(this.g);32
this.events.length = 0;33
}34
}35
update() {36
// nothing37
}38
render() {39
this.g.clearRect(0, 0, this.width, this.height);40
}41
resize(width, height) {42
this.width = width;43
this.height = height;44
scaleCanvas(this.canvas, this.width, this.height);45
}46
bindEvent(element, type) {47
element['on' + type] = event => this.events.push(event);48
}49
}
1
export class Group {2
constructor() {3
this.items = [];4
}5
update() {6
for(const item of this.items) {7
item.update();8
}9
}10
render(g) {11
for(const item of this.items) {12
item.render(g);13
}14
}15
causeEvent(type, events) {16
for(const item of this.items) {17
item.causeEvent(type, events);18
}19
}20
collide() {21
for(const i of this.items) {22
i.items = [];23
i.collided = false;24
}25
const duplicate = [];26
for(const a of this.items) {27
for(const b of this.items) {28
if(!duplicate.includes(b)) {29
if(a !== b) {30
let collided = false;31
if(a.shape instanceof Shape.Rect && b.shape instanceof Shape.Rect) {32
collided = Collision.collideRect(a, b);33
}34
else if(a.shape instanceof Shape.Circle && b.shape instanceof Shape.Circle) {35
collided = Collision.collideCircle(a, b);36
}37
else {38
collided = Collision.collideRectAndCircle(a, b);39
}40
if(!a.collided) {41
a.collided = collided;42
}43
if(!b.collided) {44
b.collided = collided;45
}46
if(collided) {47
if(!a.items.includes(b)) {48
a.items.push(b);49
}50
if(!b.items.includes(a)) {51
b.items.push(a);52
}53
}54
}55
}56
}57
duplicate.push(a);58
}59
}60
add(item) {61
this.items.push(item);62
}63
remove(item) {64
const i = this.items.findIndex(n => n === item);65
if(i !== -1) {66
this.items.splice(i, 1);67
}68
}69
[Symbol.iterator]() {70
return this.items.values();71
}72
}
1
export * from './game.js';2
export * from './group.js';3
export * from './surface.js';4
export * from './shape.js';5
export * from './collision.js';
1
class Rect {2
constructor(width, height) {3
this.width = width;4
this.height = height;5
}6
}8
class Circle {9
constructor(radius) {10
this.radius = radius;11
}12
}14
export const Shape = { Rect, Circle };14
\ No newline at end of file
1
import Game from './game.js';2
import Group from './group.js';3
import Surface from './surface.js';4
import Shape from './shape.js';5
import Sprite from './sprite/sprite.js';6
import Spritesheet from './sprite/spritesheet.js';8
import * as Collision from './util/collision.js';9
import Animation from './util/animation.js';10
import * as Canvas from './util/canvas.js';11
import Timer from './util/timer.js';12
import Bitmap from './util/bitmap.js';14
const GameEngine = {15
Game,16
Group,17
Surface,18
Shape,19
Sprite,20
Spritesheet,21
instance: null22
};24
export const Util = {25
Collision,26
Animation,27
Canvas,28
Timer,29
Bitmap30
};32
export default GameEngine;32
\ No newline at end of file
1
import GameEngine from './game-engine.js';2
import { scaleCanvas } from './util/canvas.js';4
export default class Game {5
constructor(width, height, { fps } = {}) {6
this.width = width || 900;7
this.height = height || 600;8
this.fps = 1000 / (fps || 60);9
this.then = Date.now();10
this.running = false;11
this.canvas = document.createElement('canvas');12
scaleCanvas(this.canvas, this.width, this.height);13
this.g = this.canvas.getContext('2d');14
this.g.imageSmoothingEnabled = false;15
this.events = [];16
this.sprites = [];17
GameEngine.instance = this;18
}19
async start() {20
for(const sprite of this.sprites) {21
await sprite.load();22
}23
this.running = true;24
if(this.oninit) {25
this.oninit();26
}27
requestAnimationFrame(this.loop.bind(this));28
this.update();29
this.render(this.g);30
}31
quit() {32
this.running = false;33
if(this.onquit) {34
this.onquit();35
}36
}37
loop() {38
try {39
if(this.running) {40
requestAnimationFrame(this.loop.bind(this));41
this.now = Date.now();42
this.elapsed = this.now - this.then;43
if(this.elapsed > this.fps) {44
this.then = this.now - (this.elapsed % this.fps);45
for(const event of this.events) {46
if(this['on' + event.type]) {47
this['on' + event.type](event);48
}49
}50
this.update();51
this.render(this.g);52
this.events = [];53
}54
}55
}56
catch(error) {57
console.error(error);58
this.running = false;59
}60
}61
update() {62
// nothing63
}64
render() {65
this.g.clearRect(0, 0, this.width, this.height);66
}67
resize(width, height) {68
this.width = width;69
this.height = height;70
scaleCanvas(this.canvas, this.width, this.height);71
this.g.imageSmoothingEnabled = false;72
}73
bindEvent(element, type) {74
element['on' + type] = event => this.events.push(event);75
}76
loadSprite(sprite) {77
this.sprites.push(sprite);78
}79
}
1
export default class Group {2
constructor() {3
this.items = [];4
}5
update() {6
for(const item of this.items) {7
item.update();8
}9
}10
render(g) {11
for(const item of this.items) {12
item.render(g);13
}14
}15
causeEvent(type, events) {16
for(const item of this.items) {17
item.causeEvent(type, events);18
}19
}20
add(item) {21
this.items.push(item);22
}23
remove(item) {24
const i = this.items.findIndex(n => n === item);25
if(i !== -1) {26
this.items.splice(i, 1);27
}28
}29
[Symbol.iterator]() {30
return this.items.values();31
}32
}32
\ No newline at end of file
1
class Rect {2
constructor(width, height) {3
this.width = width;4
this.height = height;5
}6
}8
class Circle {9
constructor(radius) {10
this.radius = radius;11
}12
}14
const Shape = { Rect, Circle };16
export default Shape;
1
const cache = new Map();3
export default class Sprite {4
constructor(path) {5
this.path = path;6
this.image = new Image();7
}8
load() {9
return new Promise(async (resolve, reject) => {10
this.image.onload = () => {11
if(!cache.has(this.path)) {12
cache.set(this.path, this.image.src);13
}14
resolve();15
};16
this.image.onerror = () => {17
reject(new Error(`Unable to load sprite '${this.path}'!`));18
}19
if(cache.has(this.path)) {20
this.image.src = cache.get(this.path);21
}22
else {23
if(this.path.startsWith('data:')) {24
this.image.src = this.path;25
}26
else {27
try {28
const response = await fetch(this.path);29
if(response.status !== 200) {30
reject(new Error(`Invalid response code ${response.status} for sprite '${this.path}'!`));31
}32
const blob = await response.blob();33
const url = URL.createObjectURL(blob);34
this.image.src = url;35
}36
catch(error) {37
reject(error);38
}39
}40
}41
});42
}43
}
1
import Sprite from './sprite.js';2
import { scaleCanvas } from '../util/canvas.js';4
export default class Spritesheet extends Sprite {5
constructor(path, width, height) {6
super(path);7
this.width = width;8
this.height = height || width;9
this.sprites = [];10
}11
async load() {12
await super.load();13
const {width, height} = this.image;14
if(width % this.width !== 0 || height % this.height !== 0) {15
throw new Error(`Invalid dimension ${this.width}x${this.height} for spritesheet '${this.path}'!`);16
}17
this.sprites = new Array(parseInt(width / this.width) * parseInt(height / this.height));18
const canvas = document.createElement('canvas');19
scaleCanvas(canvas, this.width, this.height);20
const g = canvas.getContext('2d');21
g.imageSmoothingEnabled = false;22
for(let i = 0; i < this.sprites.length; i++) {23
const y = parseInt(i / (width / this.width));24
const x = parseInt(i % (width / this.width));25
g.clearRect(0, 0, this.width, this.height);26
g.drawImage(this.image, -x * this.width, -y * this.height, width, height);27
const sprite = new Sprite(canvas.toDataURL('image/png'));28
await sprite.load();29
this.sprites[i] = sprite;30
}31
}32
all() {33
return this.sprites;34
}35
}
1
import Shape from './shape.js';3
export default class Surface {4
constructor(x, y, shape) {5
this.x = x;6
this.y = y;7
this.shape = shape;8
}9
update() {10
// nothing11
}12
render(g) {13
if(this.shape instanceof Shape.Rect) {14
g.beginPath();15
g.rect(this.x, this.y, this.shape.width, this.shape.height);16
g.stroke();17
}18
if(this.shape instanceof Shape.Circle) {19
g.beginPath();20
g.arc(this.x, this.y, this.shape.radius, 2 * Math.PI, 0);21
g.stroke();22
}23
}24
causeEvent(type, events) {25
if(this['on' + type]) {26
for(const event of events) {27
if(event.type === type) {28
this['on' + type](event);29
}30
}31
}32
}33
}
1
import Timer from './timer.js';3
export default class Animation {4
constructor(size, step) {5
this.size = size;6
this.timer = new Timer(step);7
this.frame = 0;8
this.offset = 0;9
}10
count() {11
if(this.timer.count()) {12
if(this.frame === this.size - 1) {13
this.frame = 0;14
}15
else {16
this.frame++;17
}18
}19
}20
reset() {21
this.timer.reset();22
this.frame = 0;23
}24
get(sprites) {25
return sprites[this.offset + this.frame];26
}27
}
1
import { scaleCanvas } from '../util/canvas.js';3
const cache = new Map();4
const canvas = document.createElement('canvas');5
const g = canvas.getContext('2d');7
export default class Bitmap {8
static from(sprite, width, height) {9
const key = `${sprite.path}:${width}x${height}`;10
if(!cache.has(key)) {11
cache.set(key, new Bitmap(sprite.image, width, height));12
}13
return cache.get(key);14
}15
constructor(image, width, height) {16
this.image = image;17
this.width = width;18
this.height = height;19
scaleCanvas(canvas, width, height);20
g.imageSmoothingEnabled = false;21
g.drawImage(this.image, 0, 0, width, height);22
const {data} = g.getImageData(0, 0, width, height);23
this.map = [];24
let row = [];25
for(let i = 0; i < data.length; i += 4) {26
const r = data[i];27
const g = data[i + 1];28
const b = data[i + 2];29
const a = data[i + 3];30
if((i / 4) % width === 0 && Math.floor(i / 4 / width) > 0) {31
this.map.push(row);32
row = [];33
}34
row.push([r, g, b, a]);35
}36
this.map.push(row);37
}38
get(x, y) {39
if(x < 0 || y < 0 || x >= this.width || y >= this.height) {40
return [];41
}42
return this.map[y][x];43
}44
}44
\ No newline at end of file
1
const DEVICE_PIXEL_RATIO = window.devicePixelRatio || 1;3
export function scaleCanvas(canvas, width, height) {4
const g = canvas.getContext('2d');5
const backingStoreRatio = g.webkitBackingStorePixelRatio || 1;6
const ratio = DEVICE_PIXEL_RATIO / backingStoreRatio;7
if(DEVICE_PIXEL_RATIO != backingStoreRatio) {8
canvas.width = width * ratio;9
canvas.height = height * ratio;10
canvas.style.width = width + 'px';11
canvas.style.height = height + 'px';12
}13
else {14
canvas.width = width;15
canvas.height = height;16
canvas.style.width = '';17
canvas.style.height = '';18
}19
g.scale(ratio, ratio);20
}20
\ No newline at end of file
1
import Shape from '../shape.js';3
export function collideRect(a, b) {4
return a.x < b.x + b.shape.width && a.x + a.shape.width > b.x5
&& a.y < b.y + b.shape.height && a.y + a.shape.height > b.y;6
}8
export function collideCircle(a, b) {9
const dx = Math.abs(a.x - b.x);10
const dy = Math.abs(a.y - b.y);11
return Math.sqrt(dx ** 2 + dy ** 2) < a.shape.radius + b.shape.radius;12
}14
export function collideRectAndCircle(a, b) {15
const [rect, circle] = a instanceof Shape.Rect ? [a, b] : [b, a];16
let x = circle.x;17
let y = circle.y;18
if(circle.x < rect.x) {19
x = rect.x;20
}21
else if(circle.x > rect.x + rect.shape.width) {22
x = rect.x + rect.shape.width;23
}24
if(circle.y < rect.y) {25
y = rect.y;26
}27
else if(circle.y > rect.y + rect.shape.height) {28
y = rect.y + rect.shape.height;29
}30
const distance = Math.sqrt((circle.x - x) ** 2 + (circle.y - y) ** 2);31
return distance <= circle.shape.radius;32
}34
export function collidePoint(object, point) {35
if(object.shape instanceof Shape.Rect) {36
const {x, y, shape: {width, height}} = object;37
return x < point.x && x + width > point.x && y < point.y && y + height > point.y;38
}39
if(object.shape instanceof Shape.Circle) {40
const {x, y, shape: {radius}} = object;41
return Math.sqrt((x - point.x) ** 2 + (y - point.y) ** 2) < radius;42
}43
}
1
export default class Timer {2
constructor(step) {3
this.time = 0;4
this.step = step || 1;5
}6
count() {7
if(this.time >= 1) {8
this.time = 0;9
return true;10
}11
this.time += 1 / this.step;12
return false;13
}14
reset() {15
this.time = 0;16
}17
}
1
import { Shape } from './shape.js';3
export class Surface {4
constructor(x, y, shape) {5
this.x = x;6
this.y = y;7
this.shape = shape;8
}9
update() {10
// nothing11
}12
render(g) {13
if(this.shape instanceof Shape.Rect) {14
g.beginPath();15
g.rect(this.x, this.y, this.shape.width, this.shape.height);16
g.stroke();17
}18
if(this.shape instanceof Shape.Circle) {19
g.beginPath();20
g.arc(this.x, this.y, this.shape.radius, 2 * Math.PI, 0);21
g.stroke();22
}23
}24
causeEvent(type, events) {25
if(this['on' + type]) {26
for(const event of events) {27
if(event.type === type) {28
this['on' + type](event);29
}30
}31
}32
}33
}33
\ No newline at end of file
1
const DEVICE_PIXEL_RATIO = window.devicePixelRatio || 1;3
export function scaleCanvas(canvas, width, height) {4
const g = canvas.getContext('2d');5
const backingStoreRatio = g.webkitBackingStorePixelRatio || 1;6
const ratio = DEVICE_PIXEL_RATIO / backingStoreRatio;7
if(DEVICE_PIXEL_RATIO != backingStoreRatio) {8
canvas.width = width * ratio;9
canvas.height = height * ratio;10
canvas.style.width = width + 'px';11
canvas.style.height = height + 'px';12
}13
else {14
canvas.width = width;15
canvas.height = height;16
canvas.style.width = '';17
canvas.style.height = '';18
}19
g.scale(ratio, ratio);20
}