301 lines
8.7 KiB
JavaScript
301 lines
8.7 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const { Server } = require('socket.io');
|
|
const path = require('path');
|
|
const riddles = require('./riddles');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const io = new Server(server);
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
const FINISH_LINE = 10;
|
|
const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#e91e63'];
|
|
|
|
const rooms = {};
|
|
|
|
function genRoomId() {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
let id = '';
|
|
for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)];
|
|
return id;
|
|
}
|
|
|
|
function shuffle(arr) {
|
|
const a = [...arr];
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
|
|
function getOrdinal(n) {
|
|
const s = ['th', 'st', 'nd', 'rd'];
|
|
const v = n % 100;
|
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
}
|
|
|
|
function normalizeAnswer(ans) {
|
|
return ans.toLowerCase().trim().replace(/^(a |an |the )/, '').replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function checkAnswer(playerAns, riddleIndex) {
|
|
const riddle = riddles[riddleIndex];
|
|
const norm = normalizeAnswer(playerAns);
|
|
return riddle.answers.some(a => normalizeAnswer(a) === norm);
|
|
}
|
|
|
|
function publicPlayers(room) {
|
|
return Object.entries(room.players).map(([id, p]) => ({
|
|
id,
|
|
name: p.name,
|
|
position: p.position,
|
|
color: p.color,
|
|
finished: p.finished,
|
|
finishPosition: p.finishPosition
|
|
}));
|
|
}
|
|
|
|
io.on('connection', (socket) => {
|
|
let roomId = null;
|
|
|
|
socket.on('createRoom', ({ name }) => {
|
|
if (!name || name.trim().length === 0) return;
|
|
roomId = genRoomId();
|
|
// Make sure room ID is unique
|
|
while (rooms[roomId]) roomId = genRoomId();
|
|
|
|
const color = COLORS[0];
|
|
rooms[roomId] = {
|
|
host: socket.id,
|
|
state: 'lobby',
|
|
finishCount: 0,
|
|
players: {
|
|
[socket.id]: {
|
|
name: name.trim().slice(0, 20),
|
|
color,
|
|
position: 0,
|
|
queue: shuffle(Array.from({ length: riddles.length }, (_, i) => i)),
|
|
queuePos: 0,
|
|
finished: false,
|
|
finishPosition: null
|
|
}
|
|
}
|
|
};
|
|
|
|
socket.join(roomId);
|
|
socket.emit('roomCreated', { roomId });
|
|
socket.emit('lobbyState', {
|
|
players: publicPlayers(rooms[roomId]),
|
|
isHost: true,
|
|
roomId
|
|
});
|
|
});
|
|
|
|
socket.on('joinRoom', ({ name, code }) => {
|
|
if (!name || name.trim().length === 0) return socket.emit('joinError', 'Please enter your name.');
|
|
const upperCode = (code || '').trim().toUpperCase();
|
|
const room = rooms[upperCode];
|
|
if (!room) return socket.emit('joinError', 'Room not found. Check the code and try again.');
|
|
if (room.state !== 'lobby') return socket.emit('joinError', 'That race already started. Wait for the next one!');
|
|
if (Object.keys(room.players).length >= 8) return socket.emit('joinError', 'Room is full (max 8 racers).');
|
|
|
|
const usedColors = new Set(Object.values(room.players).map(p => p.color));
|
|
const color = COLORS.find(c => !usedColors.has(c)) || COLORS[Object.keys(room.players).length % COLORS.length];
|
|
|
|
room.players[socket.id] = {
|
|
name: name.trim().slice(0, 20),
|
|
color,
|
|
position: 0,
|
|
queue: shuffle(Array.from({ length: riddles.length }, (_, i) => i)),
|
|
queuePos: 0,
|
|
finished: false,
|
|
finishPosition: null
|
|
};
|
|
|
|
roomId = upperCode;
|
|
socket.join(upperCode);
|
|
|
|
socket.emit('joinedRoom', { roomId: upperCode });
|
|
socket.emit('lobbyState', {
|
|
players: publicPlayers(room),
|
|
isHost: false,
|
|
roomId: upperCode
|
|
});
|
|
|
|
// Tell everyone else a new racer joined
|
|
socket.to(upperCode).emit('playerUpdate', {
|
|
players: publicPlayers(room),
|
|
message: `${room.players[socket.id].name} joined the race!`
|
|
});
|
|
});
|
|
|
|
socket.on('startGame', () => {
|
|
if (!roomId) return;
|
|
const room = rooms[roomId];
|
|
if (!room || room.host !== socket.id || room.state !== 'lobby') return;
|
|
if (Object.keys(room.players).length < 1) return;
|
|
|
|
room.state = 'playing';
|
|
|
|
// Send each player their first riddle individually
|
|
Object.entries(room.players).forEach(([pid, player]) => {
|
|
const riddleIdx = player.queue[player.queuePos];
|
|
io.to(pid).emit('gameStarted', {
|
|
players: publicPlayers(room),
|
|
riddle: {
|
|
question: riddles[riddleIdx].question,
|
|
hint: riddles[riddleIdx].hint
|
|
},
|
|
riddleNum: 1,
|
|
total: FINISH_LINE
|
|
});
|
|
});
|
|
});
|
|
|
|
socket.on('submitAnswer', ({ answer }) => {
|
|
if (!roomId) return;
|
|
const room = rooms[roomId];
|
|
if (!room || room.state !== 'playing') return;
|
|
|
|
const player = room.players[socket.id];
|
|
if (!player || player.finished) return;
|
|
if (!answer || answer.trim().length === 0) return;
|
|
|
|
const riddleIdx = player.queue[player.queuePos];
|
|
|
|
if (!checkAnswer(answer, riddleIdx)) {
|
|
return socket.emit('answerResult', { correct: false });
|
|
}
|
|
|
|
// Correct answer!
|
|
player.position++;
|
|
player.queuePos++;
|
|
|
|
if (player.position >= FINISH_LINE) {
|
|
room.finishCount++;
|
|
player.finished = true;
|
|
player.finishPosition = room.finishCount;
|
|
|
|
// Update track for all
|
|
io.to(roomId).emit('positionUpdate', { players: publicPlayers(room) });
|
|
|
|
// Announce finish to all
|
|
io.to(roomId).emit('playerFinished', {
|
|
name: player.name,
|
|
place: player.finishPosition
|
|
});
|
|
|
|
// Tell this player they're done
|
|
socket.emit('answerResult', {
|
|
correct: true,
|
|
finished: true,
|
|
finishPosition: player.finishPosition
|
|
});
|
|
|
|
// Check if everyone is done
|
|
if (Object.values(room.players).every(p => p.finished)) {
|
|
room.state = 'finished';
|
|
const results = Object.values(room.players)
|
|
.sort((a, b) => a.finishPosition - b.finishPosition)
|
|
.map(p => ({ name: p.name, color: p.color, place: p.finishPosition }));
|
|
setTimeout(() => io.to(roomId).emit('gameOver', { results }), 1500);
|
|
}
|
|
} else {
|
|
const nextRiddleIdx = player.queue[player.queuePos];
|
|
const nextRiddle = riddles[nextRiddleIdx];
|
|
|
|
// Update track for all
|
|
io.to(roomId).emit('positionUpdate', { players: publicPlayers(room) });
|
|
|
|
// Send next riddle to this player
|
|
socket.emit('answerResult', {
|
|
correct: true,
|
|
finished: false,
|
|
riddle: {
|
|
question: nextRiddle.question,
|
|
hint: nextRiddle.hint
|
|
},
|
|
riddleNum: player.position + 1,
|
|
total: FINISH_LINE
|
|
});
|
|
}
|
|
});
|
|
|
|
socket.on('getHint', () => {
|
|
if (!roomId) return;
|
|
const room = rooms[roomId];
|
|
if (!room || room.state !== 'playing') return;
|
|
const player = room.players[socket.id];
|
|
if (!player || player.finished) return;
|
|
|
|
const riddle = riddles[player.queue[player.queuePos]];
|
|
socket.emit('hint', { hint: riddle.hint });
|
|
});
|
|
|
|
socket.on('playAgain', () => {
|
|
if (!roomId) return;
|
|
const room = rooms[roomId];
|
|
if (!room || room.host !== socket.id) return;
|
|
|
|
room.state = 'lobby';
|
|
room.finishCount = 0;
|
|
|
|
Object.values(room.players).forEach(p => {
|
|
p.position = 0;
|
|
p.queue = shuffle(Array.from({ length: riddles.length }, (_, i) => i));
|
|
p.queuePos = 0;
|
|
p.finished = false;
|
|
p.finishPosition = null;
|
|
});
|
|
|
|
io.to(roomId).emit('returnToLobby', { players: publicPlayers(room) });
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
if (!roomId || !rooms[roomId]) return;
|
|
const room = rooms[roomId];
|
|
const player = room.players[socket.id];
|
|
if (!player) return;
|
|
|
|
const name = player.name;
|
|
const wasHost = room.host === socket.id;
|
|
delete room.players[socket.id];
|
|
|
|
if (Object.keys(room.players).length === 0) {
|
|
delete rooms[roomId];
|
|
return;
|
|
}
|
|
|
|
if (wasHost) {
|
|
room.host = Object.keys(room.players)[0];
|
|
}
|
|
|
|
io.to(roomId).emit('playerUpdate', {
|
|
players: publicPlayers(room),
|
|
message: `${name} left the race.`,
|
|
newHost: wasHost ? room.host : null
|
|
});
|
|
|
|
// If game was in progress and all remaining players finished, end it
|
|
if (room.state === 'playing') {
|
|
const allDone = Object.values(room.players).every(p => p.finished);
|
|
if (allDone && Object.keys(room.players).length > 0) {
|
|
room.state = 'finished';
|
|
const results = Object.values(room.players)
|
|
.sort((a, b) => a.finishPosition - b.finishPosition)
|
|
.map(p => ({ name: p.name, color: p.color, place: p.finishPosition }));
|
|
io.to(roomId).emit('gameOver', { results });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
server.listen(PORT, () => {
|
|
console.log(`\n🏎️ Riddle Racer is running!`);
|
|
console.log(` Open http://localhost:${PORT} in your browser\n`);
|
|
});
|