Files
race-game/public/game.js
2026-02-18 13:55:52 +00:00

435 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════
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
// ── 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();
}
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;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}