From 0a613fbdd68b8d3b9fb5d6f09a95a14c30fd2e78 Mon Sep 17 00:00:00 2001 From: Avior Date: Thu, 24 Nov 2022 12:03:00 +0100 Subject: [PATCH] feat: Updated engine Signed-off-by: Avior --- src/GameEngine/2D/Collision/BoxCollider2D.ts | 28 +- src/GameEngine/2D/Debug/ColliderDebugger.ts | 1 + src/GameEngine/2D/Debug/ComponentDebug.ts | 16 +- src/GameEngine/2D/Debug/PointDebugger.ts | 9 +- src/GameEngine/2D/Debug/TillingDebugger.ts | 4 +- src/GameEngine/2D/Scale2D.ts | 30 -- src/GameEngine/2D/Vector2D.ts | 87 +++- src/GameEngine/Asset.ts | 38 +- src/GameEngine/Camera.ts | 5 - src/GameEngine/Component2D.ts | 127 ++++- src/GameEngine/Components/Camera.ts | 45 ++ src/GameEngine/Components/Cursor.ts | 162 +++++++ src/GameEngine/Components/FPSCounter.ts | 52 +++ src/GameEngine/Controller.ts | 464 +++++++++++++++++++ src/GameEngine/Renderer/ImageRenderer.ts | 69 +++ src/GameEngine/Renderer/RectRenderer.ts | 53 ++- src/GameEngine/Renderer/TextRenderer.ts | 66 +++ src/GameEngine/Renderer/TileRenderer.ts | 10 +- src/GameEngine/Renderer/index.ts | 9 +- src/GameEngine/Scene.ts | 226 +++++++-- src/GameEngine/Tileset.ts | 2 + src/GameEngine/index.ts | 184 ++++++-- 22 files changed, 1499 insertions(+), 188 deletions(-) delete mode 100644 src/GameEngine/2D/Scale2D.ts delete mode 100644 src/GameEngine/Camera.ts create mode 100644 src/GameEngine/Components/Camera.ts create mode 100644 src/GameEngine/Components/Cursor.ts create mode 100644 src/GameEngine/Components/FPSCounter.ts create mode 100644 src/GameEngine/Controller.ts create mode 100644 src/GameEngine/Renderer/ImageRenderer.ts create mode 100644 src/GameEngine/Renderer/TextRenderer.ts diff --git a/src/GameEngine/2D/Collision/BoxCollider2D.ts b/src/GameEngine/2D/Collision/BoxCollider2D.ts index d5a9aba..4489fdb 100644 --- a/src/GameEngine/2D/Collision/BoxCollider2D.ts +++ b/src/GameEngine/2D/Collision/BoxCollider2D.ts @@ -1,7 +1,8 @@ +import GameEngine from 'GameEngine' import Component2D from 'GameEngine/Component2D' import Vector2D from '../Vector2D' -type BuiltinCollisionTypes = 'click' +type BuiltinCollisionTypes = 'click' | 'pointerDown' | 'pointerUp' export default class BoxCollider2D { public constructor( @@ -22,7 +23,12 @@ export default class BoxCollider2D { public pos(): [Vector2D, Vector2D] { const scale = this.scale.multiply(this.component.scale) - const positionCenter = this.component.origin.sub( + const positionCenter = GameEngine.getGameEngine().currentScene?.position.sum(this.component.origin.sub( + new Vector2D( + this.component.position.x, + this.component.position.y + ) + )) ?? this.component.origin.sub( new Vector2D( this.component.position.x, this.component.position.y @@ -31,12 +37,22 @@ export default class BoxCollider2D { const center = this.center.sum(positionCenter) return [new Vector2D( - center.x - scale.x / 2, - center.y - scale.y / 2 + center.x, + center.y ), new Vector2D( - center.x + scale.x / 2, - center.y + scale.y / 2 + center.x + scale.x, + center.y + scale.y )] } + + public collideWith(collider: BoxCollider2D) { + const selfPos = this.pos() + const otherPos = collider.pos() + + return selfPos[1].x >= otherPos[0].x && // self bottom higher than other top + selfPos[0].x <= otherPos[1].x && + selfPos[1].y >= otherPos[0].y && + selfPos[0].y <= otherPos[1].y + } } diff --git a/src/GameEngine/2D/Debug/ColliderDebugger.ts b/src/GameEngine/2D/Debug/ColliderDebugger.ts index a69bd6d..67cb55b 100644 --- a/src/GameEngine/2D/Debug/ColliderDebugger.ts +++ b/src/GameEngine/2D/Debug/ColliderDebugger.ts @@ -4,6 +4,7 @@ import BoxCollider2D from '../Collision/BoxCollider2D' import Vector2D from '../Vector2D' export default class ColliderDebugger extends Component2D { + public readonly name = 'ColliderDebugger' public constructor(component: Component2D, collider: BoxCollider2D) { super() this.collider = collider diff --git a/src/GameEngine/2D/Debug/ComponentDebug.ts b/src/GameEngine/2D/Debug/ComponentDebug.ts index 3f81536..cebbde4 100644 --- a/src/GameEngine/2D/Debug/ComponentDebug.ts +++ b/src/GameEngine/2D/Debug/ComponentDebug.ts @@ -1,14 +1,20 @@ import Component2D from 'GameEngine/Component2D' -import RectRenderer from 'GameEngine/Renderer/RectRenderer' import Vector2D from '../Vector2D' +import PointDebugger from './PointDebugger' export default class ComponentDebug extends Component2D { + public readonly name = 'ComponentDebug' public constructor(component: Component2D) { super() - this.position = component.position - this.origin = component.origin - this.scale = new Vector2D(.1, .1) + this.position = new Vector2D(0, 0) + // this.origin = component.origin + this.scale = component.scale console.log('Position of the origin point', this.position) - this.renderer = new RectRenderer(this, {material: 'red'}) + // this.renderer = new RectRenderer(this, {material: 'red'}) + this.childs = [ + new PointDebugger(new Vector2D(0, 0), 'aqua'), + new PointDebugger(this.origin, 'green'), + new PointDebugger(component.position.sum(component.scale), 'aqua') + ] } } diff --git a/src/GameEngine/2D/Debug/PointDebugger.ts b/src/GameEngine/2D/Debug/PointDebugger.ts index a333bf9..ec14695 100644 --- a/src/GameEngine/2D/Debug/PointDebugger.ts +++ b/src/GameEngine/2D/Debug/PointDebugger.ts @@ -3,12 +3,13 @@ import RectRenderer from 'GameEngine/Renderer/RectRenderer' import Vector2D from '../Vector2D' export default class PointDebugger extends Component2D { - public constructor(point: Vector2D) { + public readonly name = 'PointDebugger' + public constructor(point: Vector2D, color = 'red') { super() - this.scale = new Vector2D(.1, .1) + this.scale = new Vector2D(1, 1) this.position = point - console.log('Debugging point at location', point) + // console.log('Debugging point at location', point) // this.origin = component.origin - this.renderer = new RectRenderer(this, {material: 'red'}) + this.renderer = new RectRenderer(this, {material: color}) } } diff --git a/src/GameEngine/2D/Debug/TillingDebugger.ts b/src/GameEngine/2D/Debug/TillingDebugger.ts index 9fcc1bb..04005af 100644 --- a/src/GameEngine/2D/Debug/TillingDebugger.ts +++ b/src/GameEngine/2D/Debug/TillingDebugger.ts @@ -1,9 +1,10 @@ /* eslint-disable max-classes-per-file */ -import Component2D, { ComponentState } from 'GameEngine/Component2D' +import Component2D from 'GameEngine/Component2D' import RectRenderer from 'GameEngine/Renderer/RectRenderer' import Vector2D from '../Vector2D' export default class TilingDebugger extends Component2D { + public readonly name = 'TilingDebugger' public constructor() { super() for (let i0 = 0; i0 < 10; i0++) { @@ -22,6 +23,7 @@ export default class TilingDebugger extends Component2D { } class CaseDebugger extends Component2D { + public readonly name = 'CaseDebugger' public renderer: RectRenderer = new RectRenderer(this, {stroke: 'black'}) public constructor(pos: Vector2D) { super() diff --git a/src/GameEngine/2D/Scale2D.ts b/src/GameEngine/2D/Scale2D.ts deleted file mode 100644 index faee7b3..0000000 --- a/src/GameEngine/2D/Scale2D.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -export default class Size2D { - private _width: number - private _height?: number - - public constructor( - width: number, - height?: number - ) { - this._width = width - this._height = height - } - - public get width() { - return this._width - } - - public set width(v: number) { - this._width = v - } - - - public get height() { - return this._height ?? this._width - } - - public set height(v: number) { - this._height = v - } -} diff --git a/src/GameEngine/2D/Vector2D.ts b/src/GameEngine/2D/Vector2D.ts index 4dc26e8..3625f14 100644 --- a/src/GameEngine/2D/Vector2D.ts +++ b/src/GameEngine/2D/Vector2D.ts @@ -1,9 +1,20 @@ +/* eslint-disable id-length */ export default class Vector2D { - public constructor( - public x: number, - public y: number - ) {} + public x: number + public y: number + + public constructor( + x: number, + y?: number + ) { + this.x = x + if (typeof y === 'number') { + this.y = y + } else { + this.y = x + } + } public multiply(v: Vector2D): Vector2D { return new Vector2D( @@ -12,7 +23,10 @@ export default class Vector2D { ) } - public sum(v: Vector2D): Vector2D { + public sum(v: Vector2D | number, y?: number): Vector2D { + if (typeof v === 'number') { + return new Vector2D(this.x + v, this.y + (y ?? v)) + } return new Vector2D( v.x + this.x, v.y + this.y @@ -26,10 +40,73 @@ export default class Vector2D { ) } + public div(v: number): Vector2D { + return new Vector2D( + this.x / v, + this.y / v + ) + } + public isIn(topLeft: Vector2D, bottomRight: Vector2D): boolean { return this.x >= topLeft.x && this.y >= topLeft.y && this.x <= bottomRight.x && this.y <= bottomRight.y } + + public decimalCount(nDecimal: number) { + return new Vector2D( + parseFloat(this.x.toFixed(nDecimal)), + parseFloat(this.y.toFixed(nDecimal)) + ) + } + + /** + * return a new Vector with the this minus the other vector/x,y + */ + public minus(x: Vector2D | number, y?: number) { + return this.sum( + typeof x === 'number' ? -x : -x.x, + y? -y : typeof x === 'number' ? -x : -x.y, + ) + } + + public set(x: number | Vector2D, y?: number) { + + if (typeof x === 'object') { + this.x = x.x + this.y = x.y + return this + } + + this.x = x + if (!y) { + this.y = x + } else { + this.y = y + } + return this + } + + public setY(y: number) { + this.y = y + return this + } + + public setX(x: number) { + this.x = x + return this + } + + public clone() { + return new Vector2D(this.x, this.y) + } + + public toString() { + return `${this.x}, ${this.y}` + } + + public equal(vector: Vector2D): boolean { + return vector.x === this.x && vector.y === this.y + } } diff --git a/src/GameEngine/Asset.ts b/src/GameEngine/Asset.ts index 87f8bb8..110dda9 100644 --- a/src/GameEngine/Asset.ts +++ b/src/GameEngine/Asset.ts @@ -1,8 +1,22 @@ +import Vector2D from './2D/Vector2D' + +// eslint-disable-next-line no-shadow +export enum AssetStatus { + NOT_LOADED, + LOADING, + LOADED, + ERROR +} + +/** + * Asset management Class + */ export default class Asset { public static assets: Record = {} public isLoaded = false + public status: AssetStatus = AssetStatus.NOT_LOADED private image!: HTMLImageElement @@ -18,21 +32,37 @@ export default class Asset { } public async load() { + if (this.status === AssetStatus.LOADED || this.status === AssetStatus.LOADING) { + return + } + this.status = AssetStatus.LOADING return new Promise((res, rej) => { this.image = new Image() this.image.src = this.path this.image.onload = () => { this.isLoaded = true + this.status = AssetStatus.LOADED res() } - this.image.onerror = rej + this.image.onerror = () => { + console.error('Error loading image') + this.status = AssetStatus.ERROR + rej() + } }) } - public async get() { - if (!this.isLoaded) { - await this.load() + public get() { + if (this.status !== AssetStatus.LOADED) { + throw new Error('Can\'t get an unloaded asset, please load it before') } return this.image } + + public size(): Vector2D { + if (this.status !== AssetStatus.LOADED) { + throw new Error('Can\'t get an unloaded asset, please load it before') + } + return new Vector2D(this.image.width, this.image.height) + } } diff --git a/src/GameEngine/Camera.ts b/src/GameEngine/Camera.ts deleted file mode 100644 index a6d02b9..0000000 --- a/src/GameEngine/Camera.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Vector2D from './2D/Vector2D' - -export default class Camera { - public topLeft = new Vector2D(0.5, 0.5) -} diff --git a/src/GameEngine/Component2D.ts b/src/GameEngine/Component2D.ts index baa1da6..1603495 100644 --- a/src/GameEngine/Component2D.ts +++ b/src/GameEngine/Component2D.ts @@ -1,35 +1,156 @@ import BoxCollider2D from './2D/Collision/BoxCollider2D' import Vector2D from './2D/Vector2D' import Renderer from './Renderer' +import Scene from './Scene' export interface ComponentState { - mouseHovering: boolean + mouseHovering?: boolean /** * is it is collinding return the type of collision */ isColliding?: string + collideWith?: Array + scene?: Scene + isInitialized?: boolean } -export default abstract class Component2D { +export type StaticComponent< + // eslint-disable-next-line @typescript-eslint/ban-types + T extends {} | void = {} | void +> = + new (params: T | undefined) => Component2D +/** + * 2D Component + */ +export default abstract class Component2D< +// eslint-disable-next-line @typescript-eslint/ban-types +T extends {} | void = {} | void +> { + + private static components = 0 + + + public state: ComponentState = {} + + /** + * unique number for the component + * + * maximum number of components is equal to Number.MAX_VALUE or 1.7976931348623157e+308 + */ + public id: number = Component2D.components++ + + /** + * Indicate how the component is rendered + * + * @type {Renderer} + * @memberof Component2D + */ public renderer?: Renderer + /** + * Component position relative to the parent position and to the component origin + * + * (see also: Component2D.origin) + * + * @type {Vector2D} + * @memberof Component2D + */ public position: Vector2D = new Vector2D(0, 0) + /** + * Component scale relative to 1 case size + * + * (see also: GameEngine.caseSize) + * + * @type {Vector2D} + * @memberof Component2D + */ public scale: Vector2D = new Vector2D(1, 1) + /** + * Component collider for events + * + * @type {BoxCollider2D} + * @memberof Component2D + */ public collider?: BoxCollider2D /** - * Change the origin point (default to middle) + * Parent component of self is any + * this value is automatically set + * + * @type {Component2D} + * @memberof Component2D + */ + public parent?: Component2D + + /** + * Change the origin point (default to top left) */ public origin: Vector2D = new Vector2D(0 , 0) + /** + * Component Child Components + * + * @type {Array} + * @memberof Component2D + */ public childs: Array = [] + /** + * Component in debug mode + * It will display more informations depending on the Collider and other items + * + * note: Does not apply to childs components + * + * @type {boolean} + * @memberof Component2D + */ public debug?: boolean + protected params: T = {} as T + + /** + * the name of the component + * used to detect components + */ + public abstract readonly name: string + + public constructor(it: T) { + if (it) { + this.params = it + } + } + + /** + * Function run when the component is initialized + */ public init?(): Promise | void + /** + * Function run on each game ticks + * @param state the component state + */ public update?(state: ComponentState): Promise | void + + public destroy?(): Promise | void + + public getAbsolutePosition(): Vector2D { + const realPosition = this.position.sum( + this.scale.multiply(this.origin) + ) + if (!this.parent) { + return realPosition + } + return realPosition.sum(this.parent.getAbsolutePosition()) + } + + public setState(key: keyof ComponentState, value: any): void { + (this.state[key] as typeof value) = value + } + + public updateParam(key: keyof T, value: any): void { + this.params[key] = value + } } diff --git a/src/GameEngine/Components/Camera.ts b/src/GameEngine/Components/Camera.ts new file mode 100644 index 0000000..a254334 --- /dev/null +++ b/src/GameEngine/Components/Camera.ts @@ -0,0 +1,45 @@ +import GameEngine from 'GameEngine' +import Vector2D from 'GameEngine/2D/Vector2D' +import Component2D from 'GameEngine/Component2D' +import Cursor from './Cursor' + +/** + * Currently not working Camera implementation + */ +export default class Camera extends Component2D { + public name = 'Camera' + public position: Vector2D = new Vector2D(0) + + public zoom = 1 + + public update() { + let needCursorUpdate = false + const scene = GameEngine.getGameEngine().currentScene + if (!scene) { + return + } + + if (scene.scale !== this.zoom) { + scene.scale = this.zoom + needCursorUpdate = true + } + + if (!scene.position.equal(this.position)) { + scene.position.set(this.position) + needCursorUpdate = true + } + + if (needCursorUpdate) { + const cursor = scene.getComponents().find((it) => it.name === 'Cursor') as Cursor | undefined + cursor?.triggerUpdate() + } + } + + /** + * + * @param value zoom with 1 being the base + */ + public setZoom(value: number) { + this.zoom = value + } +} diff --git a/src/GameEngine/Components/Cursor.ts b/src/GameEngine/Components/Cursor.ts new file mode 100644 index 0000000..3ca27eb --- /dev/null +++ b/src/GameEngine/Components/Cursor.ts @@ -0,0 +1,162 @@ +import GameEngine from 'GameEngine' +import BoxCollider2D from 'GameEngine/2D/Collision/BoxCollider2D' +import Vector2D from 'GameEngine/2D/Vector2D' +import Component2D from 'GameEngine/Component2D' +import RectRenderer from 'GameEngine/Renderer/RectRenderer' + +export default class Cursor extends Component2D<{ + debug?: boolean +} | void> { + public name = 'Cursor' + + /** + * cursor position + */ + public position: Vector2D = new Vector2D(0,0) + + /** + * is the cursor down + */ + public isDown = false + + /** + * was the cursor down on previous frame + */ + public wasDown = false + + public origin: Vector2D = new Vector2D(0) + + public scale: Vector2D = new Vector2D(1) + + public collider: BoxCollider2D = new BoxCollider2D(this) + + /** + * event handled down event + * (can be updated between frames while the isDown/wasDown are updated with the other components) + */ + private eventDown = false + + private oldPosition: [number, number] = [0, 0] + + public init(): void | Promise { + this.debug = this.params?.debug + const canvas = GameEngine.getGameEngine().canvas + canvas.addEventListener('mousemove', this.onMouseMove) + canvas.addEventListener('mousedown', this.onMouseDown) + canvas.addEventListener('touchmove', this.onTouchMove) + canvas.addEventListener('touchstart', this.onTouchStart) + + // add up events on document so they are catched everywhere + document.addEventListener('mouseup', this.onMouseUp) + document.addEventListener('touchend', this.onTouchEnd) + + if (this.debug) { + this.renderer = new RectRenderer(this, {material: 'blue'}) + } + } + + public destroy(): void | Promise { + const canvas = GameEngine.getGameEngine().canvas + canvas.removeEventListener('mousemove', this.onMouseMove) + canvas.removeEventListener('mousedown', this.onMouseDown) + canvas.removeEventListener('touchmove', this.onTouchMove) + canvas.removeEventListener('touchstart', this.onTouchStart) + + // add up events on document so they are catched everywhere + document.removeEventListener('mouseup', this.onMouseUp) + document.removeEventListener('touchend', this.onTouchEnd) + } + + public update(): void | Promise { + this.wasDown = this.isDown + this.isDown = this.eventDown + } + + public triggerUpdate() { + this.updatePosition(...this.oldPosition) + } + + private onMouseMove = (ev: MouseEvent) => { + // console.log('onMouseMove') + this.onMove(ev) + } + + private onMouseDown = (ev: MouseEvent) => { + // console.log('onMouseDown') + this.onDown(ev) + } + + private onMouseUp = (ev: MouseEvent) => { + // console.log('onMouseUp') + this.onUp(ev) + } + + private onTouchMove = (ev: TouchEvent) => { + // console.log('onTouchMove') + this.onMove(ev.touches.item(0) ?? undefined) + } + + private onTouchStart = (ev: TouchEvent) => { + // console.log('onTouchStart') + this.onDown(ev.touches.item(0) ?? undefined) + } + + private onTouchEnd = (ev: TouchEvent) => { + // console.log('onTouchEnd') + this.onUp(ev.touches.item(0) ?? undefined) + } + + /** + * Catch the onMove events + */ + private onMove(ev?: MouseEvent | Touch) { + if (ev) { + this.updatePosition( + ev.clientX ?? 0, + ev.clientY ?? 0 + ) + } + } + + /** + * Catch the onDown events + */ + private onDown(ev?: MouseEvent | Touch) { + console.log('cursor down') + if (ev) { + this.updatePosition( + ev.clientX ?? 0, + ev.clientY ?? 0 + ) + } + this.eventDown = true + } + + /** + * catch the onUp events + */ + private onUp(ev?: MouseEvent | Touch) { + console.log('cursor up') + if (ev) { + this.updatePosition( + ev.clientX ?? 0, + ev.clientY ?? 0 + ) + } + this.eventDown = false + } + + private updatePosition(clientX: number, clientY: number) { + const ge = GameEngine.getGameEngine() + this.oldPosition = [clientX, clientY] + this.position.set( + ((clientX ?? 0) + window.scrollX - ge.canvas.offsetLeft) / + (ge.currentScene?.scale ?? 1) * ge.getXCaseCount() / + ge.canvas.offsetWidth + (ge.currentScene?.position?.x ?? 0), + + ((clientY ?? 0) + window.scrollY - ge.canvas.offsetTop) / + (ge.currentScene?.scale ?? 1) * ge.getYCaseCount() / + ge.canvas.offsetHeight + (ge.currentScene?.position?.y ?? 0) + ) + } +} diff --git a/src/GameEngine/Components/FPSCounter.ts b/src/GameEngine/Components/FPSCounter.ts new file mode 100644 index 0000000..7f7bab6 --- /dev/null +++ b/src/GameEngine/Components/FPSCounter.ts @@ -0,0 +1,52 @@ +import GameEngine from 'GameEngine' +import Vector2D from 'GameEngine/2D/Vector2D' +import Component2D from 'GameEngine/Component2D' +import TextRenderer from 'GameEngine/Renderer/TextRenderer' + +export default class FPSCounter extends Component2D<{size?: number}> { + public name = 'FPSCounter' + + public position: Vector2D = new Vector2D(1) + public renderer: TextRenderer = new TextRenderer(this, {text: 'loading...', color: 'black', stroke: 'white'}) + + private length = 1 + private previousFrameTimes: Array = [] + private lastUpdate = window.performance.now() * 1000 + + public init() { + const fps = GameEngine.getGameEngine().options?.goalFramerate + if (!fps || fps < 1) { + this.length = 60 + } else { + this.length = fps + } + + if (this.params.size) { + this.renderer.size = this.params.size + } + } + + public update() { + const lastFrame = GameEngine.getGameEngine().lastFrame + // this.renderer.color = 'black' + // if (!t) {return} + // console.log(this.previousFrameTimes, t) + const diff = lastFrame - this.lastUpdate + this.lastUpdate = lastFrame + this.previousFrameTimes.push(diff) + if (this.previousFrameTimes.length > this.length) { + this.previousFrameTimes.shift() + } + // const time = (this.previousFrameTimes.reduce((p, c) => p + c, 0)) / this.previousFrameTimes.length + const time = this.previousFrameTimes.slice().sort()[Math.round(this.previousFrameTimes.length / 2)] + // this.renderer.text = this.previousFrameTimes.join(', ') + if (time === 0) { + this.renderer.text = 'a lot' + } else if (time < 0) { + this.renderer.text = 'Loading...' + } else { + this.renderer.text = (1000 / time).toFixed(0) + } + } + +} diff --git a/src/GameEngine/Controller.ts b/src/GameEngine/Controller.ts new file mode 100644 index 0000000..5153c25 --- /dev/null +++ b/src/GameEngine/Controller.ts @@ -0,0 +1,464 @@ +import Listener from '@dzeio/listener' + +export interface ControllerConnectionEvent { + controller: ControllerInterface +} + +/** + * Event containing a button state change + */ +export interface ControllerButtonEvent { + /** + * The key as a text + * + * can be the keyboard/gamepad character + */ + key: string + + /** + * Warning: prefer the use of 'key' + */ + keyId: number + + /** + * Say if the button is pressed or not + */ + pressed: boolean + + /** + * Reference to the controller + */ + controller: ControllerInterface +} + +export interface ControllerAxisEvent { + + /** + * axis id that changed + */ + axis: string + + /** + * axis new value as a value between 0 and 1 + * + * @see Controller.setControllerDeadZone to setup the controller dead zone globally + */ + value: number + + /** + * Reference to the controller + */ + controller: ControllerInterface +} + +export interface ControllerStates { + /** + * timestamp of new the input changed value last time + */ + lastChange: number + + /** + * if the key being repeated + * + * mostly for internal usage + */ + repeat?: boolean + + /** + * the current value of the input + */ + value: number +} + +interface GamepadControllerInterface { + /** + * The Gamepad ID (mostly the name/brand with some unique identifier somewhere in the text) + */ + id: string + + /** + * the type of controller + * + * can be 'gamepad' | 'keyboard' + */ + type: 'gamepad' + + /** + * the Browser gamepad class + */ + gamepad: Gamepad + + /** + * the gamepad mapping + */ + mapping: GamepadMapping + + /** + * the input states + */ + states: Record +} + +type ControllerInterface = { + /** + * the type of controller + * + * can be 'gamepad' | 'keyboard' + */ + type: 'keyboard' + + /** + * The Gamepad ID (mostly the name/brand with some unique identifier somewhere in the text) + */ + id: 'keyboard' +} | GamepadControllerInterface + +/** + * Gamepad mapping of IDs into human readable keys + */ +type GamepadMapping = Array + +/** + * Nintendo Switch specific controls + */ +const SwitchMapping: GamepadMapping = [ + 'b', + 'a', + 'x', + 'y', + 'screen', + 'l', + 'r', + 'zl', + 'zr', + 'select', + 'start', + 'home', + 'leftthumb', + 'rightthumb' +] + +/** + * Xbox specific controls + */ +const XboxMapping: GamepadMapping = [ + 'a', + 'b', + null, + 'x', + 'y', + null, + 'lb', + 'rb', + null, + null, + null, + 'start', + null, + 'leftthumb', + 'rightthumb' +] + +/** + * Default mapping made as a base + * + * to add a new mapping use this url + * https://luser.github.io/gamepadtest/ + */ +const DefaultMapping: GamepadMapping = [ + 'a', + 'b', + 'y', + 'x', + 'l', + 'r', + 'zl', + 'zr', + 'select', + 'start' +] + +/** + * This class allows you to get the controller states and update + * your application using the different Controllers like a keyboard or a Gamepad + * + * Please use `Controller.destroy()` at the end of your + * usage to finish the event listeners setup by the class + */ +export default class Controller extends Listener<{ + /** + * event sent when a new connection is established + */ + connected: (ev: ControllerConnectionEvent) => void + + /** + * event sent when a connection is broken + */ + disconnected: (ev: ControllerConnectionEvent) => void + + /** + * event sent when the key is down + */ + keyDown: (ev: ControllerButtonEvent) => void + + /** + * event sent once the key is up + */ + keyUp: (ev: ControllerButtonEvent) => void + + /** + * Event sent when a key is pressed + */ + keyPress: (ev: ControllerButtonEvent) => void + + /** + * Event sent when an axe is moving + * + * Event is sent continiously until it goes back to 0 + * + * @see Controller.setControllerDeadZone to setup the controller dead zone globally + */ + axisMove: (ev: ControllerAxisEvent) => void + all: (eventName: string, ...args: Array) => void +}> { + + /** + * List of external gamepads + */ + private gamepads: Array = [] + + /** + * Gamepad key/axes loop + */ + private doLoop = false + + /** + * Controller axes dead zone + */ + private controllerDeadZone = 0.5 + + public constructor() { + super() + + // Add the gamepad event listeners + window.addEventListener('gamepadconnected', this.onGamepadConnect) + window.addEventListener('gamepaddisconnected', this.onGamepadDisconnect) + + // add the keyboard event listeners + document.addEventListener('keydown', this.keyboardKeyDownEvent) + document.addEventListener('keyup', this.keyboardKeyPressEvent) + document.addEventListener('keypress', this.keyboardKeyUpEvent) + } + + /** + * set the controller (Gamepads only) axis dead zone + * @param value value between 0 and 1 + */ + public setControllerDeadZone(value: number) { + if (value < 0 || value >= 1) { + throw new Error(`Controller Dead Zone Out of bound (must respect 0 < value (${value}) < 1)`) + } + this.controllerDeadZone = value + } + + /** + * terminate the class + */ + public destroy() { + this.doLoop = false + this.gamepads = [] + window.removeEventListener('gamepadconnected', this.onGamepadConnect) + window.removeEventListener('gamepaddisconnected', this.onGamepadDisconnect) + document.removeEventListener('keydown', this.keyboardKeyDownEvent) + document.removeEventListener('keyup', this.keyboardKeyPressEvent) + document.removeEventListener('keypress', this.keyboardKeyUpEvent) + } + + /** + * Browser keyboard event handler + */ + private keyboardKeyDownEvent = (ev: KeyboardEvent) => { + this.emit('keyDown', { + key: ev.key, + keyId: ev.keyCode, + pressed: true, + controller: { + type: 'keyboard', + id: 'keyboard' + } + }) + } + + /** + * Browser keyboard event handler + */ + private keyboardKeyPressEvent = (ev: KeyboardEvent) => { + this.emit('keyPress', { + key: ev.key, + keyId: ev.keyCode, + pressed: true, + controller: { + type: 'keyboard', + id: 'keyboard' + } + }) + } + + /** + * Browser keyboard event handler + */ + private keyboardKeyUpEvent = (ev: KeyboardEvent) => { + this.emit('keyUp', { + key: ev.key, + keyId: ev.keyCode, + pressed: false, + controller: { + type: 'keyboard', + id: 'keyboard' + } + }) + } + + /** + * Handle gamepad disconnection + */ + private onGamepadDisconnect = (ev: GamepadEvent) => { + const index = this.gamepads.findIndex((it) => it.id === ev.gamepad.id) + if (index < 0) { + return + } + const gamepad = this.gamepads.splice(index, 1)[0] + console.log('Controller disconnected', gamepad.id) + if (this.gamepads.length === 0) { + this.doLoop = false + } + this.emit('disconnected', { + controller: gamepad + }) + } + + /** + * Handle gamepad connection + */ + private onGamepadConnect = (ev: GamepadEvent) => { + const gp = ev.gamepad + + // create it's interface + const gamepad: ControllerInterface = { + type: 'gamepad', + id: gp.id, + gamepad: gp, + mapping: gp.id.includes('Switch') ? SwitchMapping : gp.id.includes('Xbox') ? XboxMapping : DefaultMapping, + states: {} + } + + // add buttons to states + for (let idx = 0; idx < gp.buttons.length; idx++) { + const button = gp.buttons[idx] + gamepad.states['button-' + idx] = { + lastChange: new Date().getTime(), + value: button.pressed ? 1 : 0 + } + } + + // add axis to states + for (let idx = 0; idx < gp.axes.length; idx++) { + const axe = gp.axes[idx] + gamepad.states['axe-' + idx] = { + lastChange: new Date().getTime(), + value: axe + } + } + + console.log('New Controller connected', gamepad.id) + + // add it to the global gamepads list + this.gamepads.push(gamepad) + + this.emit('connected', { + controller: gamepad + }) + + // start gamepads polling for new states + if (!this.doLoop) { + this.doLoop = true + this.update() + } + } + + /** + * Polling to check if the gamepad has changes with it's buttons or not + */ + private update() { + const now = new Date().getTime() + + // loop through every gamepads + for (let gIdx = 0; gIdx < this.gamepads.length; gIdx++) { + const gamepad = this.gamepads[gIdx] + if (gamepad.type !== 'gamepad') { + continue + } + + // Chromium specific as gamepad.gamepad is not updated + gamepad.gamepad = navigator.getGamepads()[gIdx] ?? gamepad.gamepad + + // loop through each buttons + for (let idx = 0; idx < gamepad.gamepad.buttons.length; idx++) { + const button = gamepad.gamepad.buttons[idx] + const gs = gamepad.states['button-' + idx] + const repeatedPress = gs.repeat || gs.lastChange + 300 < now + // handle state change or press repetition + if (button.pressed !== !!gs.value || button.pressed && repeatedPress) { + if (button.pressed && gs.value && repeatedPress) { + gs.repeat = true + } else if (!button.pressed) { + gs.repeat = false + } + + // send keypress event once + if (button.pressed && !gs.value) { + this.emit('keyPress', { + key: gamepad.mapping[idx] ?? idx.toString(), + keyId: idx, + pressed: button.pressed, + controller: gamepad + }) + } + + // send keydown/keyup event + gs.lastChange = now + gamepad.states['button-' + idx].value = button.pressed ? 1 : 0 + this.emit(button.pressed ? 'keyDown' : 'keyUp', { + key: gamepad.mapping[idx] ?? idx.toString(), + keyId: idx, + pressed: button.pressed, + controller: gamepad + }) + } + } + + // loop through each axises + for (let idx = 0; idx < gamepad.gamepad.axes.length; idx++) { + let axe = gamepad.gamepad.axes[idx] + if (Math.abs(axe) < this.controllerDeadZone) { + axe = 0 + } + + // emit event when value is not a 0 + if (axe !== gamepad.states['axe-' + idx].value || axe !== 0) { + gamepad.states['axe-' + idx].value = axe + this.emit('axisMove', { + axis: idx.toString(), + value: axe, + controller: gamepad + }) + } + } + } + + // ask for new loop when available + if (this.doLoop) { + requestAnimationFrame(() => this.update()) + } + } +} diff --git a/src/GameEngine/Renderer/ImageRenderer.ts b/src/GameEngine/Renderer/ImageRenderer.ts new file mode 100644 index 0000000..52f28be --- /dev/null +++ b/src/GameEngine/Renderer/ImageRenderer.ts @@ -0,0 +1,69 @@ +import { objectLoop } from '@dzeio/object-util' +import GameEngine from 'GameEngine' +import Asset, { AssetStatus } from 'GameEngine/Asset' +import Component2D from 'GameEngine/Component2D' +import Renderer from '.' + +interface Params { + asset?: Asset + stream?: boolean + debug?: boolean +} + +/** + * TODO: Add origin support + */ +export default class ImageRenderer extends Renderer implements Params { + + public asset?: Asset + public stream = true + public debug = false + + public constructor(component: Component2D, params?: Params) { + super(component) + objectLoop(params ?? {}, (value, key) => {this[key as keyof Params] = value}) + } + + public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { + + if (!this.asset) { + return + } + + if (this.asset.status !== AssetStatus.LOADED) { + if (this.stream) { + // load asset but do not stop threads + this.asset.load() + return + } else { + await this.asset.load() + } + } + + if (this.asset.status === AssetStatus.LOADING && this.stream) { + return + } + + const globalScale = ge.currentScene?.scale ?? 1 + const size = this.asset.size() + const position = this.getPosition() + const final: [number, number, number, number] = [ + position.x * ge.caseSize.x * globalScale, + position.y * ge.caseSize.y * globalScale, + (this.component.scale.x ?? ge.caseSize.x) * ge.caseSize.x * globalScale, + (this.component.scale.y ?? ge.caseSize.y) * ge.caseSize.y * globalScale + ] + if (this.debug || this.component.debug) { + ctx.fillStyle = 'red' + ctx.fillRect(...final) + } + ctx.drawImage( + this.asset.get(), + 0, + 0, + size.x, + size.y, + ...final + ) + } +} diff --git a/src/GameEngine/Renderer/RectRenderer.ts b/src/GameEngine/Renderer/RectRenderer.ts index 14d8b63..1530083 100644 --- a/src/GameEngine/Renderer/RectRenderer.ts +++ b/src/GameEngine/Renderer/RectRenderer.ts @@ -1,51 +1,60 @@ import { objectLoop } from '@dzeio/object-util' import GameEngine from 'GameEngine' -import Asset from 'GameEngine/Asset' import Component2D from 'GameEngine/Component2D' import Renderer from '.' interface Params { - material?: string | Asset - stroke?: string + material?: string + stroke?: string | {color: string, width: number} + debug?: boolean } -export default class RectRenderer extends Renderer implements Partial { +export default class RectRenderer extends Renderer implements Params { - public material?: string | Asset - public stroke?: string + public material?: string + public stroke?: string | {color: string, width: number} + public debug?: boolean | undefined public constructor(component: Component2D, params?: Params) { super(component) - objectLoop(params ?? {}, (v, k) => {this[k as 'material'] = v}) + objectLoop(params ?? {}, (value, key) => {this[key as 'material'] = value}) } public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { const position = this.getPosition() + const globalScale = ge.currentScene?.scale ?? 1 const item: [number, number, number, number] = [ // source x - // 0 - 1.5 - -1.5 - position.x * (ge.caseSize.x), + position.x * ge.caseSize.x * globalScale, // source y - position.y * (ge.caseSize.y), - // source end X - this.component.scale.x * (ge.caseSize.x), - // source end Y - this.component.scale.y * (ge.caseSize.y) + position.y * ge.caseSize.y * globalScale, + // size X + this.component.scale.x * ge.caseSize.x * globalScale, + // size Y + this.component.scale.y * ge.caseSize.y * globalScale ] - if (this.material instanceof Asset) { - ctx.drawImage( - await this.material.get(), - ...item - ) - return - } if (this.material) { ctx.fillStyle = this.material ctx.fillRect(...item) } if (this.stroke) { - ctx.strokeStyle = this.stroke + if (typeof this.stroke === 'string') { + ctx.strokeStyle = this.stroke + } else { + ctx.strokeStyle = this.stroke.color + ctx.lineWidth = this.stroke.width * (ge.currentScene?.scale ?? 1) + } + ctx.strokeRect(...item) + } + + if (this.debug) { + if (typeof this.stroke === 'string') { + ctx.strokeStyle = this.stroke + } else { + ctx.strokeStyle = 'red' + ctx.lineWidth = 1 * (ge.currentScene?.scale ?? 1) + } ctx.strokeRect(...item) } } diff --git a/src/GameEngine/Renderer/TextRenderer.ts b/src/GameEngine/Renderer/TextRenderer.ts new file mode 100644 index 0000000..bd79105 --- /dev/null +++ b/src/GameEngine/Renderer/TextRenderer.ts @@ -0,0 +1,66 @@ +import { objectLoop } from '@dzeio/object-util' +import GameEngine from 'GameEngine' +import Component2D from 'GameEngine/Component2D' +import Renderer from '.' + +interface Params { + text?: string + size?: number + weight?: 'bold' + stroke?: string | {color: string, width: number} + color?: string + debug?: boolean +} + +export default class TextRenderer extends Renderer implements Params { + + public text?: string + public size?: number + public weight?: 'bold' + public color?: string + public stroke?: string | {color: string, width: number} + public debug?: boolean + + public constructor(component: Component2D, params?: Params) { + super(component) + objectLoop(params ?? {}, (v, k) => {this[k as 'text'] = v}) + } + + public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { + const position = this.getPosition() + const globalScale = ge.currentScene?.scale ?? 1 + const item: [number, number] = [ + // source x + // 0 - 1.5 - -1.5 + position.x * ge.caseSize.x * globalScale, + // source y + position.y * ge.caseSize.y * globalScale + ] + + const size = this.component.scale.y * ge.caseSize.y + + // console.log + if (this.text) { + ctx.textBaseline = 'top' + ctx.textAlign = 'left' + + ctx.font = `${this.weight ? `${this.weight} ` : ''}${(this.size ?? size) / 16 * ge.caseSize.x * globalScale * 3}px sans-serif` + if (this.color) { + ctx.fillStyle = this.color ?? 'black' + ctx.fillText(this.text, ...item) + } + if (this.stroke) { + if (typeof this.stroke === 'string') { + ctx.strokeStyle = this.stroke + ctx.lineWidth = ge.currentScene?.scale ?? 1 + } else { + ctx.strokeStyle = this.stroke.color + ctx.lineWidth = this.stroke.width * (ge.currentScene?.scale ?? 1) + } + ctx.strokeText(this.text, ...item) + } + } else if (this.debug) { + console.warn('no text, no display') + } + } +} diff --git a/src/GameEngine/Renderer/TileRenderer.ts b/src/GameEngine/Renderer/TileRenderer.ts index 47a8f81..9e48a3d 100644 --- a/src/GameEngine/Renderer/TileRenderer.ts +++ b/src/GameEngine/Renderer/TileRenderer.ts @@ -1,6 +1,5 @@ import { objectLoop } from '@dzeio/object-util' import GameEngine from 'GameEngine' -import Vector2D from 'GameEngine/2D/Vector2D' import Component2D from 'GameEngine/Component2D' import Tileset from 'GameEngine/Tileset' import Renderer from '.' @@ -20,7 +19,7 @@ export default class TileRenderer extends Renderer implements Params { public constructor(component: Component2D, params?: Params) { super(component) - objectLoop(params ?? {}, (v, k) => {this[k as 'id'] = v}) + objectLoop(params ?? {}, (value, key) => {this[key as 'id'] = value}) } public async render(ge: GameEngine, ctx: CanvasRenderingContext2D) { @@ -29,14 +28,15 @@ export default class TileRenderer extends Renderer implements Params { } const {sx, sy} = this.tileset.getSourceData(this.id) const position = this.getPosition() + await this.tileset.asset.load() ctx.drawImage( - await this.tileset.asset.get(), + this.tileset.asset.get(), sx, sy, this.tileset.width(this.id), this.tileset.height(this.id), - position.x * (ge.caseSize.x), - position.y * (ge.caseSize.y), + position.x * ge.caseSize.x, + position.y * ge.caseSize.y, (this.component.scale.x ?? ge.caseSize.x) * ge.caseSize.x, (this.component.scale.y ?? ge.caseSize.y) * ge.caseSize.y ) diff --git a/src/GameEngine/Renderer/index.ts b/src/GameEngine/Renderer/index.ts index c0892c5..7976b39 100644 --- a/src/GameEngine/Renderer/index.ts +++ b/src/GameEngine/Renderer/index.ts @@ -10,11 +10,12 @@ export default abstract class Renderer { protected getPosition(): Vector2D { const ge = GameEngine.getGameEngine() - const realPosition = ge.currentScene.camera.topLeft.sum(this.component.position) - return new Vector2D( - realPosition.x - this.component.scale.x / 2 - this.component.origin.x, - realPosition.y - this.component.scale.y / 2 - this.component.origin.y + const realPosition = this.component.getAbsolutePosition().sum( + -(ge.currentScene?.position?.x ?? 0), + -(ge.currentScene?.position?.y ?? 0) ) + + return realPosition } public abstract render(ge: GameEngine, ctx: CanvasRenderingContext2D): Promise diff --git a/src/GameEngine/Scene.ts b/src/GameEngine/Scene.ts index 49c0301..2e934b0 100644 --- a/src/GameEngine/Scene.ts +++ b/src/GameEngine/Scene.ts @@ -1,7 +1,8 @@ import GameEngine from 'GameEngine' -import AssetsManager from './Asset' -import Camera from './Camera' import Component2D, { ComponentState } from './Component2D' +import BoxCollider2D from './2D/Collision/BoxCollider2D' +import Vector2D from './2D/Vector2D' +import { ComponentType } from 'react' export default class Scene { public static scenes: Record = {} @@ -9,11 +10,13 @@ export default class Scene { public background?: string public id: string - public camera: Camera = new Camera() + public position: Vector2D = new Vector2D(0) + public scale = 1 private components: Array = [] + private componentsInitialized: Array = [] private ge!: GameEngine - + private hasClickedComponent: number | undefined public constructor(sceneId: string) { Scene.scenes[sceneId] = this @@ -21,69 +24,200 @@ export default class Scene { } public addComponent(...cp: Array) { + this.componentsInitialized.push(...cp.map(() => false)) return this.components.push(...cp) } + public getComponents(): Array { + return this.components + } + + /** + * delete the component + * @param component the component + */ + public removeComponent(component: Component2D): Scene + /** + * delete the component by it's index + * @param component the component index + */ + public removeComponent(component: number | Component2D): Scene { + if (typeof component === 'number') { + this.components.splice(component, 1) + this.componentsInitialized.splice(component, 1) + return this + } + + const index = this.components.findIndex((it) => it.id === component.id) + if (index > -1) { + this.components.splice(index, 1) + this.componentsInitialized.splice(index, 1) + } + + return this + } + public setGameEngine(ge: GameEngine) { this.ge = ge } public async init() { - this.components.forEach((v) => { - if (v.init) { - v.init() - } - }) + for await (const component of this.components) { + await this.initComponent(component) + } } + // private count = 0 + public async update() { - for (const component of this.components) { + // console.log('new scene frame', this.count++) + for await (const component of this.components) { + await this.initComponent(component) await this.updateComponent(component) } + for await (const component of this.components) { + await this.renderComponent(component) + } } - private async updateComponent(v: Component2D) { - const debug = v.debug - if (debug) { - console.log('Processing Component', v) + public async destroy() { + for await (const component of this.components) { + await this.destroyComponent(component) } - const state: Partial = {} - // const width = (v.width() ?? 1) * this.ge.caseSize[0] - // const height = (v.height() ?? 1) * this.ge.caseSize[1] - if (v.collider && v.collider.type === 'click' && this.ge.cursor.isDown && !this.ge.cursor.wasDown) { - if (v.collider.pointColliding(this.ge.cursor.position, 'click')) { - state.isColliding = 'click' + } + + public checkColisions(component: Component2D, exclusion?: Array): Array { + const list: Array = [] + if (component.collider instanceof BoxCollider2D) { + const [topLeft, bottomRight] = component.collider.pos() + for (const otherComponent of this.components) { + if ( + otherComponent === undefined || + otherComponent.id === component.id || + !(otherComponent.collider instanceof BoxCollider2D) + ) { + continue + } + + // Exclude components from being checked + if (exclusion?.find((it) => it.id === otherComponent.id)) { + continue + } + + // Check for collision + const otherCollider = otherComponent.collider.pos() + if ( + bottomRight.x > otherCollider[0].x && // self bottom higher than other top + topLeft.x < otherCollider[1].x && + bottomRight.y > otherCollider[0].y && + topLeft.y < otherCollider[1].y + ) { + list.push(otherComponent) + } } } - // if (v.pos) { - // const ax = v.pos.x * this.ge.caseSize[0] - // const ay = v.pos.y * this.ge.caseSize[1] - // state.mouseHovering = - // this.ge.cursor.x >= ax && this.ge.cursor.x < (ax + width) && - // this.ge.cursor.y >= ay && this.ge.cursor.y < (ay + height) - // state.mouseClicking = state.mouseHovering && this.ge.cursor.isDown - // state.mouseClicked = state.mouseClicking && !this.ge.cursor.wasDown - // } - if (v.renderer) { - if (debug) { - console.log('Rendering Component', v) - } - // console.log('is rendering new element') - await v.renderer.render(this.ge, this.ge.ctx) + return list + } + + private async initComponent(component: Component2D) { + if (component.state.isInitialized) { + return } - if (v.update) { - if (debug) { - console.log('Updating Component', v) - } - v.update(state as ComponentState) + if (component.init) { + await component?.init() } - if (v.childs) { - if (debug) { - console.log('Processing childs', v) - } - for (const child of v.childs) { - await this.updateComponent(child) + component.setState('isInitialized', true) + + if (component.childs) { + for await (const child of component.childs) { + await this.initComponent(child) } } } + + /** + * Update a specific component + * + * note: It first update the childs THEN the component + * + * @param component the component to update + * @returns the list of components to exclude in collision check + */ + private async updateComponent(component: Component2D): Promise> { + const debug = component.debug + if (debug) { + console.group('updating:', component.name, component.id) + } + + // update childs first + const toExclude: Array = [] + if (component.childs && component.childs.length > 0) { + for await (const child of component.childs) { + child.parent = component + toExclude.push(...await this.updateComponent(child)) + } + } + + const state: Partial = { + collideWith: [], + scene: this + } + + state.collideWith = this.checkColisions(component, toExclude) + if (debug) { + console.debug('collider [collisions, excludedCollisions]', state.collideWith.length, toExclude.length) + } + + if (this.hasClickedComponent === component.id && !state.isColliding) { + this.hasClickedComponent = undefined + } + + if (component.update) { + if (debug) { + console.log('Updating component') + } + component.update(state as ComponentState) + } else if (debug) { + console.log('Component has no updater') + } + + if (component.debug) { + console.groupEnd() + } + + return toExclude + } + + private async renderComponent(component: Component2D) { + const debug = component.debug + if (debug) { + console.group('rendering: ', component.name, component.id) + } + if (component.renderer) { + if (debug) { + console.log('rendering!') + } + // console.log('is rendering new element') + await component.renderer.render(this.ge, this.ge.ctx) + } else if (debug) { + console.log('component has no renderer') + } + + if (component.childs && component.childs.length > 0) { + for await (const child of component.childs) { + child.parent = component + await this.renderComponent(child) + } + } + if (component.debug) { + console.groupEnd() + } + } + + private async destroyComponent(component: Component2D) { + for await (const child of component.childs) { + await this.destroyComponent(child) + } + await component.destroy?.() + } } diff --git a/src/GameEngine/Tileset.ts b/src/GameEngine/Tileset.ts index ba39b7a..9d5ddfd 100644 --- a/src/GameEngine/Tileset.ts +++ b/src/GameEngine/Tileset.ts @@ -32,7 +32,9 @@ export default class Tileset { } // const {x, y} = this.getPosFromId(id) const cols = Math.trunc(this.declaration.fileSize.width / this.width(id)) + // eslint-disable-next-line id-length const x = id % cols + // eslint-disable-next-line id-length const y = Math.trunc(id / cols) const sx = x * this.width(id) + x * (this.declaration.spacing ?? 0) const sy = y * this.height(id) + y * (this.declaration.spacing ?? 0) diff --git a/src/GameEngine/index.ts b/src/GameEngine/index.ts index 6d91247..cd24e64 100644 --- a/src/GameEngine/index.ts +++ b/src/GameEngine/index.ts @@ -1,3 +1,4 @@ +import { objectValues } from '@dzeio/object-util' import Vector2D from './2D/Vector2D' import Scene from './Scene' @@ -12,26 +13,49 @@ export default class GameEngine { public ctx: CanvasRenderingContext2D public canvas: HTMLCanvasElement public caseSize: Vector2D = new Vector2D(1, 1) - public cursor: { - position: Vector2D - isDown: boolean - wasDown: boolean - } = { - position: new Vector2D(0, 0), - isDown: false, - wasDown: false - } - public currentScene!: Scene - private isRunning = false + public componentId = 0 + + public currentScene?: Scene + + // last frame timestamp + public lastFrame = 0 + + /** + * last frame execution time in milliseconds + * + * @memberof GameEngine + */ + public frameTime = 0 + + /** + * indicate if the engine is running + */ + public isRunning = false + + + // timer between frames + private timer = 0 + + private loopId?: NodeJS.Timer public constructor( - private id: string, + id: string, public options?: { caseCount?: number | [number, number] background?: string debugColliders?: boolean + /** + * Maximum framerate you want to achieve + * + * note: -1 mean infinite + */ + goalFramerate?: number } ) { + if (GameEngine.ge) { + this.destroy() + // throw new Error('GameEngine already init') + } GameEngine.ge = this const canvas = document.querySelector(id) if (!canvas) { @@ -40,10 +64,12 @@ export default class GameEngine { this.canvas = canvas if (this.options?.caseCount) { this.caseSize = new Vector2D( - // @ts-expect-error idc - this.canvas.width / ((typeof this.options.caseCount) !== 'number' ? this.options.caseCount[0] : this.options.caseCount ), - // @ts-expect-error idc2 lol - this.canvas.height / ((typeof this.options.caseCount) !== 'number' ? this.options.caseCount[1] : this.options.caseCount) + this.canvas.width / ( + typeof this.options.caseCount !== 'number' ? this.options.caseCount[0] : this.options.caseCount + ), + this.canvas.height / ( + typeof this.options.caseCount !== 'number' ? this.options.caseCount[1] : this.options.caseCount + ) ) } @@ -51,12 +77,17 @@ export default class GameEngine { if (!ctx) { throw new Error('Error, Context could not get found!') } - ctx.imageSmoothingEnabled = false + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' this.ctx = ctx + + if (options?.goalFramerate && options.goalFramerate >= 0) { + this.timer = 1000 / options.goalFramerate + } } public static getGameEngine(): GameEngine { - return this.ge + return this.ge as GameEngine } public start() { @@ -65,51 +96,108 @@ export default class GameEngine { return } this.isRunning = true - requestAnimationFrame(() => { - this.update() - }) - document.addEventListener('mousemove', (ev) => { - this.cursor.position = new Vector2D( - ev.clientX / this.caseSize.x - this.currentScene.camera.topLeft.x, - ev.clientY / this.caseSize.y - this.currentScene.camera.topLeft.y - ) - if (this.cursor.isDown) { - this.cursor.wasDown = true - } - }) - document.addEventListener('mousedown', () => { - this.cursor.isDown = true - }) - document.addEventListener('mouseup', () => { - this.cursor.isDown = false - this.cursor.wasDown = false - }) + this.currentScene?.init().then(() => this.update()) } public pause() { this.isRunning = false } - public setScene(scene: Scene | string) { + public async destroy() { + this.isRunning = false + for await (const scene of objectValues(Scene.scenes)) { + await scene.destroy() + } + if (GameEngine.ge) { + // @ts-expect-error normal behavior + delete GameEngine.ge as any + } + if (this.loopId) { + clearInterval(this.loopId) + } + } + + public getXCaseCount(): number { + const caseCount = this.options?.caseCount + if (caseCount) { + if (typeof caseCount === 'number') { + return caseCount + } else { + return caseCount[0] + } + } + return this.canvas.offsetWidth + } + + + public getYCaseCount(): number { + const caseCount = this.options?.caseCount + if (caseCount) { + if (typeof caseCount === 'number') { + return caseCount + } else { + return caseCount[1] + } + } + return this.canvas.offsetWidth + } + public async setScene(scene: Scene | string) { console.log('Setting scene', typeof scene === 'string' ? scene : scene.id) + const wasRunning = this.isRunning + if (wasRunning) { + this.isRunning = false + } + await this.currentScene?.destroy() + await this.currentScene?.init() + if (wasRunning) { + this.isRunning = true + } this.currentScene = typeof scene === 'string' ? Scene.scenes[scene] : scene this.currentScene.setGameEngine(this) } private update() { - if (!this.isRunning) { + if (this.loopId) { + console.error('Already initialized') return } - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) - if (this.options?.background) { - this.ctx.fillStyle = this.options.background - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) - } - this.currentScene?.update() - setTimeout(() => { - this.update() - }, 0) + // console.log('update') + this.loopId = setInterval(async () => { + + // get current time + const now = window.performance.now() + + // game is not runnig, wait a frame + if (!this.isRunning) { + // console.log('skip frame 1') + return + } + + // game is running too fast, wait until necessary + if (this.lastFrame + this.timer > now) { + // console.log('skip frame 2') + return + } + + + // console.log('new frame') + + // if a background need to be drawn + if (this.options?.background) { + this.ctx.fillStyle = this.options.background + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) + } else { + // clear the previous frame + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + } + + await this.currentScene?.update() + // calculate for next frame + this.lastFrame = window.performance.now() + this.frameTime = window.performance.now() - now + }) } + } export interface GameState {