diff --git a/packages/core/src/Networking/Client/NetworkManager.ts b/packages/core/src/Networking/Client/NetworkManager.ts index f4dfb72..3ca3ec2 100644 --- a/packages/core/src/Networking/Client/NetworkManager.ts +++ b/packages/core/src/Networking/Client/NetworkManager.ts @@ -1,6 +1,7 @@ import type { ClientChannel } from '@geckos.io/client' import type { ClientOptions } from '@geckos.io/common/lib/types' +import type { CommandPacket } from '../Server/Commands' import { geckos } from '@geckos.io/client' import Game from '../../BaseGame' import EventEmitter from '../../Utils/EventEmitter' @@ -13,6 +14,15 @@ export default class NetworkManager extends EventEmitter { pingNow = 0 private _connected = false + private currentSequenceId = 0 + + /** + * This command queue is used for replaying packets locally. + * When ServerCommand.SV_STATE comes in we drop anything thats below + * the lastProcessedSequenceId + */ + private localCommandQueue: CommandPacket[] = [] + constructor(options: ClientOptions) { super() // eslint-disable-next-line ts/no-this-alias @@ -60,9 +70,24 @@ export default class NetworkManager extends EventEmitter { instance = undefined } - protected onPong() {} + /** + * All commands need to go through here to get the + * sequenceId assigned and add it to the queue for local replay + */ + public sendCommand(commandPacket: CommandPacket) { + commandPacket.sequenceId = this.currentSequenceId++ + + this.localCommandQueue.push(commandPacket) + this.socket.emit('command', commandPacket) + } + + public dropCommandsAtSequenceId(sequenceId: number) { + this.localCommandQueue = this.localCommandQueue.filter(packet => packet.sequenceId! > sequenceId) + } + + protected onPong() { } - protected onDisconnect() {} + protected onDisconnect() { } protected pingCheck() { this.pingNow = performance.now() diff --git a/packages/core/src/Networking/Entities/Player.ts b/packages/core/src/Networking/Entities/Player.ts index 1bc176e..091fb10 100644 --- a/packages/core/src/Networking/Entities/Player.ts +++ b/packages/core/src/Networking/Entities/Player.ts @@ -8,10 +8,28 @@ export default abstract class Player extends NetworkedLivingActor { trackedEntities = new Set() + private _lastProcessedSequenceId = 0 + + get lastProcessedSequenceId() { + return this._lastProcessedSequenceId + } + + updateLastProcessedSequenceId(sequenceId: number) { + this._lastProcessedSequenceId = sequenceId + } + + networkedFieldCallbacks(): Record void> { + return { + ...super.networkedFieldCallbacks(), + lastProcessedSequenceId: sequenceId => this._lastProcessedSequenceId = sequenceId as number, + } + } + public serialize() { return { ...super.serialize(), $typeName: this.$typeName, + lastProcessedSequenceId: this.lastProcessedSequenceId, } } } diff --git a/packages/core/src/Networking/Server/Commands.ts b/packages/core/src/Networking/Server/Commands.ts index 3a8f886..a96e0b8 100644 --- a/packages/core/src/Networking/Server/Commands.ts +++ b/packages/core/src/Networking/Server/Commands.ts @@ -1,13 +1,21 @@ +import type { ChannelId } from '@geckos.io/client' + export enum ServerCommand { + SV_PONG = 'sv_pong', SV_STATE = 'sv_state', SV_REMOVE_ENTITY = 'sv_remove_entity', } +export enum ClientCommand { + CL_PING = 'cl_ping', +} + +export type Command = ClientCommand | ServerCommand -export interface CommandPacket { - cmd: T - data: unknown +export interface CommandPacket { + type: T + sequenceId?: number } -export type SV_TEST = CommandPacket & { - boo: string +export interface IncomingClientCommandPacket extends CommandPacket { + playerId: ChannelId } diff --git a/packages/core/src/Networking/Server/LatencySimulator.ts b/packages/core/src/Networking/Server/LatencySimulator.ts new file mode 100644 index 0000000..f30dab7 --- /dev/null +++ b/packages/core/src/Networking/Server/LatencySimulator.ts @@ -0,0 +1,48 @@ +import type winston from 'winston' + +export interface LatencySimulatorOptions { + /** Minimum latency to simulate */ + fixedLatency?: number + + /** How much ms jitter should we include */ + jitter?: number + + /** + * How much percent packet loss to simulate between 0 - 1 + * 1 = 100% + * 0 = 0% + */ + packetLoss?: number +} + +export default class LatencySimulator { + private options: Required + + constructor(options: LatencySimulatorOptions, logger: winston.Logger) { + this.options = { + fixedLatency: 0, + jitter: 0, + packetLoss: 0, + ...options, // Spread incoming over the defaults + } + + logger.warn(`RUNNING WITH LATENCY SIMULATION: latency ${this.options.fixedLatency}ms, jitter: ${this.options.jitter}ms, packet loss: ${this.options.packetLoss}%`) + } + + /** + * Calculate our latency items and then + * run the callback as appropriate + */ + public handle(cb: () => void) { + if (Math.random() < this.options.packetLoss) { + // Drop the packet completely + return + } + + const latency = this.options.fixedLatency + (Math.random() - 0.5) * this.options.jitter + + setTimeout(() => { + cb() + }, latency) + } +} diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index d49cfb6..cec17ba 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -5,21 +5,28 @@ import type winston from 'winston' import type GameObject from '../../World/GameObject' import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' +import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' +import type { LatencySimulatorOptions } from './LatencySimulator' import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' import express from 'express' import BaseGame from '../../BaseGame' import { ServerCommand } from './Commands' +import LatencySimulator from './LatencySimulator' import BandwidthTracker from './Stats/BandwidthTracker' import CpuTracker from './Stats/CpuTracker' import ServerWorld from './World' let instance: Server +export interface ServerOptions { + latencySimulation?: LatencySimulatorOptions +} + export default abstract class Server { game: BaseGame - private commandBuffer: object[] = [] + private commandBuffer: IncomingClientCommandPacket[] = [] gameSocket: GeckosServer httpServer: Express = express() @@ -31,11 +38,12 @@ export default abstract class Server { bandwidthTracker = new BandwidthTracker() private cpuTracker: CpuTracker + private latencySimulator?: LatencySimulator protected abstract onConnection(channel: ServerChannel): TClient protected abstract onCommand(command: any, delta: number): void - constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World) { + constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World, options?: ServerOptions) { instance = this as unknown as Server this.game = new BaseGame(logger, phyicsWorld) @@ -45,6 +53,10 @@ export default abstract class Server { this.logger = logger this.cpuTracker = new CpuTracker(this.logger) + if (options?.latencySimulation) { + this.latencySimulator = new LatencySimulator(options.latencySimulation, this.logger) + } + this.gameSocket.onConnection((channel) => { this.logger.info(`Connected: ${channel.id}`) @@ -54,7 +66,14 @@ export default abstract class Server { this.logger.info(`${(this.game.world as ServerWorld).players.length} total players connected`) channel.on('ping', () => { - channel.emit('pong') + const pong = () => channel.emit('pong') + + if (this.latencySimulator) { + this.latencySimulator.handle(pong) + } + else { + pong() + } }) channel.onDisconnect(() => { @@ -110,7 +129,7 @@ export default abstract class Server { private bufferIncomingCommand(channel: ServerChannel, command: Data) { this.commandBuffer.push({ playerId: channel.id!, - ...(command) as object, + ...(command) as CommandPacket, }) } @@ -175,7 +194,15 @@ export default abstract class Server { } this.bandwidthTracker.recordSent('server', Buffer.byteLength(JSON.stringify(state))) - con.channel.emit(ServerCommand.SV_STATE, state) + + const SendState = () => con.channel.emit(ServerCommand.SV_STATE, state) + + if (this.latencySimulator) { + this.latencySimulator.handle(SendState) + } + else { + SendState() + } }) // Mark entities as synced ONLY if they were actually sent to at least one client @@ -189,10 +216,36 @@ export default abstract class Server { abstract getStateSyncDistance(): number private runThroughBuffer() { + /** + * Due to network latency packets could come in different orders. First step is sort by sequence Id + * + * This currently will move recently connected clients to the + * front of the tick to be processed. Not really an issue at the moment but if it does + * turn out to be an issue then we need to sort the packets by playerId too. + * + * TODO fix ordering to sort packets only for player ids. + * If in an FPS player1 shoots first but this sorting puts player2 shot to be + * ticked over first because their sequenceId is lower its not really fair + */ + this.commandBuffer.sort((a, b) => a.sequenceId! - b.sequenceId!) + while (this.commandBuffer.length > 0) { const currentCommand = this.commandBuffer[0] - this.onCommand(currentCommand, this.calculateDelta()) + const process = () => this.onCommand(currentCommand, this.calculateDelta()) + + if (this.latencySimulator) { + this.latencySimulator.handle(process) + } + else { + process() + } + + /** + * Update the players lastProcessedSequenceId after the command has executed + */ + const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player + player.updateLastProcessedSequenceId(currentCommand.sequenceId!) this.commandBuffer.shift() } diff --git a/packages/core/tests/Networking/Client/NetworkManager.test.ts b/packages/core/tests/Networking/Client/NetworkManager.test.ts index 32c6527..f88b958 100644 --- a/packages/core/tests/Networking/Client/NetworkManager.test.ts +++ b/packages/core/tests/Networking/Client/NetworkManager.test.ts @@ -125,4 +125,77 @@ describe('networkManager', () => { expect(mockSocket.emit).toHaveBeenCalledWith('ping') vi.useRealTimers() }) + + describe('command queue', () => { + it('sendCommand assigns sequential sequenceIds starting at 0', () => { + const nm = new NetworkManager({}) + const a = { type: 'cmd_a' as any } + const b = { type: 'cmd_b' as any } + const c = { type: 'cmd_c' as any } + + nm.sendCommand(a) + nm.sendCommand(b) + nm.sendCommand(c) + + expect(a.sequenceId).toBe(0) + expect(b.sequenceId).toBe(1) + expect(c.sequenceId).toBe(2) + }) + + it('sendCommand pushes the packet to the local queue and emits it on the socket', () => { + const nm = new NetworkManager({}) + const packet = { type: 'cmd' as any } + + nm.sendCommand(packet) + + const queue = (nm as any).localCommandQueue + expect(queue).toHaveLength(1) + expect(queue[0]).toBe(packet) + expect(mockSocket.emit).toHaveBeenCalledWith('command', packet) + }) + + it('dropCommandsAtSequenceId removes acknowledged commands with sequenceId <= given', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + nm.sendCommand({ type: 'd' as any }) + + nm.dropCommandsAtSequenceId(2) + + const queue = (nm as any).localCommandQueue as { type: string, sequenceId: number }[] + expect(queue.map(p => p.sequenceId)).toEqual([3]) + }) + + it('dropCommandsAtSequenceId keeps commands with sequenceId greater than given', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + + nm.dropCommandsAtSequenceId(0) + + const queue = (nm as any).localCommandQueue as { sequenceId: number }[] + expect(queue.map(p => p.sequenceId)).toEqual([1, 2]) + }) + + it('dropCommandsAtSequenceId clears the queue when the ack covers every command', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + + nm.dropCommandsAtSequenceId(10) + + const queue = (nm as any).localCommandQueue as unknown[] + expect(queue).toHaveLength(0) + }) + + it('dropCommandsAtSequenceId is a no-op on an empty queue', () => { + const nm = new NetworkManager({}) + + expect(() => nm.dropCommandsAtSequenceId(5)).not.toThrow() + expect((nm as any).localCommandQueue).toHaveLength(0) + }) + }) }) diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index cc3a8a4..739714a 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -141,8 +141,11 @@ describe('server', () => { it('should call onCommand', () => { const server = new TestServer(logger) + const player = new TestClient('p1') + server.game.world.entities.items.set('p1', player) + const onCommand = vi.fn() - const testCommand = { command: 'test-command' } + const testCommand = { command: 'test-command', playerId: 'p1', sequenceId: 0 } const randomDelta = Math.random(); (server as any).onCommand = onCommand; @@ -155,6 +158,29 @@ describe('server', () => { expect(onCommand).toBeCalledWith(testCommand, randomDelta) }) + it('sorts the commandBuffer by sequenceId before processing', () => { + const server = new TestServer(logger) + + const player = new TestClient('p1') + server.game.world.entities.items.set('p1', player) + + const processed: number[] = [] + ;(server as any).onCommand = (cmd: { sequenceId: number }) => { + processed.push(cmd.sequenceId) + } + ;(server as any).commandBuffer = [ + { sequenceId: 3, playerId: 'p1' }, + { sequenceId: 1, playerId: 'p1' }, + { sequenceId: 4, playerId: 'p1' }, + { sequenceId: 2, playerId: 'p1' }, + ] + + ;(server as any).runThroughBuffer() + + expect(processed).toEqual([1, 2, 3, 4]) + expect(player.lastProcessedSequenceId).toBe(4) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/core/tests/Networking/Server/LatencySimulator.test.ts b/packages/core/tests/Networking/Server/LatencySimulator.test.ts new file mode 100644 index 0000000..7a19fff --- /dev/null +++ b/packages/core/tests/Networking/Server/LatencySimulator.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LatencySimulator from '../../../src/Networking/Server/LatencySimulator' + +function makeLogger() { + return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as any +} + +describe('latencySimulator', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('logs configuration on construction', () => { + const logger = makeLogger() + void new LatencySimulator({ fixedLatency: 100, jitter: 20, packetLoss: 0.1 }, logger) + expect(logger.warn).toHaveBeenCalledWith( + 'RUNNING WITH LATENCY SIMULATION: latency 100ms, jitter: 20ms, packet loss: 0.1%', + ) + }) + + it('fills missing options with zero defaults', () => { + const logger = makeLogger() + void new LatencySimulator({}, logger) + expect(logger.warn).toHaveBeenCalledWith( + 'RUNNING WITH LATENCY SIMULATION: latency 0ms, jitter: 0ms, packet loss: 0%', + ) + }) + + it('invokes the callback synchronously when latency is 0 and no jitter', () => { + const sim = new LatencySimulator({}, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + expect(cb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(0) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('delays the callback by fixedLatency ms', () => { + const sim = new LatencySimulator({ fixedLatency: 150 }, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + + vi.advanceTimersByTime(149) + expect(cb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('applies jitter within +/- jitter/2 of fixedLatency', () => { + const sim = new LatencySimulator({ fixedLatency: 100, jitter: 40 }, makeLogger()) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const randomSpy = vi.spyOn(Math, 'random') + + // handle() calls Math.random() twice: once for packet-loss check, once for jitter. + // First sample: 0.5 (no drop, packetLoss = 0 anyway), jitter draw: 0 → 100 + (0 - 0.5) * 40 = 80 + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(0) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 80) + + // jitter draw: 1 → 100 + (1 - 0.5) * 40 = 120 + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(1) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 120) + + // jitter draw: 0.5 → 100 (no jitter) + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(0.5) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 100) + }) + + it('drops the packet when packetLoss is 1', () => { + const sim = new LatencySimulator({ fixedLatency: 100, packetLoss: 1 }, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + + vi.runAllTimers() + expect(cb).not.toHaveBeenCalled() + }) + + it('never drops the packet when packetLoss is 0', () => { + const sim = new LatencySimulator({ fixedLatency: 50, packetLoss: 0 }, makeLogger()) + const cb = vi.fn() + + // Force Math.random() near 0 — would drop if packetLoss > 0 + vi.spyOn(Math, 'random').mockReturnValue(0) + + sim.handle(cb) + vi.runAllTimers() + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('drops only when Math.random() falls below packetLoss threshold', () => { + const sim = new LatencySimulator({ fixedLatency: 0, packetLoss: 0.3 }, makeLogger()) + + const dropped = vi.fn() + vi.spyOn(Math, 'random').mockReturnValueOnce(0.1) // below 0.3 → dropped + sim.handle(dropped) + vi.runAllTimers() + expect(dropped).not.toHaveBeenCalled() + + const delivered = vi.fn() + vi.spyOn(Math, 'random').mockReturnValueOnce(0.5).mockReturnValueOnce(0.5) + sim.handle(delivered) + vi.runAllTimers() + expect(delivered).toHaveBeenCalledTimes(1) + }) + + it('handles many concurrent callbacks independently', () => { + const sim = new LatencySimulator({ fixedLatency: 100 }, makeLogger()) + const cbs = Array.from({ length: 5 }, () => vi.fn()) + + cbs.forEach(cb => sim.handle(cb)) + + vi.advanceTimersByTime(100) + cbs.forEach(cb => expect(cb).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 96d3b50..7014a6a 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -12,6 +12,7 @@ "strict": true, "strictNullChecks": true, "noImplicitAny": true, + "declarationMap": true, "emitDeclarationOnly": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/packages/multiplayer-template/client/src/GameSyncManager.ts b/packages/multiplayer-template/client/src/GameSyncManager.ts index f2d8270..5002267 100644 --- a/packages/multiplayer-template/client/src/GameSyncManager.ts +++ b/packages/multiplayer-template/client/src/GameSyncManager.ts @@ -6,7 +6,7 @@ import Game from '@mavonengine/core/Game' import NetworkedActor from '@mavonengine/core/Networking/NetworkedActor' import NetworkedEntityFactory from '@mavonengine/core/Networking/NetworkedEntityFactory' import NetworkedGameObject from '@mavonengine/core/Networking/NetworkedGameObject' -import { ServerCommand } from '@template/server/Commands' +import { ServerCommand } from '@template/server/Commands/Server' import Character from './Entities/Player' import Trees from './World/Trees' diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index 4549161..c8ccc17 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,6 +1,7 @@ import type { SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' -import { ClientCommand, ServerCommand } from '@template/server/Commands' +import { ClientCommand } from '@template/server/Commands/Client' +import { ServerCommand } from '@template/server/Commands/Server' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' diff --git a/packages/multiplayer-template/client/src/PlayerController.ts b/packages/multiplayer-template/client/src/PlayerController.ts index b47ccaa..1abb580 100644 --- a/packages/multiplayer-template/client/src/PlayerController.ts +++ b/packages/multiplayer-template/client/src/PlayerController.ts @@ -2,7 +2,7 @@ import type { CL_MOVE } from '@template/server/Commands/Client' import type Character from './Entities/Player' import Game from '@mavonengine/core/Game' import GameObject from '@mavonengine/core/World/GameObject' -import { ClientCommand } from '@template/server/Commands' +import { ClientCommand } from '@template/server/Commands/Client' import { Vector3 } from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import NetworkManager from './NetworkManager' @@ -15,7 +15,6 @@ import NetworkManager from './NetworkManager' export default class PlayerController extends GameObject { player: Character orbitControls: OrbitControls - lerpTo?: Vector3 | null private lastMove?: object constructor(player: Character) { @@ -35,11 +34,6 @@ export default class PlayerController extends GameObject { } update(delta: number): void { - // Lerp toward server-corrected position - if (this.lerpTo && this.lerpTo.distanceTo(this.player.position) > 0.01) { - this.player.position.lerp(this.lerpTo, 0.2) - } - // Smoothly follow player const desiredTarget = new Vector3().copy(this.player.position).add(new Vector3(0, 0.5, 0)) this.orbitControls.target.lerp(desiredTarget, 0.1) @@ -67,29 +61,21 @@ export default class PlayerController extends GameObject { const move: CL_MOVE = { type: ClientCommand.CL_MOVE, - sequenceId: 0, keys, yaw: this.player.rotation.y, } if (JSON.stringify(this.lastMove) !== JSON.stringify(move)) { - this.lastMove = move - NetworkManager.getInstance().socket.emit('command', move) + // Spread so its a different object. Otherwise sendCommand assigns sequenceId which fails the !== check + this.lastMove = { ...move } + NetworkManager.getInstance().sendCommand(move) } } updateFromNetwork(entityData: any) { - const serverPosition = new Vector3( - entityData.position.x, - entityData.position.y, - entityData.position.z, - ) - - if (serverPosition.distanceTo(this.player.position) > 0.2) { - this.lerpTo = serverPosition - } - this.player.updateFromNetwork(entityData) + + NetworkManager.getInstance().dropCommandsAtSequenceId(this.player.lastProcessedSequenceId) } destroy(): void { diff --git a/packages/multiplayer-template/server/src/Base/Player.ts b/packages/multiplayer-template/server/src/Base/Player.ts index d0d9e88..e1a49ac 100644 --- a/packages/multiplayer-template/server/src/Base/Player.ts +++ b/packages/multiplayer-template/server/src/Base/Player.ts @@ -14,6 +14,13 @@ export default class Player extends BasePlayer { maxHealth = 100 name?: string + /** + * We use this to check when the last movement + * command came in. If nothing comes in we reset the keys + * (incase packets go missing due to network congestion) + */ + lastMovementCommandTimestamp: number | null = null + /** * Current keys held down - updated each tick from client input or from network. */ @@ -43,6 +50,16 @@ export default class Player extends BasePlayer { } } + /** + * How long should movement continue on for (ms) + * if we havent received a movement packet + * + * override this as needed + */ + getMovementTimeout() { + return 400 + } + isDead(): boolean { return this.health <= 0 } @@ -57,6 +74,14 @@ export default class Player extends BasePlayer { update(delta: number): void { this.horizontalIntent.set(0, 0, 0) + + /** + * If we havent received a movement update in a while lets reset. + */ + if (this.lastMovementCommandTimestamp && performance.now() - this.lastMovementCommandTimestamp > this.getMovementTimeout()) { + this.keysPressed.clear() + } + super.update(delta) // states run here and may write to horizontalIntent if (this.isDead() || !this.characterCollider || !this.rigidBody) diff --git a/packages/multiplayer-template/server/src/Commands.ts b/packages/multiplayer-template/server/src/Commands.ts deleted file mode 100644 index 261f882..0000000 --- a/packages/multiplayer-template/server/src/Commands.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type Command = ClientCommand | ServerCommand - -export enum ClientCommand { - CL_INIT = 'cl_init', - CL_MOVE = 'cl_move', - CL_CHAT = 'cl_chat', -} - -export enum ServerCommand { - SV_STATE = 'sv_state', - SV_REMOVE_ENTITY = 'sv_remove_entity', - SV_CHAT = 'sv_chat', - SV_TREES = 'sv_trees', -} - -export interface CommandPacket { - type: T - sequenceId: number -} - -export type IncomingCommandPacket = CommandPacket & { - playerId: string -} diff --git a/packages/multiplayer-template/server/src/Commands/Client.ts b/packages/multiplayer-template/server/src/Commands/Client.ts index 49569f2..4325feb 100644 --- a/packages/multiplayer-template/server/src/Commands/Client.ts +++ b/packages/multiplayer-template/server/src/Commands/Client.ts @@ -1,14 +1,36 @@ -import type { ClientCommand, CommandPacket } from '../Commands' +import type { + CommandPacket, +} from '@mavonengine/core/Networking/Server/Commands' +import { + ClientCommand as BaseClientCommand, +} from '@mavonengine/core/Networking/Server/Commands' -export type CL_INIT = CommandPacket & { +/** + * Define all available client commands here that get sent to the server. + */ +export enum LocalClientCommand { + CL_INIT = 'cl_init', + CL_MOVE = 'cl_move', + CL_CHAT = 'cl_chat', +} + +export const ClientCommand = { + ...BaseClientCommand, + ...LocalClientCommand, +} as const + +/** + * Define the structure of the packets below for your defined packet names above + */ +export type CL_INIT = CommandPacket & { name: string } -export type CL_MOVE = CommandPacket & { +export type CL_MOVE = CommandPacket & { keys: string[] yaw: number } -export type CL_CHAT = CommandPacket & { +export type CL_CHAT = CommandPacket & { message: string } diff --git a/packages/multiplayer-template/server/src/Commands/Server.ts b/packages/multiplayer-template/server/src/Commands/Server.ts index 39b39d4..c317252 100644 --- a/packages/multiplayer-template/server/src/Commands/Server.ts +++ b/packages/multiplayer-template/server/src/Commands/Server.ts @@ -1,9 +1,23 @@ -import type { CommandPacket, ServerCommand } from '../Commands' +import { + ServerCommand as BaseServerCommand, +} from '@mavonengine/core/Networking/Server/Commands' -export type SV_REMOVE_ENTITY = CommandPacket & { - id: string +/** + * Define all available server commands here that get sent to the client. + */ +export enum LocalServerCommand { + SV_CHAT = 'sv_chat', + SV_TREES = 'sv_trees', } +export const ServerCommand = { + ...LocalServerCommand, + ...BaseServerCommand, +} + +/** + * Define the structure of the packets below for your defined packet names above + */ export interface SV_CHAT { playerId: string playerName: string diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index 0f527b7..24342a3 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -7,7 +7,8 @@ import BaseServer from '@mavonengine/core/Networking/Server/Server' import { randRange } from '@mavonengine/core/Utils/Math' import { Vector3 } from 'three' import Tree from './Base/Vegetation/Tree' -import { ClientCommand, ServerCommand } from './Commands' +import { ClientCommand } from './Commands/Client' +import { ServerCommand } from './Commands/Server' import Player from './Server/Entities/Player' const TREE_COUNT = 15 @@ -99,6 +100,7 @@ export default class Server extends BaseServer { const cmd = command as unknown as CL_MOVE player.keysPressed.clear() cmd.keys.forEach(key => player.keysPressed.add(key)) + player.lastMovementCommandTimestamp = performance.now() player.rotation.y = cmd.yaw break }