From 8fee2faf1ecfafaa39bf82357caf933184cae801 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 6 Mar 2026 19:13:07 -0500 Subject: [PATCH] feat: add Socket.io server with state broadcasting and player input handling Co-Authored-By: Claude Opus 4.6 --- server/src/main.ts | 14 ++++ server/src/network/SocketServer.ts | 101 ++++++++++++++++++++++++++ server/src/network/stateSerializer.ts | 36 +++++++++ shared/package.json | 6 +- 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 server/src/main.ts create mode 100644 server/src/network/SocketServer.ts create mode 100644 server/src/network/stateSerializer.ts diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..ed87f50 --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,14 @@ +import http from 'http'; +import { GameLoop } from './game/GameLoop.js'; +import { SocketServer } from './network/SocketServer.js'; + +const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; + +const gameLoop = new GameLoop(); +const httpServer = http.createServer(); +new SocketServer(httpServer, gameLoop); + +httpServer.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); + gameLoop.start(); +}); diff --git a/server/src/network/SocketServer.ts b/server/src/network/SocketServer.ts new file mode 100644 index 0000000..88dc591 --- /dev/null +++ b/server/src/network/SocketServer.ts @@ -0,0 +1,101 @@ +import { Server } from 'socket.io'; +import type http from 'http'; +import type { ClientEvents, ServerEvents, PlayerInput, Position, Movement, PlayerControlled } from '@dflike/shared'; +import { Direction } from '@dflike/shared'; +import type { GameLoop } from '../game/GameLoop.js'; +import { serializeWorldState, serializeStateUpdate } from './stateSerializer.js'; + +export class SocketServer { + private io: Server; + private gameLoop: GameLoop; + + constructor(httpServer: http.Server, gameLoop: GameLoop) { + this.io = new Server(httpServer, { + cors: { origin: '*' }, + }); + this.gameLoop = gameLoop; + + this.setupBroadcast(); + this.setupConnections(); + } + + private setupBroadcast(): void { + this.gameLoop.setBroadcastHandler(() => { + const update = serializeStateUpdate(this.gameLoop.world, this.gameLoop.getTick()); + this.io.emit('state-update', update); + }); + } + + private setupConnections(): void { + this.io.on('connection', (socket) => { + const playerId = socket.id; + console.log(`Player connected: ${playerId}`); + + // Create player entity (starts in camera mode — no avatar) + const world = this.gameLoop.world; + const map = this.gameLoop.map; + const entity = world.createEntity(); + const startPos = map.getRandomWalkable(); + world.addComponent(entity, 'position', startPos); + world.addComponent(entity, 'movement', { + state: 'idle', target: null, path: [], direction: Direction.DOWN, + }); + world.addComponent(entity, 'playerControlled', { + playerId, mode: 'camera', + }); + + // Send full world state + socket.emit('world-state', serializeWorldState(world, map)); + socket.emit('player-joined', { playerId, entityId: entity }); + + // Notify others + socket.broadcast.emit('player-joined', { playerId, entityId: entity }); + + // Handle inputs + socket.on('player-input', (input: PlayerInput) => { + this.handleInput(playerId, entity, input); + }); + + // Handle disconnect + socket.on('disconnect', () => { + console.log(`Player disconnected: ${playerId}`); + world.removeEntity(entity); + this.io.emit('player-left', { playerId }); + }); + }); + } + + private handleInput(playerId: string, entityId: number, input: PlayerInput): void { + const world = this.gameLoop.world; + const pc = world.getComponent(entityId, 'playerControlled'); + if (!pc) return; + + switch (input.type) { + case 'toggle-mode': { + pc.mode = pc.mode === 'avatar' ? 'camera' : 'avatar'; + break; + } + case 'move': { + if (pc.mode !== 'avatar' || !input.direction) break; + const pos = world.getComponent(entityId, 'position'); + const mov = world.getComponent(entityId, 'movement'); + if (!pos || !mov) break; + + const newX = pos.x + input.direction.dx; + const newY = pos.y + input.direction.dy; + if (this.gameLoop.map.isWalkable(newX, newY)) { + pos.x = newX; + pos.y = newY; + + const { dx, dy } = input.direction; + if (Math.abs(dx) > Math.abs(dy)) { + mov.direction = dx > 0 ? Direction.RIGHT : Direction.LEFT; + } else { + mov.direction = dy > 0 ? Direction.DOWN : Direction.UP; + } + } + break; + } + } + } +} diff --git a/server/src/network/stateSerializer.ts b/server/src/network/stateSerializer.ts new file mode 100644 index 0000000..aa2625f --- /dev/null +++ b/server/src/network/stateSerializer.ts @@ -0,0 +1,36 @@ +import type { + EntityState, WorldState, StateUpdate, + Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, +} from '@dflike/shared'; +import { TILE_SIZE } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; + +export function serializeEntity(world: World, entityId: number): EntityState { + return { + id: entityId, + position: world.getComponent(entityId, 'position')!, + movement: world.getComponent(entityId, 'movement')!, + appearance: world.getComponent(entityId, 'appearance')!, + needs: world.getComponent(entityId, 'needs'), + npcBrain: world.getComponent(entityId, 'npcBrain'), + playerControlled: world.getComponent(entityId, 'playerControlled'), + }; +} + +export function serializeWorldState(world: World, map: GameMap): WorldState { + const entities = world.query('position').map(id => serializeEntity(world, id)); + return { + entities, + worldWidth: map.width, + worldHeight: map.height, + tileSize: TILE_SIZE, + obstacles: map.getObstacles(), + pointsOfInterest: map.getPointsOfInterest(), + }; +} + +export function serializeStateUpdate(world: World, tick: number): StateUpdate { + const entities = world.query('position').map(id => serializeEntity(world, id)); + return { entities, tick }; +} diff --git a/shared/package.json b/shared/package.json index f28151a..8a92b8f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -2,6 +2,10 @@ "name": "@dflike/shared", "version": "0.1.0", "private": true, + "type": "module", "main": "src/index.ts", - "types": "src/index.ts" + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + } }