diff --git a/Dockerfile.backend b/Dockerfile.backend index 1e30789..2a01680 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -8,7 +8,7 @@ COPY yarn.lock . RUN yarn COPY apps/backend ./apps/backend -COPY apps/shared ./apps/shared +COPY libs ./libs COPY prisma ./prisma COPY tsconfig.base.json ./tsconfig.base.json COPY .env ./.env diff --git a/Dockerfile.frontend b/Dockerfile.frontend index f3cf973..39904e5 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -10,7 +10,7 @@ COPY yarn.lock . RUN yarn COPY apps/frontend ./apps/frontend -COPY apps/shared ./apps/shared +COPY libs ./libs COPY tsconfig.base.json ./tsconfig.base.json COPY .env ./.env COPY nx.json ./nx.json diff --git a/Dockerfile.multiplayer b/Dockerfile.multiplayer new file mode 100644 index 0000000..a278937 --- /dev/null +++ b/Dockerfile.multiplayer @@ -0,0 +1,30 @@ +FROM node:20-alpine as build + +WORKDIR /app + +COPY package.json . +COPY yarn.lock . + +RUN yarn + +COPY apps/multiplayer ./apps/multiplayer +COPY libs ./libs +COPY prisma ./prisma +COPY tsconfig.base.json ./tsconfig.base.json +COPY .env ./.env +COPY nx.json ./nx.json + +RUN yarn nx reset +RUN yarn prisma generate +RUN yarn nx run multiplayer:build:production + +FROM node:20-alpine as production + +WORKDIR /app + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist/apps/multiplayer ./dist +COPY --from=build /app/prisma . +COPY --from=build /app/package.json . + +CMD [ "node", "dist/main.js" ] diff --git a/apps/frontend/src/models/ClientEngine.ts b/apps/frontend/src/models/ClientEngine.ts index fd4f239..6ca2847 100644 --- a/apps/frontend/src/models/ClientEngine.ts +++ b/apps/frontend/src/models/ClientEngine.ts @@ -30,6 +30,9 @@ import { restoreEngineFromSnapshot, areSnapshotsClose, SchemaPlayer, + GameEventWithLocalId, + DeactivateObjectsMessageData, + ActivateObjectsMessageData, } from '@pinball/engine' import { SnapshotInterpolation } from 'snapshot-interpolation' @@ -80,12 +83,19 @@ enum ClientInputActionKeyCodes { SPACE = 'Space', } -// TODO: Rewrite event system so events are stored in a queue from all snapshots received from server export class ClientEngine extends EventEmitter { private static PADDLE_LEFT_LABEL = 'paddle_bottom_left' private static PADDLE_RIGHT_LABEL = 'paddle_bottom_right' + /** + * Local engine which is used to render game field + * Handles user input instantaneously + */ reconciliationEngine: Engine + /** + * Engine which is updated by server events and used + * as a source of truth when reconciling + */ engine: Engine serverSnapshots: SnapshotInterpolation client?: Colyseus.Client @@ -96,13 +106,15 @@ export class ClientEngine extends EventEmitter { keysReleased: Set = new Set() heldKeys: Set = new Set() players: Map = new Map() - /** Events that are executed on snapshots sync (ClientEngine.update) */ - // deferredEvents: SnapshotEvent[] = [] localVKUserData?: VKUserData /** Changed to true after GameEvent.GAME_INIT event from server */ isGameInitialized = false /** Queue for storing all events (including deferred events) */ pendingEvents: SnapshotEvent[] = [] + /** Map for events that have pending confirmation from server via snapshot events */ + unconfirmedEvents: Map = new Map() + /** Used for uniquely identifying events which is useful for event confirmation */ + lastEventId: number = -1 constructor(userId: string | null, localVKUserData?: VKUserData) { super() @@ -215,10 +227,10 @@ export class ClientEngine extends EventEmitter { snapshots.forEach((snapshot) => { this.serverSnapshots.addSnapshot(snapshot) - this.pendingEvents.concat(snapshot.events) + this.pendingEvents = [...this.pendingEvents, ...snapshot.events] }) - const lastSnapshot = snapshots.at(-1) + const lastSnapshot = this.serverSnapshots.vault.getLast() if (!lastSnapshot) return // Update players statistics for React<->ClientEngine compatibility @@ -318,7 +330,8 @@ export class ClientEngine extends EventEmitter { if (!currentPlayerSnapshot) { console.warn( - `[reconcile ${serverSnapshot.frame}] Cannot find snapshot in client vault closest to time ${currentTime}` + `[reconcile ${serverSnapshot.frame}] Cannot find snapshot ` + + `in client vault closest to time ${currentTime}` ) return } @@ -359,32 +372,32 @@ export class ClientEngine extends EventEmitter { if (!currentPlayerSnapshot) { console.warn( - `[reconcile ${serverSnapshot.frame}] Cannot find snapshot in client vault closest to time ${currentTime} (inside loop)` + `[reconcile ${serverSnapshot.frame}] Cannot find snapshot in ` + + `client vault closest to time ${currentTime} (inside loop)` ) currentTime += Engine.MIN_DELTA this.reconciliationEngine.update(Engine.MIN_DELTA) continue } - currentPlayerSnapshot.events.forEach((event) => { - const data = JSON.parse(event.data || '') + const currentFrameUnconfirmedEvents = this.unconfirmedEvents.get( + currentPlayerSnapshot.frame + ) + currentFrameUnconfirmedEvents?.forEach((event) => { switch (event.event) { case GameEventName.ACTIVATE_OBJECTS: { - const typedEventData = data as ActivateObjectsEventData - console.log('activate objects', typedEventData) - this.changeObjectsStateInEngine(typedEventData.labels, 'activate') + const data = parseData(event.data || '') + data && this.reconciliationEngine.game.handleActivateObjects(data) break } case GameEventName.DEACTIVATE_OBJECTS: { - const typedEventData = data as DeactivateObjectsEventData - console.log('deactivate objects', typedEventData) - this.changeObjectsStateInEngine(typedEventData.labels, 'deactivate') + const data = parseData(event.data || '') + data && this.reconciliationEngine.game.handleDeactivateObjects(data) break } case GameEventName.PLAYER_LOST_ROUND: { - console.log('lose round') - this.handlePlayerLostRoundEvent(data) + this.handlePlayerLostRoundEvent(event.data) break } } @@ -407,6 +420,41 @@ export class ClientEngine extends EventEmitter { ) } + confirmSnapshotEvent(event: SnapshotEvent) { + console.log('Trying to confirm event:', event) + + // Cannot happen as we only provide events with type GameEventWithLocalId + if (!event.data) { + return + } + + const data = parseData(event.data) + if (!data) { + console.warn('Could not parse data for event, skipping...') + return + } + + const events = this.unconfirmedEvents.get(data.fromLocalFrame) + if (!events) { + console.warn('No unconfirmed events found on frame', data.fromLocalFrame) + return + } + + const removeIndex = events.findIndex((e) => e.event === event.event) + events.splice(removeIndex, 1) + + if (events.length === 0) { + this.unconfirmedEvents.delete(data.fromLocalFrame) + } + + console.log('Confirmed event:', data) + } + + getNewEventId() { + this.lastEventId++ + return this.lastEventId + } + handleBeforeUpdate(event: BeforeUpdateEvent) { this.handlePressedKeys() this.handleReleasedKeys() @@ -427,11 +475,13 @@ export class ClientEngine extends EventEmitter { processSnapshotEvent(event: SnapshotEvent) { switch (event.event) { case GameEventName.UPDATE: - case GameEventName.ACTIVATE_OBJECTS: - case GameEventName.DEACTIVATE_OBJECTS: case GameEventName.PLAYER_PINBALL_REDEPLOY: case GameEventName.PLAYER_LOST_ROUND: break + case GameEventName.ACTIVATE_OBJECTS: + case GameEventName.DEACTIVATE_OBJECTS: + this.confirmSnapshotEvent(event) + break // We handle these events separately in constructor of ClientEngine // Events below are sent by server via regular send method, not with snapshot case GameEventName.INIT: @@ -519,7 +569,8 @@ export class ClientEngine extends EventEmitter { if (this.userId === schemaPlayer.id) { engine.game.setMe(player) - // note: This applies only for pinball game as we cannot add external pinballs to a game + // NOTE: This applies only for pinball game as we + // cannot add external pinballs to a game schemaPlayer.map.pinballs.forEach((schemaPinball) => { const pinball = engine.game.world.addPinballForPlayer( schemaPinball.id, @@ -614,61 +665,79 @@ export class ClientEngine extends EventEmitter { } handleActivateObjects(labels: string[]) { - this.room?.send(GameEventName.ACTIVATE_OBJECTS, labels) - this.changeObjectsStateInEngine(labels, 'activate') + if (!this.room || !this.room.connection.isOpen) { + console.warn('handleActivateObjects: connection to room is closed') + return + } + + const data: ActivateObjectsMessageData = { + labels, + fromLocalFrame: this.reconciliationEngine.frame, + localEventId: this.getNewEventId(), + } + + this.room.send(GameEventName.ACTIVATE_OBJECTS, data) + this.changeObjectsStateInEngine(data, 'activate') } handleDeactivateObjects(labels: string[]) { - this.room?.send(GameEventName.DEACTIVATE_OBJECTS, labels) - this.changeObjectsStateInEngine(labels, 'deactivate') + if (!this.room || !this.room.connection.isOpen) { + console.warn('handleDeactivateObjects: connection to room is closed') + return + } + + const data: DeactivateObjectsMessageData = { + labels, + fromLocalFrame: this.reconciliationEngine.frame, + localEventId: this.getNewEventId(), + } + + this.room.send(GameEventName.DEACTIVATE_OBJECTS, data) + this.changeObjectsStateInEngine(data, 'deactivate') } changeObjectsStateInEngine( - labels: string[], + data: ActivateObjectsMessageData | DeactivateObjectsMessageData, state: 'activate' | 'deactivate' ) { if (!this.userId) return if (state === 'activate') { - this.engine.game.handleActivateObjects(labels) - this.reconciliationEngine.game.handleActivateObjects(labels) + this.engine.game.handleActivateObjects(data) + this.reconciliationEngine.game.handleActivateObjects(data) } else { - this.engine.game.handleDeactivateObjects(labels) - this.reconciliationEngine.game.handleDeactivateObjects(labels) + this.engine.game.handleDeactivateObjects(data) + this.reconciliationEngine.game.handleDeactivateObjects(data) } - // const eventName = - // GameEventName[state ? 'ACTIVATE_OBJECTS' : 'DEACTIVATE_OBJECTS'] - // const eventData: ActivateObjectsEventData | DeactivateObjectsEventData = { - // name: eventName, - // frame: this.localEngine.frame, - // time: this.localEngine.frameTimestamp, - // labels, - // playerId: this.userId, - // } - // const mapActiveObjects: string[] = [] - // const clientSnapshot = this.localEngine.snapshots.vault.getLast() - // if (!clientSnapshot) { - // console.warn( - // 'Last client snapshot when changing object state was not found' - // ) - // return - // } - - // this.localEngine.game.world.map?.activePaddles.forEach((label) => { - // mapActiveObjects.push(label) - // }) - - // clientSnapshot.mapActiveObjects = mapActiveObjects - // clientSnapshot.events.push({ - // event: eventName, - // data: JSON.stringify(eventData), - // }) - - // this.localEngine.snapshots.vault.remove( - // this.localEngine.snapshots.vault.size - 1 - // ) - // this.localEngine.snapshots.vault.unsafe_addWithoutSort(clientSnapshot) + const eventName = + GameEventName[state ? 'ACTIVATE_OBJECTS' : 'DEACTIVATE_OBJECTS'] + const eventData: ActivateObjectsEventData | DeactivateObjectsEventData = { + ...data, + name: eventName, + frame: this.reconciliationEngine.frame, + time: this.reconciliationEngine.frameTimestamp, + playerId: this.userId, + } + const event = { + event: eventName, + data: JSON.stringify(eventData), + } + const currentFrameEvents = + this.unconfirmedEvents.get(this.reconciliationEngine.frame) || [] + const newCurrentFrameEvents: SnapshotEvent[] = [ + ...currentFrameEvents, + event, + ] + this.unconfirmedEvents.set( + this.reconciliationEngine.frame, + newCurrentFrameEvents + ) + console.log( + 'Added an unconfirmed event at frame', + this.reconciliationEngine.frame, + newCurrentFrameEvents + ) } onKeyDown(e: KeyboardEvent) { diff --git a/apps/frontend/src/pixi/components/CurrentScore.ts b/apps/frontend/src/pixi/components/CurrentScore.ts index a7d8a45..262d5eb 100644 --- a/apps/frontend/src/pixi/components/CurrentScore.ts +++ b/apps/frontend/src/pixi/components/CurrentScore.ts @@ -1,7 +1,7 @@ import * as PIXI from 'pixi.js' import { ClientEngine } from '../../models/ClientEngine' -class CurrentScore extends PIXI.Graphics { +class CurrentScore extends PIXI.Container { public static FONT_SIZE = 96 clientEngine: ClientEngine diff --git a/apps/multiplayer/src/colyseus/controllers/GameController.ts b/apps/multiplayer/src/colyseus/controllers/GameController.ts index 2eee66f..3451566 100644 --- a/apps/multiplayer/src/colyseus/controllers/GameController.ts +++ b/apps/multiplayer/src/colyseus/controllers/GameController.ts @@ -6,6 +6,8 @@ import { GameRoomState, SchemaPlayer, Snapshot, + ActivateObjectsMessageData, + DeactivateObjectsMessageData, } from '@pinball/engine' import { Client } from '../rooms/game' @@ -38,15 +40,18 @@ class GamePlayer { } setScore(score: number) { - this.engine.game.me!.score = score + if (!this.engine.game.me) return + this.engine.game.me.score = score } setCurrentScore(score: number) { - this.engine.game.me!.currentScore = score + if (!this.engine.game.me) return + this.engine.game.me.currentScore = score } setHighScore(score: number) { - this.engine.game.me!.highScore = score + if (!this.engine.game.me) return + this.engine.game.me.highScore = score } } @@ -170,7 +175,7 @@ class GameController { this.players.clear() } - handleActivateObjects(client: Client, labels: string[]) { + handleActivateObjects(client: Client, data: ActivateObjectsMessageData) { const userId = client.userData?.userId if (!userId) return @@ -180,10 +185,10 @@ class GameController { return } - player.engine.game.world.handleActivateObjects(labels) + player.engine.game.world.handleActivateObjects(data) } - handleDeactivateObjects(client: Client, labels: string[]) { + handleDeactivateObjects(client: Client, data: DeactivateObjectsMessageData) { const userId = client.userData?.userId if (!userId) return @@ -195,7 +200,7 @@ class GameController { return } - player.engine.game.world.handleDeactivateObjects(labels) + player.engine.game.world.handleDeactivateObjects(data) } } diff --git a/apps/multiplayer/src/colyseus/rooms/game/index.ts b/apps/multiplayer/src/colyseus/rooms/game/index.ts index 63f9d7c..9f6d861 100644 --- a/apps/multiplayer/src/colyseus/rooms/game/index.ts +++ b/apps/multiplayer/src/colyseus/rooms/game/index.ts @@ -384,15 +384,23 @@ export class GameRoom extends Room { ) } - handleActivateObjects(client: Client, labels: string[]) { + handleActivateObjects( + client: Client, + data: Pick< + ActivateObjectsEventData, + 'localEventId' | 'fromLocalFrame' | 'labels' + > + ) { if (!client.userData?.userId) return - this.gameController.handleActivateObjects(client, labels) + this.gameController.handleActivateObjects(client, data) const eventData: ActivateObjectsEventData = { name: GameEventName.ACTIVATE_OBJECTS, playerId: client.userData?.userId, - labels, + labels: data.labels, + localEventId: data.localEventId, + fromLocalFrame: data.fromLocalFrame, frame: this.state.frame, time: this.state.timestamp, } @@ -402,15 +410,23 @@ export class GameRoom extends Room { ) } - handleDeactivateObjects(client: Client, labels: string[]) { + handleDeactivateObjects( + client: Client, + data: Pick< + DeactivateObjectsEventData, + 'localEventId' | 'fromLocalFrame' | 'labels' + > + ) { if (!client.userData?.userId) return - this.gameController.handleDeactivateObjects(client, labels) + this.gameController.handleDeactivateObjects(client, data) const eventData: DeactivateObjectsEventData = { name: GameEventName.DEACTIVATE_OBJECTS, playerId: client.userData.userId, - labels, + labels: data.labels, + localEventId: data.localEventId, + fromLocalFrame: data.fromLocalFrame, frame: this.state.frame, time: this.state.timestamp, } diff --git a/docker-compose.yml b/docker-compose.yml index b3f184a..cb4fdc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,26 @@ services: command: sh -c "yarn prisma migrate deploy && node dist/main.js" restart: on-failure + multiplayer: + build: + context: ./ + dockerfile: Dockerfile.multiplayer + target: production + tty: true + env_file: + - .env + expose: + - '${BACKEND_PORT}' + - '${MULTIPLAYER_PORT}' + ports: + - '${BACKEND_PORT}:${BACKEND_PORT}' + - '${MULTIPLAYER_PORT}:${MULTIPLAYER_PORT}' + depends_on: + db: + condition: service_healthy + command: sh -c "yarn prisma migrate deploy && node dist/main.js" + restart: on-failure + frontend: profiles: ['frontend'] build: @@ -37,7 +57,7 @@ services: db: image: postgres:16-alpine - container_name: postgres + container_name: pinball-postgres environment: # Remember to change those to secure vault parameters! - POSTGRES_USER=postgres @@ -54,7 +74,7 @@ services: retries: 10 pgadmin: - container_name: pgadmin + container_name: pinball-pgadmin depends_on: - db image: dpage/pgadmin4 diff --git a/libs/engine/src/Game.ts b/libs/engine/src/Game.ts index bd0f276..4bb67d1 100644 --- a/libs/engine/src/Game.ts +++ b/libs/engine/src/Game.ts @@ -2,7 +2,12 @@ import { World, WorldEmitterEvents, WorldEvents } from './World' import { Player } from './Player' import { Engine } from './Engine' import { GameMapData } from '@pinball/shared' -import { GameEvent, GameEventName } from './GameEvent' +import { + ActivateObjectsMessageData, + DeactivateObjectsMessageData, + GameEvent, + GameEventName, +} from './GameEvent' export class Game { /** Game duration in ms */ @@ -59,33 +64,37 @@ export class Game { ) } - public handleActivateObjects(labels: string[]) { + public handleActivateObjects(data: ActivateObjectsMessageData) { if (!this.me) return - labels.forEach((label) => this.world.map?.activePaddles.add(label)) + data.labels.forEach((label) => this.world.map?.activePaddles.add(label)) this.events.push( new GameEvent(GameEventName.ACTIVATE_OBJECTS, { name: GameEventName.ACTIVATE_OBJECTS, frame: this.engine.frame, time: this.engine.frameTimestamp, - labels, + localEventId: data.localEventId, + fromLocalFrame: data.fromLocalFrame, + labels: data.labels, playerId: this.me.id, }) ) } - public handleDeactivateObjects(labels: string[]) { + public handleDeactivateObjects(data: DeactivateObjectsMessageData) { if (!this.me) return - labels.forEach((label) => this.world.map?.activePaddles.delete(label)) + data.labels.forEach((label) => this.world.map?.activePaddles.delete(label)) this.events.push( new GameEvent(GameEventName.DEACTIVATE_OBJECTS, { name: GameEventName.DEACTIVATE_OBJECTS, frame: this.engine.frame, time: this.engine.frameTimestamp, - labels, + localEventId: data.localEventId, + fromLocalFrame: data.fromLocalFrame, + labels: data.labels, playerId: this.me.id, }) ) diff --git a/libs/engine/src/GameEvent.ts b/libs/engine/src/GameEvent.ts index f586ba3..cd1f727 100644 --- a/libs/engine/src/GameEvent.ts +++ b/libs/engine/src/GameEvent.ts @@ -34,6 +34,11 @@ export type GameEventData = { name: GameEventName } +export type GameEventWithLocalId = { + localEventId: number + fromLocalFrame: number +} + export type InitEventData = GameEventData & { name: GameEventName.INIT players: Record @@ -67,17 +72,19 @@ export type GameResultsEventData = GameEventData & { eloChange: Record } -export type ActivateObjectsEventData = GameEventData & { - name: GameEventName.ACTIVATE_OBJECTS - playerId: string - labels: string[] -} +export type ActivateObjectsEventData = GameEventData & + GameEventWithLocalId & { + name: GameEventName.ACTIVATE_OBJECTS + playerId: string + labels: string[] + } -export type DeactivateObjectsEventData = GameEventData & { - name: GameEventName.DEACTIVATE_OBJECTS - playerId: string - labels: string[] -} +export type DeactivateObjectsEventData = GameEventData & + GameEventWithLocalId & { + name: GameEventName.DEACTIVATE_OBJECTS + playerId: string + labels: string[] + } export type PingObjectsEventData = GameEventData & { name: GameEventName.PING_OBJECTS @@ -115,3 +122,9 @@ export class GameEvent { this.data = data } } + +export type ActivateObjectsMessageData = GameEventWithLocalId & + Pick + +export type DeactivateObjectsMessageData = GameEventWithLocalId & + Pick diff --git a/libs/engine/src/GameMap.ts b/libs/engine/src/GameMap.ts index 556e50f..6c95c9c 100644 --- a/libs/engine/src/GameMap.ts +++ b/libs/engine/src/GameMap.ts @@ -287,12 +287,10 @@ export class GameMap { public handleActivatePaddle(paddle: Paddle) { paddle.activate() - this.world.handleActivateObjects([paddle.fieldObject.label]) } public handleDeactivatePaddle(paddle: Paddle) { paddle.deactivate() - this.world.handleDeactivateObjects([paddle.fieldObject.label]) } public update() { diff --git a/libs/engine/src/World.ts b/libs/engine/src/World.ts index 32ba06e..7729782 100644 --- a/libs/engine/src/World.ts +++ b/libs/engine/src/World.ts @@ -11,6 +11,10 @@ import { } from '@pinball/shared' import { Pinball } from './Pinball' import { Game } from './Game' +import { + ActivateObjectsMessageData, + DeactivateObjectsMessageData, +} from './GameEvent' export enum WorldEvents { PLAYER_SPAWN = 'player_spawn', @@ -169,24 +173,24 @@ export class World extends EventEmitter { }) } - public handleActivateObjects(labels: string[]) { - this.game.handleActivateObjects(labels) + public handleActivateObjects(data: ActivateObjectsMessageData) { + this.game.handleActivateObjects(data) if (this.game.me) { this.eventEmitter.emit(WorldEvents.ACTIVATE_OBJECTS, { playerId: this.game.me.id, - labels, + labels: data.labels, }) } } - public handleDeactivateObjects(labels: string[]) { - this.game.handleDeactivateObjects(labels) + public handleDeactivateObjects(data: DeactivateObjectsMessageData) { + this.game.handleDeactivateObjects(data) if (this.game.me) { this.eventEmitter.emit(WorldEvents.DEACTIVATE_OBJECTS, { playerId: this.game.me.id, - labels, + labels: data.labels, }) } }