don't update controllable entities on the client

This commit is contained in:
Elizabeth Hunt 2023-08-29 12:05:02 -06:00
parent 8a4ab8d79b
commit fd1bb1cca9
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
13 changed files with 129 additions and 45 deletions

View File

@ -8,7 +8,8 @@ import {
Physics, Physics,
Input, Input,
Collision, Collision,
NetworkUpdate NetworkUpdate,
SystemNames
} from '@engine/systems'; } from '@engine/systems';
import { import {
type MessageQueueProvider, type MessageQueueProvider,
@ -20,6 +21,7 @@ import {
type EntityUpdateBody type EntityUpdateBody
} from '@engine/network'; } from '@engine/network';
import { stringify, parse } from '@engine/utils'; import { stringify, parse } from '@engine/utils';
import { ComponentNames, Control, NetworkUpdateable } from '@engine/components';
class ClientMessageProcessor implements MessageProcessor { class ClientMessageProcessor implements MessageProcessor {
private game: Game; private game: Game;
@ -32,11 +34,27 @@ class ClientMessageProcessor implements MessageProcessor {
switch (message.type) { switch (message.type) {
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( const entity = Entity.from(
Entity.from(addBody.entityName, addBody.id, addBody.args) addBody.entityName,
) addBody.id,
); addBody.args
);
if (entity.hasComponent(ComponentNames.Control)) {
const clientId = this.game.getSystem<Input>(
SystemNames.Input
).clientId;
const control = entity.getComponent<Control>(
ComponentNames.Control
);
if (control.controllableBy === clientId) {
entity.addComponent(new NetworkUpdateable());
}
}
this.game.addEntity(entity);
});
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[];
@ -44,9 +62,22 @@ class ClientMessageProcessor implements MessageProcessor {
break; break;
case MessageType.UPDATE_ENTITIES: case MessageType.UPDATE_ENTITIES:
const entityUpdates = message.body as unknown as EntityUpdateBody[]; const entityUpdates = message.body as unknown as EntityUpdateBody[];
entityUpdates.forEach( entityUpdates.forEach(({ id, args }) => {
({ id, args }) => this.game.getEntity(id)?.setFrom(args) const entity = this.game.getEntity(id);
); if (!entity) return;
if (entity && entity.hasComponent(ComponentNames.Control)) {
const clientId = this.game.getSystem<Input>(
SystemNames.Input
).clientId;
const control = entity.getComponent<Control>(
ComponentNames.Control
);
// don't listen to entities which we control
if (control.controllableBy == clientId) return;
}
entity.setFrom(args);
});
break; break;
default: default:
break; break;
@ -131,6 +162,7 @@ export class JumpStorm {
const grid = new Grid(); const grid = new Grid();
[ [
new Physics(),
new NetworkUpdate( new NetworkUpdate(
clientSocketMessageQueueProvider, clientSocketMessageQueueProvider,
clientSocketMessagePublisher, clientSocketMessagePublisher,
@ -138,7 +170,6 @@ export class JumpStorm {
), ),
inputSystem, inputSystem,
new FacingDirection(), new FacingDirection(),
new Physics(),
new Collision(grid), new Collision(grid),
new WallBounds(), new WallBounds(),
new Render(ctx) new Render(ctx)

View File

@ -5,10 +5,9 @@ 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://10.0.0.237:8080', target: 'http://localhost:8080',
ws: true, ws: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }

View File

@ -56,8 +56,8 @@ export class Game {
this.systems.set(system.name, system); this.systems.set(system.name, system);
} }
public getSystem(name: string): System | undefined { public getSystem<T>(name: string): T {
return this.systems.get(name); return this.systems.get(name) as unknown as T;
} }
public doGameLoop(timeStamp: number) { public doGameLoop(timeStamp: number) {

View File

@ -3,16 +3,17 @@ 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 public isControllable?: boolean; // updated by the input system
constructor( constructor(
controllableBy: string, controllableBy: string,
controlVelocityComponent: Velocity = new Velocity() controlVelocityComponent: Velocity = new Velocity(),
isControllable?: boolean
) { ) {
super(ComponentNames.Control); super(ComponentNames.Control);
this.controllableBy = controllableBy; this.controllableBy = controllableBy;
this.isControllable = isControllable;
this.controlVelocityComponent = controlVelocityComponent; this.controlVelocityComponent = controlVelocityComponent;
this.isControllable = false;
} }
} }

View File

@ -82,7 +82,7 @@ export class Player extends Entity {
} }
public setFrom(args: Record<string, any>) { public setFrom(args: Record<string, any>) {
const { control, velocity, forces, boundingBox } = args; const { control, forces, velocity, boundingBox } = args;
let center = boundingBox.center; let center = boundingBox.center;
@ -92,7 +92,8 @@ export class Player extends Entity {
const distance = Math.sqrt( const distance = Math.sqrt(
Math.pow(center.y - myCenter.y, 2) + Math.pow(center.x - myCenter.x, 2) Math.pow(center.y - myCenter.y, 2) + Math.pow(center.x - myCenter.x, 2)
); );
if (distance < 30) center = myCenter; const clientServerPredictionCenterThreshold = 30;
if (distance < clientServerPredictionCenterThreshold) center = myCenter;
[ [
Object.assign(new Control(control.controllableBy), control), Object.assign(new Control(control.controllableBy), control),

View File

@ -11,4 +11,5 @@ export interface RefreshingCollisionFinderBehavior {
insert(boxedEntry: BoxedEntry): void; insert(boxedEntry: BoxedEntry): void;
getNeighborIds(boxedEntry: BoxedEntry): Set<string>; getNeighborIds(boxedEntry: BoxedEntry): Set<string>;
setTopLeft(topLeft: Coord2D): void; setTopLeft(topLeft: Coord2D): void;
setDimension(dimension: Dimension2D): void;
} }

View File

@ -8,13 +8,16 @@ import {
MessageType, MessageType,
EntityUpdateBody EntityUpdateBody
} from '../network'; } from '../network';
import { stringify } from '../utils';
type EntityUpdateInfo = { timer: number; hash: string };
export class NetworkUpdate extends System { export class NetworkUpdate extends System {
private queueProvider: MessageQueueProvider; private queueProvider: MessageQueueProvider;
private publisher: MessagePublisher; private publisher: MessagePublisher;
private messageProcessor: MessageProcessor; private messageProcessor: MessageProcessor;
private entityUpdateTimers: Map<string, number>; private entityUpdateInfo: Map<string, EntityUpdateInfo>;
constructor( constructor(
queueProvider: MessageQueueProvider, queueProvider: MessageQueueProvider,
@ -27,10 +30,20 @@ export class NetworkUpdate extends System {
this.publisher = publisher; this.publisher = publisher;
this.messageProcessor = messageProcessor; this.messageProcessor = messageProcessor;
this.entityUpdateTimers = new Map(); this.entityUpdateInfo = new Map();
} }
public update(dt: number, game: Game) { public update(dt: number, game: Game) {
// 0. remove unnecessary info for removed entities
const networkUpdateableEntities = game.componentEntities.get(
ComponentNames.NetworkUpdateable
);
for (const entityId of this.entityUpdateInfo.keys()) {
if (!networkUpdateableEntities?.has(entityId)) {
this.entityUpdateInfo.delete(entityId);
}
}
// 1. process new messages // 1. process new messages
this.queueProvider this.queueProvider
.getNewMessages() .getNewMessages()
@ -39,34 +52,51 @@ export class NetworkUpdate extends System {
// 2. send entity updates // 2. send entity updates
const updateMessages: EntityUpdateBody[] = []; const updateMessages: EntityUpdateBody[] = [];
// todo: figure out if we can use the controllable component to determine if we should publish an update
game.forEachEntityWithComponent( game.forEachEntityWithComponent(
ComponentNames.NetworkUpdateable, ComponentNames.NetworkUpdateable,
(entity) => { (entity) => {
let timer = this.entityUpdateTimers.get(entity.id) ?? dt; const newHash = stringify(entity.serialize());
timer -= dt; let updateInfo: EntityUpdateInfo = this.entityUpdateInfo.get(
this.entityUpdateTimers.set(entity.id, timer); entity.id
) ?? {
timer: this.getNextUpdateTimeMs(),
hash: newHash
};
if (timer > 0) return; // update timer
this.entityUpdateTimers.set(entity.id, this.getNextUpdateTimeMs()); updateInfo.timer -= dt;
this.entityUpdateInfo.set(entity.id, updateInfo);
if (updateInfo.timer > 0) return;
updateInfo.timer = this.getNextUpdateTimeMs();
this.entityUpdateInfo.set(entity.id, updateInfo);
if (entity.hasComponent(ComponentNames.NetworkUpdateable)) { // maybe update if hash is not consitent
updateMessages.push({ if (updateInfo.hash == newHash) {
id: entity.id, return;
args: entity.serialize()
});
} }
updateInfo.hash = newHash;
this.entityUpdateInfo.set(entity.id, updateInfo);
updateMessages.push({
id: entity.id,
args: entity.serialize()
});
} }
); );
this.publisher.addMessage({
type: MessageType.UPDATE_ENTITIES, if (updateMessages.length)
body: updateMessages this.publisher.addMessage({
}); type: MessageType.UPDATE_ENTITIES,
body: updateMessages
});
// 3. publish changes // 3. publish changes
this.publisher.publish(); this.publisher.publish();
} }
private getNextUpdateTimeMs() { private getNextUpdateTimeMs() {
return Math.random() * 70 + 50; return Math.random() * 30 + 50;
} }
} }

View File

@ -11,7 +11,7 @@ import {
Control Control
} from '../components'; } from '../components';
import { PhysicsConstants } from '../config'; import { PhysicsConstants } from '../config';
import type { Force2D, Velocity2D } from '../interfaces'; import type { Force2D } from '../interfaces';
import { Game } from '../Game'; import { Game } from '../Game';
export class Physics extends System { export class Physics extends System {
@ -98,7 +98,7 @@ export class Physics extends System {
? 360 + boundingBox.rotation ? 360 + boundingBox.rotation
: boundingBox.rotation) % 360; : boundingBox.rotation) % 360;
// clear the control velocity // clear the control velocity if and only if we are controlling
if (control && control.isControllable) { if (control && control.isControllable) {
control.controlVelocityComponent = new Velocity(); control.controlVelocityComponent = new Velocity();
} }

View File

@ -9,6 +9,18 @@ const replacer = (_key: any, value: any) => {
} }
}; };
const sortObj = (obj: any): any =>
obj === null || typeof obj !== 'object'
? obj
: Array.isArray(obj)
? obj.map(sortObj)
: Object.assign(
{},
...Object.entries(obj)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([k, v]) => ({ [k]: sortObj(v) }))
);
const reviver = (_key: any, value: any) => { const reviver = (_key: any, value: any) => {
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
if (value.dataType === 'Map') { if (value.dataType === 'Map') {
@ -18,8 +30,9 @@ const reviver = (_key: any, value: any) => {
return value; return value;
}; };
// "deterministic" stringify
export const stringify = (obj: any) => { export const stringify = (obj: any) => {
return JSON.stringify(obj, replacer); return JSON.stringify(sortObj(obj), replacer);
}; };
export const parse = <T>(str: string) => { export const parse = <T>(str: string) => {

View File

@ -1,6 +1,6 @@
export namespace Constants { export namespace Constants {
export const SERVER_PORT = 8080; export const SERVER_PORT = 8080;
export const SERVER_TICK_RATE = (1 / 60) * 1000; export const SERVER_TICK_RATE = (1 / 120) * 1000;
export const GAME_TOPIC = 'game'; export const GAME_TOPIC = 'game';
export const MAX_PLAYERS = 8; export const MAX_PLAYERS = 8;
} }

View File

@ -30,9 +30,9 @@ const server = new GameServer(
); );
[ [
new Physics(),
new SessionInputSystem(sessionManager), new SessionInputSystem(sessionManager),
new NetworkUpdate(messageReceiver, messagePublisher, messageProcessor), new NetworkUpdate(messageReceiver, messagePublisher, messageProcessor),
new Physics(),
new Collision(new Grid()), new Collision(new Grid()),
new WallBounds() new WallBounds()
].forEach((system) => game.addSystem(system)); ].forEach((system) => game.addSystem(system));
@ -44,9 +44,9 @@ floor.addComponent(
new BoundingBox( new BoundingBox(
{ {
x: Miscellaneous.WIDTH / 2, x: Miscellaneous.WIDTH / 2,
y: Miscellaneous.HEIGHT + floorHeight / 2 y: Miscellaneous.HEIGHT - floorHeight / 2
}, },
{ width: Miscellaneous.WIDTH, height: floorHeight } { width: Miscellaneous.WIDTH / 2, height: floorHeight }
) )
); );
game.addEntity(floor); game.addEntity(floor);

View File

@ -29,8 +29,16 @@ export class ServerMessageProcessor implements MessageProcessor {
session?.inputSystem.keyReleased(message.body as string); session?.inputSystem.keyReleased(message.body as string);
break; break;
} }
default: case MessageType.UPDATE_ENTITIES: {
const entityUpdates = message.body as unknown as EntityUpdateBody[];
entityUpdates.forEach(({ id, args }) =>
this.game.getEntity(id)?.setFrom(args)
);
break; break;
}
default: {
break;
}
} }
} }
} }

View File

@ -1,7 +1,7 @@
import { Game } from '@engine/Game'; import { Game } from '@engine/Game';
import { SessionManager } from '.'; import { SessionManager } from '.';
import { System } from '@engine/systems'; import { System } from '@engine/systems';
import { BoundingBox, ComponentNames, Control } from '@engine/components'; import { ComponentNames } from '@engine/components';
export class SessionInputSystem extends System { export class SessionInputSystem extends System {
private sessionManager: SessionManager; private sessionManager: SessionManager;