add entity updates over network!

This commit is contained in:
Elizabeth Hunt 2023-08-26 17:55:27 -06:00
parent 594921352c
commit 6ce6946a44
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
19 changed files with 473 additions and 179 deletions

View File

@ -1,5 +1,5 @@
import { Game } from '@engine/Game'; import { Game } from '@engine/Game';
import { Entity, Floor } from '@engine/entities'; import { Entity } from '@engine/entities';
import { Grid } from '@engine/structures'; import { Grid } from '@engine/structures';
import { import {
WallBounds, WallBounds,
@ -16,11 +16,10 @@ import {
type MessageProcessor, type MessageProcessor,
type Message, type Message,
type EntityAddBody, type EntityAddBody,
MessageType MessageType,
type EntityUpdateBody
} from '@engine/network'; } from '@engine/network';
import { stringify, parse } from '@engine/utils'; import { stringify, parse } from '@engine/utils';
import { BoundingBox, Sprite } from '@engine/components';
import { Miscellaneous } from '@engine/config';
class ClientMessageProcessor implements MessageProcessor { class ClientMessageProcessor implements MessageProcessor {
private game: Game; private game: Game;
@ -34,17 +33,24 @@ class ClientMessageProcessor implements MessageProcessor {
case MessageType.NEW_ENTITIES: case MessageType.NEW_ENTITIES:
const entityAdditions = message.body as unknown as EntityAddBody[]; const entityAdditions = message.body as unknown as EntityAddBody[];
entityAdditions.forEach((addBody) => entityAdditions.forEach((addBody) =>
this.game.addEntity(Entity.from(addBody.entityName, addBody.args)) this.game.addEntity(
Entity.from(addBody.entityName, addBody.id, addBody.args)
)
); );
break; break;
case MessageType.REMOVE_ENTITIES: case MessageType.REMOVE_ENTITIES:
const ids = message.body as unknown as string[]; const ids = message.body as unknown as string[];
ids.forEach((id) => this.game.removeEntity(id)); ids.forEach((id) => this.game.removeEntity(id));
break; 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: default:
break; break;
} }
console.log(message);
} }
} }
@ -85,9 +91,12 @@ class ClientSocketMessagePublisher implements MessagePublisher {
} }
public publish() { public publish() {
this.messages.forEach((message: Message) => if (this.socket.readyState == WebSocket.OPEN) {
this.socket.send(stringify(message)) this.messages.forEach((message: Message) =>
); this.socket.send(stringify(message))
);
this.messages = [];
}
} }
} }
@ -105,19 +114,9 @@ export class JumpStorm {
wsMethod: string, wsMethod: string,
host: string host: string
) { ) {
await fetch(`${httpMethod}://${host}/assign`) this.clientId = await this.getAssignedCookie(
.then((resp) => { `${httpMethod}://${host}/assign`
if (resp.ok) { );
return resp.text();
}
throw resp;
})
.then((cookie) => {
this.clientId = cookie;
});
const grid = new Grid();
const socket = new WebSocket(`${wsMethod}://${host}/game`); const socket = new WebSocket(`${wsMethod}://${host}/game`);
const clientSocketMessageQueueProvider = const clientSocketMessageQueueProvider =
new ClientSocketMessageQueueProvider(socket); new ClientSocketMessageQueueProvider(socket);
@ -125,33 +124,25 @@ export class JumpStorm {
socket socket
); );
const clientMessageProcessor = new ClientMessageProcessor(this.game); const clientMessageProcessor = new ClientMessageProcessor(this.game);
const inputSystem = new Input(this.clientId, clientSocketMessagePublisher);
this.addWindowEventListenersToInputSystem(inputSystem);
const grid = new Grid();
[ [
this.createInputSystem(),
new FacingDirection(),
new Physics(),
new Collision(grid),
new WallBounds(),
new NetworkUpdate( new NetworkUpdate(
clientSocketMessageQueueProvider, clientSocketMessageQueueProvider,
clientSocketMessagePublisher, clientSocketMessagePublisher,
clientMessageProcessor clientMessageProcessor
), ),
inputSystem,
new FacingDirection(),
new Physics(),
new Collision(grid),
new WallBounds(),
new Render(ctx) new Render(ctx)
].forEach((system) => this.game.addSystem(system)); ].forEach((system) => this.game.addSystem(system));
const floor = new Floor(160);
const floorHeight = 40;
floor.addComponent(
new BoundingBox(
{
x: Miscellaneous.WIDTH / 2,
y: Miscellaneous.HEIGHT - floorHeight / 2
},
{ width: Miscellaneous.WIDTH, height: floorHeight }
)
);
this.game.addEntity(floor);
} }
public play() { public play() {
@ -164,17 +155,26 @@ export class JumpStorm {
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
private createInputSystem(): Input { private addWindowEventListenersToInputSystem(input: Input) {
const inputSystem = new Input(this.clientId);
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (!e.repeat) { if (!e.repeat) {
inputSystem.keyPressed(e.key); input.keyPressed(e.key.toLowerCase());
} }
}); });
window.addEventListener('keyup', (e) => inputSystem.keyReleased(e.key)); window.addEventListener('keyup', (e) =>
input.keyReleased(e.key.toLowerCase())
);
}
return inputSystem; private async getAssignedCookie(endpoint: string): Promise<string> {
return fetch(endpoint)
.then((resp) => {
if (resp.ok) {
return resp.text();
}
throw resp;
})
.then((cookie) => cookie);
} }
} }

View File

@ -5,9 +5,10 @@ import { fileURLToPath, URL } from 'node:url';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target: 'http://10.0.0.237:8080',
ws: true, ws: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }

View File

@ -15,7 +15,6 @@ export class BoundingBox extends Component {
this.rotation = rotation ?? 0; this.rotation = rotation ?? 0;
} }
// https://en.wikipedia.org/wiki/Hyperplane_separation_theorem
public isCollidingWith(box: BoundingBox): boolean { public isCollidingWith(box: BoundingBox): boolean {
if (this.rotation == 0 && box.rotation == 0) { if (this.rotation == 0 && box.rotation == 0) {
const thisTopLeft = this.getTopLeft(); const thisTopLeft = this.getTopLeft();
@ -36,6 +35,7 @@ export class BoundingBox extends Component {
return true; return true;
} }
// https://en.wikipedia.org/wiki/Hyperplane_separation_theorem
const boxes = [this.getVertices(), box.getVertices()]; const boxes = [this.getVertices(), box.getVertices()];
for (const poly of boxes) { for (const poly of boxes) {
for (let i = 0; i < poly.length; i++) { for (let i = 0; i < poly.length; i++) {
@ -89,6 +89,8 @@ export class BoundingBox extends Component {
let rads = this.getRotationInPiOfUnitCircle(); let rads = this.getRotationInPiOfUnitCircle();
const { width, height } = this.dimension; const { width, height } = this.dimension;
if (rads == 0) return this.dimension;
if (rads <= Math.PI / 2) { if (rads <= Math.PI / 2) {
return { return {
width: Math.abs(height * Math.sin(rads) + width * Math.cos(rads)), width: Math.abs(height * Math.sin(rads) + width * Math.cos(rads)),

View File

@ -3,6 +3,7 @@ import { Component, ComponentNames, Velocity } from '.';
export class Control extends Component { export class Control extends Component {
public controlVelocityComponent: Velocity; public controlVelocityComponent: Velocity;
public controllableBy: string; public controllableBy: string;
public isControllable: boolean; // computed each update in the input system
constructor( constructor(
controllableBy: string, controllableBy: string,
@ -12,5 +13,6 @@ export class Control extends Component {
this.controllableBy = controllableBy; this.controllableBy = controllableBy;
this.controlVelocityComponent = controlVelocityComponent; this.controlVelocityComponent = controlVelocityComponent;
this.isControllable = false;
} }
} }

View File

@ -1,13 +1,7 @@
import { Component, ComponentNames } from '.'; import { Component, ComponentNames } from '.';
export class NetworkUpdateable extends Component { export class NetworkUpdateable extends Component {
public isPublish: boolean; constructor() {
public isSubscribe: boolean;
constructor(isPublish: boolean, isSubscribe: boolean) {
super(ComponentNames.NetworkUpdateable); super(ComponentNames.NetworkUpdateable);
this.isPublish = isPublish;
this.isSubscribe = isSubscribe;
} }
} }

View File

@ -3,13 +3,13 @@ import { Action } from '../interfaces';
export namespace KeyConstants { export namespace KeyConstants {
export const KeyActions: Record<string, Action> = { export const KeyActions: Record<string, Action> = {
a: Action.MOVE_LEFT, a: Action.MOVE_LEFT,
ArrowLeft: Action.MOVE_LEFT, arrowleft: Action.MOVE_LEFT,
d: Action.MOVE_RIGHT, d: Action.MOVE_RIGHT,
ArrowRight: Action.MOVE_RIGHT, arrowright: Action.MOVE_RIGHT,
w: Action.JUMP, w: Action.JUMP,
ArrowUp: Action.JUMP, arrowup: Action.JUMP,
' ': Action.JUMP ' ': Action.JUMP
}; };
@ -18,7 +18,7 @@ export namespace KeyConstants {
export const ActionKeys: Map<Action, string[]> = Object.keys( export const ActionKeys: Map<Action, string[]> = Object.keys(
KeyActions KeyActions
).reduce((acc: Map<Action, string[]>, key) => { ).reduce((acc: Map<Action, string[]>, key) => {
const action = KeyActions[key]; const action = KeyActions[key.toLowerCase()];
if (acc.has(action)) { if (acc.has(action)) {
acc.get(action)!.push(key); acc.get(action)!.push(key);
@ -33,7 +33,7 @@ export namespace KeyConstants {
export namespace PhysicsConstants { export namespace PhysicsConstants {
export const MAX_JUMP_TIME_MS = 150; export const MAX_JUMP_TIME_MS = 150;
export const GRAVITY = 0.0075; export const GRAVITY = 0.0075;
export const PLAYER_MOVE_VEL = 1; export const PLAYER_MOVE_VEL = 0.8;
export const PLAYER_JUMP_ACC = -0.008; export const PLAYER_JUMP_ACC = -0.008;
export const PLAYER_JUMP_INITIAL_VEL = -1; export const PLAYER_JUMP_INITIAL_VEL = -1;
} }

View File

@ -1,12 +1,15 @@
import { EntityNames, Player } from '.'; import { EntityNames, Floor, Player } from '.';
import type { Component } from '../components'; import { type Component } from '../components';
const randomId = () =>
(performance.now() + Math.random() * 10_000_000).toString();
export abstract class Entity { export abstract class Entity {
public id: string; public id: string;
public components: Map<string, Component>; public components: Map<string, Component>;
public name: string; public name: string;
constructor(name: string, id: string = crypto.randomUUID()) { constructor(name: string, id: string = randomId()) {
this.name = name; this.name = name;
this.id = id; this.id = id;
this.components = new Map(); this.components = new Map();
@ -31,14 +34,29 @@ export abstract class Entity {
return this.components.has(name); return this.components.has(name);
} }
static from(entityName: string, args: any): Entity { public static from(entityName: string, id: string, args: any): Entity {
let entity: Entity;
switch (entityName) { switch (entityName) {
case EntityNames.Player: case EntityNames.Player:
const player = new Player(args.playerId); const player = new Player();
player.id = args.id; player.setFrom(args);
return player; entity = player;
break;
case EntityNames.Floor:
const floor = new Floor(args.floorWidth);
floor.setFrom(args);
entity = floor;
break;
default: default:
throw new Error('.from() Entity type not implemented: ' + entityName); throw new Error('.from() Entity type not implemented: ' + entityName);
} }
entity.id = id;
return entity;
} }
public abstract setFrom(args: Record<string, any>): void;
public abstract serialize(): Record<string, any>;
} }

View File

@ -1,5 +1,5 @@
import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config'; import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from '../config';
import { BoundingBox, Sprite } from '../components'; import { BoundingBox, ComponentNames, Sprite } from '../components';
import { TopCollidable } from '../components/TopCollidable'; import { TopCollidable } from '../components/TopCollidable';
import { Entity, EntityNames } from '../entities'; import { Entity, EntityNames } from '../entities';
@ -8,9 +8,13 @@ export class Floor extends Entity {
Sprites.FLOOR Sprites.FLOOR
) as SpriteSpec; ) as SpriteSpec;
private width: number;
constructor(width: number) { constructor(width: number) {
super(EntityNames.Floor); super(EntityNames.Floor);
this.width = width;
this.addComponent( this.addComponent(
new Sprite( new Sprite(
IMAGES.get((Floor.spriteSpec?.states?.get(width) as SpriteSpec).sheet), IMAGES.get((Floor.spriteSpec?.states?.get(width) as SpriteSpec).sheet),
@ -23,4 +27,22 @@ export class Floor extends Entity {
this.addComponent(new TopCollidable()); this.addComponent(new TopCollidable());
} }
public serialize() {
return {
floorWidth: this.width,
boundingBox: this.getComponent<BoundingBox>(ComponentNames.BoundingBox)
};
}
public setFrom(args: any) {
const { boundingBox } = args;
this.addComponent(
new BoundingBox(
boundingBox.center,
boundingBox.dimension,
boundingBox.rotation
)
);
}
} }

View File

@ -10,9 +10,10 @@ import {
WallBounded, WallBounded,
Forces, Forces,
Collide, Collide,
Control,
Mass, Mass,
Moment Moment,
ComponentNames,
Control
} from '../components'; } from '../components';
import { Direction } from '../interfaces'; import { Direction } from '../interfaces';
@ -24,14 +25,14 @@ export class Player extends Entity {
Sprites.COFFEE Sprites.COFFEE
) as SpriteSpec; ) as SpriteSpec;
constructor(playerId: string) { constructor() {
super(EntityNames.Player); super(EntityNames.Player);
this.addComponent( this.addComponent(
new BoundingBox( new BoundingBox(
{ {
x: 300, x: 0,
y: 100 y: 0
}, },
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height }, { width: Player.spriteSpec.width, height: Player.spriteSpec.height },
0 0
@ -48,7 +49,6 @@ export class Player extends Entity {
this.addComponent(new Gravity()); this.addComponent(new Gravity());
this.addComponent(new Jump()); this.addComponent(new Jump());
this.addComponent(new Control(playerId));
this.addComponent(new Collide()); this.addComponent(new Collide());
this.addComponent(new WallBounded()); this.addComponent(new WallBounded());
@ -69,6 +69,36 @@ export class Player extends Entity {
); );
this.addComponent(new FacingDirection(leftSprite, rightSprite)); this.addComponent(new FacingDirection(leftSprite, rightSprite));
this.addComponent(leftSprite); // face Left by default this.addComponent(leftSprite); // face left by default
}
public serialize(): Record<string, any> {
return {
control: this.getComponent<Control>(ComponentNames.Control),
boundingBox: this.getComponent<BoundingBox>(ComponentNames.BoundingBox),
velocity: this.getComponent<Velocity>(ComponentNames.Velocity),
forces: this.getComponent<Forces>(ComponentNames.Forces)
};
}
public setFrom(args: Record<string, any>) {
const { control, velocity, forces, boundingBox } = args;
let center = boundingBox.center;
const myCenter = this.getComponent<BoundingBox>(
ComponentNames.BoundingBox
).center;
const distance = Math.sqrt(
Math.pow(center.y - myCenter.y, 2) + Math.pow(center.x - myCenter.x, 2)
);
if (distance < 30) center = myCenter;
[
Object.assign(new Control(control.controllableBy), control),
new Velocity(velocity.velocity),
new Forces(forces.forces),
new BoundingBox(center, boundingBox.dimension, boundingBox.rotation)
].forEach((component) => this.addComponent(component));
} }
} }

View File

@ -1,12 +1,20 @@
export enum MessageType { export enum MessageType {
NEW_ENTITIES = 'NEW_ENTITIES', NEW_ENTITIES = 'NEW_ENTITIES',
REMOVE_ENTITIES = 'REMOVE_ENTITIES', REMOVE_ENTITIES = 'REMOVE_ENTITIES',
UPDATE_ENTITY = 'UPDATE_ENTITY' UPDATE_ENTITIES = 'UPDATE_ENTITIES',
NEW_INPUT = 'NEW_INPUT',
REMOVE_INPUT = 'REMOVE_INPUT'
} }
export type EntityAddBody = { export type EntityAddBody = {
entityName: string; entityName: string;
args: any; id: string;
args: Record<string, any>;
};
export type EntityUpdateBody = {
id: string;
args: Record<string, any>;
}; };
export type Message = { export type Message = {

View File

@ -10,26 +10,111 @@ import { Game } from '../Game';
import { KeyConstants, PhysicsConstants } from '../config'; import { KeyConstants, PhysicsConstants } from '../config';
import { Action } from '../interfaces'; import { Action } from '../interfaces';
import { System, SystemNames } from '.'; import { System, SystemNames } from '.';
import { MessagePublisher, MessageType } from '../network';
import { Entity } from '../entities';
export class Input extends System { export class Input extends System {
public clientId: string; public clientId: string;
private keys: Set<string>; private keys: Set<string>;
private actionTimeStamps: Map<Action, number>; private actionTimeStamps: Map<Action, number>;
private messagePublisher?: MessagePublisher;
constructor(clientId: string) { constructor(clientId: string, messagePublisher?: MessagePublisher) {
super(SystemNames.Input); super(SystemNames.Input);
this.clientId = clientId; this.clientId = clientId;
this.keys = new Set<string>(); this.keys = new Set();
this.actionTimeStamps = new Map<Action, number>(); this.actionTimeStamps = new Map();
this.messagePublisher = messagePublisher;
} }
public keyPressed(key: string) { public keyPressed(key: string) {
this.keys.add(key); this.keys.add(key);
if (this.messagePublisher) {
this.messagePublisher.addMessage({
type: MessageType.NEW_INPUT,
body: key
});
}
} }
public keyReleased(key: string) { public keyReleased(key: string) {
this.keys.delete(key); this.keys.delete(key);
if (this.messagePublisher) {
this.messagePublisher.addMessage({
type: MessageType.REMOVE_INPUT,
body: key
});
}
}
public update(_dt: number, game: Game) {
game.forEachEntityWithComponent(ComponentNames.Control, (entity) =>
this.handleInput(entity)
);
}
public handleInput(entity: Entity) {
const controlComponent = entity.getComponent<Control>(
ComponentNames.Control
);
controlComponent.isControllable =
controlComponent.controllableBy === this.clientId;
if (!controlComponent.isControllable) return;
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) {
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
PhysicsConstants.PLAYER_MOVE_VEL;
}
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))) {
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
-PhysicsConstants.PLAYER_MOVE_VEL;
}
if (
entity.hasComponent(ComponentNames.Jump) &&
this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP))
) {
this.performJump(entity);
}
}
private performJump(entity: Entity) {
const velocity = entity.getComponent<Velocity>(
ComponentNames.Velocity
).velocity;
const jump = entity.getComponent<Jump>(ComponentNames.Jump);
if (jump.canJump) {
this.actionTimeStamps.set(Action.JUMP, performance.now());
velocity.dCartesian.dy += PhysicsConstants.PLAYER_JUMP_INITIAL_VEL;
jump.canJump = false;
}
if (
performance.now() - (this.actionTimeStamps.get(Action.JUMP) || 0) <
PhysicsConstants.MAX_JUMP_TIME_MS
) {
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
const jumpForce = {
fCartesian: {
fy: mass * PhysicsConstants.PLAYER_JUMP_ACC,
fx: 0
},
torque: 0
};
entity
.getComponent<Forces>(ComponentNames.Forces)
?.forces.push(jumpForce);
}
} }
private hasSomeKey(keys?: string[]): boolean { private hasSomeKey(keys?: string[]): boolean {
@ -38,57 +123,4 @@ export class Input extends System {
} }
return false; return false;
} }
public update(_dt: number, game: Game) {
game.forEachEntityWithComponent(ComponentNames.Control, (entity) => {
const controlComponent = entity.getComponent<Control>(
ComponentNames.Control
);
if (controlComponent.controllableBy != this.clientId) return;
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) {
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
PhysicsConstants.PLAYER_MOVE_VEL;
}
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))) {
controlComponent.controlVelocityComponent.velocity.dCartesian.dx +=
-PhysicsConstants.PLAYER_MOVE_VEL;
}
if (entity.hasComponent(ComponentNames.Jump)) {
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());
velocity.dCartesian.dy += PhysicsConstants.PLAYER_JUMP_INITIAL_VEL;
jump.canJump = false;
}
if (
performance.now() - (this.actionTimeStamps.get(Action.JUMP) || 0) <
PhysicsConstants.MAX_JUMP_TIME_MS
) {
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
const jumpForce = {
fCartesian: {
fy: mass * PhysicsConstants.PLAYER_JUMP_ACC,
fx: 0
},
torque: 0
};
entity
.getComponent<Forces>(ComponentNames.Forces)
?.forces.push(jumpForce);
}
}
}
});
}
} }

View File

@ -1,10 +1,12 @@
import { System, SystemNames } from '.'; import { System, SystemNames } from '.';
import { Game } from '../Game'; import { Game } from '../Game';
import { ComponentNames, NetworkUpdateable } from '../components'; import { ComponentNames } from '../components';
import { import {
type MessageQueueProvider, type MessageQueueProvider,
type MessagePublisher, type MessagePublisher,
type MessageProcessor type MessageProcessor,
MessageType,
EntityUpdateBody
} from '../network'; } from '../network';
export class NetworkUpdate extends System { export class NetworkUpdate extends System {
@ -12,6 +14,8 @@ export class NetworkUpdate extends System {
private publisher: MessagePublisher; private publisher: MessagePublisher;
private messageProcessor: MessageProcessor; private messageProcessor: MessageProcessor;
private entityUpdateTimers: Map<string, number>;
constructor( constructor(
queueProvider: MessageQueueProvider, queueProvider: MessageQueueProvider,
publisher: MessagePublisher, publisher: MessagePublisher,
@ -22,23 +26,47 @@ export class NetworkUpdate extends System {
this.queueProvider = queueProvider; this.queueProvider = queueProvider;
this.publisher = publisher; this.publisher = publisher;
this.messageProcessor = messageProcessor; this.messageProcessor = messageProcessor;
this.entityUpdateTimers = new Map();
} }
public update(_dt: number, game: Game) { public update(dt: number, game: Game) {
// 1. process new messages
this.queueProvider this.queueProvider
.getNewMessages() .getNewMessages()
.forEach((message) => this.messageProcessor.process(message)); .forEach((message) => this.messageProcessor.process(message));
this.queueProvider.clearMessages(); this.queueProvider.clearMessages();
// 2. send entity updates
const updateMessages: EntityUpdateBody[] = [];
game.forEachEntityWithComponent( game.forEachEntityWithComponent(
ComponentNames.NetworkUpdateable, ComponentNames.NetworkUpdateable,
(entity) => { (entity) => {
const networkUpdateComponent = entity.getComponent<NetworkUpdateable>( let timer = this.entityUpdateTimers.get(entity.id) ?? dt;
ComponentNames.NetworkUpdateable 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(); this.publisher.publish();
} }
private getNextUpdateTimeMs() {
return Math.random() * 70 + 50;
}
} }

View File

@ -99,7 +99,7 @@ export class Physics extends System {
: boundingBox.rotation) % 360; : boundingBox.rotation) % 360;
// clear the control velocity // clear the control velocity
if (control) { if (control && control.isControllable) {
control.controlVelocityComponent = new Velocity(); control.controlVelocityComponent = new Velocity();
} }
}); });

View File

@ -2,28 +2,55 @@ import { Grid } from '@engine/structures';
import { import {
ServerMessageProcessor, ServerMessageProcessor,
ServerSocketMessagePublisher, ServerSocketMessagePublisher,
ServerSocketMessageReceiver ServerSocketMessageReceiver,
MemorySessionManager,
SessionInputSystem
} from './network'; } from './network';
import { Collision, NetworkUpdate, Physics, WallBounds } from '@engine/systems'; import { Collision, NetworkUpdate, Physics, WallBounds } from '@engine/systems';
import { Game } from '@engine/Game'; import { Game } from '@engine/Game';
import { Constants } from './constants'; import { Constants } from './constants';
import { GameServer } from './server'; import { GameServer } from './server';
import { Floor } from '@engine/entities';
const messageReceiver = new ServerSocketMessageReceiver(); import { BoundingBox } from '@engine/components';
const messagePublisher = new ServerSocketMessagePublisher(); import { Miscellaneous } from '@engine/config';
const messageProcessor = new ServerMessageProcessor();
const game = new Game(); const game = new Game();
const server = new GameServer(game, messageReceiver, messagePublisher); 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 Physics(),
new Collision(new Grid()), new Collision(new Grid()),
new WallBounds(), new WallBounds()
new NetworkUpdate(messageReceiver, messagePublisher, messageProcessor)
].forEach((system) => game.addSystem(system)); ].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(); game.start();
setInterval(() => { setInterval(() => {
game.doGameLoop(performance.now()); game.doGameLoop(performance.now());

View File

@ -1,8 +1,36 @@
import { MessageProcessor } from '@engine/network'; import {
import { ServerMessage } from '.'; EntityUpdateBody,
MessageProcessor,
MessageType
} from '@engine/network';
import { ServerMessage, SessionManager } from '.';
import { Game } from '@engine/Game';
export class ServerMessageProcessor implements MessageProcessor { export class ServerMessageProcessor implements MessageProcessor {
constructor() {} private game: Game;
private sessionManager: SessionManager;
public process(_message: ServerMessage) {} constructor(game: Game, sessionManager: SessionManager) {
this.game = game;
this.sessionManager = sessionManager;
}
public process(message: ServerMessage) {
switch (message.type) {
case MessageType.NEW_INPUT: {
const { sessionId } = message.sessionData;
const session = this.sessionManager.getSession(sessionId);
session?.inputSystem.keyPressed(message.body as string);
break;
}
case MessageType.REMOVE_INPUT: {
const { sessionId } = message.sessionData;
const session = this.sessionManager.getSession(sessionId);
session?.inputSystem.keyReleased(message.body as string);
break;
}
default:
break;
}
}
} }

View File

@ -0,0 +1,32 @@
import { Game } from '@engine/Game';
import { SessionManager } from '.';
import { System } from '@engine/systems';
import { BoundingBox, ComponentNames, Control } from '@engine/components';
export class SessionInputSystem extends System {
private sessionManager: SessionManager;
constructor(sessionManager: SessionManager) {
super('SessionInputSystem');
this.sessionManager = sessionManager;
}
public update(_dt: number, game: Game) {
this.sessionManager.getSessions().forEach((sessionId) => {
const session = this.sessionManager.getSession(sessionId);
if (!session) return;
const { inputSystem } = session;
session.controllableEntities.forEach((entityId) => {
const entity = game.getEntity(entityId);
if (!entity) return;
if (entity.hasComponent(ComponentNames.Control)) {
inputSystem.handleInput(entity);
}
});
});
}
}

View File

@ -0,0 +1,33 @@
import { Session, SessionManager } from '.';
export class MemorySessionManager implements SessionManager {
private sessions: Map<string, Session>;
constructor() {
this.sessions = new Map();
}
public getSessions() {
return Array.from(this.sessions.keys());
}
public uniqueSessionId() {
return crypto.randomUUID();
}
public getSession(id: string) {
return this.sessions.get(id);
}
public putSession(id: string, session: Session) {
return this.sessions.set(id, session);
}
public numSessions() {
return this.sessions.size;
}
public removeSession(id: string) {
this.sessions.delete(id);
}
}

View File

@ -1,16 +1,29 @@
import { Message } from '@engine/network'; import { Message } from '@engine/network';
import { Input } from '@engine/systems';
export * from './MessageProcessor'; export * from './MessageProcessor';
export * from './MessagePublisher'; export * from './MessagePublisher';
export * from './MessageReceiver'; export * from './MessageReceiver';
export * from './SessionManager';
export * from './SessionInputSystem';
export type SessionData = { sessionId: string }; export type SessionData = { sessionId: string };
export type Session = { export type Session = {
sessionId: string; sessionId: string;
controllableEntities: Set<string>; controllableEntities: Set<string>;
inputSystem: Input;
}; };
export interface ServerMessage extends Message { export interface ServerMessage extends Message {
sessionData: SessionData; sessionData: SessionData;
} }
export interface SessionManager {
uniqueSessionId(): string;
getSession(id: string): Session | undefined;
getSessions(): string[];
putSession(id: string, session: Session): void;
removeSession(id: string): void;
numSessions(): number;
}

View File

@ -1,35 +1,38 @@
import { Game } from '@engine/Game'; import { Game } from '@engine/Game';
import { EntityNames, Player } from '@engine/entities'; import { Player } from '@engine/entities';
import { MessageType } from '@engine/network'; import { Message, MessageType } from '@engine/network';
import { Constants } from './constants'; import { Constants } from './constants';
import { import {
ServerSocketMessageReceiver, ServerSocketMessageReceiver,
ServerSocketMessagePublisher, ServerSocketMessagePublisher,
SessionData, SessionData,
ServerMessage, ServerMessage,
Session Session,
SessionManager
} from './network'; } from './network';
import { parse } from '@engine/utils'; import { parse } from '@engine/utils';
import { Server, ServerWebSocket } from 'bun'; import { Server, ServerWebSocket } from 'bun';
import { Input } from '@engine/systems';
import { Control, NetworkUpdateable } from '@engine/components';
import { stringify } from '@engine/utils';
export class GameServer { export class GameServer {
private sessions: Map<string, Session>;
private server?: Server; private server?: Server;
private game: Game; private game: Game;
private messageReceiver: ServerSocketMessageReceiver; private messageReceiver: ServerSocketMessageReceiver;
private messagePublisher: ServerSocketMessagePublisher; private messagePublisher: ServerSocketMessagePublisher;
private sessionManager: SessionManager;
constructor( constructor(
game: Game, game: Game,
messageReceiver: ServerSocketMessageReceiver, messageReceiver: ServerSocketMessageReceiver,
messagePublisher: ServerSocketMessagePublisher messagePublisher: ServerSocketMessagePublisher,
sessionManager: SessionManager
) { ) {
this.sessions = new Map();
this.game = game; this.game = game;
this.messageReceiver = messageReceiver; this.messageReceiver = messageReceiver;
this.messagePublisher = messagePublisher; this.messagePublisher = messagePublisher;
this.sessionManager = sessionManager;
} }
public serve() { public serve() {
@ -64,10 +67,12 @@ export class GameServer {
private closeWebsocket(websocket: ServerWebSocket<SessionData>) { private closeWebsocket(websocket: ServerWebSocket<SessionData>) {
const { sessionId } = websocket.data; const { sessionId } = websocket.data;
const sessionEntities = this.sessions.get(sessionId)!.controllableEntities; const sessionEntities =
this.sessions.delete(sessionId); this.sessionManager.getSession(sessionId)!.controllableEntities;
this.sessionManager.removeSession(sessionId);
if (!sessionEntities) return; if (!sessionEntities) return;
sessionEntities.forEach((id) => this.game.removeEntity(id));
this.messagePublisher.addMessage({ this.messagePublisher.addMessage({
type: MessageType.REMOVE_ENTITIES, type: MessageType.REMOVE_ENTITIES,
@ -79,28 +84,51 @@ export class GameServer {
websocket.subscribe(Constants.GAME_TOPIC); websocket.subscribe(Constants.GAME_TOPIC);
const { sessionId } = websocket.data; const { sessionId } = websocket.data;
if (this.sessions.has(sessionId)) { if (this.sessionManager.getSession(sessionId)) {
return; return;
} }
this.sessions.set(sessionId, { const newSession: Session = {
sessionId, sessionId,
controllableEntities: new Set() controllableEntities: new Set(),
}); inputSystem: new Input(sessionId)
};
const player = new Player(sessionId); const player = new Player();
player.addComponent(new Control(sessionId));
player.addComponent(new NetworkUpdateable());
this.game.addEntity(player); this.game.addEntity(player);
this.messagePublisher.addMessage({
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, type: MessageType.NEW_ENTITIES,
body: [ body: [
{ {
entityName: EntityNames.Player, id: player.id,
args: { playerId: sessionId, id: player.id } entityName: player.name,
args: player.serialize()
} }
] ]
}); };
this.messagePublisher.addMessage(addNewPlayer);
this.sessions.get(sessionId)!.controllableEntities.add(player.id);
} }
private fetchHandler(req: Request, server: Server): Response { private fetchHandler(req: Request, server: Server): Response {
@ -110,7 +138,7 @@ export class GameServer {
headers.set('Access-Control-Allow-Origin', '*'); headers.set('Access-Control-Allow-Origin', '*');
if (url.pathname == '/assign') { if (url.pathname == '/assign') {
if (this.sessions.size > Constants.MAX_PLAYERS) if (this.sessionManager.numSessions() > Constants.MAX_PLAYERS)
return new Response('too many players', { headers, status: 400 }); return new Response('too many players', { headers, status: 400 });
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
@ -127,10 +155,6 @@ export class GameServer {
const sessionId = cookie.split(';').at(0)!.split('SessionId=').at(1); const sessionId = cookie.split(';').at(0)!.split('SessionId=').at(1);
if (url.pathname == '/game') { if (url.pathname == '/game') {
headers.set(
'Set-Cookie',
`SessionId=${sessionId}; HttpOnly; SameSite=Strict;`
);
server.upgrade(req, { server.upgrade(req, {
headers, headers,
data: { data: {