316 lines
8.6 KiB
JavaScript
316 lines
8.6 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
const PORT = parseInt(process.env.PORT, 10) || 8080;
|
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
|
|
// ─── Static file server ────────────────────────────────────────────────
|
|
|
|
const MIME = {
|
|
'.html': 'text/html',
|
|
'.js': 'application/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
const httpServer = http.createServer((req, res) => {
|
|
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
if (urlPath === '/') urlPath = '/index.html';
|
|
|
|
const filePath = path.join(ROOT_DIR, urlPath);
|
|
|
|
// Prevent path traversal
|
|
if (!filePath.startsWith(ROOT_DIR)) {
|
|
res.writeHead(403);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
fs.readFile(filePath, (err, data) => {
|
|
if (err) {
|
|
res.writeHead(404);
|
|
res.end('Not found');
|
|
return;
|
|
}
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mime = MIME[ext] || 'application/octet-stream';
|
|
res.writeHead(200, { 'Content-Type': mime });
|
|
res.end(data);
|
|
});
|
|
});
|
|
|
|
// ─── WebSocket server ──────────────────────────────────────────────────
|
|
|
|
const wss = new WebSocketServer({ server: httpServer });
|
|
|
|
// Room storage: roomCode -> { state, players: Map<playerId, ws>, claims: Map<groupId, playerId>, nextPlayerId }
|
|
const rooms = new Map();
|
|
|
|
let globalPlayerId = 1;
|
|
|
|
wss.on('connection', (ws) => {
|
|
let playerId = null;
|
|
let roomCode = null;
|
|
|
|
ws.on('message', (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
switch (msg.type) {
|
|
|
|
// ─── Room lifecycle ────────────────────────────────────────
|
|
|
|
case 'create_room': {
|
|
roomCode = msg.roomCode;
|
|
playerId = globalPlayerId++;
|
|
|
|
rooms.set(roomCode, {
|
|
state: msg.state,
|
|
players: new Map([[playerId, ws]]),
|
|
claims: new Map(), // groupId -> playerId
|
|
nextPlayerId: playerId + 1,
|
|
});
|
|
|
|
send(ws, {
|
|
type: 'room_created',
|
|
roomCode,
|
|
playerId,
|
|
});
|
|
|
|
console.log(`Room ${roomCode} created by player ${playerId}`);
|
|
break;
|
|
}
|
|
|
|
case 'join_room': {
|
|
roomCode = msg.roomCode;
|
|
const room = rooms.get(roomCode);
|
|
|
|
if (!room) {
|
|
send(ws, { type: 'error', message: 'Room not found' });
|
|
return;
|
|
}
|
|
|
|
playerId = room.nextPlayerId++;
|
|
room.players.set(playerId, ws);
|
|
|
|
// Build claims list as { pieceId -> playerId } for the joiner
|
|
const claimsList = {};
|
|
for (const [gid, pid] of room.claims) {
|
|
claimsList[gid] = pid;
|
|
}
|
|
|
|
// Build player list
|
|
const playerList = Array.from(room.players.keys());
|
|
|
|
send(ws, {
|
|
type: 'room_joined',
|
|
roomCode,
|
|
playerId,
|
|
state: room.state,
|
|
players: playerList,
|
|
claims: claimsList,
|
|
});
|
|
|
|
// Notify existing players
|
|
broadcast(room, playerId, {
|
|
type: 'player_joined',
|
|
playerId,
|
|
});
|
|
|
|
console.log(`Player ${playerId} joined room ${roomCode}`);
|
|
break;
|
|
}
|
|
|
|
// ─── Piece interaction ─────────────────────────────────────
|
|
|
|
case 'claim': {
|
|
const room = rooms.get(roomCode);
|
|
if (!room) return;
|
|
|
|
const pieceId = msg.pieceId;
|
|
|
|
// Find which group this piece belongs to using the room state
|
|
const groupId = findGroupForPiece(room.state.groups, pieceId);
|
|
if (groupId === null) return;
|
|
|
|
// Check if any piece in this group is already claimed
|
|
if (room.claims.has(groupId)) {
|
|
const heldBy = room.claims.get(groupId);
|
|
if (heldBy !== playerId) {
|
|
send(ws, { type: 'claim_denied', pieceId, heldBy });
|
|
return;
|
|
}
|
|
}
|
|
|
|
room.claims.set(groupId, playerId);
|
|
|
|
// Broadcast claim to all players (including the claimer for confirmation)
|
|
broadcastAll(room, {
|
|
type: 'claim_ok',
|
|
pieceId,
|
|
playerId,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'move': {
|
|
const room = rooms.get(roomCode);
|
|
if (!room) return;
|
|
|
|
// Relay to all other players
|
|
broadcast(room, playerId, {
|
|
type: 'move',
|
|
pieceId: msg.pieceId,
|
|
playerId,
|
|
dx: msg.dx,
|
|
dy: msg.dy,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'release': {
|
|
const room = rooms.get(roomCode);
|
|
if (!room) return;
|
|
|
|
// Update canonical state with new positions
|
|
if (msg.positions) {
|
|
const posMap = new Map(msg.positions.map(p => [p.id, p]));
|
|
room.state.pieces.forEach(p => {
|
|
const update = posMap.get(p.id);
|
|
if (update) {
|
|
p.x = update.x;
|
|
p.y = update.y;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update canonical groups
|
|
if (msg.groups) {
|
|
room.state.groups = msg.groups;
|
|
}
|
|
|
|
// Release the claim
|
|
const pieceId = msg.pieceId;
|
|
const groupId = findGroupForPiece(room.state.groups, pieceId);
|
|
if (groupId !== null) {
|
|
room.claims.delete(groupId);
|
|
}
|
|
// Also clean up any old group claims by this player (groups may have merged)
|
|
for (const [gid, pid] of room.claims) {
|
|
if (pid === playerId) {
|
|
room.claims.delete(gid);
|
|
}
|
|
}
|
|
|
|
// Check completion
|
|
const completed = room.state.groups.length === 1 &&
|
|
room.state.groups[0].length === room.state.pieces.length;
|
|
if (completed) {
|
|
room.state.completed = true;
|
|
}
|
|
|
|
// Broadcast to all other players
|
|
broadcast(room, playerId, {
|
|
type: 'release',
|
|
pieceId: msg.pieceId,
|
|
playerId,
|
|
positions: msg.positions,
|
|
groups: msg.groups,
|
|
});
|
|
|
|
if (completed) {
|
|
broadcastAll(room, { type: 'completed' });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
if (!roomCode || !playerId) return;
|
|
const room = rooms.get(roomCode);
|
|
if (!room) return;
|
|
|
|
// Release all claims by this player
|
|
for (const [gid, pid] of room.claims) {
|
|
if (pid === playerId) {
|
|
room.claims.delete(gid);
|
|
}
|
|
}
|
|
|
|
room.players.delete(playerId);
|
|
|
|
if (room.players.size === 0) {
|
|
// Keep room alive for a while in case of reconnection
|
|
setTimeout(() => {
|
|
const r = rooms.get(roomCode);
|
|
if (r && r.players.size === 0) {
|
|
rooms.delete(roomCode);
|
|
console.log(`Room ${roomCode} cleaned up (empty)`);
|
|
}
|
|
}, 5 * 60 * 1000); // 5 minutes
|
|
} else {
|
|
broadcast(room, playerId, {
|
|
type: 'player_left',
|
|
playerId,
|
|
});
|
|
}
|
|
|
|
console.log(`Player ${playerId} left room ${roomCode}`);
|
|
});
|
|
});
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
function send(ws, obj) {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify(obj));
|
|
}
|
|
}
|
|
|
|
function broadcast(room, excludePlayerId, obj) {
|
|
const data = JSON.stringify(obj);
|
|
for (const [pid, ws] of room.players) {
|
|
if (pid !== excludePlayerId && ws.readyState === ws.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
function broadcastAll(room, obj) {
|
|
const data = JSON.stringify(obj);
|
|
for (const [, ws] of room.players) {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Find the index of the group containing pieceId in the groups array. */
|
|
function findGroupForPiece(groups, pieceId) {
|
|
for (let i = 0; i < groups.length; i++) {
|
|
if (groups[i].includes(pieceId)) {
|
|
return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Start ─────────────────────────────────────────────────────────────
|
|
|
|
httpServer.listen(PORT, () => {
|
|
console.log(`iPuzzle server running on http://localhost:${PORT}`);
|
|
});
|