elpy
Version:
2D JavaScript game engine.
733 lines (581 loc) • 23.3 kB
JavaScript
const EngineObject = require('./EngineObject');
class Engine {
constructor(link, width, height, options = {}) {
this._link = link;
this._width = width || window.innerWidth;
this._height = height || window.innerHeight;
this._preload = typeof options.preload === 'boolean' ? options.preload : true;
this._favicon = typeof options.favicon === 'boolean' ? options.favicon : true;
this._field = null;
this._ctx = null;
this._exist = true;
this._keys = [];
this._events = {};
this._storage = {
images: []
};
this._objects = {};
this._offset = {
object: null,
x: 0,
y: 0
};
this._init();
}
create(name, x, y, width, height, options = {}) {
this._objects[name] = new EngineObject(name, x, y, width, height, options);
if (!this._objects[name].options.disabledEvents) {
this._objects[name].on('destroy', this._onDestroyObject.bind(this, name));
}
return this._objects[name];
}
add(object) {
if (Array.isArray(object)) {
object.forEach(item => {
item.added = true;
this._addObjectImages(item);
this._render();
});
} else {
object.added = true;
this._addObjectImages(object);
this._render();
}
}
key(callback) {
document.addEventListener('keydown', this._onMultiKeydown.bind(this));
document.addEventListener('keyup', this._onMultiKeyup.bind(this));
requestAnimationFrame(this._streamKeys.bind(this, callback));
}
keydown(callback) {
document.addEventListener('keydown', event => {
callback(event.code);
});
}
keyup(callback) {
document.addEventListener('keyup', event => {
callback(event.code);
});
}
mousemove(callback) {
this._field.addEventListener('mousemove', event => {
callback(event.x - this._field.offsetLeft, event.y - this._field.offsetTop);
});
}
click(callback) {
this._field.addEventListener('click', event => {
callback(event.x - this._field.offsetLeft, event.y - this._field.offsetTop);
});
}
tick(callback) {
const response = callback();
if (response !== false) {
requestAnimationFrame(this.tick.bind(this, callback));
}
}
nextTick(callback) {
requestAnimationFrame(callback);
}
checkObjectInViewport(object) {
return this._checkObjectInViewportX(object) && this._checkObjectInViewportY(object);
}
fixingCamera(object, fixedCamera = {}) {
this._setOffsetObject(object);
this._setOffsetObjectToObjects();
object.options.fixedCamera.x = typeof fixedCamera === 'object' && fixedCamera !== null ? fixedCamera.x === undefined ? false : fixedCamera.x : false;
object.options.fixedCamera.y = typeof fixedCamera === 'object' && fixedCamera !== null ? fixedCamera.y === undefined ? false : fixedCamera.y : false;
if (object.options.fixedCamera.x) {
object.offset.x = (this._offset.x - ((this._width / 2) - (object.width / 2)));
this._offset.x = ((this._width / 2) - (object.width / 2));
}
if (object.options.fixedCamera.y) {
object.offset.y = (this._offset.y - ((this._height / 2) - (object.height / 2)));
this._offset.y = ((this._height / 2) - (object.height / 2));
}
}
unfixingCamera() {
Object.values(this._objects).forEach(object => {
if (object !== this._offset.object) {
object.x = object.x - this._offset.object.offset.x;
object.y = object.y - this._offset.object.offset.y;
}
});
this._offset.object.options.fixedCamera.x = false;
this._offset.object.options.fixedCamera.y = false;
this._offset.object.x = this._offset.object.x - this._offset.object.offset.x;
this._offset.object.y = this._offset.object.y - this._offset.object.offset.y;
this._offset.x = 0;
this._offset.y = 0;
}
destroy() {
Object.values(this._objects).forEach(object => {
object.destroy();
});
this.nextTick(() => {
this._exist = false;
});
}
on(event, callback) {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push(callback);
}
async load() {
if (this._imagesIsLoading) {
await this._render();
requestAnimationFrame(this.load.bind(this));
} else {
this._preload = false;
await this._render();
this._dispatchEvent('load');
}
}
get width() {
return this._width;
}
get height() {
return this._height;
}
get offset() {
return this._offset;
}
get objects() {
return this._objects;
}
get _imagesIsLoaded() {
if (this._preload) {
const loadedImages = this._storage.images.filter(item => item.loaded);
const isLoaded = loadedImages.length === this._storage.images.length;
return isLoaded;
} else {
return true;
}
}
get _imagesIsLoading() {
if (this._preload) {
const loadedImages = this._storage.images.filter(item => item.loaded);
const isLoading = loadedImages.length !== this._storage.images.length;
return isLoading;
} else {
return false;
}
}
_setOffsetObject(object) {
this._offset.object = object;
this._offset.x = object.x;
this._offset.y = object.y;
}
_setOffsetObjectToObjects() {
for (const name in this._objects) {
const object = this._objects[name];
object.setOffsetObject(this._offset.object);
}
}
_onMultiKeydown(event) {
if (!this._keys.includes(event.code)) {
this._keys.push(event.code);
}
}
_onMultiKeyup(event) {
const index = this._keys.indexOf(event.code);
if (index !== -1) {
this._keys.splice(index, 1);
}
}
_addObjectImages(object) {
const images = object.options.images.list;
const image = object.options.image.path;
if (image) {
this._addObjectImageToStorage(object);
}
if (images) {
this._addObjectImagesToStorage(object);
}
}
_addObjectImageToStorage(object) {
const id = `${object.name}:${object.options.image.path}`;
if (!this._storage.images.find(image => image.id === id)) {
this._storage.images.push({ id, loaded: false });
}
}
_addObjectImagesToStorage(object) {
object.options.images.list.forEach(images => {
images.paths.forEach(path => {
const id = `${object.name}:${images.state}:${path}`;
if (!this._storage.images.find(image => image.id === id)) {
this._storage.images.push({ id, loaded: false });
}
});
});
}
_loadImage(url, object, state) {
return new Promise(resolve => {
const image = new Image();
image.src = url;
image.addEventListener('load', () => {
if (state) {
const id = `${object.name}:${state}:${url}`;
const item = this._storage.images.find(item => item.id === id);
item.loaded = true;
} else {
const id = `${object.name}:${url}`;
const item = this._storage.images.find(item => item.id === id);
item.loaded = true;
}
resolve(image);
});
});
}
_loadImages(listImages, object) {
return new Promise(async resolve => {
const images = {};
for(let i = 0; i < listImages.length; i++) {
const listImageItem = listImages[i];
if (!images[listImageItem.state]) {
images[listImageItem.state] = this._getImageParams(listImageItem);
}
for(let j = 0; j < listImageItem.paths.length; j++) {
const path = listImageItem.paths[j];
images[listImageItem.state].list[j] = await this._loadImage(path, object, listImageItem.state);
if (i === listImages.length - 1 && j === listImageItem.paths.length - 1) {
resolve(images);
}
};
};
});
}
_getImageParams(image) {
const params = {
list: [],
time: image.time || 0,
currentImage: null,
lastRenderTime: 0
}
return params;
}
_checkObjectInViewportX(object) {
return this._offset.object
&& object.x < (this._offset.object.x + this._width)
&& object.x > (this._offset.object.x - this._width);
}
_checkObjectInViewportY(object) {
return this._offset.object
&& object.y < (this._offset.object.y + this._height)
&& object.y > (this._offset.object.y - this._height);
}
async _render() {
if (!this._preload) {
this._ctx.clearRect(0, 0, this._width, this._height);
}
for(const name in this._objects) {
const object = this._objects[name];
const images = object.options.images.list;
const image = object.options.image.path;
if (!object.added) {
return;
}
if (image) {
await this._renderImage(object);
}
if (images) {
await this._renderImages(object);
}
if (!image && !images && this._imagesIsLoaded) {
this._renderShape(object);
}
}
if (this._preload) {
const images = this._storage.images.length;
const imagesLoaded = this._storage.images.filter(item => item.loaded).length;
this._showLoadingScreen(images, imagesLoaded);
}
}
async _renderImage(object) {
if (!object.isExist || object.options.image.rendering) {
return;
}
await this._renderingImage(object);
const offset = {
x: 0,
y: 0
}
if (this._offset.object) {
offset.x = this._offset.object.offset.x;
offset.y = this._offset.object.offset.y;
} else {
offset.x = this._offset.x;
offset.y = this._offset.y;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.x) {
offset.x = 0;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.y) {
offset.y = 0;
}
if (object.options.image.repeat) {
this._drawRepeatImage(object, offset);
return;
}
const x = (object.x - offset.x) + object.width / 2;
const y = (object.y - offset.y) + object.height / 2;
const angle = object.degrees * Math.PI / 180;
if ((object.x > (offset.x + this._width) || (object.x + object.width) < offset.x)
|| (object.y > (offset.y + this._height) || (object.y + object.height) < offset.y)) {
return;
}
this._ctx.save();
this._ctx.translate(x - object.offset.rotate.x, y - object.offset.rotate.y);
this._ctx.rotate(angle);
this._ctx.translate(-x + object.offset.rotate.x, -y + object.offset.rotate.y);
this._ctx.drawImage(object.options.image.cached, (object.x - offset.x), (object.y - offset.y), object.width, object.height);
this._ctx.restore();
}
async _renderImages(object) {
if (!object.isExist || object.options.images.rendering) {
return;
}
await this._renderingImages(object);
if (!object.state) {
object.state = this._getFirstState(object.options.images.cached);
}
const cached = object.options.images.cached[object.state];
if (object.animate) {
this._calculateRenderTime(cached);
this._notifyAboutRenderedImage(object, cached);
} else {
cached.currentImage = cached.list[0];
}
if (this._imagesIsLoading) {
return;
}
const offset = {
x: 0,
y: 0
}
if (this._offset.object) {
offset.x = this._offset.object.offset.x;
offset.y = this._offset.object.offset.y;
} else {
offset.x = this._offset.x;
offset.y = this._offset.y;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.x) {
offset.x = 0;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.y) {
offset.y = 0;
}
const x = (object.x - offset.x) + object.width / 2;
const y = (object.y - offset.y) + object.height / 2;
const angle = object.degrees * Math.PI / 180;
if (!this._offset.object) {
this._ctx.save();
this._ctx.translate(x - object.offset.rotate.x, y - object.offset.rotate.y);
this._ctx.rotate(angle);
this._ctx.translate(-x + object.offset.rotate.x, -y + object.offset.rotate.y);
this._ctx.drawImage(cached.currentImage, object.x, object.y, object.width, object.height);
this._ctx.restore();
return;
}
this._ctx.save();
this._ctx.translate(x - object.offset.rotate.x, y - object.offset.rotate.y);
this._ctx.rotate(angle);
this._ctx.translate(-x + object.offset.rotate.x, -y + object.offset.rotate.y);
if (object === this._offset.object) {
let x;
let y;
if (this._offset.object.options.fixedCamera.x) {
x = this._offset.x;
} else {
x = object.x;
}
if (this._offset.object.options.fixedCamera.y) {
y = this._offset.y;
} else {
y = object.y;
}
this._ctx.drawImage(cached.currentImage, x, y, object.width, object.height);
} else {
this._ctx.drawImage(cached.currentImage, (object.x - offset.x), (object.y - offset.y), object.width, object.height);
}
this._ctx.restore();
}
async _renderingImage(object) {
object.options.image.rendering = true;
if (!object.options.image.cached) {
object.options.image.cached = await this._loadImage(object.options.image.path, object);
}
object.options.image.rendering = false;
}
async _renderingImages(object) {
object.options.images.rendering = true;
if (this._isEmpty(object.options.images.cached)) {
object.options.images.cached = await this._loadImages(object.options.images.list, object);
}
object.options.images.rendering = false;
}
_renderShape(object) {
const offset = {
x: 0,
y: 0
}
if (this._offset.object) {
offset.x = this._offset.object.offset.x;
offset.y = this._offset.object.offset.y;
} else {
offset.x = this._offset.x;
offset.y = this._offset.y;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.x) {
offset.x = 0;
}
if (this._offset.object && !this._offset.object.options.fixedCamera.y) {
offset.y = 0;
}
const x = (object.x - offset.x) + object.width / 2;
const y = (object.y - offset.y) + object.height / 2;
const angle = object.degrees * Math.PI / 180;
this._ctx.save();
this._ctx.translate(x - object.offset.rotate.x, y - object.offset.rotate.y);
this._ctx.rotate(angle);
this._ctx.translate(-x + object.offset.rotate.x, -y + object.offset.rotate.y);
this._ctx.fillStyle = object.options.color;
this._ctx.fillRect((object.x - offset.x), (object.y - offset.y), object.width, object.height);
this._ctx.restore();
}
_getFirstState(states) {
return Object.keys(states)[0];
}
_calculateRenderTime(cached) {
if (performance.now() > (cached.lastRenderTime + cached.time)) {
let index = cached.list.indexOf(cached.currentImage);
if ((index + 1) > (cached.list.length - 1) || index === -1) {
index = 0;
} else {
index = index + 1;
}
cached.lastRenderTime = performance.now();
cached.currentImage = cached.list[index];
}
if (!cached.currentImage) {
cached.currentImage = cached.list[0];
}
}
_notifyAboutRenderedImage(object, cached) {
const image = cached.currentImage.getAttribute('src');
const images = object.options.images.list.find(i => i.state === object.state).paths;
this._dispatchEvent('animation', object, image, images);
}
_streamKeys(callback) {
this._keys.forEach(key => {
callback(key);
});
requestAnimationFrame(this._streamKeys.bind(this, callback));
}
_onDestroyObject(name) {
delete this._objects[name];
}
_drawRepeatImage(object, offset) {
const pattern = this._ctx.createPattern(object.options.image.cached, 'repeat');
const delta = this._getRepeatImageDelta(object);
this._ctx.fillStyle = pattern;
this._ctx.save();
this._ctx.translate(-offset.x + delta.x, -offset.y + delta.y);
this._ctx.fillRect(object.x - delta.x, object.y - delta.y, object.width, object.height);
this._ctx.restore();
}
_getRepeatImageDelta(object) {
const delta = {
x: 0,
y: 0
}
const difference = {
x: object.width - Math.abs(object.x),
y: object.height - Math.abs(object.y)
}
if (object.x < 0) {
delta.x = difference.x % object.options.image.cached.width;
} else {
delta.x = -(difference.x % object.options.image.cached.width);
}
if (object.y < 0) {
delta.y = difference.y % object.options.image.cached.height;
} else {
delta.y = -(difference.y % object.options.image.cached.height);
}
return delta;
}
_showLoadingScreen(current, max) {
const x = (this._width / 2) - ((this._width / 2) / 2);
const y = (this._height / 2) - (((this._height / 100) * 10) / 2);
const width = this._width / 2;
const height = (this._height / 100) * 10;
this._ctx.fillStyle = 'black';
this._ctx.fillRect(0, 0, this._width, this._height);
this._ctx.fillStyle = 'white';
this._ctx.fillRect(x, y, width, height);
this._ctx.fillStyle = 'black';
this._ctx.fillRect(x + 5, y + 6, width - 10, height - 12);
this._ctx.fillStyle = 'white';
this._ctx.fillRect(x + 4, y + 5, ((width - 8) / current) * max, height - 10);
}
_isEmpty(object) {
return Object.values(object).length === 0;
}
_setFavIcon() {
const favicon = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAk1JREFUOE9jZKAQMJKqv6c4UqSkd/kbmD64AY9XhXLeu/Yl8s7xR17MfxifsrOy9EfuuPAApDDGQNF1yYX7u/uyQlXef/nC27xo+3kMA0ACfaGWnIzPn5s//cO0SICFRVyTj3OxtJhY5/xL97vkuFmznojIlHx//7t6wYEDP7AaABOsidFx+Pj+5543z78zR0mJ/z337f89ht+/VzwXFHWasWmfDbK3cYZBTbRh249P3yq/fmFgkGXmYuD8+ffvw59/jk04dc2egYHhP14XgCRzcz3ZPz58Z/Xl298yM30Tj1fXbjA8fHif4R8Hm9b6c7ev4zWgsSTO/8fXb7P+/P0v9vcvI4OUiDjDh9c/GO69ecJw5e5dv0tX72zGaUB1dog/CwvL2h8/fjDzcPEyvP30leHPr38MzAwsDPdfvv5879lbpytXrpzBakBDQwPTx8dnbv7981eFk52T4d///wyvPn1jePzsPcPb958e/mP853H58q0bOAOxIjPYhYHh3+4nz94x/GX4x8DLxcPw+cd/hkfPXzG8+/i16/r1m+XoCQ8lFlKj/PL//P4x4dGzNwy8vFwMChJyDNcePGZ49/kLAxsLk/uxk2d34TUgLtBLjZuH+8b563dOMzH85VWQlhP6//+fOBcnG8PDZ2899hw+vBOvAVkp4ZpCbNzXrt178Pf20+chFrpa8oz/GSd8/f6d4frDh8fPnb9ijZwGQIahJyRGTzd7j3sPHzv8+PFjo5ayxstP3941vHzz4drPfz93P773/CwhA0jNnAwAsYwMINM2tAQAAAAASUVORK5CYII=';
const head = document.getElementsByTagName('head')[0];
const link = document.createElement('link');
link.rel = 'shortcut icon';
link.href = 'data:image/png;base64,' + favicon;
head.appendChild(link);
}
_setDefaultStyle() {
document.body.style.margin = 0;
}
_setFieldStyle() {
if (typeof this._link === 'string') {
this._field = document.querySelector(this._link);
}
if (this._link instanceof HTMLCanvasElement) {
this._field = this._link;
}
this._field.width = this._width;
this._field.height = this._height;
this._field.style.display = 'block';
this._ctx = this._field.getContext('2d');
}
_checkReadyStateChange() {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') {
this.load();
}
});
}
_dispatchEvent(name, ...data) {
if (this._events[name] && Array.isArray(this._events[name]) && this._events[name].length > 0) {
this._events[name].forEach(callback => {
callback(...data);
});
}
}
_frameRender() {
this.tick(() => {
if (!this._exist) {
return false;
}
this._render();
});
}
_init() {
if (this._favicon) {
this._setFavIcon();
}
this.on('load', this._frameRender.bind(this));
this._setDefaultStyle();
this._setFieldStyle();
this._checkReadyStateChange();
}
}
module.exports = Engine;