Merge pull request #1 from Simponic/network

Network
This commit is contained in:
Elizabeth Hunt 2023-08-26 17:57:05 -06:00 committed by GitHub
commit 8a4ab8d79b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 1542 additions and 512 deletions

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "none"
}

View File

@ -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'
},
}
};

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View File

@ -10,7 +10,7 @@
--orange: #af3a03;
}
[data-theme="dark"] {
[data-theme='dark'] {
--bg: #282828;
--text: #f9f5d7;
--red: #fb4934;

View File

@ -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;
}

View File

@ -17,7 +17,7 @@
rgba(162, 254, 254, 1) 100%
);
content: "";
content: '';
width: 100%;
height: 100%;
top: 0;

View File

@ -1,4 +1,4 @@
@import url("./colors.css");
@import url('./colors.css');
.primary {
color: var(--aqua);

View File

@ -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<Message[]>(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<string> {
return fetch(endpoint)
.then((resp) => {
if (resp.ok) {
return resp.text();
}
throw resp;
})
.then((cookie) => cookie);
}
}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { loadAssets } from "@engine/config";
import { Game } from "@engine/Game";
import { JumpStorm } from "../JumpStorm";
let canvas: HTMLCanvasElement;
@ -9,16 +10,17 @@
export let width: number;
export let height: number;
let jumpStorm: JumpStorm;
onMount(() => {
onMount(async () => {
ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
loadAssets().then(() => {
jumpStorm = new JumpStorm(ctx);
jumpStorm.play();
});
await loadAssets();
const game = new Game();
const jumpStorm = new JumpStorm(game);
await jumpStorm.init(ctx, "http", "ws", document.location.host + "/api");
jumpStorm.play();
});
</script>

View File

@ -3,7 +3,7 @@
const MAX_ENTRIES = 8;
export let entries: { name: string, score: number }[] = [];
export let entries: { name: string; score: number }[] = [];
</script>
<div class="leaderboard">

View File

@ -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;

View File

@ -6,7 +6,6 @@
let width: number = Miscellaneous.WIDTH;
let height: number = Miscellaneous.HEIGHT;
</script>
<div class="centered-game">

View File

@ -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()
};

View File

@ -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" }]
}

View File

@ -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))
}
}
});

View File

@ -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<number, Entity>;
public entities: Map<string, Entity>;
public systems: Map<string, System>;
public componentEntities: Map<string, Set<number>>;
public componentEntities: Map<string, Set<string>>;
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<number>([entity.id]),
new Set<string>([entity.id])
);
return;
}
this.componentEntities.get(component.name)?.add(entity.id);
}),
})
);
this.systemOrder.forEach((systemName) => {
this.systems.get(systemName)?.update(dt, this);
});
};
}
}

View File

@ -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
};
}
}

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class Collide extends Component {
constructor() {

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames, Sprite } from ".";
import { Component, ComponentNames, Sprite } from '.';
export class FacingDirection extends Component {
public readonly facingLeftSprite: Sprite;

View File

@ -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)

View File

@ -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;

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class Jump extends Component {
public canJump: boolean;

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class Mass extends Component {
public mass: number;

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class Moment extends Component {
public inertia: number;

View File

@ -0,0 +1,7 @@
import { Component, ComponentNames } from '.';
export class NetworkUpdateable extends Component {
constructor() {
super(ComponentNames.NetworkUpdateable);
}
}

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class TopCollidable extends Component {
constructor() {

View File

@ -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;
}
}
}

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from ".";
import { Component, ComponentNames } from '.';
export class WallBounded extends Component {
constructor() {

View File

@ -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';

View File

@ -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';
}

View File

@ -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<string, HTMLImageElement>();
export const loadSpritesIntoImageElements = (
spriteSpecs: Partial<SpriteSpec>[],
spriteSpecs: Partial<SpriteSpec>[]
): Promise<void>[] => {
const spritePromises: Promise<void>[] = [];
@ -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
]);

View File

@ -1,34 +1,39 @@
import { Action } from "../interfaces";
import { Action } from '../interfaces';
export namespace KeyConstants {
export const KeyActions: Record<string, Action> = {
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<Action, string[]> = Object.keys(
KeyActions,
KeyActions
).reduce((acc: Map<Action, string[]>, 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<Action, string[]>());
}, 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;
}

View File

@ -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';

View File

@ -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<number, Partial<SpriteSpec>>(),
states: new Map<number, Partial<SpriteSpec>>()
};
[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<string, Partial<SpriteSpec>>(),
states: new Map<string, Partial<SpriteSpec>>()
};
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);

View File

@ -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<string, Component>;
public name: string;
public readonly id: number;
public readonly components: Map<string, Component>;
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<T extends Component>(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<string, any>): void;
public abstract serialize(): Record<string, any>;
}

View File

@ -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<BoundingBox>(ComponentNames.BoundingBox)
};
}
public setFrom(args: any) {
const { boundingBox } = args;
this.addComponent(
new BoundingBox(
boundingBox.center,
boundingBox.dimension,
boundingBox.rotation
)
);
}
}

View File

@ -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<string, any> {
return {
control: this.getComponent<Control>(ComponentNames.Control),
boundingBox: this.getComponent<BoundingBox>(ComponentNames.BoundingBox),
velocity: this.getComponent<Velocity>(ComponentNames.Velocity),
forces: this.getComponent<Forces>(ComponentNames.Forces)
};
}
public setFrom(args: Record<string, any>) {
const { control, velocity, forces, boundingBox } = args;
let center = boundingBox.center;
const myCenter = this.getComponent<BoundingBox>(
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));
}
}

View File

@ -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';

4
engine/entities/names.ts Normal file
View File

@ -0,0 +1,4 @@
export namespace EntityNames {
export const Player = 'Player';
export const Floor = 'Floor';
}

View File

@ -1,5 +1,5 @@
export enum Action {
MOVE_LEFT,
MOVE_RIGHT,
JUMP,
JUMP
}

View File

@ -1,6 +1,6 @@
export enum Direction {
UP = "UP",
DOWN = "DOWN",
LEFT = "LEFT",
RIGHT = "RIGHT",
UP = 'UP',
DOWN = 'DOWN',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}

View File

@ -1,4 +1,4 @@
import type { Coord2D, Dimension2D } from "./";
import type { Coord2D, Dimension2D } from './';
export interface DrawArgs {
center: Coord2D;

View File

@ -9,8 +9,11 @@ export interface Dimension2D {
}
export interface Velocity2D {
dx: number;
dy: number;
dCartesian: {
dx: number;
dy: number;
};
dTheta: number;
}
export interface Force2D {

View File

@ -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';

37
engine/network/index.ts Normal file
View File

@ -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<string, any>;
};
export type EntityUpdateBody = {
id: string;
args: Record<string, any>;
};
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;
}

104
engine/structures/Grid.ts Normal file
View File

@ -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<number, string[]>;
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<string> {
const neighborIds: Set<string> = 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;
}
}

View File

@ -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<Quadrant, QuadTree>();
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<string> {
const neighbors = new Set<string>(
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;
}
}

View File

@ -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<string>;
setTopLeft(topLeft: Coord2D): void;
}

View File

@ -1 +1,3 @@
export * from "./QuadTree";
export * from './RefreshingCollisionFinderBehavior';
export * from './QuadTree';
export * from './Grid';

View File

@ -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<number>) =>
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<BoundingBox>(
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<BoundingBox>(ComponentNames.BoundingBox),
(entity) => entity.getComponent<BoundingBox>(ComponentNames.BoundingBox)
);
let velocity = new Velocity();
let velocity: Velocity2D = { dCartesian: { dx: 0, dy: 0 }, dTheta: 0 };
if (entityA.hasComponent(ComponentNames.Velocity)) {
velocity = entityA.getComponent<Velocity>(ComponentNames.Velocity);
velocity = entityA.getComponent<Velocity>(
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<Forces>(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<BoundingBox>(
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<BoundingBox>(
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 (

View File

@ -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<Control>(ComponentNames.Control);
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
totalVelocity.add(velocity);
const velocity = entity.getComponent<Velocity>(
ComponentNames.Velocity
).velocity;
totalVelocityComponent.add(velocity);
if (control) {
totalVelocity.add(control.controlVelocity);
totalVelocityComponent.add(control.controlVelocityComponent.velocity);
}
const facingDirection = entity.getComponent<FacingDirectionComponent>(
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);
}
},
}
);
}
}

View File

@ -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<string>;
private actionTimeStamps: Map<Action, number>;
private messagePublisher?: MessagePublisher;
constructor() {
constructor(clientId: string, messagePublisher?: MessagePublisher) {
super(SystemNames.Input);
this.keys = new Set<string>();
this.actionTimeStamps = new Map<Action, number>();
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<Control>(
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<Velocity>(
ComponentNames.Velocity
).velocity;
const jump = entity.getComponent<Jump>(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<Mass>(ComponentNames.Mass).mass;
const jumpForce = {
fCartesian: {
fy: mass * PhysicsConstants.PLAYER_JUMP_ACC,
fx: 0
},
torque: 0
};
entity
.getComponent<Forces>(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<Control>(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<Velocity>(ComponentNames.Velocity);
const jump = entity.getComponent<Jump>(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<Mass>(ComponentNames.Mass).mass;
entity.getComponent<Forces>(ComponentNames.Forces)?.forces.push({
fCartesian: {
fy: mass * PhysicsConstants.PLAYER_JUMP_ACC,
fx: 0,
},
torque: 0,
});
}
}
}
});
}
}

View File

@ -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<string, number>;
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;
}
}

View File

@ -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<Mass>(ComponentNames.Mass).mass;
const forces = entity.getComponent<Forces>(ComponentNames.Forces).forces;
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
const velocity = entity.getComponent<Velocity>(
ComponentNames.Velocity
).velocity;
const inertia = entity.getComponent<Moment>(
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<Control>(ComponentNames.Control);
velocity.add(entity.getComponent<Velocity>(ComponentNames.Velocity));
velocityComponent.add(
entity.getComponent<Velocity>(ComponentNames.Velocity).velocity
);
if (control) {
velocity.add(control.controlVelocity);
velocityComponent.add(control.controlVelocityComponent.velocity);
}
const boundingBox = entity.getComponent<BoundingBox>(
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();
}
});
}

View File

@ -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<BoundingBox>(
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);

View File

@ -1,4 +1,4 @@
import { Game } from "../Game";
import { Game } from '../Game';
export abstract class System {
public readonly name: string;

View File

@ -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<BoundingBox>(
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
);
});
}

View File

@ -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';

View File

@ -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';
}

27
engine/utils/coding.ts Normal file
View File

@ -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 = <T>(str: string) => {
return JSON.parse(str, reviver) as unknown as T;
};

View File

@ -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;

View File

@ -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';

View File

@ -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
};
};

Binary file not shown.

View File

@ -8,6 +8,5 @@
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
}
"dependencies": {}
}

6
server/src/constants.ts Normal file
View File

@ -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;
}

59
server/src/main.ts Normal file
View File

@ -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();

View File

@ -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;
}
}
}

View File

@ -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 = [];
}
}
}

View File

@ -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 = [];
}
}

View File

@ -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);
}
});
});
}
}

View File

@ -0,0 +1,33 @@
import { Session, SessionManager } from '.';
export class MemorySessionManager implements SessionManager {
private sessions: Map<string, Session>;
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);
}
}

View File

@ -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<string>;
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;
}

View File

@ -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<SessionData>({
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<SessionData>,
message: string | Uint8Array
) {
if (typeof message == 'string') {
const receivedMessage = parse<ServerMessage>(message);
receivedMessage.sessionData = websocket.data;
this.messageReceiver.addMessage(receivedMessage);
}
}
private closeWebsocket(websocket: ServerWebSocket<SessionData>) {
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<SessionData>) {
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 });
}
}

View File

@ -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
}
}

15
tsconfig.engine.json Normal file
View File

@ -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"]
}
}
}