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 = { module.exports = {
extends: [ extends: [
// add more generic rule sets here, such as: // add more generic rule sets here, such as:
"eslint:recommended", 'eslint:recommended',
"plugin:svelte/recommended", 'plugin:svelte/recommended'
], ],
rules: { rules: {
// override/add rules settings here, such as: // override/add rules settings here, such as:
// 'svelte/rule-name': 'error' // 'svelte/rule-name': 'error'
}, }
}; };

View File

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

View File

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

View File

@ -1,15 +1,15 @@
@import url("./theme.css"); @import url('./theme.css');
@import url("./tf.css"); @import url('./tf.css');
@font-face { @font-face {
font-family: "scientifica"; font-family: 'scientifica';
src: url("/fonts/scientifica.ttf"); src: url('/fonts/scientifica.ttf');
} }
* { * {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-family: "scientifica", monospace; font-family: 'scientifica', monospace;
transition: background 0.2s ease-in-out; transition: background 0.2s ease-in-out;
font-smooth: never; font-smooth: never;
} }

View File

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

View File

@ -1,4 +1,4 @@
@import url("./colors.css"); @import url('./colors.css');
.primary { .primary {
color: var(--aqua); 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 { import {
WallBounds, WallBounds,
FacingDirection, FacingDirection,
@ -7,28 +8,141 @@ import {
Physics, Physics,
Input, Input,
Collision, 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 { export class JumpStorm {
private game: Game; private game: Game;
private socket: WebSocket; private clientId: string;
constructor(ctx: CanvasRenderingContext2D) { constructor(game: Game) {
this.game = new Game(); this.game = game;
this.socket = new WebSocket("ws://localhost:8080"); }
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 FacingDirection(),
new Physics(), new Physics(),
new Collision(), new Collision(grid),
new WallBounds(ctx.canvas.width), new WallBounds(),
new Render(ctx), new Render(ctx)
].forEach((system) => this.game.addSystem(system)); ].forEach((system) => this.game.addSystem(system));
[new Floor(160), new Player()].forEach((entity) =>
this.game.addEntity(entity),
);
} }
public play() { public play() {
@ -41,16 +155,26 @@ export class JumpStorm {
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
private createInputSystem(): Input { private addWindowEventListenersToInputSystem(input: Input) {
const inputSystem = new Input(); window.addEventListener('keydown', (e) => {
window.addEventListener("keydown", (e) => {
if (!e.repeat) { 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"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { loadAssets } from "@engine/config"; import { loadAssets } from "@engine/config";
import { Game } from "@engine/Game";
import { JumpStorm } from "../JumpStorm"; import { JumpStorm } from "../JumpStorm";
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@ -9,16 +10,17 @@
export let width: number; export let width: number;
export let height: number; export let height: number;
let jumpStorm: JumpStorm; onMount(async () => {
onMount(() => {
ctx = canvas.getContext("2d"); ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
loadAssets().then(() => { await loadAssets();
jumpStorm = new JumpStorm(ctx);
jumpStorm.play(); const game = new Game();
}); const jumpStorm = new JumpStorm(game);
await jumpStorm.init(ctx, "http", "ws", document.location.host + "/api");
jumpStorm.play();
}); });
</script> </script>

View File

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

View File

@ -1,7 +1,7 @@
import App from "./App.svelte"; import App from './App.svelte';
const app = new App({ const app = new App({
target: document.getElementById("app"), target: document.getElementById('app')
}); });
export default app; export default app;

View File

@ -6,7 +6,6 @@
let width: number = Miscellaneous.WIDTH; let width: number = Miscellaneous.WIDTH;
let height: number = Miscellaneous.HEIGHT; let height: number = Miscellaneous.HEIGHT;
</script> </script>
<div class="centered-game"> <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 { export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors // 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": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
@ -24,8 +24,5 @@
"src/**/*.js", "src/**/*.js",
"src/**/*.svelte" "src/**/*.svelte"
], ],
"paths": {
"@engine/*": ["../engine/*"]
},
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -1,13 +1,24 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelte } from '@sveltejs/vite-plugin-svelte';
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from 'node:url';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ 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()], plugins: [svelte()],
resolve: { resolve: {
alias: { 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 { Entity } from './entities';
import { System } from "./systems"; import { System } from './systems';
export class Game { export class Game {
private systemOrder: string[]; private systemOrder: string[];
@ -7,9 +7,9 @@ export class Game {
private running: boolean; private running: boolean;
private lastTimeStamp: number; private lastTimeStamp: number;
public entities: Map<number, Entity>; public entities: Map<string, Entity>;
public systems: Map<string, System>; public systems: Map<string, System>;
public componentEntities: Map<string, Set<number>>; public componentEntities: Map<string, Set<string>>;
constructor() { constructor() {
this.lastTimeStamp = performance.now(); this.lastTimeStamp = performance.now();
@ -29,17 +29,17 @@ export class Game {
this.entities.set(entity.id, entity); this.entities.set(entity.id, entity);
} }
public getEntity(id: number): Entity | undefined { public getEntity(id: string): Entity | undefined {
return this.entities.get(id); return this.entities.get(id);
} }
public removeEntity(id: number) { public removeEntity(id: string) {
this.entities.delete(id); this.entities.delete(id);
} }
public forEachEntityWithComponent( public forEachEntityWithComponent(
componentName: string, componentName: string,
callback: (entity: Entity) => void, callback: (entity: Entity) => void
) { ) {
this.componentEntities.get(componentName)?.forEach((entityId) => { this.componentEntities.get(componentName)?.forEach((entityId) => {
const entity = this.getEntity(entityId); const entity = this.getEntity(entityId);
@ -60,7 +60,7 @@ export class Game {
return this.systems.get(name); return this.systems.get(name);
} }
public doGameLoop = (timeStamp: number) => { public doGameLoop(timeStamp: number) {
if (!this.running) { if (!this.running) {
return; return;
} }
@ -75,16 +75,16 @@ export class Game {
if (!this.componentEntities.has(component.name)) { if (!this.componentEntities.has(component.name)) {
this.componentEntities.set( this.componentEntities.set(
component.name, component.name,
new Set<number>([entity.id]), new Set<string>([entity.id])
); );
return; return;
} }
this.componentEntities.get(component.name)?.add(entity.id); this.componentEntities.get(component.name)?.add(entity.id);
}), })
); );
this.systemOrder.forEach((systemName) => { this.systemOrder.forEach((systemName) => {
this.systems.get(systemName)?.update(dt, this); this.systems.get(systemName)?.update(dt, this);
}); });
}; }
} }

View File

@ -1,6 +1,6 @@
import { Component, ComponentNames } from "."; import { Component, ComponentNames } from '.';
import type { Coord2D, Dimension2D } from "../interfaces"; import type { Coord2D, Dimension2D } from '../interfaces';
import { dotProduct, rotateVector } from "../utils"; import { dotProduct, rotateVector } from '../utils';
export class BoundingBox extends Component { export class BoundingBox extends Component {
public center: Coord2D; public center: Coord2D;
@ -15,8 +15,27 @@ export class BoundingBox extends Component {
this.rotation = rotation ?? 0; this.rotation = rotation ?? 0;
} }
// https://en.wikipedia.org/wiki/Hyperplane_separation_theorem
public isCollidingWith(box: BoundingBox): boolean { 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()]; const boxes = [this.getVertices(), box.getVertices()];
for (const poly of boxes) { for (const poly of boxes) {
for (let i = 0; i < poly.length; i++) { for (let i = 0; i < poly.length; i++) {
@ -29,8 +48,8 @@ export class BoundingBox extends Component {
const projection = dotProduct(normal, vertex); const projection = dotProduct(normal, vertex);
return [Math.min(min, projection), Math.max(max, projection)]; return [Math.min(min, projection), Math.max(max, projection)];
}, },
[Infinity, -Infinity], [Infinity, -Infinity]
), )
); );
if (maxThis < minBox || maxBox < minThis) return false; 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 }, { 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) => { .map((vertex) => {
// translate
return { return {
x: vertex.x + this.center.x, 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); let rads = this.rotation * (Math.PI / 180);
if (rads >= Math.PI) { if (rads >= Math.PI) {
// Physics system guarantees rotation \in [0, 360)
rads -= Math.PI; rads -= Math.PI;
} }
return rads; return rads;
@ -68,17 +89,33 @@ export class BoundingBox extends Component {
let rads = this.getRotationInPiOfUnitCircle(); let rads = this.getRotationInPiOfUnitCircle();
const { width, height } = this.dimension; const { width, height } = this.dimension;
if (rads == 0) return this.dimension;
if (rads <= Math.PI / 2) { if (rads <= Math.PI / 2) {
return { return {
width: Math.abs(height * Math.sin(rads) + width * Math.cos(rads)), 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; rads -= Math.PI / 2;
return { return {
width: Math.abs(height * Math.cos(rads) + width * Math.sin(rads)), 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 { export class Collide extends Component {
constructor() { constructor() {

View File

@ -1,11 +1,18 @@
import { Component, ComponentNames, Velocity } from "."; import { Component, ComponentNames, Velocity } from '.';
export class Control extends Component { 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); 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 { export class FacingDirection extends Component {
public readonly facingLeftSprite: Sprite; public readonly facingLeftSprite: Sprite;

View File

@ -1,6 +1,6 @@
import type { Force2D } from "../interfaces"; import type { Force2D } from '../interfaces';
import { Component } from "./Component"; import { Component } from './Component';
import { ComponentNames } from "."; import { ComponentNames } from '.';
/** /**
* A list of forces and torque, (in newtons, and newton-meters respectively) * 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 { export class Gravity extends Component {
private static DEFAULT_TERMINAL_VELOCITY = 5; private static DEFAULT_TERMINAL_VELOCITY = 4.5;
public terminalVelocity: number; public terminalVelocity: number;

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { Component, ComponentNames } from "."; import { Component, ComponentNames } from '.';
export class Moment extends Component { export class Moment extends Component {
public inertia: number; 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 { Component, ComponentNames } from '.';
import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces"; import type { Dimension2D, DrawArgs, Coord2D } from '../interfaces';
export class Sprite extends Component { export class Sprite extends Component {
private sheet: HTMLImageElement; private sheet: HTMLImageElement;
@ -17,7 +17,7 @@ export class Sprite extends Component {
spriteImgPos: Coord2D, spriteImgPos: Coord2D,
spriteImgDimensions: Dimension2D, spriteImgDimensions: Dimension2D,
msPerFrame: number, msPerFrame: number,
numFrames: number, numFrames: number
) { ) {
super(ComponentNames.Sprite); super(ComponentNames.Sprite);
@ -56,12 +56,12 @@ export class Sprite extends Component {
ctx.drawImage( ctx.drawImage(
this.sheet, this.sheet,
...this.getSpriteArgs(), ...this.getSpriteArgs(),
...this.getDrawArgs(drawArgs), ...this.getDrawArgs(drawArgs)
); );
if (tint) { if (tint) {
ctx.globalAlpha = 0.5; ctx.globalAlpha = 0.5;
ctx.globalCompositeOperation = "source-atop"; ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = tint; ctx.fillStyle = tint;
ctx.fillRect(...this.getDrawArgs(drawArgs)); ctx.fillRect(...this.getDrawArgs(drawArgs));
} }
@ -74,19 +74,23 @@ export class Sprite extends Component {
this.spriteImgPos.x + this.currentFrame * this.spriteImgDimensions.width, this.spriteImgPos.x + this.currentFrame * this.spriteImgDimensions.width,
this.spriteImgPos.y, this.spriteImgPos.y,
this.spriteImgDimensions.width, this.spriteImgDimensions.width,
this.spriteImgDimensions.height, this.spriteImgDimensions.height
]; ];
} }
private getDrawArgs({ private getDrawArgs({
center, center,
dimension, dimension
}: DrawArgs): [dx: number, dy: number, dw: number, dh: number] { }: DrawArgs): [dx: number, dy: number, dw: number, dh: number] {
return [ return [
center.x - dimension.width / 2, center.x - dimension.width / 2,
center.y - dimension.height / 2, center.y - dimension.height / 2,
dimension.width, 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 { export class TopCollidable extends Component {
constructor() { constructor() {

View File

@ -1,23 +1,23 @@
import type { Velocity2D } from "../interfaces"; import type { Velocity2D } from '../interfaces';
import { Component } from "./Component"; import { Component } from './Component';
import { ComponentNames } from "."; import { ComponentNames } from '.';
export class Velocity extends Component { export class Velocity extends Component {
public dCartesian: Velocity2D; public velocity: Velocity2D;
public dTheta: number;
constructor(dCartesian: Velocity2D = { dx: 0, dy: 0 }, dTheta: number = 0) { constructor(
velocity: Velocity2D = { dCartesian: { dx: 0, dy: 0 }, dTheta: 0 }
) {
super(ComponentNames.Velocity); super(ComponentNames.Velocity);
this.dCartesian = dCartesian; this.velocity = velocity;
this.dTheta = dTheta;
} }
public add(velocity?: Velocity) { public add(velocity?: Velocity2D) {
if (velocity) { if (velocity) {
this.dCartesian.dx += velocity.dCartesian.dx; this.velocity.dCartesian.dx += velocity.dCartesian.dx;
this.dCartesian.dy += velocity.dCartesian.dy; this.velocity.dCartesian.dy += velocity.dCartesian.dy;
this.dTheta += velocity.dTheta; this.velocity.dTheta += velocity.dTheta;
} }
} }
} }

View File

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

View File

@ -1,15 +1,16 @@
export * from "./Component"; export * from './Component';
export * from "./BoundingBox"; export * from './BoundingBox';
export * from "./Velocity"; export * from './Velocity';
export * from "./Forces"; export * from './Forces';
export * from "./Sprite"; export * from './Sprite';
export * from "./FacingDirection"; export * from './FacingDirection';
export * from "./Jump"; export * from './Jump';
export * from "./TopCollidable"; export * from './TopCollidable';
export * from "./Collide"; export * from './Collide';
export * from "./Control"; export * from './Control';
export * from "./WallBounded"; export * from './WallBounded';
export * from "./Gravity"; export * from './Gravity';
export * from "./Mass"; export * from './Mass';
export * from "./Moment"; export * from './Moment';
export * from "./names"; export * from './NetworkUpdateable';
export * from './names';

View File

@ -1,15 +1,16 @@
export namespace ComponentNames { export namespace ComponentNames {
export const Sprite = "Sprite"; export const Sprite = 'Sprite';
export const BoundingBox = "BoundingBox"; export const BoundingBox = 'BoundingBox';
export const Velocity = "Velocity"; export const Velocity = 'Velocity';
export const FacingDirection = "FacingDirection"; export const FacingDirection = 'FacingDirection';
export const Control = "Control"; export const Control = 'Control';
export const Jump = "Jump"; export const Jump = 'Jump';
export const TopCollidable = "TopCollidable"; export const TopCollidable = 'TopCollidable';
export const Collide = "Collide"; export const Collide = 'Collide';
export const WallBounded = "WallBounded"; export const WallBounded = 'WallBounded';
export const Gravity = "Gravity"; export const Gravity = 'Gravity';
export const Forces = "Forces"; export const Forces = 'Forces';
export const Mass = "Mass"; export const Mass = 'Mass';
export const Moment = "Moment"; export const Moment = 'Moment';
export const NetworkUpdateable = 'NetworkUpdateable';
} }

View File

@ -1,10 +1,10 @@
import type { SpriteSpec } from "./sprites"; import type { SpriteSpec } from './sprites';
import { SPRITE_SPECS } from "./sprites"; import { SPRITE_SPECS } from './sprites';
export const IMAGES = new Map<string, HTMLImageElement>(); export const IMAGES = new Map<string, HTMLImageElement>();
export const loadSpritesIntoImageElements = ( export const loadSpritesIntoImageElements = (
spriteSpecs: Partial<SpriteSpec>[], spriteSpecs: Partial<SpriteSpec>[]
): Promise<void>[] => { ): Promise<void>[] => {
const spritePromises: Promise<void>[] = []; const spritePromises: Promise<void>[] = [];
@ -17,13 +17,13 @@ export const loadSpritesIntoImageElements = (
spritePromises.push( spritePromises.push(
new Promise((resolve) => { new Promise((resolve) => {
img.onload = () => resolve(); img.onload = () => resolve();
}), })
); );
} }
if (spriteSpec.states) { if (spriteSpec.states) {
spritePromises.push( spritePromises.push(
...loadSpritesIntoImageElements(Array.from(spriteSpec.states.values())), ...loadSpritesIntoImageElements(Array.from(spriteSpec.states.values()))
); );
} }
} }
@ -35,8 +35,8 @@ export const loadAssets = () =>
Promise.all([ Promise.all([
...loadSpritesIntoImageElements( ...loadSpritesIntoImageElements(
Array.from(SPRITE_SPECS.keys()).map( Array.from(SPRITE_SPECS.keys()).map(
(key) => SPRITE_SPECS.get(key) as SpriteSpec, (key) => SPRITE_SPECS.get(key) as SpriteSpec
), )
), )
// TODO: Sound // TODO: Sound
]); ]);

View File

@ -1,34 +1,39 @@
import { Action } from "../interfaces"; import { Action } from '../interfaces';
export namespace KeyConstants { export namespace KeyConstants {
export const KeyActions: Record<string, Action> = { export const KeyActions: Record<string, Action> = {
a: Action.MOVE_LEFT, a: Action.MOVE_LEFT,
ArrowLeft: Action.MOVE_LEFT, arrowleft: Action.MOVE_LEFT,
d: Action.MOVE_RIGHT, d: Action.MOVE_RIGHT,
ArrowRight: Action.MOVE_RIGHT, arrowright: Action.MOVE_RIGHT,
w: Action.JUMP, w: Action.JUMP,
ArrowUp: Action.JUMP, arrowup: Action.JUMP,
' ': Action.JUMP
}; };
// value -> [key] from KeyActions
export const ActionKeys: Map<Action, string[]> = Object.keys( export const ActionKeys: Map<Action, string[]> = Object.keys(
KeyActions, KeyActions
).reduce((acc: Map<Action, string[]>, key) => { ).reduce((acc: Map<Action, string[]>, key) => {
const action = KeyActions[key]; const action = KeyActions[key.toLowerCase()];
if (acc.has(action)) { if (acc.has(action)) {
acc.get(action)?.push(key); acc.get(action)!.push(key);
return acc; return acc;
} }
acc.set(action, [key]); acc.set(action, [key]);
return acc; return acc;
}, new Map<Action, string[]>()); }, new Map());
} }
export namespace PhysicsConstants { export namespace PhysicsConstants {
export const MAX_JUMP_TIME_MS = 150; export const MAX_JUMP_TIME_MS = 150;
export const GRAVITY = 0.0075; 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_ACC = -0.008;
export const PLAYER_JUMP_INITIAL_VEL = -1; export const PLAYER_JUMP_INITIAL_VEL = -1;
} }
@ -36,4 +41,7 @@ export namespace PhysicsConstants {
export namespace Miscellaneous { export namespace Miscellaneous {
export const WIDTH = 600; export const WIDTH = 600;
export const HEIGHT = 800; 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 './constants';
export * from "./assets.ts"; export * from './assets.ts';
export * from "./sprites.ts"; export * from './sprites.ts';

View File

@ -1,7 +1,7 @@
export enum Sprites { export enum Sprites {
FLOOR, FLOOR,
TRAMPOLINE, TRAMPOLINE,
COFFEE, COFFEE
} }
export interface SpriteSpec { export interface SpriteSpec {
@ -22,12 +22,12 @@ const floorSpriteSpec = {
height: 40, height: 40,
frames: 3, frames: 3,
msPerFrame: 125, msPerFrame: 125,
states: new Map<number, Partial<SpriteSpec>>(), states: new Map<number, Partial<SpriteSpec>>()
}; };
[40, 80, 120, 160].forEach((width) => { [40, 80, 120, 160].forEach((width) => {
floorSpriteSpec.states.set(width, { floorSpriteSpec.states.set(width, {
width, width,
sheet: `/assets/floor_tile_${width}.png`, sheet: `/assets/floor_tile_${width}.png`
}); });
}); });
SPRITE_SPECS.set(Sprites.FLOOR, floorSpriteSpec); SPRITE_SPECS.set(Sprites.FLOOR, floorSpriteSpec);
@ -37,12 +37,12 @@ const coffeeSpriteSpec = {
width: 60, width: 60,
height: 45, height: 45,
frames: 3, frames: 3,
states: new Map<string, Partial<SpriteSpec>>(), states: new Map<string, Partial<SpriteSpec>>()
}; };
coffeeSpriteSpec.states.set("LEFT", { coffeeSpriteSpec.states.set('LEFT', {
sheet: "/assets/coffee_left.png", sheet: '/assets/coffee_left.png'
}); });
coffeeSpriteSpec.states.set("RIGHT", { coffeeSpriteSpec.states.set('RIGHT', {
sheet: "/assets/coffee_right.png", sheet: '/assets/coffee_right.png'
}); });
SPRITE_SPECS.set(Sprites.COFFEE, coffeeSpriteSpec); 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 { export abstract class Entity {
private static ID = 0; public id: string;
public components: Map<string, Component>;
public name: string;
public readonly id: number; constructor(name: string, id: string = randomId()) {
public readonly components: Map<string, Component>; this.name = name;
this.id = id;
constructor() {
this.id = Entity.ID++;
this.components = new Map(); this.components = new Map();
} }
@ -17,7 +21,7 @@ export abstract class Entity {
public getComponent<T extends Component>(name: string): T { public getComponent<T extends Component>(name: string): T {
if (!this.hasComponent(name)) { 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; return this.components.get(name) as T;
} }
@ -29,4 +33,30 @@ export abstract class Entity {
public hasComponent(name: string): boolean { public hasComponent(name: string): boolean {
return this.components.has(name); 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 { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config';
import { BoundingBox, Sprite } from "../components"; import { BoundingBox, ComponentNames, Sprite } from '../components';
import { TopCollidable } from "../components/TopCollidable"; import { TopCollidable } from '../components/TopCollidable';
import { Entity } from "../entities"; import { Entity, EntityNames } from '../entities';
export class Floor extends Entity { export class Floor extends Entity {
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(
Sprites.FLOOR, Sprites.FLOOR
) as SpriteSpec; ) as SpriteSpec;
private width: number;
constructor(width: number) { constructor(width: number) {
super(); super(EntityNames.Floor);
this.width = width;
this.addComponent( this.addComponent(
new Sprite( new Sprite(
@ -17,17 +21,28 @@ export class Floor extends Entity {
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ width, height: Floor.spriteSpec.height }, { width, height: Floor.spriteSpec.height },
Floor.spriteSpec.msPerFrame, Floor.spriteSpec.msPerFrame,
Floor.spriteSpec.frames, Floor.spriteSpec.frames
), )
);
this.addComponent(
new BoundingBox(
{ x: 300, y: 300 },
{ width, height: Floor.spriteSpec.height },
),
); );
this.addComponent(new TopCollidable()); 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 { Entity, EntityNames } from '.';
import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config"; import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config';
import { import {
Jump, Jump,
FacingDirection, FacingDirection,
@ -10,32 +10,38 @@ import {
WallBounded, WallBounded,
Forces, Forces,
Collide, Collide,
Control,
Mass, Mass,
Moment, Moment,
} from "../components"; ComponentNames,
import { Direction } from "../interfaces"; Control
} from '../components';
import { Direction } from '../interfaces';
export class Player extends Entity { export class Player extends Entity {
private static MASS: number = 10; private static MASS: number = 10;
private static MOI: number = 1000; private static MOI: number = 100;
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(
Sprites.COFFEE, Sprites.COFFEE
) as SpriteSpec; ) as SpriteSpec;
constructor() { constructor() {
super(); super(EntityNames.Player);
this.addComponent( this.addComponent(
new BoundingBox( new BoundingBox(
{ x: 300, y: 100 }, {
x: 0,
y: 0
},
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height }, { 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 Mass(Player.MASS));
this.addComponent(new Moment(Player.MOI)); this.addComponent(new Moment(Player.MOI));
@ -43,7 +49,6 @@ export class Player extends Entity {
this.addComponent(new Gravity()); this.addComponent(new Gravity());
this.addComponent(new Jump()); this.addComponent(new Jump());
this.addComponent(new Control());
this.addComponent(new Collide()); this.addComponent(new Collide());
this.addComponent(new WallBounded()); this.addComponent(new WallBounded());
@ -59,11 +64,41 @@ export class Player extends Entity {
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height }, { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
Player.spriteSpec.msPerFrame, Player.spriteSpec.msPerFrame,
Player.spriteSpec.frames, Player.spriteSpec.frames
), )
); );
this.addComponent(new FacingDirection(leftSprite, rightSprite)); 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 './Entity';
export * from "./Floor"; export * from './Floor';
export * from "./Player"; 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 { export enum Action {
MOVE_LEFT, MOVE_LEFT,
MOVE_RIGHT, MOVE_RIGHT,
JUMP, JUMP
} }

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export * from "./Vec2"; export * from './Vec2';
export * from "./Draw"; export * from './Draw';
export * from "./Direction"; export * from './Direction';
export * from "./Action"; 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"; import type { Coord2D, Dimension2D } from '../interfaces';
import type { BoxedEntry, RefreshingCollisionFinderBehavior } from '.';
interface BoxedEntry {
id: number;
dimension: Dimension2D;
center: Coord2D;
}
enum Quadrant { enum Quadrant {
I, I,
II, II,
III, 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 maxLevels: number;
private splitThreshold: number; private splitThreshold: number;
private level: number; private level: number;
@ -24,34 +26,33 @@ export class QuadTree {
private objects: BoxedEntry[]; private objects: BoxedEntry[];
constructor( constructor(
topLeft: Coord2D, topLeft: Coord2D = { x: 0, y: 0 },
dimension: Dimension2D, dimension: Dimension2D,
maxLevels: number, maxLevels: number = QuadTree.QUADTREE_MAX_LEVELS,
splitThreshold: number, splitThreshold: number = QuadTree.QUADTREE_SPLIT_THRESHOLD,
level?: number, level: number = 0
) { ) {
this.children = new Map<Quadrant, QuadTree>(); this.children = new Map<Quadrant, QuadTree>();
this.objects = []; this.objects = [];
this.maxLevels = maxLevels; this.maxLevels = maxLevels;
this.splitThreshold = splitThreshold; this.splitThreshold = splitThreshold;
this.level = level ?? 0; this.level = level;
this.topLeft = topLeft; this.topLeft = topLeft;
this.dimension = dimension; this.dimension = dimension;
} }
public insert(id: number, dimension: Dimension2D, center: Coord2D): void { public insert(boxedEntry: BoxedEntry): void {
const box: BoxedEntry = { id, center, dimension };
if (this.hasChildren()) { if (this.hasChildren()) {
this.getQuadrants(box).forEach((quadrant) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrantBox = this.children.get(quadrant); const quadrantBox = this.children.get(quadrant);
quadrantBox?.insert(id, dimension, center); quadrantBox!.insert(boxedEntry);
}); });
return; return;
} }
this.objects.push({ id, dimension, center }); this.objects.push(boxedEntry);
if ( if (
this.objects.length > this.splitThreshold && this.objects.length > this.splitThreshold &&
@ -66,22 +67,24 @@ export class QuadTree {
public clear(): void { public clear(): void {
this.objects = []; this.objects = [];
if (this.hasChildren()) { if (this.hasChildren()) {
this.children.forEach((child) => child.clear()); this.children.forEach((child) => child.clear());
this.children.clear(); this.children.clear();
} }
} }
public getNeighborIds(boxedEntry: BoxedEntry): number[] { public getNeighborIds(boxedEntry: BoxedEntry): Set<string> {
const neighbors: number[] = this.objects.map(({ id }) => id); const neighbors = new Set<string>(
this.objects.map(({ id }) => id).filter((id) => id != boxedEntry.id)
);
if (this.hasChildren()) { if (this.hasChildren()) {
this.getQuadrants(boxedEntry).forEach((quadrant) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrantBox = this.children.get(quadrant); const quadrantBox = this.children.get(quadrant);
quadrantBox quadrantBox
?.getNeighborIds(boxedEntry) ?.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.III, { x: this.topLeft.x, y: this.topLeft.y + halfHeight }],
[ [
Quadrant.IV, Quadrant.IV,
{ x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight }, { x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight }
], ]
] as [[Quadrant, Coord2D]] ] as [Quadrant, Coord2D][]
).forEach(([quadrant, pos]) => { ).forEach(([quadrant, pos]) => {
this.children.set( this.children.set(
quadrant, quadrant,
@ -110,8 +113,8 @@ export class QuadTree {
{ width: halfWidth, height: halfHeight }, { width: halfWidth, height: halfHeight },
this.maxLevels, this.maxLevels,
this.splitThreshold, this.splitThreshold,
this.level + 1, this.level + 1
), )
); );
}); });
} }
@ -119,52 +122,48 @@ export class QuadTree {
private getQuadrants(boxedEntry: BoxedEntry): Quadrant[] { private getQuadrants(boxedEntry: BoxedEntry): Quadrant[] {
const treeCenter: Coord2D = { const treeCenter: Coord2D = {
x: this.topLeft.x + this.dimension.width / 2, 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 ( return (
[ [
[ [
Quadrant.I, Quadrant.I,
(x: number, y: number) => x >= treeCenter.x && y < treeCenter.y, (x: number, y: number) => x >= treeCenter.x && y < treeCenter.y
], ],
[ [
Quadrant.II, Quadrant.II,
(x: number, y: number) => x < treeCenter.x && y < treeCenter.y, (x: number, y: number) => x < treeCenter.x && y < treeCenter.y
], ],
[ [
Quadrant.III, Quadrant.III,
(x: number, y: number) => x < treeCenter.x && y >= treeCenter.y, (x: number, y: number) => x < treeCenter.x && y >= treeCenter.y
], ],
[ [
Quadrant.IV, Quadrant.IV,
(x: number, y: number) => x >= treeCenter.x && y >= treeCenter.y, (x: number, y: number) => x >= treeCenter.x && y >= treeCenter.y
], ]
] as [[Quadrant, (x: number, y: number) => boolean]] ] as [Quadrant, (x: number, y: number) => boolean][]
) )
.filter( .filter(
([_quadrant, condition]) => ([_quadrant, condition]) =>
condition( condition(
boxedEntry.center.x + boxedEntry.dimension.width / 2, boxedEntry.center.x + boxedEntry.dimension.width / 2,
boxedEntry.center.y + boxedEntry.dimension.height / 2, boxedEntry.center.y + boxedEntry.dimension.height / 2
) || ) ||
condition( condition(
boxedEntry.center.x - boxedEntry.dimension.width / 2, boxedEntry.center.x - boxedEntry.dimension.width / 2,
boxedEntry.center.y - boxedEntry.dimension.height / 2, boxedEntry.center.y - boxedEntry.dimension.height / 2
), )
) )
.map(([quadrant]) => quadrant); .map(([quadrant]) => quadrant);
} }
private realignObjects(): void { private realignObjects(): void {
this.objects.forEach((boxedEntry) => { this.objects.forEach((boxedEntry) => {
this.getQuadrants(boxedEntry).forEach((direction) => { this.getQuadrants(boxedEntry).forEach((quadrant) => {
const quadrant = this.children.get(direction); const quadrantBox = this.children.get(quadrant);
quadrant?.insert( quadrantBox!.insert(boxedEntry);
boxedEntry.id,
boxedEntry.dimension,
boxedEntry.center,
);
}); });
}); });
@ -174,4 +173,12 @@ export class QuadTree {
private hasChildren() { private hasChildren() {
return this.children && this.children.size > 0; 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 { import {
Mass, Mass,
BoundingBox, BoundingBox,
ComponentNames, ComponentNames,
Jump, Jump,
Velocity, Velocity,
Forces, Forces
} from "../components"; } from '../components';
import { Game } from "../Game"; import { Game } from '../Game';
import { PhysicsConstants } from "../config"; import { Miscellaneous, PhysicsConstants } from '../config';
import { Entity } from "../entities"; import { Entity } from '../entities';
import type { Dimension2D } from "../interfaces"; import type { Coord2D, Dimension2D, Velocity2D } from '../interfaces';
import { QuadTree } from "../structures"; import { BoxedEntry, RefreshingCollisionFinderBehavior } from '../structures';
export class Collision extends System { export class Collision extends System {
private static readonly COLLIDABLE_COMPONENT_NAMES = [ private static readonly COLLIDABLE_COMPONENT_NAMES = [
ComponentNames.Collide, 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); super(SystemNames.Collision);
this.quadTree = new QuadTree( this.collisionFinder = refreshingCollisionFinder;
{ x: 0, y: 0 },
screenDimensions,
Collision.QUADTREE_MAX_LEVELS,
Collision.QUADTREE_SPLIT_THRESHOLD,
);
} }
public update(_dt: number, game: Game) { public update(_dt: number, game: Game) {
// rebuild the quadtree this.collisionFinder.clear();
this.quadTree.clear();
const entitiesToAddToQuadtree: Entity[] = []; const entitiesToAddToCollisionFinder: Entity[] = [];
Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) => Collision.COLLIDABLE_COMPONENT_NAMES.map((componentName) =>
game.componentEntities.get(componentName), game.forEachEntityWithComponent(componentName, (entity) => {
).forEach( if (!entity.hasComponent(ComponentNames.BoundingBox)) {
(entityIds?: Set<number>) => return;
entityIds?.forEach((id) => { }
const entity = game.entities.get(id); entitiesToAddToCollisionFinder.push(entity);
if (!entity || !entity.hasComponent(ComponentNames.BoundingBox)) { })
return;
}
entitiesToAddToQuadtree.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>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
let dimension = { ...boundingBox.dimension }; let dimension = { ...boundingBox.dimension };
@ -63,18 +61,43 @@ export class Collision extends System {
dimension = boundingBox.getOutscribedBoxDims(); 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 // set bounds first
const collidingEntities = this.getCollidingEntities( if (entities.length > 0) {
entitiesToAddToQuadtree, this.collisionFinder.setTopLeft(topLeft);
game, 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]) => { collidingEntities.forEach(([entityAId, entityBId]) => {
const [entityA, entityB] = [entityAId, entityBId].map((id) => const [entityA, entityB] = [entityAId, entityBId].map((id) =>
game.entities.get(id), game.entities.get(id)
); );
if (entityA && entityB) { if (entityA && entityB) {
this.performCollision(entityA, entityB); this.performCollision(entityA, entityB);
@ -84,12 +107,14 @@ export class Collision extends System {
private performCollision(entityA: Entity, entityB: Entity) { private performCollision(entityA: Entity, entityB: Entity) {
const [entityABoundingBox, entityBBoundingBox] = [entityA, entityB].map( 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)) { if (entityA.hasComponent(ComponentNames.Velocity)) {
velocity = entityA.getComponent<Velocity>(ComponentNames.Velocity); velocity = entityA.getComponent<Velocity>(
ComponentNames.Velocity
).velocity;
} }
if ( if (
@ -100,7 +125,7 @@ export class Collision extends System {
) { ) {
if (entityBBoundingBox.rotation != 0) { if (entityBBoundingBox.rotation != 0) {
throw new Error( 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({ entityA.getComponent<Forces>(ComponentNames.Forces).forces.push({
fCartesian: { fy: F_n, fx: 0 }, fCartesian: { fy: F_n, fx: 0 },
torque: 0, torque: 0
}); });
} }
@ -132,35 +157,33 @@ export class Collision extends System {
private getCollidingEntities( private getCollidingEntities(
collidableEntities: Entity[], collidableEntities: Entity[],
game: Game, game: Game
): [number, number][] { ): [string, string][] {
const collidingEntityIds: [number, number][] = []; const collidingEntityIds: [string, string][] = [];
for (const entity of collidableEntities) { for (const entity of collidableEntities) {
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
const neighborIds = this.quadTree const neighborIds = this.collisionFinder.getNeighborIds({
.getNeighborIds({ id: entity.id,
id: entity.id, dimension: boundingBox.dimension,
dimension: boundingBox.dimension, center: boundingBox.center
center: boundingBox.center, });
})
.filter((neighborId) => neighborId != entity.id);
neighborIds.forEach((neighborId) => { for (const neighborId of neighborIds) {
const neighbor = game.getEntity(neighborId); const neighbor = game.getEntity(neighborId);
if (!neighbor) return; if (!neighbor) return;
const neighborBoundingBox = neighbor.getComponent<BoundingBox>( const neighborBoundingBox = neighbor.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
if (boundingBox.isCollidingWith(neighborBoundingBox)) { if (boundingBox.isCollidingWith(neighborBoundingBox)) {
collidingEntityIds.push([entity.id, neighborId]); collidingEntityIds.push([entity.id, neighborId]);
} }
}); }
} }
return collidingEntityIds; return collidingEntityIds;
@ -169,11 +192,11 @@ export class Collision extends System {
// ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ // ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ
private getDyToPushOutOfFloor( private getDyToPushOutOfFloor(
entityBoundingBox: BoundingBox, entityBoundingBox: BoundingBox,
floorBoundingBox: BoundingBox, floorBoundingBox: BoundingBox
): number { ): number {
const { const {
dimension: { width, height }, dimension: { width, height },
center: { x }, center: { x }
} = entityBoundingBox; } = entityBoundingBox;
const outScribedRectangle = entityBoundingBox.getOutscribedBoxDims(); const outScribedRectangle = entityBoundingBox.getOutscribedBoxDims();
@ -192,7 +215,7 @@ export class Collision extends System {
if (x >= floorBoundingBox.center.x) { if (x >= floorBoundingBox.center.x) {
boundedCollisionX = Math.min( boundedCollisionX = Math.min(
floorBoundingBox.center.x + floorBoundingBox.dimension.width / 2, floorBoundingBox.center.x + floorBoundingBox.dimension.width / 2,
clippedX, clippedX
); );
return ( return (
outScribedRectangle.height / 2 - outScribedRectangle.height / 2 -
@ -202,7 +225,7 @@ export class Collision extends System {
boundedCollisionX = Math.max( boundedCollisionX = Math.max(
floorBoundingBox.center.x - floorBoundingBox.dimension.width / 2, floorBoundingBox.center.x - floorBoundingBox.dimension.width / 2,
clippedX, clippedX
); );
return ( return (

View File

@ -2,10 +2,10 @@ import {
ComponentNames, ComponentNames,
Velocity, Velocity,
FacingDirection as FacingDirectionComponent, FacingDirection as FacingDirectionComponent,
Control, Control
} from "../components"; } from '../components';
import { Game } from "../Game"; import { Game } from '../Game';
import { System, SystemNames } from "./"; import { System, SystemNames } from './';
export class FacingDirection extends System { export class FacingDirection extends System {
constructor() { constructor() {
@ -20,24 +20,27 @@ export class FacingDirection extends System {
return; return;
} }
const totalVelocity: Velocity = new Velocity(); const totalVelocityComponent = new Velocity();
const control = entity.getComponent<Control>(ComponentNames.Control); const control = entity.getComponent<Control>(ComponentNames.Control);
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity); const velocity = entity.getComponent<Velocity>(
totalVelocity.add(velocity); ComponentNames.Velocity
).velocity;
totalVelocityComponent.add(velocity);
if (control) { if (control) {
totalVelocity.add(control.controlVelocity); totalVelocityComponent.add(control.controlVelocityComponent.velocity);
} }
const facingDirection = entity.getComponent<FacingDirectionComponent>( const facingDirection = entity.getComponent<FacingDirectionComponent>(
ComponentNames.FacingDirection, ComponentNames.FacingDirection
); );
if (totalVelocity.dCartesian.dx > 0) { if (totalVelocityComponent.velocity.dCartesian.dx > 0) {
entity.addComponent(facingDirection.facingRightSprite); entity.addComponent(facingDirection.facingRightSprite);
} else if (totalVelocity.dCartesian.dx < 0) { } else if (totalVelocityComponent.velocity.dCartesian.dx < 0) {
entity.addComponent(facingDirection.facingLeftSprite); entity.addComponent(facingDirection.facingLeftSprite);
} }
}, }
); );
} }
} }

View File

@ -4,30 +4,117 @@ import {
ComponentNames, ComponentNames,
Velocity, Velocity,
Mass, Mass,
Control, Control
} from "../components"; } from '../components';
import { Game } from "../Game"; import { Game } from '../Game';
import { KeyConstants, PhysicsConstants } from "../config"; import { KeyConstants, PhysicsConstants } from '../config';
import { Action } from "../interfaces"; import { Action } from '../interfaces';
import { System, SystemNames } from "./"; import { System, SystemNames } from '.';
import { MessagePublisher, MessageType } from '../network';
import { Entity } from '../entities';
export class Input extends System { export class Input extends System {
public clientId: string;
private keys: Set<string>; private keys: Set<string>;
private actionTimeStamps: Map<Action, number>; private actionTimeStamps: Map<Action, number>;
private messagePublisher?: MessagePublisher;
constructor() { constructor(clientId: string, messagePublisher?: MessagePublisher) {
super(SystemNames.Input); super(SystemNames.Input);
this.keys = new Set<string>(); this.clientId = clientId;
this.actionTimeStamps = new Map<Action, number>(); this.keys = new Set();
this.actionTimeStamps = new Map();
this.messagePublisher = messagePublisher;
} }
public keyPressed(key: string) { public keyPressed(key: string) {
this.keys.add(key); this.keys.add(key);
if (this.messagePublisher) {
this.messagePublisher.addMessage({
type: MessageType.NEW_INPUT,
body: key
});
}
} }
public keyReleased(key: string) { public keyReleased(key: string) {
this.keys.delete(key); 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 { private hasSomeKey(keys?: string[]): boolean {
@ -36,48 +123,4 @@ export class Input extends System {
} }
return false; 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 { import {
BoundingBox, BoundingBox,
ComponentNames, ComponentNames,
@ -8,11 +8,11 @@ import {
Mass, Mass,
Jump, Jump,
Moment, Moment,
Control, Control
} from "../components"; } from '../components';
import { PhysicsConstants } from "../config"; import { PhysicsConstants } from '../config';
import type { Force2D } from "../interfaces"; import type { Force2D, Velocity2D } from '../interfaces';
import { Game } from "../Game"; import { Game } from '../Game';
export class Physics extends System { export class Physics extends System {
constructor() { constructor() {
@ -23,9 +23,11 @@ export class Physics extends System {
game.forEachEntityWithComponent(ComponentNames.Forces, (entity) => { game.forEachEntityWithComponent(ComponentNames.Forces, (entity) => {
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass; const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
const forces = entity.getComponent<Forces>(ComponentNames.Forces).forces; 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>( const inertia = entity.getComponent<Moment>(
ComponentNames.Moment, ComponentNames.Moment
).inertia; ).inertia;
// F_g = mg, applied only until terminal velocity is reached // F_g = mg, applied only until terminal velocity is reached
@ -35,9 +37,9 @@ export class Physics extends System {
forces.push({ forces.push({
fCartesian: { fCartesian: {
fy: mass * PhysicsConstants.GRAVITY, 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) => ({ (accum: Force2D, { fCartesian, torque }: Force2D) => ({
fCartesian: { fCartesian: {
fx: accum.fCartesian.fx + (fCartesian?.fx ?? 0), 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 // integrate accelerations
const [ddy, ddx] = [ const [ddy, ddx] = [
sumOfForces.fCartesian.fy, sumOfForces.fCartesian.fy,
sumOfForces.fCartesian.fx, sumOfForces.fCartesian.fx
].map((x) => x / mass); ].map((x) => x / mass);
velocity.dCartesian.dx += ddx * dt; velocity.dCartesian.dx += ddx * dt;
velocity.dCartesian.dy += ddy * dt; velocity.dCartesian.dy += ddy * dt;
@ -73,30 +75,32 @@ export class Physics extends System {
}); });
game.forEachEntityWithComponent(ComponentNames.Velocity, (entity) => { game.forEachEntityWithComponent(ComponentNames.Velocity, (entity) => {
const velocity: Velocity = new Velocity(); const velocityComponent: Velocity = new Velocity();
const control = entity.getComponent<Control>(ComponentNames.Control); const control = entity.getComponent<Control>(ComponentNames.Control);
velocity.add(entity.getComponent<Velocity>(ComponentNames.Velocity)); velocityComponent.add(
entity.getComponent<Velocity>(ComponentNames.Velocity).velocity
);
if (control) { if (control) {
velocity.add(control.controlVelocity); velocityComponent.add(control.controlVelocityComponent.velocity);
} }
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
// integrate velocity // integrate velocity
boundingBox.center.x += velocity.dCartesian.dx * dt; boundingBox.center.x += velocityComponent.velocity.dCartesian.dx * dt;
boundingBox.center.y += velocity.dCartesian.dy * dt; boundingBox.center.y += velocityComponent.velocity.dCartesian.dy * dt;
boundingBox.rotation += velocity.dTheta * dt; boundingBox.rotation += velocityComponent.velocity.dTheta * dt;
boundingBox.rotation = boundingBox.rotation =
(boundingBox.rotation < 0 (boundingBox.rotation < 0
? 360 + boundingBox.rotation ? 360 + boundingBox.rotation
: boundingBox.rotation) % 360; : boundingBox.rotation) % 360;
// clear the control velocity // clear the control velocity
if (control) { if (control && control.isControllable) {
control.controlVelocity = new Velocity(); control.controlVelocityComponent = new Velocity();
} }
}); });
} }

View File

@ -1,7 +1,7 @@
import { System, SystemNames } from "."; import { System, SystemNames } from '.';
import { BoundingBox, ComponentNames, Sprite } from "../components"; import { BoundingBox, ComponentNames, Sprite } from '../components';
import { Game } from "../Game"; import { Game } from '../Game';
import { clamp } from "../utils"; import { clamp } from '../utils';
export class Render extends System { export class Render extends System {
private ctx: CanvasRenderingContext2D; private ctx: CanvasRenderingContext2D;
@ -19,7 +19,7 @@ export class Render extends System {
sprite.update(dt); sprite.update(dt);
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
// don't render if we're outside the screen // don't render if we're outside the screen
@ -27,12 +27,12 @@ export class Render extends System {
clamp( clamp(
boundingBox.center.y, boundingBox.center.y,
-boundingBox.dimension.height / 2, -boundingBox.dimension.height / 2,
this.ctx.canvas.height + boundingBox.dimension.height / 2, this.ctx.canvas.height + boundingBox.dimension.height / 2
) != boundingBox.center.y || ) != boundingBox.center.y ||
clamp( clamp(
boundingBox.center.x, boundingBox.center.x,
-boundingBox.dimension.width / 2, -boundingBox.dimension.width / 2,
this.ctx.canvas.width + boundingBox.dimension.width / 2, this.ctx.canvas.width + boundingBox.dimension.width / 2
) != boundingBox.center.x ) != boundingBox.center.x
) { ) {
return; return;
@ -41,7 +41,7 @@ export class Render extends System {
const drawArgs = { const drawArgs = {
center: boundingBox.center, center: boundingBox.center,
dimension: boundingBox.dimension, dimension: boundingBox.dimension,
rotation: boundingBox.rotation, rotation: boundingBox.rotation
}; };
sprite.draw(this.ctx, drawArgs); sprite.draw(this.ctx, drawArgs);

View File

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

View File

@ -1,28 +1,24 @@
import { System, SystemNames } from "."; import { System, SystemNames } from '.';
import { BoundingBox, ComponentNames } from "../components"; import { BoundingBox, ComponentNames } from '../components';
import { Game } from "../Game"; import { Game } from '../Game';
import type { Entity } from "../entities"; import { clamp } from '../utils';
import { clamp } from "../utils"; import { Miscellaneous } from '../config';
export class WallBounds extends System { export class WallBounds extends System {
private screenWidth: number; constructor() {
constructor(screenWidth: number) {
super(SystemNames.WallBounds); super(SystemNames.WallBounds);
this.screenWidth = screenWidth;
} }
public update(_dt: number, game: Game) { public update(_dt: number, game: Game) {
game.forEachEntityWithComponent(ComponentNames.WallBounded, (entity) => { game.forEachEntityWithComponent(ComponentNames.WallBounded, (entity) => {
const boundingBox = entity.getComponent<BoundingBox>( const boundingBox = entity.getComponent<BoundingBox>(
ComponentNames.BoundingBox, ComponentNames.BoundingBox
); );
boundingBox.center.x = clamp( boundingBox.center.x = clamp(
boundingBox.center.x, boundingBox.center.x,
boundingBox.dimension.width / 2, 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 './names';
export * from "./System"; export * from './System';
export * from "./Render"; export * from './Render';
export * from "./Physics"; export * from './Physics';
export * from "./Input"; export * from './Input';
export * from "./FacingDirection"; export * from './FacingDirection';
export * from "./Collision"; export * from './Collision';
export * from "./WallBounds"; export * from './WallBounds';
export * from './NetworkUpdate';

View File

@ -1,8 +1,9 @@
export namespace SystemNames { export namespace SystemNames {
export const Render = "Render"; export const Render = 'Render';
export const Physics = "Physics"; export const Physics = 'Physics';
export const FacingDirection = "FacingDirection"; export const FacingDirection = 'FacingDirection';
export const Input = "Input"; export const Input = 'Input';
export const Collision = "Collision"; export const Collision = 'Collision';
export const WallBounds = "WallBounds"; 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 => export const dotProduct = (vector1: Coord2D, vector2: Coord2D): number =>
vector1.x * vector2.x + vector1.y * vector2.y; vector1.x * vector2.x + vector1.y * vector2.y;

View File

@ -1,3 +1,4 @@
export * from "./rotateVector"; export * from './rotateVector';
export * from "./dotProduct"; export * from './dotProduct';
export * from "./clamp"; 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,) * ([[cos(θ), -sin(θ),]) ([x,)
@ -10,6 +10,6 @@ export const rotateVector = (vector: Coord2D, theta: number): Coord2D => {
return { return {
x: vector.x * cos - vector.y * sin, 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": { "peerDependencies": {
"typescript": "^5.0.0" "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 { Game } from '@engine/Game';
import { Floor, Player } from "../../engine/entities"; import { Player } from '@engine/entities';
import { WallBounds, Physics, Collision } from "../../engine/systems"; import { Message, MessageType } from '@engine/network';
import { Miscellaneous } from "../../engine/config"; 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;
}
[ public serve() {
new Physics(), if (!this.server)
new Collision({ width: Miscellaneous.WIDTH, height: Miscellaneous.HEIGHT }), this.server = Bun.serve<SessionData>({
new WallBounds(Miscellaneous.WIDTH), port: Constants.SERVER_PORT,
].forEach((system) => game.addSystem(system)); 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(); console.log(`Listening on ${this.server.hostname}:${this.server.port}`);
setInterval(() => { }
game.doGameLoop(performance.now());
}, TICK_RATE);
const server = Bun.serve<>({ private websocketMessage(
port: 8080, websocket: ServerWebSocket<SessionData>,
fetch(req, server) { message: string | Uint8Array
server.upgrade(req, { ) {
data: {}, 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 private openWebsocket(websocket: ServerWebSocket<SessionData>) {
async message(ws, message) { websocket.subscribe(Constants.GAME_TOPIC);
console.log(`Received ${message}`);
}, const { sessionId } = websocket.data;
}, if (this.sessionManager.getSession(sessionId)) {
}); return;
console.log(`Listening on localhost:${server.port}`); }
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": { "compilerOptions": {
"lib": ["ESNext"], // add Bun type definitions
"types": ["bun-types"],
// enable latest features
"lib": ["esnext"],
"module": "esnext", "module": "esnext",
"target": "esnext", "target": "esnext",
// if TS 5.x+
"moduleResolution": "bundler", "moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true, "noEmit": true,
"types": [ "allowImportingTsExtensions": true,
"bun-types" // add Bun global "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"]
}
}
}