diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..32ebab4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "none" +} diff --git a/client/.eslintrc.js b/client/.eslintrc.js index f200fbf..a9b1e2d 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -1,11 +1,11 @@ module.exports = { extends: [ // add more generic rule sets here, such as: - "eslint:recommended", - "plugin:svelte/recommended", + 'eslint:recommended', + 'plugin:svelte/recommended' ], rules: { // override/add rules settings here, such as: // 'svelte/rule-name': 'error' - }, + } }; diff --git a/client/index.html b/client/index.html index 00b94e7..892a3af 100644 --- a/client/index.html +++ b/client/index.html @@ -1,4 +1,4 @@ - + diff --git a/client/public/css/colors.css b/client/public/css/colors.css index 067ddcd..6108028 100644 --- a/client/public/css/colors.css +++ b/client/public/css/colors.css @@ -10,7 +10,7 @@ --orange: #af3a03; } -[data-theme="dark"] { +[data-theme='dark'] { --bg: #282828; --text: #f9f5d7; --red: #fb4934; diff --git a/client/public/css/style.css b/client/public/css/style.css index cdfef76..4dfe605 100644 --- a/client/public/css/style.css +++ b/client/public/css/style.css @@ -1,15 +1,15 @@ -@import url("./theme.css"); -@import url("./tf.css"); +@import url('./theme.css'); +@import url('./tf.css'); @font-face { - font-family: "scientifica"; - src: url("/fonts/scientifica.ttf"); + font-family: 'scientifica'; + src: url('/fonts/scientifica.ttf'); } * { padding: 0; margin: 0; - font-family: "scientifica", monospace; + font-family: 'scientifica', monospace; transition: background 0.2s ease-in-out; font-smooth: never; } diff --git a/client/public/css/tf.css b/client/public/css/tf.css index c1acd72..855fe0d 100644 --- a/client/public/css/tf.css +++ b/client/public/css/tf.css @@ -17,7 +17,7 @@ rgba(162, 254, 254, 1) 100% ); - content: ""; + content: ''; width: 100%; height: 100%; top: 0; diff --git a/client/public/css/theme.css b/client/public/css/theme.css index c65b2a8..eeb15ee 100644 --- a/client/public/css/theme.css +++ b/client/public/css/theme.css @@ -1,4 +1,4 @@ -@import url("./colors.css"); +@import url('./colors.css'); .primary { color: var(--aqua); diff --git a/client/src/JumpStorm.ts b/client/src/JumpStorm.ts index bd48483..6f9e24f 100644 --- a/client/src/JumpStorm.ts +++ b/client/src/JumpStorm.ts @@ -1,5 +1,6 @@ -import { Floor, Player } from "@engine/entities"; -import { Game } from "@engine/Game"; +import { Game } from '@engine/Game'; +import { Entity } from '@engine/entities'; +import { Grid } from '@engine/structures'; import { WallBounds, FacingDirection, @@ -7,28 +8,141 @@ import { Physics, Input, Collision, -} from "@engine/systems"; + NetworkUpdate +} from '@engine/systems'; +import { + type MessageQueueProvider, + type MessagePublisher, + type MessageProcessor, + type Message, + type EntityAddBody, + MessageType, + type EntityUpdateBody +} from '@engine/network'; +import { stringify, parse } from '@engine/utils'; + +class ClientMessageProcessor implements MessageProcessor { + private game: Game; + + constructor(game: Game) { + this.game = game; + } + + public process(message: Message) { + switch (message.type) { + case MessageType.NEW_ENTITIES: + const entityAdditions = message.body as unknown as EntityAddBody[]; + entityAdditions.forEach((addBody) => + this.game.addEntity( + Entity.from(addBody.entityName, addBody.id, addBody.args) + ) + ); + break; + case MessageType.REMOVE_ENTITIES: + const ids = message.body as unknown as string[]; + ids.forEach((id) => this.game.removeEntity(id)); + break; + case MessageType.UPDATE_ENTITIES: + const entityUpdates = message.body as unknown as EntityUpdateBody[]; + entityUpdates.forEach( + ({ id, args }) => this.game.getEntity(id)?.setFrom(args) + ); + break; + default: + break; + } + } +} + +class ClientSocketMessageQueueProvider implements MessageQueueProvider { + private socket: WebSocket; + private messages: Message[]; + + constructor(socket: WebSocket) { + this.socket = socket; + this.messages = []; + + this.socket.addEventListener('message', (e) => { + const messages = parse(e.data); + this.messages = this.messages.concat(messages); + }); + } + + public getNewMessages() { + return this.messages; + } + + public clearMessages() { + this.messages = []; + } +} + +class ClientSocketMessagePublisher implements MessagePublisher { + private socket: WebSocket; + private messages: Message[]; + + constructor(socket: WebSocket) { + this.socket = socket; + this.messages = []; + } + + public addMessage(message: Message) { + this.messages.push(message); + } + + public publish() { + if (this.socket.readyState == WebSocket.OPEN) { + this.messages.forEach((message: Message) => + this.socket.send(stringify(message)) + ); + this.messages = []; + } + } +} export class JumpStorm { private game: Game; - private socket: WebSocket; + private clientId: string; - constructor(ctx: CanvasRenderingContext2D) { - this.game = new Game(); - this.socket = new WebSocket("ws://localhost:8080"); + constructor(game: Game) { + this.game = game; + } + + public async init( + ctx: CanvasRenderingContext2D, + httpMethod: string, + wsMethod: string, + host: string + ) { + this.clientId = await this.getAssignedCookie( + `${httpMethod}://${host}/assign` + ); + const socket = new WebSocket(`${wsMethod}://${host}/game`); + const clientSocketMessageQueueProvider = + new ClientSocketMessageQueueProvider(socket); + const clientSocketMessagePublisher = new ClientSocketMessagePublisher( + socket + ); + const clientMessageProcessor = new ClientMessageProcessor(this.game); + + const inputSystem = new Input(this.clientId, clientSocketMessagePublisher); + this.addWindowEventListenersToInputSystem(inputSystem); + + const grid = new Grid(); [ - this.createInputSystem(), + new NetworkUpdate( + clientSocketMessageQueueProvider, + clientSocketMessagePublisher, + clientMessageProcessor + ), + inputSystem, new FacingDirection(), new Physics(), - new Collision(), - new WallBounds(ctx.canvas.width), - new Render(ctx), + new Collision(grid), + new WallBounds(), + new Render(ctx) ].forEach((system) => this.game.addSystem(system)); - - [new Floor(160), new Player()].forEach((entity) => - this.game.addEntity(entity), - ); } public play() { @@ -41,16 +155,26 @@ export class JumpStorm { requestAnimationFrame(loop); } - private createInputSystem(): Input { - const inputSystem = new Input(); - - window.addEventListener("keydown", (e) => { + private addWindowEventListenersToInputSystem(input: Input) { + window.addEventListener('keydown', (e) => { if (!e.repeat) { - inputSystem.keyPressed(e.key); + input.keyPressed(e.key.toLowerCase()); } }); - window.addEventListener("keyup", (e) => inputSystem.keyReleased(e.key)); - return inputSystem; + window.addEventListener('keyup', (e) => + input.keyReleased(e.key.toLowerCase()) + ); + } + + private async getAssignedCookie(endpoint: string): Promise { + return fetch(endpoint) + .then((resp) => { + if (resp.ok) { + return resp.text(); + } + throw resp; + }) + .then((cookie) => cookie); } } diff --git a/client/src/components/GameCanvas.svelte b/client/src/components/GameCanvas.svelte index ae8c1b0..ea7dd15 100644 --- a/client/src/components/GameCanvas.svelte +++ b/client/src/components/GameCanvas.svelte @@ -1,24 +1,26 @@ diff --git a/client/src/components/LeaderBoard.svelte b/client/src/components/LeaderBoard.svelte index 8343c56..2f3e411 100644 --- a/client/src/components/LeaderBoard.svelte +++ b/client/src/components/LeaderBoard.svelte @@ -3,7 +3,7 @@ const MAX_ENTRIES = 8; - export let entries: { name: string, score: number }[] = []; + export let entries: { name: string; score: number }[] = [];
diff --git a/client/src/main.ts b/client/src/main.ts index 5332616..aa7431f 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,7 +1,7 @@ -import App from "./App.svelte"; +import App from './App.svelte'; const app = new App({ - target: document.getElementById("app"), + target: document.getElementById('app') }); export default app; diff --git a/client/src/routes/Home.svelte b/client/src/routes/Home.svelte index 9ada10e..71ad324 100644 --- a/client/src/routes/Home.svelte +++ b/client/src/routes/Home.svelte @@ -3,10 +3,9 @@ import LeaderBoard from "../components/LeaderBoard.svelte"; import { Miscellaneous } from "@engine/config"; - + let width: number = Miscellaneous.WIDTH; let height: number = Miscellaneous.HEIGHT; -
diff --git a/client/svelte.config.js b/client/svelte.config.js index b0683fd..db735be 100644 --- a/client/svelte.config.js +++ b/client/svelte.config.js @@ -1,7 +1,7 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; export default { // Consult https://svelte.dev/docs#compile-time-svelte-preprocess // for more information about preprocessors - preprocess: vitePreprocess(), -} + preprocess: vitePreprocess() +}; diff --git a/client/tsconfig.json b/client/tsconfig.json index 781d1b3..fadebb0 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/svelte/tsconfig.json", + "extends": ["@tsconfig/svelte/tsconfig.json", "../tsconfig.engine.json"], "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, @@ -24,8 +24,5 @@ "src/**/*.js", "src/**/*.svelte" ], - "paths": { - "@engine/*": ["../engine/*"] - }, "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/client/vite.config.ts b/client/vite.config.ts index 0307338..6f0e1d0 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,13 +1,24 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { fileURLToPath, URL } from 'node:url'; // https://vitejs.dev/config/ export default defineConfig({ + server: { + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://10.0.0.237:8080', + ws: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + cors: true, plugins: [svelte()], resolve: { alias: { - "@engine": fileURLToPath(new URL("../engine", import.meta.url)), - }, - }, + '@engine': fileURLToPath(new URL('../engine', import.meta.url)) + } + } }); diff --git a/engine/Game.ts b/engine/Game.ts index 07d06e8..cdd3507 100644 --- a/engine/Game.ts +++ b/engine/Game.ts @@ -1,5 +1,5 @@ -import { Entity } from "./entities"; -import { System } from "./systems"; +import { Entity } from './entities'; +import { System } from './systems'; export class Game { private systemOrder: string[]; @@ -7,9 +7,9 @@ export class Game { private running: boolean; private lastTimeStamp: number; - public entities: Map; + public entities: Map; public systems: Map; - public componentEntities: Map>; + public componentEntities: Map>; constructor() { this.lastTimeStamp = performance.now(); @@ -29,17 +29,17 @@ export class Game { this.entities.set(entity.id, entity); } - public getEntity(id: number): Entity | undefined { + public getEntity(id: string): Entity | undefined { return this.entities.get(id); } - public removeEntity(id: number) { + public removeEntity(id: string) { this.entities.delete(id); } public forEachEntityWithComponent( componentName: string, - callback: (entity: Entity) => void, + callback: (entity: Entity) => void ) { this.componentEntities.get(componentName)?.forEach((entityId) => { const entity = this.getEntity(entityId); @@ -60,7 +60,7 @@ export class Game { return this.systems.get(name); } - public doGameLoop = (timeStamp: number) => { + public doGameLoop(timeStamp: number) { if (!this.running) { return; } @@ -75,16 +75,16 @@ export class Game { if (!this.componentEntities.has(component.name)) { this.componentEntities.set( component.name, - new Set([entity.id]), + new Set([entity.id]) ); return; } this.componentEntities.get(component.name)?.add(entity.id); - }), + }) ); this.systemOrder.forEach((systemName) => { this.systems.get(systemName)?.update(dt, this); }); - }; + } } diff --git a/engine/components/BoundingBox.ts b/engine/components/BoundingBox.ts index 5e21b2f..921feb9 100644 --- a/engine/components/BoundingBox.ts +++ b/engine/components/BoundingBox.ts @@ -1,6 +1,6 @@ -import { Component, ComponentNames } from "."; -import type { Coord2D, Dimension2D } from "../interfaces"; -import { dotProduct, rotateVector } from "../utils"; +import { Component, ComponentNames } from '.'; +import type { Coord2D, Dimension2D } from '../interfaces'; +import { dotProduct, rotateVector } from '../utils'; export class BoundingBox extends Component { public center: Coord2D; @@ -15,8 +15,27 @@ export class BoundingBox extends Component { this.rotation = rotation ?? 0; } - // https://en.wikipedia.org/wiki/Hyperplane_separation_theorem public isCollidingWith(box: BoundingBox): boolean { + if (this.rotation == 0 && box.rotation == 0) { + const thisTopLeft = this.getTopLeft(); + const thisBottomRight = this.getBottomRight(); + + const thatTopLeft = box.getTopLeft(); + const thatBottomRight = box.getBottomRight(); + + if ( + thisBottomRight.x <= thatTopLeft.x || + thisTopLeft.x >= thatBottomRight.x || + thisBottomRight.y <= thatTopLeft.y || + thisTopLeft.y >= thatBottomRight.y + ) { + return false; + } + + return true; + } + + // https://en.wikipedia.org/wiki/Hyperplane_separation_theorem const boxes = [this.getVertices(), box.getVertices()]; for (const poly of boxes) { for (let i = 0; i < poly.length; i++) { @@ -29,8 +48,8 @@ export class BoundingBox extends Component { const projection = dotProduct(normal, vertex); return [Math.min(min, projection), Math.max(max, projection)]; }, - [Infinity, -Infinity], - ), + [Infinity, -Infinity] + ) ); if (maxThis < minBox || maxBox < minThis) return false; @@ -45,20 +64,22 @@ export class BoundingBox extends Component { { x: -this.dimension.width / 2, y: -this.dimension.height / 2 }, { x: -this.dimension.width / 2, y: this.dimension.height / 2 }, { x: this.dimension.width / 2, y: this.dimension.height / 2 }, - { x: this.dimension.width / 2, y: -this.dimension.height / 2 }, + { x: this.dimension.width / 2, y: -this.dimension.height / 2 } ] - .map((vertex) => rotateVector(vertex, this.rotation)) + .map((vertex) => rotateVector(vertex, this.rotation)) // rotate .map((vertex) => { + // translate return { x: vertex.x + this.center.x, - y: vertex.y + this.center.y, + y: vertex.y + this.center.y }; }); } - public getRotationInPiOfUnitCircle() { + public getRotationInPiOfUnitCircle(): number { let rads = this.rotation * (Math.PI / 180); if (rads >= Math.PI) { + // Physics system guarantees rotation \in [0, 360) rads -= Math.PI; } return rads; @@ -68,17 +89,33 @@ export class BoundingBox extends Component { let rads = this.getRotationInPiOfUnitCircle(); const { width, height } = this.dimension; + if (rads == 0) return this.dimension; + if (rads <= Math.PI / 2) { return { width: Math.abs(height * Math.sin(rads) + width * Math.cos(rads)), - height: Math.abs(width * Math.sin(rads) + height * Math.cos(rads)), + height: Math.abs(width * Math.sin(rads) + height * Math.cos(rads)) }; } rads -= Math.PI / 2; return { width: Math.abs(height * Math.cos(rads) + width * Math.sin(rads)), - height: Math.abs(width * Math.cos(rads) + height * Math.sin(rads)), + height: Math.abs(width * Math.cos(rads) + height * Math.sin(rads)) + }; + } + + public getTopLeft(): Coord2D { + return { + x: this.center.x - this.dimension.width / 2, + y: this.center.y - this.dimension.height / 2 + }; + } + + public getBottomRight(): Coord2D { + return { + x: this.center.x + this.dimension.width / 2, + y: this.center.y + this.dimension.height / 2 }; } } diff --git a/engine/components/Collide.ts b/engine/components/Collide.ts index 889ecf8..ed72b92 100644 --- a/engine/components/Collide.ts +++ b/engine/components/Collide.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class Collide extends Component { constructor() { diff --git a/engine/components/Control.ts b/engine/components/Control.ts index 1e782ee..d3987d7 100644 --- a/engine/components/Control.ts +++ b/engine/components/Control.ts @@ -1,11 +1,18 @@ -import { Component, ComponentNames, Velocity } from "."; +import { Component, ComponentNames, Velocity } from '.'; export class Control extends Component { - public controlVelocity: Velocity; + public controlVelocityComponent: Velocity; + public controllableBy: string; + public isControllable: boolean; // computed each update in the input system - constructor(controlVelocity: Velocity = new Velocity()) { + constructor( + controllableBy: string, + controlVelocityComponent: Velocity = new Velocity() + ) { super(ComponentNames.Control); - this.controlVelocity = controlVelocity; + this.controllableBy = controllableBy; + this.controlVelocityComponent = controlVelocityComponent; + this.isControllable = false; } } diff --git a/engine/components/FacingDirection.ts b/engine/components/FacingDirection.ts index 1c701a3..8c2a9d2 100644 --- a/engine/components/FacingDirection.ts +++ b/engine/components/FacingDirection.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames, Sprite } from "."; +import { Component, ComponentNames, Sprite } from '.'; export class FacingDirection extends Component { public readonly facingLeftSprite: Sprite; diff --git a/engine/components/Forces.ts b/engine/components/Forces.ts index 91ae1c1..e397985 100644 --- a/engine/components/Forces.ts +++ b/engine/components/Forces.ts @@ -1,6 +1,6 @@ -import type { Force2D } from "../interfaces"; -import { Component } from "./Component"; -import { ComponentNames } from "."; +import type { Force2D } from '../interfaces'; +import { Component } from './Component'; +import { ComponentNames } from '.'; /** * A list of forces and torque, (in newtons, and newton-meters respectively) diff --git a/engine/components/Gravity.ts b/engine/components/Gravity.ts index 89fcb67..dd6dd2e 100644 --- a/engine/components/Gravity.ts +++ b/engine/components/Gravity.ts @@ -1,7 +1,7 @@ -import { ComponentNames, Component } from "."; +import { ComponentNames, Component } from '.'; export class Gravity extends Component { - private static DEFAULT_TERMINAL_VELOCITY = 5; + private static DEFAULT_TERMINAL_VELOCITY = 4.5; public terminalVelocity: number; diff --git a/engine/components/Jump.ts b/engine/components/Jump.ts index 0b40767..6cbfb08 100644 --- a/engine/components/Jump.ts +++ b/engine/components/Jump.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class Jump extends Component { public canJump: boolean; diff --git a/engine/components/Mass.ts b/engine/components/Mass.ts index daa2d71..a7f98fd 100644 --- a/engine/components/Mass.ts +++ b/engine/components/Mass.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class Mass extends Component { public mass: number; diff --git a/engine/components/Moment.ts b/engine/components/Moment.ts index 3d0dd2f..cd76294 100644 --- a/engine/components/Moment.ts +++ b/engine/components/Moment.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class Moment extends Component { public inertia: number; diff --git a/engine/components/NetworkUpdateable.ts b/engine/components/NetworkUpdateable.ts new file mode 100644 index 0000000..014270c --- /dev/null +++ b/engine/components/NetworkUpdateable.ts @@ -0,0 +1,7 @@ +import { Component, ComponentNames } from '.'; + +export class NetworkUpdateable extends Component { + constructor() { + super(ComponentNames.NetworkUpdateable); + } +} diff --git a/engine/components/Sprite.ts b/engine/components/Sprite.ts index bdb4982..36b944e 100644 --- a/engine/components/Sprite.ts +++ b/engine/components/Sprite.ts @@ -1,5 +1,5 @@ -import { Component, ComponentNames } from "."; -import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces"; +import { Component, ComponentNames } from '.'; +import type { Dimension2D, DrawArgs, Coord2D } from '../interfaces'; export class Sprite extends Component { private sheet: HTMLImageElement; @@ -17,7 +17,7 @@ export class Sprite extends Component { spriteImgPos: Coord2D, spriteImgDimensions: Dimension2D, msPerFrame: number, - numFrames: number, + numFrames: number ) { super(ComponentNames.Sprite); @@ -56,12 +56,12 @@ export class Sprite extends Component { ctx.drawImage( this.sheet, ...this.getSpriteArgs(), - ...this.getDrawArgs(drawArgs), + ...this.getDrawArgs(drawArgs) ); if (tint) { ctx.globalAlpha = 0.5; - ctx.globalCompositeOperation = "source-atop"; + ctx.globalCompositeOperation = 'source-atop'; ctx.fillStyle = tint; ctx.fillRect(...this.getDrawArgs(drawArgs)); } @@ -74,19 +74,23 @@ export class Sprite extends Component { this.spriteImgPos.x + this.currentFrame * this.spriteImgDimensions.width, this.spriteImgPos.y, this.spriteImgDimensions.width, - this.spriteImgDimensions.height, + this.spriteImgDimensions.height ]; } private getDrawArgs({ center, - dimension, + dimension }: DrawArgs): [dx: number, dy: number, dw: number, dh: number] { return [ center.x - dimension.width / 2, center.y - dimension.height / 2, dimension.width, - dimension.height, + dimension.height ]; } + + public getSpriteDimensions() { + return this.spriteImgDimensions; + } } diff --git a/engine/components/TopCollidable.ts b/engine/components/TopCollidable.ts index 7fb147d..05ce484 100644 --- a/engine/components/TopCollidable.ts +++ b/engine/components/TopCollidable.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class TopCollidable extends Component { constructor() { diff --git a/engine/components/Velocity.ts b/engine/components/Velocity.ts index 068d8cd..0071891 100644 --- a/engine/components/Velocity.ts +++ b/engine/components/Velocity.ts @@ -1,23 +1,23 @@ -import type { Velocity2D } from "../interfaces"; -import { Component } from "./Component"; -import { ComponentNames } from "."; +import type { Velocity2D } from '../interfaces'; +import { Component } from './Component'; +import { ComponentNames } from '.'; export class Velocity extends Component { - public dCartesian: Velocity2D; - public dTheta: number; + public velocity: Velocity2D; - constructor(dCartesian: Velocity2D = { dx: 0, dy: 0 }, dTheta: number = 0) { + constructor( + velocity: Velocity2D = { dCartesian: { dx: 0, dy: 0 }, dTheta: 0 } + ) { super(ComponentNames.Velocity); - this.dCartesian = dCartesian; - this.dTheta = dTheta; + this.velocity = velocity; } - public add(velocity?: Velocity) { + public add(velocity?: Velocity2D) { if (velocity) { - this.dCartesian.dx += velocity.dCartesian.dx; - this.dCartesian.dy += velocity.dCartesian.dy; - this.dTheta += velocity.dTheta; + this.velocity.dCartesian.dx += velocity.dCartesian.dx; + this.velocity.dCartesian.dy += velocity.dCartesian.dy; + this.velocity.dTheta += velocity.dTheta; } } } diff --git a/engine/components/WallBounded.ts b/engine/components/WallBounded.ts index 5f787e1..c1745a8 100644 --- a/engine/components/WallBounded.ts +++ b/engine/components/WallBounded.ts @@ -1,4 +1,4 @@ -import { Component, ComponentNames } from "."; +import { Component, ComponentNames } from '.'; export class WallBounded extends Component { constructor() { diff --git a/engine/components/index.ts b/engine/components/index.ts index 67f1259..6d7c1e5 100644 --- a/engine/components/index.ts +++ b/engine/components/index.ts @@ -1,15 +1,16 @@ -export * from "./Component"; -export * from "./BoundingBox"; -export * from "./Velocity"; -export * from "./Forces"; -export * from "./Sprite"; -export * from "./FacingDirection"; -export * from "./Jump"; -export * from "./TopCollidable"; -export * from "./Collide"; -export * from "./Control"; -export * from "./WallBounded"; -export * from "./Gravity"; -export * from "./Mass"; -export * from "./Moment"; -export * from "./names"; +export * from './Component'; +export * from './BoundingBox'; +export * from './Velocity'; +export * from './Forces'; +export * from './Sprite'; +export * from './FacingDirection'; +export * from './Jump'; +export * from './TopCollidable'; +export * from './Collide'; +export * from './Control'; +export * from './WallBounded'; +export * from './Gravity'; +export * from './Mass'; +export * from './Moment'; +export * from './NetworkUpdateable'; +export * from './names'; diff --git a/engine/components/names.ts b/engine/components/names.ts index e2ee3d3..97b4edd 100644 --- a/engine/components/names.ts +++ b/engine/components/names.ts @@ -1,15 +1,16 @@ export namespace ComponentNames { - export const Sprite = "Sprite"; - export const BoundingBox = "BoundingBox"; - export const Velocity = "Velocity"; - export const FacingDirection = "FacingDirection"; - export const Control = "Control"; - export const Jump = "Jump"; - export const TopCollidable = "TopCollidable"; - export const Collide = "Collide"; - export const WallBounded = "WallBounded"; - export const Gravity = "Gravity"; - export const Forces = "Forces"; - export const Mass = "Mass"; - export const Moment = "Moment"; + export const Sprite = 'Sprite'; + export const BoundingBox = 'BoundingBox'; + export const Velocity = 'Velocity'; + export const FacingDirection = 'FacingDirection'; + export const Control = 'Control'; + export const Jump = 'Jump'; + export const TopCollidable = 'TopCollidable'; + export const Collide = 'Collide'; + export const WallBounded = 'WallBounded'; + export const Gravity = 'Gravity'; + export const Forces = 'Forces'; + export const Mass = 'Mass'; + export const Moment = 'Moment'; + export const NetworkUpdateable = 'NetworkUpdateable'; } diff --git a/engine/config/assets.ts b/engine/config/assets.ts index 173bab3..289f181 100644 --- a/engine/config/assets.ts +++ b/engine/config/assets.ts @@ -1,10 +1,10 @@ -import type { SpriteSpec } from "./sprites"; -import { SPRITE_SPECS } from "./sprites"; +import type { SpriteSpec } from './sprites'; +import { SPRITE_SPECS } from './sprites'; export const IMAGES = new Map(); export const loadSpritesIntoImageElements = ( - spriteSpecs: Partial[], + spriteSpecs: Partial[] ): Promise[] => { const spritePromises: Promise[] = []; @@ -17,13 +17,13 @@ export const loadSpritesIntoImageElements = ( spritePromises.push( new Promise((resolve) => { img.onload = () => resolve(); - }), + }) ); } if (spriteSpec.states) { spritePromises.push( - ...loadSpritesIntoImageElements(Array.from(spriteSpec.states.values())), + ...loadSpritesIntoImageElements(Array.from(spriteSpec.states.values())) ); } } @@ -35,8 +35,8 @@ export const loadAssets = () => Promise.all([ ...loadSpritesIntoImageElements( Array.from(SPRITE_SPECS.keys()).map( - (key) => SPRITE_SPECS.get(key) as SpriteSpec, - ), - ), + (key) => SPRITE_SPECS.get(key) as SpriteSpec + ) + ) // TODO: Sound ]); diff --git a/engine/config/constants.ts b/engine/config/constants.ts index 3d536d3..dc98ad0 100644 --- a/engine/config/constants.ts +++ b/engine/config/constants.ts @@ -1,34 +1,39 @@ -import { Action } from "../interfaces"; +import { Action } from '../interfaces'; export namespace KeyConstants { export const KeyActions: Record = { a: Action.MOVE_LEFT, - ArrowLeft: Action.MOVE_LEFT, + arrowleft: Action.MOVE_LEFT, + d: Action.MOVE_RIGHT, - ArrowRight: Action.MOVE_RIGHT, + arrowright: Action.MOVE_RIGHT, + w: Action.JUMP, - ArrowUp: Action.JUMP, + arrowup: Action.JUMP, + + ' ': Action.JUMP }; + // value -> [key] from KeyActions export const ActionKeys: Map = Object.keys( - KeyActions, + KeyActions ).reduce((acc: Map, key) => { - const action = KeyActions[key]; + const action = KeyActions[key.toLowerCase()]; if (acc.has(action)) { - acc.get(action)?.push(key); + acc.get(action)!.push(key); return acc; } acc.set(action, [key]); return acc; - }, new Map()); + }, new Map()); } export namespace PhysicsConstants { export const MAX_JUMP_TIME_MS = 150; export const GRAVITY = 0.0075; - export const PLAYER_MOVE_VEL = 1; + export const PLAYER_MOVE_VEL = 0.8; export const PLAYER_JUMP_ACC = -0.008; export const PLAYER_JUMP_INITIAL_VEL = -1; } @@ -36,4 +41,7 @@ export namespace PhysicsConstants { export namespace Miscellaneous { export const WIDTH = 600; export const HEIGHT = 800; + + export const DEFAULT_GRID_WIDTH = 30; + export const DEFAULT_GRID_HEIGHT = 30; } diff --git a/engine/config/index.ts b/engine/config/index.ts index 7a1052a..03b2246 100644 --- a/engine/config/index.ts +++ b/engine/config/index.ts @@ -1,3 +1,3 @@ -export * from "./constants"; -export * from "./assets.ts"; -export * from "./sprites.ts"; +export * from './constants'; +export * from './assets.ts'; +export * from './sprites.ts'; diff --git a/engine/config/sprites.ts b/engine/config/sprites.ts index 1f65c18..e5fcd31 100644 --- a/engine/config/sprites.ts +++ b/engine/config/sprites.ts @@ -1,7 +1,7 @@ export enum Sprites { FLOOR, TRAMPOLINE, - COFFEE, + COFFEE } export interface SpriteSpec { @@ -22,12 +22,12 @@ const floorSpriteSpec = { height: 40, frames: 3, msPerFrame: 125, - states: new Map>(), + states: new Map>() }; [40, 80, 120, 160].forEach((width) => { floorSpriteSpec.states.set(width, { width, - sheet: `/assets/floor_tile_${width}.png`, + sheet: `/assets/floor_tile_${width}.png` }); }); SPRITE_SPECS.set(Sprites.FLOOR, floorSpriteSpec); @@ -37,12 +37,12 @@ const coffeeSpriteSpec = { width: 60, height: 45, frames: 3, - states: new Map>(), + states: new Map>() }; -coffeeSpriteSpec.states.set("LEFT", { - sheet: "/assets/coffee_left.png", +coffeeSpriteSpec.states.set('LEFT', { + sheet: '/assets/coffee_left.png' }); -coffeeSpriteSpec.states.set("RIGHT", { - sheet: "/assets/coffee_right.png", +coffeeSpriteSpec.states.set('RIGHT', { + sheet: '/assets/coffee_right.png' }); SPRITE_SPECS.set(Sprites.COFFEE, coffeeSpriteSpec); diff --git a/engine/entities/Entity.ts b/engine/entities/Entity.ts index ca8d314..63fb370 100644 --- a/engine/entities/Entity.ts +++ b/engine/entities/Entity.ts @@ -1,13 +1,17 @@ -import type { Component } from "../components"; +import { EntityNames, Floor, Player } from '.'; +import { type Component } from '../components'; + +const randomId = () => + (performance.now() + Math.random() * 10_000_000).toString(); export abstract class Entity { - private static ID = 0; + public id: string; + public components: Map; + public name: string; - public readonly id: number; - public readonly components: Map; - - constructor() { - this.id = Entity.ID++; + constructor(name: string, id: string = randomId()) { + this.name = name; + this.id = id; this.components = new Map(); } @@ -17,7 +21,7 @@ export abstract class Entity { public getComponent(name: string): T { if (!this.hasComponent(name)) { - throw new Error("Entity does not have component " + name); + throw new Error('Entity does not have component ' + name); } return this.components.get(name) as T; } @@ -29,4 +33,30 @@ export abstract class Entity { public hasComponent(name: string): boolean { return this.components.has(name); } + + public static from(entityName: string, id: string, args: any): Entity { + let entity: Entity; + + switch (entityName) { + case EntityNames.Player: + const player = new Player(); + player.setFrom(args); + entity = player; + break; + case EntityNames.Floor: + const floor = new Floor(args.floorWidth); + floor.setFrom(args); + entity = floor; + break; + default: + throw new Error('.from() Entity type not implemented: ' + entityName); + } + + entity.id = id; + return entity; + } + + public abstract setFrom(args: Record): void; + + public abstract serialize(): Record; } diff --git a/engine/entities/Floor.ts b/engine/entities/Floor.ts index 44587e6..b4f48e5 100644 --- a/engine/entities/Floor.ts +++ b/engine/entities/Floor.ts @@ -1,15 +1,19 @@ -import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config"; -import { BoundingBox, Sprite } from "../components"; -import { TopCollidable } from "../components/TopCollidable"; -import { Entity } from "../entities"; +import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config'; +import { BoundingBox, ComponentNames, Sprite } from '../components'; +import { TopCollidable } from '../components/TopCollidable'; +import { Entity, EntityNames } from '../entities'; export class Floor extends Entity { private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( - Sprites.FLOOR, + Sprites.FLOOR ) as SpriteSpec; + private width: number; + constructor(width: number) { - super(); + super(EntityNames.Floor); + + this.width = width; this.addComponent( new Sprite( @@ -17,17 +21,28 @@ export class Floor extends Entity { { x: 0, y: 0 }, { width, height: Floor.spriteSpec.height }, Floor.spriteSpec.msPerFrame, - Floor.spriteSpec.frames, - ), - ); - - this.addComponent( - new BoundingBox( - { x: 300, y: 300 }, - { width, height: Floor.spriteSpec.height }, - ), + Floor.spriteSpec.frames + ) ); this.addComponent(new TopCollidable()); } + + public serialize() { + return { + floorWidth: this.width, + boundingBox: this.getComponent(ComponentNames.BoundingBox) + }; + } + + public setFrom(args: any) { + const { boundingBox } = args; + this.addComponent( + new BoundingBox( + boundingBox.center, + boundingBox.dimension, + boundingBox.rotation + ) + ); + } } diff --git a/engine/entities/Player.ts b/engine/entities/Player.ts index 45d7500..4d91c6f 100644 --- a/engine/entities/Player.ts +++ b/engine/entities/Player.ts @@ -1,5 +1,5 @@ -import { Entity } from "."; -import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config"; +import { Entity, EntityNames } from '.'; +import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config'; import { Jump, FacingDirection, @@ -10,32 +10,38 @@ import { WallBounded, Forces, Collide, - Control, Mass, Moment, -} from "../components"; -import { Direction } from "../interfaces"; + ComponentNames, + Control +} from '../components'; +import { Direction } from '../interfaces'; export class Player extends Entity { private static MASS: number = 10; - private static MOI: number = 1000; + private static MOI: number = 100; private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( - Sprites.COFFEE, + Sprites.COFFEE ) as SpriteSpec; constructor() { - super(); + super(EntityNames.Player); this.addComponent( new BoundingBox( - { x: 300, y: 100 }, + { + x: 0, + y: 0 + }, { width: Player.spriteSpec.width, height: Player.spriteSpec.height }, - 0, - ), + 0 + ) ); - this.addComponent(new Velocity({ dx: 0, dy: 0 }, 0)); + this.addComponent( + new Velocity({ dCartesian: { dx: 0, dy: 0 }, dTheta: 0 }) + ); this.addComponent(new Mass(Player.MASS)); this.addComponent(new Moment(Player.MOI)); @@ -43,7 +49,6 @@ export class Player extends Entity { this.addComponent(new Gravity()); this.addComponent(new Jump()); - this.addComponent(new Control()); this.addComponent(new Collide()); this.addComponent(new WallBounded()); @@ -59,11 +64,41 @@ export class Player extends Entity { { x: 0, y: 0 }, { width: Player.spriteSpec.width, height: Player.spriteSpec.height }, Player.spriteSpec.msPerFrame, - Player.spriteSpec.frames, - ), + Player.spriteSpec.frames + ) ); this.addComponent(new FacingDirection(leftSprite, rightSprite)); - this.addComponent(leftSprite); // face Left by default + this.addComponent(leftSprite); // face left by default + } + + public serialize(): Record { + return { + control: this.getComponent(ComponentNames.Control), + boundingBox: this.getComponent(ComponentNames.BoundingBox), + velocity: this.getComponent(ComponentNames.Velocity), + forces: this.getComponent(ComponentNames.Forces) + }; + } + + public setFrom(args: Record) { + const { control, velocity, forces, boundingBox } = args; + + let center = boundingBox.center; + + const myCenter = this.getComponent( + ComponentNames.BoundingBox + ).center; + const distance = Math.sqrt( + Math.pow(center.y - myCenter.y, 2) + Math.pow(center.x - myCenter.x, 2) + ); + if (distance < 30) center = myCenter; + + [ + Object.assign(new Control(control.controllableBy), control), + new Velocity(velocity.velocity), + new Forces(forces.forces), + new BoundingBox(center, boundingBox.dimension, boundingBox.rotation) + ].forEach((component) => this.addComponent(component)); } } diff --git a/engine/entities/index.ts b/engine/entities/index.ts index a921512..8aee83c 100644 --- a/engine/entities/index.ts +++ b/engine/entities/index.ts @@ -1,3 +1,4 @@ -export * from "./Entity"; -export * from "./Floor"; -export * from "./Player"; +export * from './Entity'; +export * from './Floor'; +export * from './Player'; +export * from './names'; diff --git a/engine/entities/names.ts b/engine/entities/names.ts new file mode 100644 index 0000000..cf65f9f --- /dev/null +++ b/engine/entities/names.ts @@ -0,0 +1,4 @@ +export namespace EntityNames { + export const Player = 'Player'; + export const Floor = 'Floor'; +} diff --git a/engine/interfaces/Action.ts b/engine/interfaces/Action.ts index 61c89e1..f0e6a66 100644 --- a/engine/interfaces/Action.ts +++ b/engine/interfaces/Action.ts @@ -1,5 +1,5 @@ export enum Action { MOVE_LEFT, MOVE_RIGHT, - JUMP, + JUMP } diff --git a/engine/interfaces/Direction.ts b/engine/interfaces/Direction.ts index 0bc6ef3..af1c7ac 100644 --- a/engine/interfaces/Direction.ts +++ b/engine/interfaces/Direction.ts @@ -1,6 +1,6 @@ export enum Direction { - UP = "UP", - DOWN = "DOWN", - LEFT = "LEFT", - RIGHT = "RIGHT", + UP = 'UP', + DOWN = 'DOWN', + LEFT = 'LEFT', + RIGHT = 'RIGHT' } diff --git a/engine/interfaces/Draw.ts b/engine/interfaces/Draw.ts index 6561a01..8479fe4 100644 --- a/engine/interfaces/Draw.ts +++ b/engine/interfaces/Draw.ts @@ -1,4 +1,4 @@ -import type { Coord2D, Dimension2D } from "./"; +import type { Coord2D, Dimension2D } from './'; export interface DrawArgs { center: Coord2D; diff --git a/engine/interfaces/Vec2.ts b/engine/interfaces/Vec2.ts index b2bae37..04be4be 100644 --- a/engine/interfaces/Vec2.ts +++ b/engine/interfaces/Vec2.ts @@ -9,8 +9,11 @@ export interface Dimension2D { } export interface Velocity2D { - dx: number; - dy: number; + dCartesian: { + dx: number; + dy: number; + }; + dTheta: number; } export interface Force2D { diff --git a/engine/interfaces/index.ts b/engine/interfaces/index.ts index 8cdf4d8..c2f6896 100644 --- a/engine/interfaces/index.ts +++ b/engine/interfaces/index.ts @@ -1,4 +1,4 @@ -export * from "./Vec2"; -export * from "./Draw"; -export * from "./Direction"; -export * from "./Action"; +export * from './Vec2'; +export * from './Draw'; +export * from './Direction'; +export * from './Action'; diff --git a/engine/network/index.ts b/engine/network/index.ts new file mode 100644 index 0000000..5dc7ece --- /dev/null +++ b/engine/network/index.ts @@ -0,0 +1,37 @@ +export enum MessageType { + NEW_ENTITIES = 'NEW_ENTITIES', + REMOVE_ENTITIES = 'REMOVE_ENTITIES', + UPDATE_ENTITIES = 'UPDATE_ENTITIES', + NEW_INPUT = 'NEW_INPUT', + REMOVE_INPUT = 'REMOVE_INPUT' +} + +export type EntityAddBody = { + entityName: string; + id: string; + args: Record; +}; + +export type EntityUpdateBody = { + id: string; + args: Record; +}; + +export type Message = { + type: MessageType; + body: any; +}; + +export interface MessageQueueProvider { + getNewMessages(): Message[]; + clearMessages(): void; +} + +export interface MessagePublisher { + addMessage(message: Message): void; + publish(): void; +} + +export interface MessageProcessor { + process(message: Message): void; +} diff --git a/engine/structures/Grid.ts b/engine/structures/Grid.ts new file mode 100644 index 0000000..5f0e053 --- /dev/null +++ b/engine/structures/Grid.ts @@ -0,0 +1,104 @@ +import type { Coord2D, Dimension2D } from '../interfaces'; +import type { BoxedEntry, RefreshingCollisionFinderBehavior } from '.'; +import { Miscellaneous } from '../config/constants'; + +export class Grid implements RefreshingCollisionFinderBehavior { + private cellEntities: Map; + + private gridDimension: Dimension2D; + private cellDimension: Dimension2D; + private topLeft: Coord2D; + + constructor( + gridDimension: Dimension2D = { + width: Miscellaneous.WIDTH, + height: Miscellaneous.HEIGHT + }, + cellDimension: Dimension2D = { + width: Miscellaneous.DEFAULT_GRID_WIDTH, + height: Miscellaneous.DEFAULT_GRID_HEIGHT + }, + topLeft = { x: 0, y: 0 } + ) { + this.gridDimension = gridDimension; + this.cellDimension = cellDimension; + this.topLeft = topLeft; + + this.cellEntities = new Map(); + } + + public insert(boxedEntry: BoxedEntry) { + this.getOverlappingCells(boxedEntry).forEach((gridIdx) => { + if (!this.cellEntities.has(gridIdx)) { + this.cellEntities.set(gridIdx, []); + } + this.cellEntities.get(gridIdx)!.push(boxedEntry.id); + }); + } + + public getNeighborIds(boxedEntry: BoxedEntry): Set { + const neighborIds: Set = new Set(); + this.getOverlappingCells(boxedEntry).forEach((gridIdx) => { + if (this.cellEntities.has(gridIdx)) { + this.cellEntities.get(gridIdx)!.forEach((id) => neighborIds.add(id)); + } + }); + return neighborIds; + } + + public clear() { + this.cellEntities.clear(); + } + + public setTopLeft(topLeft: Coord2D) { + this.topLeft = topLeft; + } + + public setDimension(dimension: Dimension2D) { + this.gridDimension = dimension; + } + + public setCellDimension(cellDimension: Dimension2D) { + this.cellDimension = cellDimension; + } + + private getOverlappingCells(boxedEntry: BoxedEntry): number[] { + const { center, dimension } = boxedEntry; + const yBoxes = Math.ceil( + this.gridDimension.height / this.cellDimension.height + ); + const xBoxes = Math.ceil( + this.gridDimension.width / this.cellDimension.width + ); + + const translated: Coord2D = { + y: center.y - this.topLeft.y, + x: center.x - this.topLeft.x + }; + + const topLeftBox = { + x: Math.floor( + (translated.x - dimension.width / 2) / this.cellDimension.width + ), + y: Math.floor( + (translated.y - dimension.height / 2) / this.cellDimension.height + ) + }; + const bottomRightBox = { + x: Math.floor( + (translated.x + dimension.width / 2) / this.cellDimension.width + ), + y: Math.floor( + (translated.y + dimension.height / 2) / this.cellDimension.height + ) + }; + + const cells: number[] = []; + + for (let y = topLeftBox.y; y <= bottomRightBox.y; ++y) + for (let x = topLeftBox.x; x <= bottomRightBox.x; ++x) + cells.push(yBoxes * y + x); + + return cells; + } +} diff --git a/engine/structures/QuadTree.ts b/engine/structures/QuadTree.ts index d1ff3b1..93702d0 100644 --- a/engine/structures/QuadTree.ts +++ b/engine/structures/QuadTree.ts @@ -1,19 +1,21 @@ -import type { Coord2D, Dimension2D } from "../interfaces"; - -interface BoxedEntry { - id: number; - dimension: Dimension2D; - center: Coord2D; -} +import type { Coord2D, Dimension2D } from '../interfaces'; +import type { BoxedEntry, RefreshingCollisionFinderBehavior } from '.'; enum Quadrant { I, II, III, - IV, + IV } -export class QuadTree { +/* + unused due to performance problems. here anyways, in case it _really_ is necessary at some point + (and to justify the amount of time i spent here). +*/ +export class QuadTree implements RefreshingCollisionFinderBehavior { + private static readonly QUADTREE_MAX_LEVELS = 3; + private static readonly QUADTREE_SPLIT_THRESHOLD = 2000; + private maxLevels: number; private splitThreshold: number; private level: number; @@ -24,34 +26,33 @@ export class QuadTree { private objects: BoxedEntry[]; constructor( - topLeft: Coord2D, + topLeft: Coord2D = { x: 0, y: 0 }, dimension: Dimension2D, - maxLevels: number, - splitThreshold: number, - level?: number, + maxLevels: number = QuadTree.QUADTREE_MAX_LEVELS, + splitThreshold: number = QuadTree.QUADTREE_SPLIT_THRESHOLD, + level: number = 0 ) { this.children = new Map(); this.objects = []; this.maxLevels = maxLevels; this.splitThreshold = splitThreshold; - this.level = level ?? 0; + this.level = level; this.topLeft = topLeft; this.dimension = dimension; } - public insert(id: number, dimension: Dimension2D, center: Coord2D): void { - const box: BoxedEntry = { id, center, dimension }; + public insert(boxedEntry: BoxedEntry): void { if (this.hasChildren()) { - this.getQuadrants(box).forEach((quadrant) => { + this.getQuadrants(boxedEntry).forEach((quadrant) => { const quadrantBox = this.children.get(quadrant); - quadrantBox?.insert(id, dimension, center); + quadrantBox!.insert(boxedEntry); }); return; } - this.objects.push({ id, dimension, center }); + this.objects.push(boxedEntry); if ( this.objects.length > this.splitThreshold && @@ -66,22 +67,24 @@ export class QuadTree { public clear(): void { this.objects = []; + if (this.hasChildren()) { this.children.forEach((child) => child.clear()); this.children.clear(); } } - public getNeighborIds(boxedEntry: BoxedEntry): number[] { - const neighbors: number[] = this.objects.map(({ id }) => id); + public getNeighborIds(boxedEntry: BoxedEntry): Set { + const neighbors = new Set( + this.objects.map(({ id }) => id).filter((id) => id != boxedEntry.id) + ); if (this.hasChildren()) { this.getQuadrants(boxedEntry).forEach((quadrant) => { const quadrantBox = this.children.get(quadrant); - quadrantBox ?.getNeighborIds(boxedEntry) - .forEach((id) => neighbors.push(id)); + .forEach((id) => neighbors.add(id)); }); } @@ -99,9 +102,9 @@ export class QuadTree { [Quadrant.III, { x: this.topLeft.x, y: this.topLeft.y + halfHeight }], [ Quadrant.IV, - { x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight }, - ], - ] as [[Quadrant, Coord2D]] + { x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight } + ] + ] as [Quadrant, Coord2D][] ).forEach(([quadrant, pos]) => { this.children.set( quadrant, @@ -110,8 +113,8 @@ export class QuadTree { { width: halfWidth, height: halfHeight }, this.maxLevels, this.splitThreshold, - this.level + 1, - ), + this.level + 1 + ) ); }); } @@ -119,52 +122,48 @@ export class QuadTree { private getQuadrants(boxedEntry: BoxedEntry): Quadrant[] { const treeCenter: Coord2D = { x: this.topLeft.x + this.dimension.width / 2, - y: this.topLeft.y + this.dimension.height / 2, + y: this.topLeft.y + this.dimension.height / 2 }; return ( [ [ Quadrant.I, - (x: number, y: number) => x >= treeCenter.x && y < treeCenter.y, + (x: number, y: number) => x >= treeCenter.x && y < treeCenter.y ], [ Quadrant.II, - (x: number, y: number) => x < treeCenter.x && y < treeCenter.y, + (x: number, y: number) => x < treeCenter.x && y < treeCenter.y ], [ Quadrant.III, - (x: number, y: number) => x < treeCenter.x && y >= treeCenter.y, + (x: number, y: number) => x < treeCenter.x && y >= treeCenter.y ], [ Quadrant.IV, - (x: number, y: number) => x >= treeCenter.x && y >= treeCenter.y, - ], - ] as [[Quadrant, (x: number, y: number) => boolean]] + (x: number, y: number) => x >= treeCenter.x && y >= treeCenter.y + ] + ] as [Quadrant, (x: number, y: number) => boolean][] ) .filter( ([_quadrant, condition]) => condition( boxedEntry.center.x + boxedEntry.dimension.width / 2, - boxedEntry.center.y + boxedEntry.dimension.height / 2, + boxedEntry.center.y + boxedEntry.dimension.height / 2 ) || condition( boxedEntry.center.x - boxedEntry.dimension.width / 2, - boxedEntry.center.y - boxedEntry.dimension.height / 2, - ), + boxedEntry.center.y - boxedEntry.dimension.height / 2 + ) ) .map(([quadrant]) => quadrant); } private realignObjects(): void { this.objects.forEach((boxedEntry) => { - this.getQuadrants(boxedEntry).forEach((direction) => { - const quadrant = this.children.get(direction); - quadrant?.insert( - boxedEntry.id, - boxedEntry.dimension, - boxedEntry.center, - ); + this.getQuadrants(boxedEntry).forEach((quadrant) => { + const quadrantBox = this.children.get(quadrant); + quadrantBox!.insert(boxedEntry); }); }); @@ -174,4 +173,12 @@ export class QuadTree { private hasChildren() { return this.children && this.children.size > 0; } + + public setTopLeft(topLeft: Coord2D) { + this.topLeft = topLeft; + } + + public setDimension(dimension: Dimension2D) { + this.dimension = dimension; + } } diff --git a/engine/structures/RefreshingCollisionFinderBehavior.ts b/engine/structures/RefreshingCollisionFinderBehavior.ts new file mode 100644 index 0000000..573ddd8 --- /dev/null +++ b/engine/structures/RefreshingCollisionFinderBehavior.ts @@ -0,0 +1,14 @@ +import type { Coord2D, Dimension2D } from '../interfaces'; + +export interface BoxedEntry { + id: string; + dimension: Dimension2D; + center: Coord2D; +} + +export interface RefreshingCollisionFinderBehavior { + clear(): void; + insert(boxedEntry: BoxedEntry): void; + getNeighborIds(boxedEntry: BoxedEntry): Set; + setTopLeft(topLeft: Coord2D): void; +} diff --git a/engine/structures/index.ts b/engine/structures/index.ts index 605a82a..679dbd4 100644 --- a/engine/structures/index.ts +++ b/engine/structures/index.ts @@ -1 +1,3 @@ -export * from "./QuadTree"; +export * from './RefreshingCollisionFinderBehavior'; +export * from './QuadTree'; +export * from './Grid'; diff --git a/engine/systems/Collision.ts b/engine/systems/Collision.ts index 2bba03b..4a838dd 100644 --- a/engine/systems/Collision.ts +++ b/engine/systems/Collision.ts @@ -1,61 +1,59 @@ -import { SystemNames, System } from "."; +import { SystemNames, System } from '.'; import { Mass, BoundingBox, ComponentNames, Jump, Velocity, - Forces, -} from "../components"; -import { Game } from "../Game"; -import { PhysicsConstants } from "../config"; -import { Entity } from "../entities"; -import type { Dimension2D } from "../interfaces"; -import { QuadTree } from "../structures"; + Forces +} from '../components'; +import { Game } from '../Game'; +import { Miscellaneous, PhysicsConstants } from '../config'; +import { Entity } from '../entities'; +import type { Coord2D, Dimension2D, Velocity2D } from '../interfaces'; +import { BoxedEntry, RefreshingCollisionFinderBehavior } from '../structures'; export class Collision extends System { private static readonly COLLIDABLE_COMPONENT_NAMES = [ ComponentNames.Collide, - ComponentNames.TopCollidable, + ComponentNames.TopCollidable ]; - private static readonly QUADTREE_MAX_LEVELS = 10; - private static readonly QUADTREE_SPLIT_THRESHOLD = 10; - private quadTree: QuadTree; + private collisionFinder: RefreshingCollisionFinderBehavior; - constructor(screenDimensions: Dimension2D) { + constructor(refreshingCollisionFinder: RefreshingCollisionFinderBehavior) { super(SystemNames.Collision); - this.quadTree = new QuadTree( - { x: 0, y: 0 }, - screenDimensions, - Collision.QUADTREE_MAX_LEVELS, - Collision.QUADTREE_SPLIT_THRESHOLD, - ); + this.collisionFinder = refreshingCollisionFinder; } public update(_dt: number, game: Game) { - // rebuild the quadtree - this.quadTree.clear(); + this.collisionFinder.clear(); - const entitiesToAddToQuadtree: Entity[] = []; + const entitiesToAddToCollisionFinder: Entity[] = []; Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) => - game.componentEntities.get(componentName), - ).forEach( - (entityIds?: Set) => - entityIds?.forEach((id) => { - const entity = game.entities.get(id); - if (!entity || !entity.hasComponent(ComponentNames.BoundingBox)) { - return; - } - entitiesToAddToQuadtree.push(entity); - }), + game.forEachEntityWithComponent(componentName, (entity) => { + if (!entity.hasComponent(ComponentNames.BoundingBox)) { + return; + } + entitiesToAddToCollisionFinder.push(entity); + }) ); - entitiesToAddToQuadtree.forEach((entity) => { + this.insertEntitiesAndUpdateBounds(entitiesToAddToCollisionFinder); + this.findCollidingEntitiesAndCollide(entitiesToAddToCollisionFinder, game); + } + + private insertEntitiesAndUpdateBounds(entities: Entity[]) { + const collisionFinderInsertions: BoxedEntry[] = []; + + const topLeft: Coord2D = { x: Infinity, y: Infinity }; + const bottomRight: Coord2D = { x: -Infinity, y: -Infinity }; + + entities.forEach((entity) => { const boundingBox = entity.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); let dimension = { ...boundingBox.dimension }; @@ -63,18 +61,43 @@ export class Collision extends System { dimension = boundingBox.getOutscribedBoxDims(); } - this.quadTree.insert(entity.id, dimension, boundingBox.center); + const { center } = boundingBox; + const topLeftBoundingBox = boundingBox.getTopLeft(); + const bottomRightBoundingBox = boundingBox.getBottomRight(); + + topLeft.x = Math.min(topLeftBoundingBox.x, topLeft.x); + topLeft.y = Math.min(topLeftBoundingBox.y, topLeft.y); + bottomRight.x = Math.max(bottomRightBoundingBox.x, bottomRight.x); + bottomRight.y = Math.max(bottomRightBoundingBox.y, bottomRight.y); + + collisionFinderInsertions.push({ + id: entity.id, + dimension, + center + }); }); - // find colliding entities and perform collisions - const collidingEntities = this.getCollidingEntities( - entitiesToAddToQuadtree, - game, + // set bounds first + if (entities.length > 0) { + this.collisionFinder.setTopLeft(topLeft); + this.collisionFinder.setDimension({ + width: bottomRight.x - topLeft.x, + height: bottomRight.y - topLeft.y + }); + } + + // then, begin insertions + collisionFinderInsertions.forEach((boxedEntry: BoxedEntry) => + this.collisionFinder.insert(boxedEntry) ); + } + + private findCollidingEntitiesAndCollide(entities: Entity[], game: Game) { + const collidingEntities = this.getCollidingEntities(entities, game); collidingEntities.forEach(([entityAId, entityBId]) => { const [entityA, entityB] = [entityAId, entityBId].map((id) => - game.entities.get(id), + game.entities.get(id) ); if (entityA && entityB) { this.performCollision(entityA, entityB); @@ -84,12 +107,14 @@ export class Collision extends System { private performCollision(entityA: Entity, entityB: Entity) { const [entityABoundingBox, entityBBoundingBox] = [entityA, entityB].map( - (entity) => entity.getComponent(ComponentNames.BoundingBox), + (entity) => entity.getComponent(ComponentNames.BoundingBox) ); - let velocity = new Velocity(); + let velocity: Velocity2D = { dCartesian: { dx: 0, dy: 0 }, dTheta: 0 }; if (entityA.hasComponent(ComponentNames.Velocity)) { - velocity = entityA.getComponent(ComponentNames.Velocity); + velocity = entityA.getComponent( + ComponentNames.Velocity + ).velocity; } if ( @@ -100,7 +125,7 @@ export class Collision extends System { ) { if (entityBBoundingBox.rotation != 0) { throw new Error( - `entity with id ${entityB.id} has TopCollidable component and a non-zero rotation. that is not (yet) supported.`, + `entity with id ${entityB.id} has TopCollidable component and a non-zero rotation. that is not (yet) supported.` ); } @@ -114,7 +139,7 @@ export class Collision extends System { entityA.getComponent(ComponentNames.Forces).forces.push({ fCartesian: { fy: F_n, fx: 0 }, - torque: 0, + torque: 0 }); } @@ -132,35 +157,33 @@ export class Collision extends System { private getCollidingEntities( collidableEntities: Entity[], - game: Game, - ): [number, number][] { - const collidingEntityIds: [number, number][] = []; + game: Game + ): [string, string][] { + const collidingEntityIds: [string, string][] = []; for (const entity of collidableEntities) { const boundingBox = entity.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); - const neighborIds = this.quadTree - .getNeighborIds({ - id: entity.id, - dimension: boundingBox.dimension, - center: boundingBox.center, - }) - .filter((neighborId) => neighborId != entity.id); + const neighborIds = this.collisionFinder.getNeighborIds({ + id: entity.id, + dimension: boundingBox.dimension, + center: boundingBox.center + }); - neighborIds.forEach((neighborId) => { + for (const neighborId of neighborIds) { const neighbor = game.getEntity(neighborId); if (!neighbor) return; const neighborBoundingBox = neighbor.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); if (boundingBox.isCollidingWith(neighborBoundingBox)) { collidingEntityIds.push([entity.id, neighborId]); } - }); + } } return collidingEntityIds; @@ -169,11 +192,11 @@ export class Collision extends System { // ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ private getDyToPushOutOfFloor( entityBoundingBox: BoundingBox, - floorBoundingBox: BoundingBox, + floorBoundingBox: BoundingBox ): number { const { dimension: { width, height }, - center: { x }, + center: { x } } = entityBoundingBox; const outScribedRectangle = entityBoundingBox.getOutscribedBoxDims(); @@ -192,7 +215,7 @@ export class Collision extends System { if (x >= floorBoundingBox.center.x) { boundedCollisionX = Math.min( floorBoundingBox.center.x + floorBoundingBox.dimension.width / 2, - clippedX, + clippedX ); return ( outScribedRectangle.height / 2 - @@ -202,7 +225,7 @@ export class Collision extends System { boundedCollisionX = Math.max( floorBoundingBox.center.x - floorBoundingBox.dimension.width / 2, - clippedX, + clippedX ); return ( diff --git a/engine/systems/FacingDirection.ts b/engine/systems/FacingDirection.ts index 4426ab6..01f32cf 100644 --- a/engine/systems/FacingDirection.ts +++ b/engine/systems/FacingDirection.ts @@ -2,10 +2,10 @@ import { ComponentNames, Velocity, FacingDirection as FacingDirectionComponent, - Control, -} from "../components"; -import { Game } from "../Game"; -import { System, SystemNames } from "./"; + Control +} from '../components'; +import { Game } from '../Game'; +import { System, SystemNames } from './'; export class FacingDirection extends System { constructor() { @@ -20,24 +20,27 @@ export class FacingDirection extends System { return; } - const totalVelocity: Velocity = new Velocity(); + const totalVelocityComponent = new Velocity(); const control = entity.getComponent(ComponentNames.Control); - const velocity = entity.getComponent(ComponentNames.Velocity); - totalVelocity.add(velocity); + const velocity = entity.getComponent( + ComponentNames.Velocity + ).velocity; + + totalVelocityComponent.add(velocity); if (control) { - totalVelocity.add(control.controlVelocity); + totalVelocityComponent.add(control.controlVelocityComponent.velocity); } const facingDirection = entity.getComponent( - ComponentNames.FacingDirection, + ComponentNames.FacingDirection ); - if (totalVelocity.dCartesian.dx > 0) { + if (totalVelocityComponent.velocity.dCartesian.dx > 0) { entity.addComponent(facingDirection.facingRightSprite); - } else if (totalVelocity.dCartesian.dx < 0) { + } else if (totalVelocityComponent.velocity.dCartesian.dx < 0) { entity.addComponent(facingDirection.facingLeftSprite); } - }, + } ); } } diff --git a/engine/systems/Input.ts b/engine/systems/Input.ts index 4aa9844..9afd1ab 100644 --- a/engine/systems/Input.ts +++ b/engine/systems/Input.ts @@ -4,30 +4,117 @@ import { ComponentNames, Velocity, Mass, - Control, -} from "../components"; -import { Game } from "../Game"; -import { KeyConstants, PhysicsConstants } from "../config"; -import { Action } from "../interfaces"; -import { System, SystemNames } from "./"; + Control +} from '../components'; +import { Game } from '../Game'; +import { KeyConstants, PhysicsConstants } from '../config'; +import { Action } from '../interfaces'; +import { System, SystemNames } from '.'; +import { MessagePublisher, MessageType } from '../network'; +import { Entity } from '../entities'; export class Input extends System { + public clientId: string; + private keys: Set; private actionTimeStamps: Map; + private messagePublisher?: MessagePublisher; - constructor() { + constructor(clientId: string, messagePublisher?: MessagePublisher) { super(SystemNames.Input); - this.keys = new Set(); - this.actionTimeStamps = new Map(); + this.clientId = clientId; + this.keys = new Set(); + this.actionTimeStamps = new Map(); + + this.messagePublisher = messagePublisher; } public keyPressed(key: string) { this.keys.add(key); + + if (this.messagePublisher) { + this.messagePublisher.addMessage({ + type: MessageType.NEW_INPUT, + body: key + }); + } } public keyReleased(key: string) { this.keys.delete(key); + + if (this.messagePublisher) { + this.messagePublisher.addMessage({ + type: MessageType.REMOVE_INPUT, + body: key + }); + } + } + + public update(_dt: number, game: Game) { + game.forEachEntityWithComponent(ComponentNames.Control, (entity) => + this.handleInput(entity) + ); + } + + public handleInput(entity: Entity) { + const controlComponent = entity.getComponent( + ComponentNames.Control + ); + controlComponent.isControllable = + controlComponent.controllableBy === this.clientId; + + if (!controlComponent.isControllable) return; + + if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) { + controlComponent.controlVelocityComponent.velocity.dCartesian.dx += + PhysicsConstants.PLAYER_MOVE_VEL; + } + + if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))) { + controlComponent.controlVelocityComponent.velocity.dCartesian.dx += + -PhysicsConstants.PLAYER_MOVE_VEL; + } + + if ( + entity.hasComponent(ComponentNames.Jump) && + this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP)) + ) { + this.performJump(entity); + } + } + + private performJump(entity: Entity) { + const velocity = entity.getComponent( + ComponentNames.Velocity + ).velocity; + const jump = entity.getComponent(ComponentNames.Jump); + + if (jump.canJump) { + this.actionTimeStamps.set(Action.JUMP, performance.now()); + + velocity.dCartesian.dy += PhysicsConstants.PLAYER_JUMP_INITIAL_VEL; + jump.canJump = false; + } + + if ( + performance.now() - (this.actionTimeStamps.get(Action.JUMP) || 0) < + PhysicsConstants.MAX_JUMP_TIME_MS + ) { + const mass = entity.getComponent(ComponentNames.Mass).mass; + + const jumpForce = { + fCartesian: { + fy: mass * PhysicsConstants.PLAYER_JUMP_ACC, + fx: 0 + }, + torque: 0 + }; + entity + .getComponent(ComponentNames.Forces) + ?.forces.push(jumpForce); + } } private hasSomeKey(keys?: string[]): boolean { @@ -36,48 +123,4 @@ export class Input extends System { } return false; } - - public update(_dt: number, game: Game) { - game.forEachEntityWithComponent(ComponentNames.Control, (entity) => { - const control = entity.getComponent(ComponentNames.Control); - - if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) { - control.controlVelocity.dCartesian.dx += - PhysicsConstants.PLAYER_MOVE_VEL; - } - - if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))) { - control.controlVelocity.dCartesian.dx += - -PhysicsConstants.PLAYER_MOVE_VEL; - } - - if (entity.hasComponent(ComponentNames.Jump)) { - const velocity = entity.getComponent(ComponentNames.Velocity); - const jump = entity.getComponent(ComponentNames.Jump); - - if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP))) { - if (jump.canJump) { - this.actionTimeStamps.set(Action.JUMP, performance.now()); - - velocity.dCartesian.dy += PhysicsConstants.PLAYER_JUMP_INITIAL_VEL; - jump.canJump = false; - } - - if ( - performance.now() - (this.actionTimeStamps.get(Action.JUMP) || 0) < - PhysicsConstants.MAX_JUMP_TIME_MS - ) { - const mass = entity.getComponent(ComponentNames.Mass).mass; - entity.getComponent(ComponentNames.Forces)?.forces.push({ - fCartesian: { - fy: mass * PhysicsConstants.PLAYER_JUMP_ACC, - fx: 0, - }, - torque: 0, - }); - } - } - } - }); - } } diff --git a/engine/systems/NetworkUpdate.ts b/engine/systems/NetworkUpdate.ts new file mode 100644 index 0000000..6d13574 --- /dev/null +++ b/engine/systems/NetworkUpdate.ts @@ -0,0 +1,72 @@ +import { System, SystemNames } from '.'; +import { Game } from '../Game'; +import { ComponentNames } from '../components'; +import { + type MessageQueueProvider, + type MessagePublisher, + type MessageProcessor, + MessageType, + EntityUpdateBody +} from '../network'; + +export class NetworkUpdate extends System { + private queueProvider: MessageQueueProvider; + private publisher: MessagePublisher; + private messageProcessor: MessageProcessor; + + private entityUpdateTimers: Map; + + constructor( + queueProvider: MessageQueueProvider, + publisher: MessagePublisher, + messageProcessor: MessageProcessor + ) { + super(SystemNames.NetworkUpdate); + + this.queueProvider = queueProvider; + this.publisher = publisher; + this.messageProcessor = messageProcessor; + + this.entityUpdateTimers = new Map(); + } + + public update(dt: number, game: Game) { + // 1. process new messages + this.queueProvider + .getNewMessages() + .forEach((message) => this.messageProcessor.process(message)); + this.queueProvider.clearMessages(); + + // 2. send entity updates + const updateMessages: EntityUpdateBody[] = []; + game.forEachEntityWithComponent( + ComponentNames.NetworkUpdateable, + (entity) => { + let timer = this.entityUpdateTimers.get(entity.id) ?? dt; + timer -= dt; + this.entityUpdateTimers.set(entity.id, timer); + + if (timer > 0) return; + this.entityUpdateTimers.set(entity.id, this.getNextUpdateTimeMs()); + + if (entity.hasComponent(ComponentNames.NetworkUpdateable)) { + updateMessages.push({ + id: entity.id, + args: entity.serialize() + }); + } + } + ); + this.publisher.addMessage({ + type: MessageType.UPDATE_ENTITIES, + body: updateMessages + }); + + // 3. publish changes + this.publisher.publish(); + } + + private getNextUpdateTimeMs() { + return Math.random() * 70 + 50; + } +} diff --git a/engine/systems/Physics.ts b/engine/systems/Physics.ts index 38962a6..b5df459 100644 --- a/engine/systems/Physics.ts +++ b/engine/systems/Physics.ts @@ -1,4 +1,4 @@ -import { System, SystemNames } from "."; +import { System, SystemNames } from '.'; import { BoundingBox, ComponentNames, @@ -8,11 +8,11 @@ import { Mass, Jump, Moment, - Control, -} from "../components"; -import { PhysicsConstants } from "../config"; -import type { Force2D } from "../interfaces"; -import { Game } from "../Game"; + Control +} from '../components'; +import { PhysicsConstants } from '../config'; +import type { Force2D, Velocity2D } from '../interfaces'; +import { Game } from '../Game'; export class Physics extends System { constructor() { @@ -23,9 +23,11 @@ export class Physics extends System { game.forEachEntityWithComponent(ComponentNames.Forces, (entity) => { const mass = entity.getComponent(ComponentNames.Mass).mass; const forces = entity.getComponent(ComponentNames.Forces).forces; - const velocity = entity.getComponent(ComponentNames.Velocity); + const velocity = entity.getComponent( + ComponentNames.Velocity + ).velocity; const inertia = entity.getComponent( - ComponentNames.Moment, + ComponentNames.Moment ).inertia; // F_g = mg, applied only until terminal velocity is reached @@ -35,9 +37,9 @@ export class Physics extends System { forces.push({ fCartesian: { fy: mass * PhysicsConstants.GRAVITY, - fx: 0, + fx: 0 }, - torque: 0, + torque: 0 }); } } @@ -47,17 +49,17 @@ export class Physics extends System { (accum: Force2D, { fCartesian, torque }: Force2D) => ({ fCartesian: { fx: accum.fCartesian.fx + (fCartesian?.fx ?? 0), - fy: accum.fCartesian.fy + (fCartesian?.fy ?? 0), + fy: accum.fCartesian.fy + (fCartesian?.fy ?? 0) }, - torque: accum.torque + (torque ?? 0), + torque: accum.torque + (torque ?? 0) }), - { fCartesian: { fx: 0, fy: 0 }, torque: 0 }, + { fCartesian: { fx: 0, fy: 0 }, torque: 0 } ); // integrate accelerations const [ddy, ddx] = [ sumOfForces.fCartesian.fy, - sumOfForces.fCartesian.fx, + sumOfForces.fCartesian.fx ].map((x) => x / mass); velocity.dCartesian.dx += ddx * dt; velocity.dCartesian.dy += ddy * dt; @@ -73,30 +75,32 @@ export class Physics extends System { }); game.forEachEntityWithComponent(ComponentNames.Velocity, (entity) => { - const velocity: Velocity = new Velocity(); + const velocityComponent: Velocity = new Velocity(); const control = entity.getComponent(ComponentNames.Control); - velocity.add(entity.getComponent(ComponentNames.Velocity)); + velocityComponent.add( + entity.getComponent(ComponentNames.Velocity).velocity + ); if (control) { - velocity.add(control.controlVelocity); + velocityComponent.add(control.controlVelocityComponent.velocity); } const boundingBox = entity.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); // integrate velocity - boundingBox.center.x += velocity.dCartesian.dx * dt; - boundingBox.center.y += velocity.dCartesian.dy * dt; - boundingBox.rotation += velocity.dTheta * dt; + boundingBox.center.x += velocityComponent.velocity.dCartesian.dx * dt; + boundingBox.center.y += velocityComponent.velocity.dCartesian.dy * dt; + boundingBox.rotation += velocityComponent.velocity.dTheta * dt; boundingBox.rotation = (boundingBox.rotation < 0 ? 360 + boundingBox.rotation : boundingBox.rotation) % 360; // clear the control velocity - if (control) { - control.controlVelocity = new Velocity(); + if (control && control.isControllable) { + control.controlVelocityComponent = new Velocity(); } }); } diff --git a/engine/systems/Render.ts b/engine/systems/Render.ts index 9bb4091..4a4500d 100644 --- a/engine/systems/Render.ts +++ b/engine/systems/Render.ts @@ -1,7 +1,7 @@ -import { System, SystemNames } from "."; -import { BoundingBox, ComponentNames, Sprite } from "../components"; -import { Game } from "../Game"; -import { clamp } from "../utils"; +import { System, SystemNames } from '.'; +import { BoundingBox, ComponentNames, Sprite } from '../components'; +import { Game } from '../Game'; +import { clamp } from '../utils'; export class Render extends System { private ctx: CanvasRenderingContext2D; @@ -19,7 +19,7 @@ export class Render extends System { sprite.update(dt); const boundingBox = entity.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); // don't render if we're outside the screen @@ -27,12 +27,12 @@ export class Render extends System { clamp( boundingBox.center.y, -boundingBox.dimension.height / 2, - this.ctx.canvas.height + boundingBox.dimension.height / 2, + this.ctx.canvas.height + boundingBox.dimension.height / 2 ) != boundingBox.center.y || clamp( boundingBox.center.x, -boundingBox.dimension.width / 2, - this.ctx.canvas.width + boundingBox.dimension.width / 2, + this.ctx.canvas.width + boundingBox.dimension.width / 2 ) != boundingBox.center.x ) { return; @@ -41,7 +41,7 @@ export class Render extends System { const drawArgs = { center: boundingBox.center, dimension: boundingBox.dimension, - rotation: boundingBox.rotation, + rotation: boundingBox.rotation }; sprite.draw(this.ctx, drawArgs); diff --git a/engine/systems/System.ts b/engine/systems/System.ts index 8b00dc5..de41988 100644 --- a/engine/systems/System.ts +++ b/engine/systems/System.ts @@ -1,4 +1,4 @@ -import { Game } from "../Game"; +import { Game } from '../Game'; export abstract class System { public readonly name: string; diff --git a/engine/systems/WallBounds.ts b/engine/systems/WallBounds.ts index a0d4a9c..7da84e4 100644 --- a/engine/systems/WallBounds.ts +++ b/engine/systems/WallBounds.ts @@ -1,28 +1,24 @@ -import { System, SystemNames } from "."; -import { BoundingBox, ComponentNames } from "../components"; -import { Game } from "../Game"; -import type { Entity } from "../entities"; -import { clamp } from "../utils"; +import { System, SystemNames } from '.'; +import { BoundingBox, ComponentNames } from '../components'; +import { Game } from '../Game'; +import { clamp } from '../utils'; +import { Miscellaneous } from '../config'; export class WallBounds extends System { - private screenWidth: number; - - constructor(screenWidth: number) { + constructor() { super(SystemNames.WallBounds); - - this.screenWidth = screenWidth; } public update(_dt: number, game: Game) { game.forEachEntityWithComponent(ComponentNames.WallBounded, (entity) => { const boundingBox = entity.getComponent( - ComponentNames.BoundingBox, + ComponentNames.BoundingBox ); boundingBox.center.x = clamp( boundingBox.center.x, boundingBox.dimension.width / 2, - this.screenWidth - boundingBox.dimension.width / 2, + Miscellaneous.WIDTH - boundingBox.dimension.width / 2 ); }); } diff --git a/engine/systems/index.ts b/engine/systems/index.ts index 6cb6f35..43181e9 100644 --- a/engine/systems/index.ts +++ b/engine/systems/index.ts @@ -1,8 +1,9 @@ -export * from "./names"; -export * from "./System"; -export * from "./Render"; -export * from "./Physics"; -export * from "./Input"; -export * from "./FacingDirection"; -export * from "./Collision"; -export * from "./WallBounds"; +export * from './names'; +export * from './System'; +export * from './Render'; +export * from './Physics'; +export * from './Input'; +export * from './FacingDirection'; +export * from './Collision'; +export * from './WallBounds'; +export * from './NetworkUpdate'; diff --git a/engine/systems/names.ts b/engine/systems/names.ts index 23f31fc..ddf6f19 100644 --- a/engine/systems/names.ts +++ b/engine/systems/names.ts @@ -1,8 +1,9 @@ export namespace SystemNames { - export const Render = "Render"; - export const Physics = "Physics"; - export const FacingDirection = "FacingDirection"; - export const Input = "Input"; - export const Collision = "Collision"; - export const WallBounds = "WallBounds"; + export const Render = 'Render'; + export const Physics = 'Physics'; + export const FacingDirection = 'FacingDirection'; + export const Input = 'Input'; + export const Collision = 'Collision'; + export const WallBounds = 'WallBounds'; + export const NetworkUpdate = 'NetworkUpdate'; } diff --git a/engine/utils/coding.ts b/engine/utils/coding.ts new file mode 100644 index 0000000..3f78889 --- /dev/null +++ b/engine/utils/coding.ts @@ -0,0 +1,27 @@ +const replacer = (_key: any, value: any) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: Array.from(value.entries()) + }; + } else { + return value; + } +}; + +const reviver = (_key: any, value: any) => { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'Map') { + return new Map(value.value); + } + } + return value; +}; + +export const stringify = (obj: any) => { + return JSON.stringify(obj, replacer); +}; + +export const parse = (str: string) => { + return JSON.parse(str, reviver) as unknown as T; +}; diff --git a/engine/utils/dotProduct.ts b/engine/utils/dotProduct.ts index 59f8857..82bcdea 100644 --- a/engine/utils/dotProduct.ts +++ b/engine/utils/dotProduct.ts @@ -1,4 +1,4 @@ -import type { Coord2D } from "../interfaces"; +import type { Coord2D } from '../interfaces'; export const dotProduct = (vector1: Coord2D, vector2: Coord2D): number => vector1.x * vector2.x + vector1.y * vector2.y; diff --git a/engine/utils/index.ts b/engine/utils/index.ts index 82a0d05..65446d1 100644 --- a/engine/utils/index.ts +++ b/engine/utils/index.ts @@ -1,3 +1,4 @@ -export * from "./rotateVector"; -export * from "./dotProduct"; -export * from "./clamp"; +export * from './rotateVector'; +export * from './dotProduct'; +export * from './clamp'; +export * from './coding'; diff --git a/engine/utils/rotateVector.ts b/engine/utils/rotateVector.ts index 82bb54d..221ffb2 100644 --- a/engine/utils/rotateVector.ts +++ b/engine/utils/rotateVector.ts @@ -1,4 +1,4 @@ -import type { Coord2D } from "../interfaces"; +import type { Coord2D } from '../interfaces'; /** * ([[cos(θ), -sin(θ),]) ([x,) @@ -10,6 +10,6 @@ export const rotateVector = (vector: Coord2D, theta: number): Coord2D => { return { x: vector.x * cos - vector.y * sin, - y: vector.x * sin + vector.y * cos, + y: vector.x * sin + vector.y * cos }; }; diff --git a/server/bun.lockb b/server/bun.lockb index 7f8b5ce..28b67ce 100755 Binary files a/server/bun.lockb and b/server/bun.lockb differ diff --git a/server/package.json b/server/package.json index 17d3c25..388cff2 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,5 @@ "peerDependencies": { "typescript": "^5.0.0" }, - "dependencies": { - } + "dependencies": {} } diff --git a/server/src/constants.ts b/server/src/constants.ts new file mode 100644 index 0000000..a2b3d12 --- /dev/null +++ b/server/src/constants.ts @@ -0,0 +1,6 @@ +export namespace Constants { + export const SERVER_PORT = 8080; + export const SERVER_TICK_RATE = (1 / 60) * 1000; + export const GAME_TOPIC = 'game'; + export const MAX_PLAYERS = 8; +} diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..0e47491 --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,59 @@ +import { Grid } from '@engine/structures'; +import { + ServerMessageProcessor, + ServerSocketMessagePublisher, + ServerSocketMessageReceiver, + MemorySessionManager, + SessionInputSystem +} from './network'; +import { Collision, NetworkUpdate, Physics, WallBounds } from '@engine/systems'; +import { Game } from '@engine/Game'; +import { Constants } from './constants'; +import { GameServer } from './server'; +import { Floor } from '@engine/entities'; +import { BoundingBox } from '@engine/components'; +import { Miscellaneous } from '@engine/config'; + +const game = new Game(); + +const sessionManager = new MemorySessionManager(); + +const messageReceiver = new ServerSocketMessageReceiver(); +const messagePublisher = new ServerSocketMessagePublisher(); +const messageProcessor = new ServerMessageProcessor(game, sessionManager); + +const server = new GameServer( + game, + messageReceiver, + messagePublisher, + sessionManager +); + +[ + new SessionInputSystem(sessionManager), + new NetworkUpdate(messageReceiver, messagePublisher, messageProcessor), + new Physics(), + new Collision(new Grid()), + new WallBounds() +].forEach((system) => game.addSystem(system)); + +const floor = new Floor(160); +const floorHeight = 200; + +floor.addComponent( + new BoundingBox( + { + x: Miscellaneous.WIDTH / 2, + y: Miscellaneous.HEIGHT + floorHeight / 2 + }, + { width: Miscellaneous.WIDTH, height: floorHeight } + ) +); +game.addEntity(floor); + +game.start(); +setInterval(() => { + game.doGameLoop(performance.now()); +}, Constants.SERVER_TICK_RATE); + +server.serve(); diff --git a/server/src/network/MessageProcessor.ts b/server/src/network/MessageProcessor.ts new file mode 100644 index 0000000..2d9f11f --- /dev/null +++ b/server/src/network/MessageProcessor.ts @@ -0,0 +1,36 @@ +import { + EntityUpdateBody, + MessageProcessor, + MessageType +} from '@engine/network'; +import { ServerMessage, SessionManager } from '.'; +import { Game } from '@engine/Game'; + +export class ServerMessageProcessor implements MessageProcessor { + private game: Game; + private sessionManager: SessionManager; + + constructor(game: Game, sessionManager: SessionManager) { + this.game = game; + this.sessionManager = sessionManager; + } + + public process(message: ServerMessage) { + switch (message.type) { + case MessageType.NEW_INPUT: { + const { sessionId } = message.sessionData; + const session = this.sessionManager.getSession(sessionId); + session?.inputSystem.keyPressed(message.body as string); + break; + } + case MessageType.REMOVE_INPUT: { + const { sessionId } = message.sessionData; + const session = this.sessionManager.getSession(sessionId); + session?.inputSystem.keyReleased(message.body as string); + break; + } + default: + break; + } + } +} diff --git a/server/src/network/MessagePublisher.ts b/server/src/network/MessagePublisher.ts new file mode 100644 index 0000000..9c6011f --- /dev/null +++ b/server/src/network/MessagePublisher.ts @@ -0,0 +1,31 @@ +import { Message, MessagePublisher } from '@engine/network'; +import { Server } from 'bun'; +import { Constants } from '../constants'; +import { stringify } from '@engine/utils'; + +export class ServerSocketMessagePublisher implements MessagePublisher { + private server?: Server; + private messages: Message[]; + + constructor(server?: Server) { + this.messages = []; + + if (server) this.setServer(server); + } + + public setServer(server: Server) { + this.server = server; + } + + public addMessage(message: Message) { + this.messages.push(message); + } + + public publish() { + if (this.messages.length) { + this.server?.publish(Constants.GAME_TOPIC, stringify(this.messages)); + + this.messages = []; + } + } +} diff --git a/server/src/network/MessageReceiver.ts b/server/src/network/MessageReceiver.ts new file mode 100644 index 0000000..fcac0a4 --- /dev/null +++ b/server/src/network/MessageReceiver.ts @@ -0,0 +1,22 @@ +import { MessageQueueProvider } from '@engine/network'; +import type { ServerMessage } from '.'; + +export class ServerSocketMessageReceiver implements MessageQueueProvider { + private messages: ServerMessage[]; + + constructor() { + this.messages = []; + } + + public addMessage(message: ServerMessage) { + this.messages.push(message); + } + + public getNewMessages() { + return this.messages; + } + + public clearMessages() { + this.messages = []; + } +} diff --git a/server/src/network/SessionInputSystem.ts b/server/src/network/SessionInputSystem.ts new file mode 100644 index 0000000..44fba54 --- /dev/null +++ b/server/src/network/SessionInputSystem.ts @@ -0,0 +1,32 @@ +import { Game } from '@engine/Game'; +import { SessionManager } from '.'; +import { System } from '@engine/systems'; +import { BoundingBox, ComponentNames, Control } from '@engine/components'; + +export class SessionInputSystem extends System { + private sessionManager: SessionManager; + + constructor(sessionManager: SessionManager) { + super('SessionInputSystem'); + + this.sessionManager = sessionManager; + } + + public update(_dt: number, game: Game) { + this.sessionManager.getSessions().forEach((sessionId) => { + const session = this.sessionManager.getSession(sessionId); + + if (!session) return; + + const { inputSystem } = session; + session.controllableEntities.forEach((entityId) => { + const entity = game.getEntity(entityId); + if (!entity) return; + + if (entity.hasComponent(ComponentNames.Control)) { + inputSystem.handleInput(entity); + } + }); + }); + } +} diff --git a/server/src/network/SessionManager.ts b/server/src/network/SessionManager.ts new file mode 100644 index 0000000..dbd4364 --- /dev/null +++ b/server/src/network/SessionManager.ts @@ -0,0 +1,33 @@ +import { Session, SessionManager } from '.'; + +export class MemorySessionManager implements SessionManager { + private sessions: Map; + + constructor() { + this.sessions = new Map(); + } + + public getSessions() { + return Array.from(this.sessions.keys()); + } + + public uniqueSessionId() { + return crypto.randomUUID(); + } + + public getSession(id: string) { + return this.sessions.get(id); + } + + public putSession(id: string, session: Session) { + return this.sessions.set(id, session); + } + + public numSessions() { + return this.sessions.size; + } + + public removeSession(id: string) { + this.sessions.delete(id); + } +} diff --git a/server/src/network/index.ts b/server/src/network/index.ts new file mode 100644 index 0000000..3cbf0ac --- /dev/null +++ b/server/src/network/index.ts @@ -0,0 +1,29 @@ +import { Message } from '@engine/network'; +import { Input } from '@engine/systems'; + +export * from './MessageProcessor'; +export * from './MessagePublisher'; +export * from './MessageReceiver'; +export * from './SessionManager'; +export * from './SessionInputSystem'; + +export type SessionData = { sessionId: string }; + +export type Session = { + sessionId: string; + controllableEntities: Set; + inputSystem: Input; +}; + +export interface ServerMessage extends Message { + sessionData: SessionData; +} + +export interface SessionManager { + uniqueSessionId(): string; + getSession(id: string): Session | undefined; + getSessions(): string[]; + putSession(id: string, session: Session): void; + removeSession(id: string): void; + numSessions(): number; +} diff --git a/server/src/server.ts b/server/src/server.ts index 74d901b..575e916 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,37 +1,174 @@ -import { Game } from "../../engine/Game"; -import { Floor, Player } from "../../engine/entities"; -import { WallBounds, Physics, Collision } from "../../engine/systems"; -import { Miscellaneous } from "../../engine/config"; +import { Game } from '@engine/Game'; +import { Player } from '@engine/entities'; +import { Message, MessageType } from '@engine/network'; +import { Constants } from './constants'; +import { + ServerSocketMessageReceiver, + ServerSocketMessagePublisher, + SessionData, + ServerMessage, + Session, + SessionManager +} from './network'; +import { parse } from '@engine/utils'; +import { Server, ServerWebSocket } from 'bun'; +import { Input } from '@engine/systems'; +import { Control, NetworkUpdateable } from '@engine/components'; +import { stringify } from '@engine/utils'; -const TICK_RATE = 60 / 1000; +export class GameServer { + private server?: Server; + private game: Game; + private messageReceiver: ServerSocketMessageReceiver; + private messagePublisher: ServerSocketMessagePublisher; + private sessionManager: SessionManager; -const game = new Game(); + constructor( + game: Game, + messageReceiver: ServerSocketMessageReceiver, + messagePublisher: ServerSocketMessagePublisher, + sessionManager: SessionManager + ) { + this.game = game; + this.messageReceiver = messageReceiver; + this.messagePublisher = messagePublisher; + this.sessionManager = sessionManager; + } -[ - new Physics(), - new Collision({ width: Miscellaneous.WIDTH, height: Miscellaneous.HEIGHT }), - new WallBounds(Miscellaneous.WIDTH), -].forEach((system) => game.addSystem(system)); + public serve() { + if (!this.server) + this.server = Bun.serve({ + port: Constants.SERVER_PORT, + fetch: (req, srv) => this.fetchHandler(req, srv), + websocket: { + open: (ws) => this.openWebsocket(ws), + message: (ws, msg) => this.websocketMessage(ws, msg), + close: (ws) => this.closeWebsocket(ws) + } + }); -[new Floor(160), new Player()].forEach((entity) => game.addEntity(entity)); + this.messagePublisher.setServer(this.server); -game.start(); -setInterval(() => { - game.doGameLoop(performance.now()); -}, TICK_RATE); + console.log(`Listening on ${this.server.hostname}:${this.server.port}`); + } -const server = Bun.serve<>({ - port: 8080, - fetch(req, server) { - server.upgrade(req, { - data: {}, + private websocketMessage( + websocket: ServerWebSocket, + message: string | Uint8Array + ) { + if (typeof message == 'string') { + const receivedMessage = parse(message); + receivedMessage.sessionData = websocket.data; + + this.messageReceiver.addMessage(receivedMessage); + } + } + + private closeWebsocket(websocket: ServerWebSocket) { + const { sessionId } = websocket.data; + + const sessionEntities = + this.sessionManager.getSession(sessionId)!.controllableEntities; + this.sessionManager.removeSession(sessionId); + + if (!sessionEntities) return; + sessionEntities.forEach((id) => this.game.removeEntity(id)); + + this.messagePublisher.addMessage({ + type: MessageType.REMOVE_ENTITIES, + body: Array.from(sessionEntities) }); - }, - websocket: { - // handler called when a message is received - async message(ws, message) { - console.log(`Received ${message}`); - }, - }, -}); -console.log(`Listening on localhost:${server.port}`); + } + + private openWebsocket(websocket: ServerWebSocket) { + websocket.subscribe(Constants.GAME_TOPIC); + + const { sessionId } = websocket.data; + if (this.sessionManager.getSession(sessionId)) { + return; + } + + const newSession: Session = { + sessionId, + controllableEntities: new Set(), + inputSystem: new Input(sessionId) + }; + + const player = new Player(); + player.addComponent(new Control(sessionId)); + player.addComponent(new NetworkUpdateable()); + this.game.addEntity(player); + + newSession.controllableEntities.add(player.id); + this.sessionManager.putSession(sessionId, newSession); + + const addCurrentEntities: Message[] = [ + { + type: MessageType.NEW_ENTITIES, + body: Array.from(this.game.entities.values()) + .filter((entity) => entity.id != player.id) + .map((entity) => { + return { + id: entity.id, + entityName: entity.name, + args: entity.serialize() + }; + }) + } + ]; + websocket.sendText(stringify(addCurrentEntities)); + + const addNewPlayer: Message = { + type: MessageType.NEW_ENTITIES, + body: [ + { + id: player.id, + entityName: player.name, + args: player.serialize() + } + ] + }; + this.messagePublisher.addMessage(addNewPlayer); + } + + private fetchHandler(req: Request, server: Server): Response { + const url = new URL(req.url); + + const headers = new Headers(); + headers.set('Access-Control-Allow-Origin', '*'); + + if (url.pathname == '/assign') { + if (this.sessionManager.numSessions() > Constants.MAX_PLAYERS) + return new Response('too many players', { headers, status: 400 }); + + const sessionId = crypto.randomUUID(); + headers.set('Set-Cookie', `SessionId=${sessionId};`); + + return new Response(sessionId, { headers }); + } + + const cookie = req.headers.get('cookie'); + if (!cookie) { + return new Response('No session', { headers, status: 401 }); + } + + const sessionId = cookie.split(';').at(0)!.split('SessionId=').at(1); + + if (url.pathname == '/game') { + server.upgrade(req, { + headers, + data: { + sessionId + } + }); + + return new Response('upgraded to ws', { headers }); + } + + if (url.pathname == '/me') { + return new Response(sessionId, { headers }); + } + + return new Response('Not found', { headers, status: 404 }); + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json index 29f8aa0..52f0ddc 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,21 +1,27 @@ { + "extends": "../tsconfig.engine.json", "compilerOptions": { - "lib": ["ESNext"], + // add Bun type definitions + "types": ["bun-types"], + + // enable latest features + "lib": ["esnext"], "module": "esnext", "target": "esnext", + + // if TS 5.x+ "moduleResolution": "bundler", - "moduleDetection": "force", - "allowImportingTsExtensions": true, - "strict": true, - "downlevelIteration": true, - "skipLibCheck": true, - "jsx": "preserve", - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "allowJs": true, "noEmit": true, - "types": [ - "bun-types" // add Bun global - ] + "allowImportingTsExtensions": true, + "moduleDetection": "force", + + "jsx": "react-jsx", // support JSX + "allowJs": true, // allow importing `.js` from `.ts` + "esModuleInterop": true, // allow default imports for CommonJS modules + + // best practices + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true } } diff --git a/tsconfig.engine.json b/tsconfig.engine.json new file mode 100644 index 0000000..52482a2 --- /dev/null +++ b/tsconfig.engine.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "paths": { + "@engine/*": ["./engine/*"], + "@engine/components": ["./engine/components"], + "@engine/config": ["./engine/config"], + "@engine/entities": ["./engine/entities"], + "@engine/interfaces": ["./engine/interfaces"], + "@engine/structures": ["./engine/structures"], + "@engine/systems": ["./engine/systems"], + "@engine/utils": ["./engine/utils"], + "@engine/network": ["./engine/network"] + } + } +}