- Lock hint button for 10s per riddle with live countdown display - Clear hint timer when player finishes their race - Raise max players from 8 to 15 with 15 distinct player colors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
/* ═══════════════════════════════════════
|
||
RIDDLE RACER – client-side game logic
|
||
═══════════════════════════════════════ */
|
||
const socket = io();
|
||
|
||
const FINISH_LINE = 10;
|
||
const CAR_EMOJIS = ['🏎️', '🚗', '🚕', '🚙', '🚓', '🚑', '🚐', '🚌'];
|
||
|
||
// ── State ──────────────────────────────
|
||
let myRoomId = null;
|
||
let amHost = false;
|
||
let myFinished = false;
|
||
let carEmojiMap = {}; // playerId → car emoji
|
||
let hintTimer = null; // countdown interval for hint button
|
||
|
||
// ── Screen helpers ─────────────────────
|
||
const screens = {
|
||
home: document.getElementById('home-screen'),
|
||
lobby: document.getElementById('lobby-screen'),
|
||
game: document.getElementById('game-screen'),
|
||
results: document.getElementById('results-screen'),
|
||
};
|
||
|
||
function showScreen(name) {
|
||
Object.values(screens).forEach(s => s.classList.remove('active'));
|
||
screens[name].classList.add('active');
|
||
}
|
||
|
||
// ── DOM refs ───────────────────────────
|
||
const nameInput = document.getElementById('name-input');
|
||
const codeInput = document.getElementById('code-input');
|
||
const errorMsg = document.getElementById('error-msg');
|
||
const createBtn = document.getElementById('create-btn');
|
||
const joinBtn = document.getElementById('join-btn');
|
||
|
||
const roomCodeEl = document.getElementById('room-code');
|
||
const copyBtn = document.getElementById('copy-btn');
|
||
const playerListEl = document.getElementById('player-list');
|
||
const playerCount = document.getElementById('player-count');
|
||
const startBtn = document.getElementById('start-btn');
|
||
const waitingMsg = document.getElementById('waiting-msg');
|
||
|
||
const gameBadge = document.getElementById('game-room-badge');
|
||
const raceTrack = document.getElementById('race-track');
|
||
|
||
const riddlePanel = document.getElementById('riddle-panel');
|
||
const riddleNumEl = document.getElementById('riddle-num');
|
||
const riddleTotEl = document.getElementById('riddle-total');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
const riddleQ = document.getElementById('riddle-question');
|
||
const answerInput = document.getElementById('answer-input');
|
||
const submitBtn = document.getElementById('submit-btn');
|
||
const hintBtn = document.getElementById('hint-btn');
|
||
const feedbackEl = document.getElementById('feedback');
|
||
const hintBox = document.getElementById('hint-box');
|
||
const hintText = document.getElementById('hint-text');
|
||
|
||
const finishedBanner = document.getElementById('finished-banner');
|
||
const finishedTrophy = document.getElementById('finished-trophy');
|
||
const finishedText = document.getElementById('finished-text');
|
||
|
||
const resultsList = document.getElementById('results-list');
|
||
const playAgainBtn = document.getElementById('play-again-btn');
|
||
const hostOnlyMsg = document.getElementById('host-only-msg');
|
||
|
||
// ══════════════════════════════════════════════
|
||
// HOME SCREEN
|
||
// ══════════════════════════════════════════════
|
||
|
||
// Tab switching
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tab = btn.dataset.tab;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById(`${tab}-tab`).classList.add('active');
|
||
clearError();
|
||
});
|
||
});
|
||
|
||
createBtn.addEventListener('click', () => {
|
||
const name = nameInput.value.trim();
|
||
if (!name) return showError('Please enter your racing name first.');
|
||
clearError();
|
||
createBtn.disabled = true;
|
||
socket.emit('createRoom', { name });
|
||
});
|
||
|
||
joinBtn.addEventListener('click', () => {
|
||
const name = nameInput.value.trim();
|
||
const code = codeInput.value.trim().toUpperCase();
|
||
if (!name) return showError('Please enter your racing name first.');
|
||
if (!code) return showError('Please enter the room code.');
|
||
clearError();
|
||
joinBtn.disabled = true;
|
||
socket.emit('joinRoom', { name, code });
|
||
});
|
||
|
||
nameInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') createBtn.click();
|
||
});
|
||
|
||
codeInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') joinBtn.click();
|
||
});
|
||
|
||
codeInput.addEventListener('input', () => {
|
||
codeInput.value = codeInput.value.toUpperCase();
|
||
});
|
||
|
||
function showError(msg) {
|
||
errorMsg.textContent = msg;
|
||
}
|
||
|
||
function clearError() {
|
||
errorMsg.textContent = '';
|
||
}
|
||
|
||
function resetHomeButtons() {
|
||
createBtn.disabled = false;
|
||
joinBtn.disabled = false;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// LOBBY SCREEN
|
||
// ══════════════════════════════════════════════
|
||
|
||
copyBtn.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(myRoomId).then(() => showNotif('Room code copied! 📋'));
|
||
});
|
||
|
||
startBtn.addEventListener('click', () => {
|
||
socket.emit('startGame');
|
||
});
|
||
|
||
function renderLobbyPlayers(players) {
|
||
playerCount.textContent = players.length;
|
||
playerListEl.innerHTML = players.map((p, i) => {
|
||
const isMe = p.id === socket.id;
|
||
const isHost = i === 0; // first player is host (by join order)
|
||
return `
|
||
<div class="lobby-player" style="border-left-color: ${p.color}">
|
||
<span style="color:${p.color}">${escHtml(p.name)}</span>
|
||
<span style="display:flex;gap:6px">
|
||
${isHost ? '<span class="host-badge">Host</span>' : ''}
|
||
${isMe ? '<span class="you-badge">You</span>' : ''}
|
||
</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// GAME SCREEN
|
||
// ══════════════════════════════════════════════
|
||
|
||
// Assign a car emoji to each player consistently
|
||
function assignCarEmojis(players) {
|
||
players.forEach((p, i) => {
|
||
if (!carEmojiMap[p.id]) {
|
||
carEmojiMap[p.id] = CAR_EMOJIS[i % CAR_EMOJIS.length];
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderTrack(players) {
|
||
assignCarEmojis(players);
|
||
raceTrack.innerHTML = players.map(p => {
|
||
const pct = Math.min((p.position / FINISH_LINE) * 88 + 4, 92);
|
||
const carEmoji = carEmojiMap[p.id] || '🏎️';
|
||
const isMe = p.id === socket.id;
|
||
return `
|
||
<div class="track-row" data-player-id="${p.id}">
|
||
<div class="player-label" style="color:${p.color}" title="${escHtml(p.name)}">
|
||
${isMe ? '▶ ' : ''}${escHtml(p.name)}
|
||
</div>
|
||
<div class="track-bar">
|
||
<span class="car-emoji" id="car-${p.id}" style="left:${pct}%">${carEmoji}</span>
|
||
</div>
|
||
<div class="finish-col">
|
||
<span class="finish-flag">🏁</span>
|
||
${p.finishPosition ? `<span class="place-badge">${getOrdinal(p.finishPosition)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateTrack(players) {
|
||
assignCarEmojis(players);
|
||
players.forEach(p => {
|
||
const carEl = document.getElementById(`car-${p.id}`);
|
||
if (!carEl) return;
|
||
const pct = Math.min((p.position / FINISH_LINE) * 88 + 4, 92);
|
||
const oldLeft = parseFloat(carEl.style.left);
|
||
if (Math.abs(pct - oldLeft) > 0.1) {
|
||
carEl.style.left = `${pct}%`;
|
||
// Trigger boost animation
|
||
carEl.classList.remove('boosting');
|
||
void carEl.offsetWidth; // reflow
|
||
carEl.classList.add('boosting');
|
||
setTimeout(() => carEl.classList.remove('boosting'), 450);
|
||
}
|
||
// Update place badge
|
||
const row = document.querySelector(`.track-row[data-player-id="${p.id}"]`);
|
||
if (row) {
|
||
const finishCol = row.querySelector('.finish-col');
|
||
const existing = finishCol.querySelector('.place-badge');
|
||
if (p.finishPosition && !existing) {
|
||
finishCol.insertAdjacentHTML('beforeend',
|
||
`<span class="place-badge">${getOrdinal(p.finishPosition)}</span>`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function showRiddle(riddle, num, total) {
|
||
riddleNumEl.textContent = num;
|
||
riddleTotEl.textContent = total;
|
||
riddleQ.textContent = riddle.question;
|
||
progressFill.style.width = `${((num - 1) / total) * 100}%`;
|
||
answerInput.value = '';
|
||
feedbackEl.textContent = '';
|
||
feedbackEl.className = 'feedback';
|
||
hintBox.style.display = 'none';
|
||
hintText.textContent = '';
|
||
answerInput.focus();
|
||
startHintCountdown();
|
||
}
|
||
|
||
function startHintCountdown() {
|
||
clearInterval(hintTimer);
|
||
hintBox.style.display = 'none';
|
||
hintBtn.disabled = true;
|
||
|
||
let remaining = 10;
|
||
hintBtn.textContent = `💡 Hint (${remaining}s)`;
|
||
|
||
hintTimer = setInterval(() => {
|
||
remaining--;
|
||
if (remaining <= 0) {
|
||
clearInterval(hintTimer);
|
||
hintTimer = null;
|
||
hintBtn.disabled = false;
|
||
hintBtn.textContent = '💡 Hint';
|
||
} else {
|
||
hintBtn.textContent = `💡 Hint (${remaining}s)`;
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function setFeedback(msg, isCorrect) {
|
||
feedbackEl.textContent = msg;
|
||
feedbackEl.className = `feedback ${isCorrect ? 'correct' : 'wrong'}`;
|
||
if (!isCorrect) {
|
||
// Shake the input
|
||
answerInput.style.borderColor = 'var(--accent-red)';
|
||
setTimeout(() => answerInput.style.borderColor = '', 700);
|
||
}
|
||
}
|
||
|
||
submitBtn.addEventListener('click', submitAnswer);
|
||
answerInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') submitAnswer();
|
||
});
|
||
|
||
function submitAnswer() {
|
||
if (myFinished) return;
|
||
const answer = answerInput.value.trim();
|
||
if (!answer) return;
|
||
socket.emit('submitAnswer', { answer });
|
||
submitBtn.disabled = true;
|
||
setTimeout(() => { submitBtn.disabled = false; }, 300);
|
||
}
|
||
|
||
hintBtn.addEventListener('click', () => {
|
||
socket.emit('getHint');
|
||
});
|
||
|
||
// ══════════════════════════════════════════════
|
||
// RESULTS SCREEN
|
||
// ══════════════════════════════════════════════
|
||
|
||
playAgainBtn.addEventListener('click', () => {
|
||
if (amHost) {
|
||
socket.emit('playAgain');
|
||
} else {
|
||
showNotif('Only the host can restart the race!');
|
||
}
|
||
});
|
||
|
||
function renderResults(results) {
|
||
const medals = ['🥇', '🥈', '🥉'];
|
||
resultsList.innerHTML = results.map((r, i) => `
|
||
<div class="result-item">
|
||
${i < 3
|
||
? `<span class="result-medal">${medals[i]}</span>`
|
||
: `<span class="result-place">${r.place}.</span>`
|
||
}
|
||
<span class="result-name" style="color:${r.color}">${escHtml(r.name)}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
if (amHost) {
|
||
playAgainBtn.style.display = 'block';
|
||
hostOnlyMsg.style.display = 'none';
|
||
} else {
|
||
playAgainBtn.style.display = 'none';
|
||
hostOnlyMsg.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// SOCKET EVENTS
|
||
// ══════════════════════════════════════════════
|
||
|
||
socket.on('roomCreated', ({ roomId }) => {
|
||
myRoomId = roomId;
|
||
amHost = true;
|
||
resetHomeButtons();
|
||
});
|
||
|
||
socket.on('joinedRoom', ({ roomId }) => {
|
||
myRoomId = roomId;
|
||
amHost = false;
|
||
resetHomeButtons();
|
||
});
|
||
|
||
socket.on('joinError', msg => {
|
||
showError(msg);
|
||
resetHomeButtons();
|
||
});
|
||
|
||
socket.on('lobbyState', ({ players, isHost, roomId }) => {
|
||
myRoomId = roomId;
|
||
amHost = isHost;
|
||
myFinished = false;
|
||
carEmojiMap = {};
|
||
|
||
roomCodeEl.textContent = roomId;
|
||
startBtn.style.display = isHost ? 'block' : 'none';
|
||
waitingMsg.style.display = isHost ? 'none' : 'block';
|
||
|
||
renderLobbyPlayers(players);
|
||
showScreen('lobby');
|
||
});
|
||
|
||
socket.on('playerUpdate', ({ players, message, newHost }) => {
|
||
if (newHost && newHost === socket.id) {
|
||
amHost = true;
|
||
startBtn.style.display = 'block';
|
||
waitingMsg.style.display = 'none';
|
||
}
|
||
renderLobbyPlayers(players);
|
||
if (message) showNotif(message);
|
||
});
|
||
|
||
socket.on('gameStarted', ({ players, riddle, riddleNum, total }) => {
|
||
myFinished = false;
|
||
gameBadge.textContent = myRoomId;
|
||
|
||
// Reset game UI
|
||
riddlePanel.style.display = 'block';
|
||
finishedBanner.style.display = 'none';
|
||
|
||
assignCarEmojis(players);
|
||
renderTrack(players);
|
||
showRiddle(riddle, riddleNum, total);
|
||
showScreen('game');
|
||
});
|
||
|
||
socket.on('positionUpdate', ({ players }) => {
|
||
updateTrack(players);
|
||
});
|
||
|
||
socket.on('answerResult', ({ correct, finished, finishPosition, riddle, riddleNum, total }) => {
|
||
if (!correct) {
|
||
setFeedback('✗ Wrong! Try again.', false);
|
||
return;
|
||
}
|
||
|
||
if (finished) {
|
||
myFinished = true;
|
||
clearInterval(hintTimer);
|
||
hintTimer = null;
|
||
progressFill.style.width = '100%';
|
||
riddlePanel.style.display = 'none';
|
||
finishedBanner.style.display = 'flex';
|
||
|
||
const trophies = ['🏆', '🥈', '🥉', '🎖️'];
|
||
finishedTrophy.textContent = trophies[Math.min(finishPosition - 1, trophies.length - 1)] || '🎖️';
|
||
finishedText.textContent = `You finished ${getOrdinal(finishPosition)}!`;
|
||
} else {
|
||
setFeedback('✓ Correct! Keep going!', true);
|
||
setTimeout(() => {
|
||
if (!myFinished) showRiddle(riddle, riddleNum, total);
|
||
}, 600);
|
||
}
|
||
});
|
||
|
||
socket.on('playerFinished', ({ name, place }) => {
|
||
showNotif(`🏁 ${escHtml(name)} finished ${getOrdinal(place)}!`, true);
|
||
});
|
||
|
||
socket.on('hint', ({ hint }) => {
|
||
hintText.textContent = hint;
|
||
hintBox.style.display = 'block';
|
||
});
|
||
|
||
socket.on('gameOver', ({ results }) => {
|
||
renderResults(results);
|
||
showScreen('results');
|
||
});
|
||
|
||
socket.on('returnToLobby', ({ players }) => {
|
||
myFinished = false;
|
||
carEmojiMap = {};
|
||
|
||
startBtn.style.display = amHost ? 'block' : 'none';
|
||
waitingMsg.style.display = amHost ? 'none' : 'block';
|
||
renderLobbyPlayers(players);
|
||
showScreen('lobby');
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
showNotif('Connection lost. Refresh to reconnect.');
|
||
});
|
||
|
||
socket.on('connect_error', () => {
|
||
showError('Could not connect to server.');
|
||
});
|
||
|
||
// ══════════════════════════════════════════════
|
||
// UTILITIES
|
||
// ══════════════════════════════════════════════
|
||
|
||
function showNotif(msg, isFinish = false) {
|
||
const container = document.getElementById('notif-container');
|
||
const el = document.createElement('div');
|
||
el.className = `notif${isFinish ? ' finish-notif' : ''}`;
|
||
el.textContent = msg;
|
||
container.appendChild(el);
|
||
setTimeout(() => el.remove(), 3200);
|
||
}
|
||
|
||
function getOrdinal(n) {
|
||
const s = ['th', 'st', 'nd', 'rd'];
|
||
const v = n % 100;
|
||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|