commit
8a4ab8d79b
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
@ -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'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
@ -10,7 +10,7 @@
|
||||
--orange: #af3a03;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
[data-theme='dark'] {
|
||||
--bg: #282828;
|
||||
--text: #f9f5d7;
|
||||
--red: #fb4934;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
rgba(162, 254, 254, 1) 100%
|
||||
);
|
||||
|
||||
content: "";
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import url("./colors.css");
|
||||
@import url('./colors.css');
|
||||
|
||||
.primary {
|
||||
color: var(--aqua);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,17 +10,18 @@
|
||||
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);
|
||||
await loadAssets();
|
||||
|
||||
const game = new Game();
|
||||
const jumpStorm = new JumpStorm(game);
|
||||
|
||||
await jumpStorm.init(ctx, "http", "ws", document.location.host + "/api");
|
||||
jumpStorm.play();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} {width} {height} />
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
|
@ -6,7 +6,6 @@
|
||||
|
||||
let width: number = Miscellaneous.WIDTH;
|
||||
let height: number = Miscellaneous.HEIGHT;
|
||||
|
||||
</script>
|
||||
|
||||
<div class="centered-game">
|
||||
|
@ -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()
|
||||
};
|
||||
|
@ -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" }]
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class Collide extends Component {
|
||||
constructor() {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames, Sprite } from ".";
|
||||
import { Component, ComponentNames, Sprite } from '.';
|
||||
|
||||
export class FacingDirection extends Component {
|
||||
public readonly facingLeftSprite: Sprite;
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class Jump extends Component {
|
||||
public canJump: boolean;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class Mass extends Component {
|
||||
public mass: number;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class Moment extends Component {
|
||||
public inertia: number;
|
||||
|
7
engine/components/NetworkUpdateable.ts
Normal file
7
engine/components/NetworkUpdateable.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class NetworkUpdateable extends Component {
|
||||
constructor() {
|
||||
super(ComponentNames.NetworkUpdateable);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class TopCollidable extends Component {
|
||||
constructor() {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import { Component, ComponentNames } from '.';
|
||||
|
||||
export class WallBounded extends Component {
|
||||
constructor() {
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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
|
||||
]);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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
4
engine/entities/names.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export namespace EntityNames {
|
||||
export const Player = 'Player';
|
||||
export const Floor = 'Floor';
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export enum Action {
|
||||
MOVE_LEFT,
|
||||
MOVE_RIGHT,
|
||||
JUMP,
|
||||
JUMP
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
export enum Direction {
|
||||
UP = "UP",
|
||||
DOWN = "DOWN",
|
||||
LEFT = "LEFT",
|
||||
RIGHT = "RIGHT",
|
||||
UP = 'UP',
|
||||
DOWN = 'DOWN',
|
||||
LEFT = 'LEFT',
|
||||
RIGHT = 'RIGHT'
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { Coord2D, Dimension2D } from "./";
|
||||
import type { Coord2D, Dimension2D } from './';
|
||||
|
||||
export interface DrawArgs {
|
||||
center: Coord2D;
|
||||
|
@ -9,8 +9,11 @@ export interface Dimension2D {
|
||||
}
|
||||
|
||||
export interface Velocity2D {
|
||||
dCartesian: {
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
dTheta: number;
|
||||
}
|
||||
|
||||
export interface Force2D {
|
||||
|
@ -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
37
engine/network/index.ts
Normal 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
104
engine/structures/Grid.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
14
engine/structures/RefreshingCollisionFinderBehavior.ts
Normal file
14
engine/structures/RefreshingCollisionFinderBehavior.ts
Normal 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;
|
||||
}
|
@ -1 +1,3 @@
|
||||
export * from "./QuadTree";
|
||||
export * from './RefreshingCollisionFinderBehavior';
|
||||
export * from './QuadTree';
|
||||
export * from './Grid';
|
||||
|
@ -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)) {
|
||||
game.forEachEntityWithComponent(componentName, (entity) => {
|
||||
if (!entity.hasComponent(ComponentNames.BoundingBox)) {
|
||||
return;
|
||||
}
|
||||
entitiesToAddToQuadtree.push(entity);
|
||||
}),
|
||||
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({
|
||||
const neighborIds = this.collisionFinder.getNeighborIds({
|
||||
id: entity.id,
|
||||
dimension: boundingBox.dimension,
|
||||
center: boundingBox.center,
|
||||
})
|
||||
.filter((neighborId) => neighborId != entity.id);
|
||||
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 (
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -4,58 +4,93 @@ 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);
|
||||
}
|
||||
|
||||
private hasSomeKey(keys?: string[]): boolean {
|
||||
if (keys) {
|
||||
return keys.some((key) => this.keys.has(key));
|
||||
if (this.messagePublisher) {
|
||||
this.messagePublisher.addMessage({
|
||||
type: MessageType.REMOVE_INPUT,
|
||||
body: key
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public update(_dt: number, game: Game) {
|
||||
game.forEachEntityWithComponent(ComponentNames.Control, (entity) => {
|
||||
const control = entity.getComponent<Control>(ComponentNames.Control);
|
||||
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))) {
|
||||
control.controlVelocity.dCartesian.dx +=
|
||||
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
|
||||
PhysicsConstants.PLAYER_MOVE_VEL;
|
||||
}
|
||||
|
||||
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))) {
|
||||
control.controlVelocity.dCartesian.dx +=
|
||||
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
|
||||
-PhysicsConstants.PLAYER_MOVE_VEL;
|
||||
}
|
||||
|
||||
if (entity.hasComponent(ComponentNames.Jump)) {
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
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 (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP))) {
|
||||
if (jump.canJump) {
|
||||
this.actionTimeStamps.set(Action.JUMP, performance.now());
|
||||
|
||||
@ -68,16 +103,24 @@ export class Input extends System {
|
||||
PhysicsConstants.MAX_JUMP_TIME_MS
|
||||
) {
|
||||
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
|
||||
entity.getComponent<Forces>(ComponentNames.Forces)?.forces.push({
|
||||
|
||||
const jumpForce = {
|
||||
fCartesian: {
|
||||
fy: mass * PhysicsConstants.PLAYER_JUMP_ACC,
|
||||
fx: 0,
|
||||
fx: 0
|
||||
},
|
||||
torque: 0,
|
||||
});
|
||||
torque: 0
|
||||
};
|
||||
entity
|
||||
.getComponent<Forces>(ComponentNames.Forces)
|
||||
?.forces.push(jumpForce);
|
||||
}
|
||||
}
|
||||
|
||||
private hasSomeKey(keys?: string[]): boolean {
|
||||
if (keys) {
|
||||
return keys.some((key) => this.keys.has(key));
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
72
engine/systems/NetworkUpdate.ts
Normal file
72
engine/systems/NetworkUpdate.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Game } from "../Game";
|
||||
import { Game } from '../Game';
|
||||
|
||||
export abstract class System {
|
||||
public readonly name: string;
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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
27
engine/utils/coding.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
BIN
server/bun.lockb
BIN
server/bun.lockb
Binary file not shown.
@ -8,6 +8,5 @@
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
|
6
server/src/constants.ts
Normal file
6
server/src/constants.ts
Normal 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
59
server/src/main.ts
Normal 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();
|
36
server/src/network/MessageProcessor.ts
Normal file
36
server/src/network/MessageProcessor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
31
server/src/network/MessagePublisher.ts
Normal file
31
server/src/network/MessagePublisher.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
22
server/src/network/MessageReceiver.ts
Normal file
22
server/src/network/MessageReceiver.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
32
server/src/network/SessionInputSystem.ts
Normal file
32
server/src/network/SessionInputSystem.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
33
server/src/network/SessionManager.ts
Normal file
33
server/src/network/SessionManager.ts
Normal 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);
|
||||
}
|
||||
}
|
29
server/src/network/index.ts
Normal file
29
server/src/network/index.ts
Normal 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;
|
||||
}
|
@ -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));
|
||||
|
||||
[new Floor(160), new Player()].forEach((entity) => game.addEntity(entity));
|
||||
|
||||
game.start();
|
||||
setInterval(() => {
|
||||
game.doGameLoop(performance.now());
|
||||
}, TICK_RATE);
|
||||
|
||||
const server = Bun.serve<>({
|
||||
port: 8080,
|
||||
fetch(req, server) {
|
||||
server.upgrade(req, {
|
||||
data: {},
|
||||
});
|
||||
},
|
||||
public serve() {
|
||||
if (!this.server)
|
||||
this.server = Bun.serve<SessionData>({
|
||||
port: Constants.SERVER_PORT,
|
||||
fetch: (req, srv) => this.fetchHandler(req, srv),
|
||||
websocket: {
|
||||
// handler called when a message is received
|
||||
async message(ws, message) {
|
||||
console.log(`Received ${message}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Listening on localhost:${server.port}`);
|
||||
open: (ws) => this.openWebsocket(ws),
|
||||
message: (ws, msg) => this.websocketMessage(ws, msg),
|
||||
close: (ws) => this.closeWebsocket(ws)
|
||||
}
|
||||
});
|
||||
|
||||
this.messagePublisher.setServer(this.server);
|
||||
|
||||
console.log(`Listening on ${this.server.hostname}:${this.server.port}`);
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
@ -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
15
tsconfig.engine.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user