initial commit

This commit is contained in:
2026-02-18 13:55:52 +00:00
commit 973fd9ba9b
8 changed files with 3043 additions and 0 deletions

434
public/game.js Normal file
View File

@@ -0,0 +1,434 @@
/* ═══════════════════════════════════════
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;');
}