A lightweight JavaScript game engine
git clone git://soophie.de/game-engine
1
import { scaleCanvas } from './util/canvas.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
this.sprites = [];14
}15
async start() {16
for(const sprite of this.sprites) {17
await sprite.load();18
}19
requestAnimationFrame(this.loop.bind(this));20
this.update();21
this.render(this.g);22
}23
loop() {24
requestAnimationFrame(this.loop.bind(this));25
this.now = Date.now();26
this.elapsed = this.now - this.then;27
if(this.elapsed > this.fps) {28
this.then = this.now - (this.elapsed % this.fps);29
for(const event of this.events) {30
if(this['on' + event.type]) {31
this['on' + event.type](event);32
}33
}34
this.update();35
this.render(this.g);36
this.events.length = 0;37
}38
}39
update() {40
// nothing41
}42
render() {43
this.g.clearRect(0, 0, this.width, this.height);44
}45
resize(width, height) {46
this.width = width;47
this.height = height;48
scaleCanvas(this.canvas, this.width, this.height);49
}50
bindEvent(element, type) {51
element['on' + type] = event => this.events.push(event);52
}53
loadSprite(sprite) {54
this.sprites.push(sprite);55
}56
}
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';6
export * from './util/canvas.js';7
export * from './util/collision.js';8
export * from './util/timer.js';9
export * from './util/animation.js';11
export * from './sprite/sprite.js';12
export * from './sprite/spritesheet.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
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, size) {6
super(path);7
this.size = size;8
this.sprites = [];9
}10
async load() {11
await super.load();12
const {width, height} = this.image;13
if(width % this.size !== 0 || height % this.size !== 0) {14
throw new Error(`Invalid size ${this.size} for spritesheet '${this.path}'!`);15
}16
this.sprites = new Array(parseInt(width / this.size) * parseInt(height / this.size));17
const canvas = document.createElement('canvas');18
scaleCanvas(canvas, this.size, this.size);19
const g = canvas.getContext('2d');20
g.imageSmoothingEnabled = false;21
for(let i = 0; i < this.sprites.length; i++) {22
const y = parseInt(i / (width / this.size));23
const x = parseInt(i % (width / this.size));24
g.clearRect(0, 0, this.size, this.size);25
g.drawImage(this.image, -x * this.size, -y * this.size, width, height);26
const sprite = new Sprite(canvas.toDataURL('image/png'));27
await sprite.load();28
this.sprites[i] = sprite;29
}30
}31
all() {32
return this.sprites;33
}34
}
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.canvas = document.createElement('canvas');11
scaleCanvas(this.canvas, this.width, this.height);12
this.g = this.canvas.getContext('2d');13
this.g.imageSmoothingEnabled = false;14
this.events = [];15
this.sprites = [];16
GameEngine.instance = this;17
}18
async start() {19
for(const sprite of this.sprites) {20
await sprite.load();21
}22
if(this.onspriteload) {23
this.onspriteload();24
}25
requestAnimationFrame(this.loop.bind(this));26
this.update();27
this.render(this.g);28
}29
loop() {30
requestAnimationFrame(this.loop.bind(this));31
this.now = Date.now();32
this.elapsed = this.now - this.then;33
if(this.elapsed > this.fps) {34
this.then = this.now - (this.elapsed % this.fps);35
for(const event of this.events) {36
if(this['on' + event.type]) {37
this['on' + event.type](event);38
}39
}40
this.update();41
this.render(this.g);42
this.events = [];43
}44
}45
update() {46
// nothing47
}48
render() {49
this.g.clearRect(0, 0, this.width, this.height);50
}51
resize(width, height) {52
this.width = width;53
this.height = height;54
scaleCanvas(this.canvas, this.width, this.height);55
this.g.imageSmoothingEnabled = false;56
}57
bindEvent(element, type) {58
element['on' + type] = event => this.events.push(event);59
}60
loadSprite(sprite) {61
this.sprites.push(sprite);62
}63
}
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, size) {6
super(path);7
this.size = size;8
this.sprites = [];9
}10
async load() {11
await super.load();12
const {width, height} = this.image;13
if(width % this.size !== 0 || height % this.size !== 0) {14
throw new Error(`Invalid size ${this.size} for spritesheet '${this.path}'!`);15
}16
this.sprites = new Array(parseInt(width / this.size) * parseInt(height / this.size));17
const canvas = document.createElement('canvas');18
scaleCanvas(canvas, this.size, this.size);19
const g = canvas.getContext('2d');20
g.imageSmoothingEnabled = false;21
for(let i = 0; i < this.sprites.length; i++) {22
const y = parseInt(i / (width / this.size));23
const x = parseInt(i % (width / this.size));24
g.clearRect(0, 0, this.size, this.size);25
g.drawImage(this.image, -x * this.size, -y * this.size, width, height);26
const sprite = new Sprite(canvas.toDataURL('image/png'));27
await sprite.load();28
this.sprites[i] = sprite;29
}30
}31
all() {32
return this.sprites;33
}34
}
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
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
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
}
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
}