diff --git a/Ludo-Game/READEM.md b/Ludo-Game/READEM.md new file mode 100644 index 000000000..e69de29bb diff --git a/Ludo-Game/index.html b/Ludo-Game/index.html new file mode 100644 index 000000000..13b8afcb2 --- /dev/null +++ b/Ludo-Game/index.html @@ -0,0 +1,245 @@ + + + + + + + + + + Ludo Game | Gauravi + + + + + + + +
+ + +
+

JavaScript Mini Project

+ +

Ludo Game

+ +

+ Roll a six, bring your tokens out and race to the finish! +

+
+ + +
+

Choose Players

+ +
+ + + + + +
+ + +
+ + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/Ludo-Game/script.js b/Ludo-Game/script.js new file mode 100644 index 000000000..38ade5f9d --- /dev/null +++ b/Ludo-Game/script.js @@ -0,0 +1,1023 @@ +"use strict"; + +/* ------------------------------------------------- + Player and game configuration +------------------------------------------------- */ + +const PLAYER_DATA = [ + { + name: "Red", + color: "red", + hex: "#e63946", + start: 0 + }, + { + name: "Green", + color: "green", + hex: "#2a9d62", + start: 13 + }, + { + name: "Yellow", + color: "yellow", + hex: "#f4b942", + start: 26 + }, + { + name: "Blue", + color: "blue", + hex: "#2878d0", + start: 39 + } +]; + +const TRACK_LENGTH = 52; + +/* Tokens cannot be captured on these cells. */ +const SAFE_CELLS = new Set([ + 0, 8, 13, 21, 26, 34, 39, 47 +]); + +const DICE_FACES = [ + "โš€", "โš", "โš‚", "โšƒ", "โš„", "โš…" +]; + + +/* ------------------------------------------------- + Select HTML elements +------------------------------------------------- */ + +const setupCard = + document.querySelector("#setupCard"); + +const gameSection = + document.querySelector("#game"); + +const track = + document.querySelector("#track"); + +const tokensLayer = + document.querySelector("#tokensLayer"); + +const turnBanner = + document.querySelector("#turnBanner"); + +const diceElement = + document.querySelector("#dice"); + +const rollButton = + document.querySelector("#rollButton"); + +const message = + document.querySelector("#message"); + +const scoreboard = + document.querySelector("#scoreboard"); + +const winnerModal = + document.querySelector("#winnerModal"); + +const winnerText = + document.querySelector("#winnerText"); + + +/* ------------------------------------------------- + Game variables +------------------------------------------------- */ + +let playerCount = 2; +let players = []; +let currentPlayer = 0; +let diceValue = 0; +let waitingForToken = false; +let gameOver = false; +let trackPositions = []; + + +/* ------------------------------------------------- + Player-selection buttons +------------------------------------------------- */ + +document + .querySelectorAll(".player-option") + .forEach(function (button) { + + button.addEventListener("click", function () { + + document + .querySelectorAll(".player-option") + .forEach(function (item) { + item.classList.remove("selected"); + }); + + button.classList.add("selected"); + + playerCount = + Number(button.dataset.count); + }); + }); + + +/* ------------------------------------------------- + Main button events +------------------------------------------------- */ + +document + .querySelector("#startButton") + .addEventListener("click", startGame); + +document + .querySelector("#newGameButton") + .addEventListener("click", showSetup); + +document + .querySelector("#playAgainButton") + .addEventListener("click", function () { + + winnerModal.classList.add("hidden"); + + startGame(); + }); + +rollButton.addEventListener( + "click", + rollDice +); + +window.addEventListener( + "resize", + renderTokens +); + + +/* ------------------------------------------------- + Create the 52-cell Ludo track +------------------------------------------------- */ + +function createTrack() { + + track.innerHTML = ""; + + trackPositions = []; + + const centre = 50; + const radius = 43; + + for ( + let index = 0; + index < TRACK_LENGTH; + index++ + ) { + const angle = + -Math.PI / 2 + + (index / TRACK_LENGTH) * + Math.PI * + 2; + + const x = + centre + + Math.cos(angle) * radius; + + const y = + centre + + Math.sin(angle) * radius; + + trackPositions.push({ + x: x, + y: y + }); + + const cell = + document.createElement("div"); + + cell.className = "cell"; + + cell.dataset.index = index; + + cell.style.left = x + "%"; + cell.style.top = y + "%"; + + if (SAFE_CELLS.has(index)) { + cell.classList.add("safe"); + cell.textContent = "โ—†"; + } + + const startOwner = + PLAYER_DATA.find(function (player) { + return player.start === index; + }); + + if (startOwner) { + cell.classList.add( + "start-" + startOwner.color + ); + + cell.textContent = "โ˜…"; + } + + track.appendChild(cell); + } +} + + +/* ------------------------------------------------- + Start or restart the game +------------------------------------------------- */ + +function startGame() { + + players = + PLAYER_DATA + .slice(0, playerCount) + .map(function (playerData, playerIndex) { + + return { + name: playerData.name, + color: playerData.color, + hex: playerData.hex, + start: playerData.start, + + tokens: Array + .from( + { length: 4 }, + function (_, tokenIndex) { + + return { + id: + playerIndex + + "-" + + tokenIndex, + + /* + -1 means the token is at home. + 0โ€“51 means it is on the track. + 52 means it has finished. + */ + progress: -1 + }; + } + ) + }; + }); + + currentPlayer = 0; + diceValue = 0; + waitingForToken = false; + gameOver = false; + + diceElement.textContent = "โ€“"; + + diceElement.setAttribute( + "aria-label", + "Dice has not been rolled" + ); + + rollButton.disabled = false; + + winnerModal.classList.add("hidden"); + setupCard.classList.add("hidden"); + gameSection.classList.remove("hidden"); + + PLAYER_DATA.forEach( + function (_, index) { + + const home = + document.querySelector( + "#home-" + index + ); + + home.classList.toggle( + "inactive-home", + index >= playerCount + ); + } + ); + + createTrack(); + renderTokens(); + + updateInterface( + "Roll the dice to begin." + ); +} + + +/* ------------------------------------------------- + Return to the player-selection screen +------------------------------------------------- */ + +function showSetup() { + + gameOver = true; + + winnerModal.classList.add("hidden"); + gameSection.classList.add("hidden"); + setupCard.classList.remove("hidden"); +} + + +/* ------------------------------------------------- + Roll the dice +------------------------------------------------- */ + +function rollDice() { + + if (waitingForToken || gameOver) { + return; + } + + rollButton.disabled = true; + + diceElement.classList.add("rolling"); + + diceElement.textContent = "?"; + + window.setTimeout( + function () { + + diceValue = + Math.floor( + Math.random() * 6 + ) + 1; + + diceElement.textContent = + DICE_FACES[diceValue - 1]; + + diceElement.setAttribute( + "aria-label", + "Dice rolled " + diceValue + ); + + diceElement.classList.remove( + "rolling" + ); + + handleRoll(); + }, + 480 + ); +} + + +/* ------------------------------------------------- + Process the dice result +------------------------------------------------- */ + +function handleRoll() { + + const player = + players[currentPlayer]; + + const movableTokens = + getMovableTokens( + player, + diceValue + ); + + /* + If no token can move, either give another roll + after a six or move to the next player. + */ + if (movableTokens.length === 0) { + + updateInterface( + player.name + + " rolled " + + diceValue + + ", but no token can move." + ); + + window.setTimeout( + function () { + + if (diceValue === 6) { + + updateInterface( + "You rolled a six. Roll again!" + ); + + rollButton.disabled = false; + } else { + nextTurn(); + } + }, + 900 + ); + + return; + } + + /* + If only one token can move, move it automatically. + */ + if (movableTokens.length === 1) { + + const tokenIndex = + player.tokens.indexOf( + movableTokens[0] + ); + + moveToken( + currentPlayer, + tokenIndex + ); + + return; + } + + /* + If several tokens can move, allow the player + to select one of the glowing tokens. + */ + waitingForToken = true; + + updateInterface( + "You rolled " + + diceValue + + ". Select a glowing token." + ); + + renderTokens(); +} + + +/* ------------------------------------------------- + Find tokens that can legally move +------------------------------------------------- */ + +function getMovableTokens(player, roll) { + + return player.tokens.filter( + function (token) { + + /* + A finished token cannot move again. + */ + if (token.progress === 52) { + return false; + } + + /* + A token can leave home only after rolling 6. + */ + if (token.progress === -1) { + return roll === 6; + } + + /* + The roll must not move the token + beyond the finishing position. + */ + return ( + token.progress + roll <= 52 + ); + } + ); +} + + +/* ------------------------------------------------- + Move a selected token +------------------------------------------------- */ + +function moveToken( + playerIndex, + tokenIndex +) { + + if (gameOver) { + return; + } + + const player = + players[playerIndex]; + + const token = + player.tokens[tokenIndex]; + + const oldProgress = + token.progress; + + /* + Bring the token onto the starting cell + when the player rolls a six. + */ + if (token.progress === -1) { + token.progress = 0; + } else { + token.progress += diceValue; + } + + waitingForToken = false; + + let captured = false; + + /* + Check captures only when the token is + currently on the main track. + */ + if ( + token.progress >= 0 && + token.progress < 52 + ) { + captured = + captureOpponent( + playerIndex, + token + ); + } + + renderTokens(); + updateScoreboard(); + + /* + Check whether all four tokens finished. + */ + const playerWon = + player.tokens.every( + function (item) { + return item.progress === 52; + } + ); + + if (playerWon) { + finishGame(player); + return; + } + + /* + Display the result of the move. + */ + if (token.progress === 52) { + + updateInterface( + player.name + + "'s token reached the finish!" + ); + + } else if (oldProgress === -1) { + + updateInterface( + player.name + + " brought a token onto the board." + ); + + } else if (captured) { + + updateInterface( + player.name + + " captured an opponent's token!" + ); + + } else { + + updateInterface( + player.name + + " moved " + + diceValue + + " spaces." + ); + } + + /* + The player gets another turn after rolling + six or capturing another token. + */ + window.setTimeout( + function () { + + if ( + diceValue === 6 || + captured + ) { + updateInterface( + player.name + + " gets another roll!" + ); + + rollButton.disabled = false; + } else { + nextTurn(); + } + }, + 650 + ); +} + + +/* ------------------------------------------------- + Capture opponent tokens +------------------------------------------------- */ + +function captureOpponent( + activePlayerIndex, + movedToken +) { + + const cellIndex = + getBoardIndex( + activePlayerIndex, + movedToken.progress + ); + + /* + No capture is allowed on a safe cell. + */ + if (SAFE_CELLS.has(cellIndex)) { + return false; + } + + let captured = false; + + players.forEach( + function (player, playerIndex) { + + if ( + playerIndex === + activePlayerIndex + ) { + return; + } + + player.tokens.forEach( + function (token) { + + const tokenIsOnTrack = + token.progress >= 0 && + token.progress < 52; + + if (!tokenIsOnTrack) { + return; + } + + const opponentCell = + getBoardIndex( + playerIndex, + token.progress + ); + + if ( + opponentCell === + cellIndex + ) { + /* + Send the captured token home. + */ + token.progress = -1; + captured = true; + } + } + ); + } + ); + + return captured; +} + + +/* ------------------------------------------------- + Convert token progress into a board-cell index +------------------------------------------------- */ + +function getBoardIndex( + playerIndex, + progress +) { + + return ( + players[playerIndex].start + + progress + ) % TRACK_LENGTH; +} + + +/* ------------------------------------------------- + Change to the next player's turn +------------------------------------------------- */ + +function nextTurn() { + + currentPlayer = + (currentPlayer + 1) % + players.length; + + diceValue = 0; + waitingForToken = false; + + rollButton.disabled = false; + + updateInterface( + players[currentPlayer].name + + "'s turn. Roll the dice." + ); + + renderTokens(); +} + + +/* ------------------------------------------------- + Display every player's tokens +------------------------------------------------- */ + +function renderTokens() { + + tokensLayer.innerHTML = ""; + + if ( + players.length === 0 || + trackPositions.length === 0 + ) { + return; + } + + players.forEach( + function (player, playerIndex) { + + player.tokens.forEach( + function (token, tokenIndex) { + + const tokenElement = + document.createElement( + "button" + ); + + tokenElement.type = + "button"; + + tokenElement.className = + "token " + + player.color; + + tokenElement.setAttribute( + "aria-label", + player.name + + " token " + + (tokenIndex + 1) + ); + + const position = + getTokenPosition( + playerIndex, + tokenIndex, + token + ); + + tokenElement.style.left = + position.x + "%"; + + tokenElement.style.top = + position.y + "%"; + + const movableTokens = + getMovableTokens( + player, + diceValue + ); + + const canMove = + waitingForToken && + playerIndex === + currentPlayer && + movableTokens.includes( + token + ); + + if (canMove) { + + tokenElement.classList.add( + "movable" + ); + + tokenElement.addEventListener( + "click", + function () { + + moveToken( + playerIndex, + tokenIndex + ); + } + ); + + } else { + + tokenElement.disabled = true; + } + + if ( + token.progress === 52 + ) { + tokenElement.classList.add( + "finished" + ); + } + + tokensLayer.appendChild( + tokenElement + ); + } + ); + } + ); +} + + +/* ------------------------------------------------- + Calculate each token's visual position +------------------------------------------------- */ + +function getTokenPosition( + playerIndex, + tokenIndex, + token +) { + + /* + Positions of tokens while inside their homes. + */ + if (token.progress === -1) { + + const homes = [ + { x: 19, y: 19 }, + { x: 81, y: 19 }, + { x: 81, y: 81 }, + { x: 19, y: 81 } + ]; + + const offsets = [ + { x: -4, y: -4 }, + { x: 4, y: -4 }, + { x: -4, y: 4 }, + { x: 4, y: 4 } + ]; + + return { + x: + homes[playerIndex].x + + offsets[tokenIndex].x, + + y: + homes[playerIndex].y + + offsets[tokenIndex].y + }; + } + + /* + Positions of tokens after reaching the finish. + */ + if (token.progress === 52) { + + const finishPositions = [ + { x: 46, y: 46 }, + { x: 54, y: 46 }, + { x: 54, y: 54 }, + { x: 46, y: 54 } + ]; + + const smallOffsets = [ + { x: -1.6, y: -1.6 }, + { x: 1.6, y: -1.6 }, + { x: -1.6, y: 1.6 }, + { x: 1.6, y: 1.6 } + ]; + + const finish = + finishPositions[playerIndex]; + + return { + x: + finish.x + + smallOffsets[tokenIndex].x, + + y: + finish.y + + smallOffsets[tokenIndex].y + }; + } + + /* + Position of a token on the track. + */ + const boardIndex = + getBoardIndex( + playerIndex, + token.progress + ); + + const cell = + trackPositions[boardIndex]; + + /* + Small offsets ensure that several tokens on + one cell remain visible. + */ + const stackOffsets = [ + { x: -1.2, y: -1.2 }, + { x: 1.2, y: -1.2 }, + { x: -1.2, y: 1.2 }, + { x: 1.2, y: 1.2 } + ]; + + return { + x: + cell.x + + stackOffsets[tokenIndex].x, + + y: + cell.y + + stackOffsets[tokenIndex].y + }; +} + + +/* ------------------------------------------------- + Update turn, message and scoreboard +------------------------------------------------- */ + +function updateInterface(text) { + + if (players.length === 0) { + return; + } + + const player = + players[currentPlayer]; + + message.textContent = text; + + turnBanner.textContent = + player.name + "'s turn"; + + turnBanner.style.background = + player.hex; + + updateScoreboard(); +} + + +/* ------------------------------------------------- + Update player progress +------------------------------------------------- */ + +function updateScoreboard() { + + scoreboard.innerHTML = + players + .map(function (player) { + + const finished = + player.tokens.filter( + function (token) { + return ( + token.progress === + 52 + ); + } + ).length; + + const active = + player.tokens.filter( + function (token) { + return ( + token.progress >= 0 && + token.progress < 52 + ); + } + ).length; + + return ` +
+ + ${player.name} + + + + ${active} active ยท + ${finished}/4 home + +
+ `; + }) + .join(""); +} + + +/* ------------------------------------------------- + Finish the game +------------------------------------------------- */ + +function finishGame(player) { + + gameOver = true; + + rollButton.disabled = true; + + winnerText.textContent = + player.name + + " brought all four tokens home and won the game!"; + + winnerModal.classList.remove("hidden"); +} \ No newline at end of file diff --git a/Ludo-Game/style.css b/Ludo-Game/style.css new file mode 100644 index 000000000..2ac41683e --- /dev/null +++ b/Ludo-Game/style.css @@ -0,0 +1,704 @@ +/* ---------- Colour variables ---------- */ + +:root { + --red: #e63946; + --green: #2a9d62; + --yellow: #f4b942; + --blue: #2878d0; + + --ink: #1e293b; + --muted: #64748b; + --paper: #ffffff; + --surface: #f8fafc; + --line: #cbd5e1; + + --shadow: 0 18px 45px rgba(15, 23, 42, 0.15); +} + + +/* ---------- Basic page styling ---------- */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + + color: var(--ink); + + background: + radial-gradient( + circle at 15% 10%, + rgba(40, 120, 208, 0.16), + transparent 30% + ), + radial-gradient( + circle at 85% 90%, + rgba(230, 57, 70, 0.14), + transparent 30% + ), + #eef2f7; +} + +button { + font: inherit; +} + +button:focus-visible { + outline: 4px solid rgba(37, 99, 235, 0.3); + outline-offset: 3px; +} + +.hidden { + display: none !important; +} + + +/* ---------- Main page container ---------- */ + +.app-shell { + width: min(1180px, 94vw); + margin: 0 auto; + padding: 32px 0 48px; +} + + +/* ---------- Heading ---------- */ + +header { + margin-bottom: 24px; + text-align: center; +} + +.eyebrow { + margin: 0 0 4px; + + color: #2563eb; + + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1 { + margin: 0; + + font-size: clamp(2.2rem, 7vw, 4.5rem); + line-height: 1; +} + +.subtitle { + margin: 10px 0 0; + color: var(--muted); +} + + +/* ---------- Cards ---------- */ + +.setup-card, +.control-panel { + background: rgba(255, 255, 255, 0.92); + + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 24px; + + box-shadow: var(--shadow); +} + + +/* ---------- Player setup card ---------- */ + +.setup-card { + width: min(540px, 100%); + margin: 48px auto; + padding: 32px; + text-align: center; +} + +.setup-card h2 { + margin-top: 0; +} + +.player-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + + gap: 10px; + margin: 22px 0; +} + +.player-option, +.secondary-button { + padding: 12px; + + color: var(--ink); + background: var(--paper); + + border: 1px solid var(--line); + border-radius: 12px; + + font-weight: 700; + cursor: pointer; +} + +.player-option.selected { + color: #ffffff; + background: #2563eb; + border-color: #2563eb; +} + + +/* ---------- Main buttons ---------- */ + +.primary-button { + width: 100%; + padding: 14px 18px; + + color: #ffffff; + + background: linear-gradient( + 135deg, + #2563eb, + #4f46e5 + ); + + border: none; + border-radius: 14px; + + box-shadow: + 0 10px 20px rgba(37, 99, 235, 0.23); + + font-weight: 800; + cursor: pointer; + + transition: + transform 0.2s ease, + opacity 0.2s ease; +} + +.primary-button:hover { + transform: translateY(-1px); +} + +.primary-button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + + +/* ---------- Game layout ---------- */ + +.game-layout { + display: grid; + + grid-template-columns: + minmax(300px, 760px) + minmax(240px, 320px); + + gap: 24px; + + align-items: center; + justify-content: center; +} + +.board-wrap { + width: 100%; +} + + +/* ---------- Ludo board ---------- */ + +.board { + position: relative; + + width: min(76vw, 720px); + aspect-ratio: 1; + + margin: auto; + overflow: hidden; + + background: #f1f5f9; + + border: 8px solid #ffffff; + border-radius: 26px; + + box-shadow: var(--shadow); +} + + +/* ---------- Player home areas ---------- */ + +.home { + position: absolute; + + width: 31%; + height: 31%; + + display: grid; + place-items: center; + + color: #ffffff; + + border-radius: 22px; + + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.home::after { + content: ""; + + position: absolute; + + width: 58%; + height: 58%; + + border: 4px solid rgba(255, 255, 255, 0.75); + border-radius: 50%; +} + +.home span { + position: relative; + z-index: 1; +} + +.red-home { + top: 8%; + left: 8%; + background: var(--red); +} + +.green-home { + top: 8%; + right: 8%; + background: var(--green); +} + +.yellow-home { + right: 8%; + bottom: 8%; + background: var(--yellow); +} + +.blue-home { + bottom: 8%; + left: 8%; + background: var(--blue); +} + +.inactive-home { + opacity: 0.28; + filter: grayscale(1); +} + + +/* ---------- Finishing area ---------- */ + +.centre-finish { + position: absolute; + + top: 41%; + left: 41%; + + width: 18%; + height: 18%; + + display: grid; + place-items: center; + + color: #475569; + + background: conic-gradient( + var(--red) 0 25%, + var(--green) 0 50%, + var(--yellow) 0 75%, + var(--blue) 0 + ); + + border: 7px solid #ffffff; + border-radius: 50%; + + font-size: clamp(0.55rem, 1.7vw, 0.9rem); + font-weight: 950; + + z-index: 1; +} + + +/* ---------- Track cells ---------- */ + +.cell { + position: absolute; + + width: 5.4%; + aspect-ratio: 1; + + display: grid; + place-items: center; + + color: #475569; + background: #ffffff; + + border: 1px solid #94a3b8; + border-radius: 7px; + + font-size: clamp(0.45rem, 1.25vw, 0.7rem); + font-weight: 800; + + transform: translate(-50%, -50%); +} + +.cell.safe { + background: #e2e8f0; +} + +.cell.start-red { + background: #fecdd3; + border-color: var(--red); +} + +.cell.start-green { + background: #bbf7d0; + border-color: var(--green); +} + +.cell.start-yellow { + background: #fef08a; + border-color: var(--yellow); +} + +.cell.start-blue { + background: #bfdbfe; + border-color: var(--blue); +} + + +/* ---------- Player tokens ---------- */ + +.token { + position: absolute; + + width: 4.4%; + aspect-ratio: 1; + + border: 3px solid #ffffff; + + border-radius: + 50% + 50% + 50% + 12%; + + box-shadow: + 0 3px 7px rgba(15, 23, 42, 0.45); + + transform: translate(-50%, -50%); + + cursor: default; + z-index: 5; + + transition: + left 0.25s ease, + top 0.25s ease, + transform 0.15s ease; +} + +.token::after { + content: ""; + + position: absolute; + + top: 24%; + left: 24%; + + width: 32%; + height: 32%; + + background: rgba(255, 255, 255, 0.75); + border-radius: 50%; +} + +.token.red { + background: var(--red); +} + +.token.green { + background: var(--green); +} + +.token.yellow { + background: var(--yellow); +} + +.token.blue { + background: var(--blue); +} + +.token.movable { + cursor: pointer; + animation: pulse 1s infinite; +} + +.token.movable:hover { + transform: + translate(-50%, -50%) + scale(1.2); +} + +.token.finished { + border-radius: 50%; + + box-shadow: + 0 0 0 4px rgba(255, 255, 255, 0.6); +} + + +/* ---------- Token animation ---------- */ + +@keyframes pulse { + 50% { + filter: brightness(1.25); + + box-shadow: + 0 0 0 7px rgba(255, 255, 255, 0.65); + } +} + + +/* ---------- Control panel ---------- */ + +.control-panel { + padding: 24px; + text-align: center; +} + +.turn-banner { + padding: 10px; + + color: #ffffff; + background: var(--red); + + border-radius: 12px; + + font-weight: 900; +} + + +/* ---------- Dice ---------- */ + +.dice { + width: 92px; + height: 92px; + + display: grid; + place-items: center; + + margin: 24px auto; + + color: #1d4ed8; + background: #ffffff; + + border: 4px solid #dbeafe; + border-radius: 20px; + + box-shadow: + inset 0 0 18px rgba(37, 99, 235, 0.1); + + font-size: 3rem; + font-weight: 950; +} + +.dice.rolling { + animation: shake 0.45s linear; +} + +@keyframes shake { + 25% { + transform: + rotate(12deg) + scale(0.92); + } + + 50% { + transform: + rotate(-12deg) + scale(1.06); + } + + 75% { + transform: rotate(8deg); + } +} + + +/* ---------- Status message ---------- */ + +.message { + min-height: 48px; + + color: var(--muted); + font-weight: 650; +} + + +/* ---------- Scoreboard ---------- */ + +.scoreboard { + display: grid; + gap: 8px; + + margin: 18px 0; + + text-align: left; +} + +.score-row { + display: flex; + justify-content: space-between; + + padding: 8px 10px; + + background: var(--surface); + + border-left: 8px solid; + border-radius: 8px; +} + +.score-row strong { + text-transform: capitalize; +} + + +/* ---------- Board legend ---------- */ + +.legend { + display: flex; + justify-content: center; + + gap: 14px; + margin: 16px 0; + + color: var(--muted); + font-size: 0.8rem; +} + +.safe-dot { + display: inline-block; + + width: 10px; + height: 10px; + + background: #cbd5e1; + border-radius: 50%; +} + +.secondary-button { + width: 100%; +} + + +/* ---------- Winner popup ---------- */ + +.modal { + position: fixed; + inset: 0; + + z-index: 20; + + display: grid; + place-items: center; + + padding: 20px; + + background: rgba(15, 23, 42, 0.72); +} + +.modal-card { + width: min(400px, 100%); + + padding: 34px; + + text-align: center; + background: #ffffff; + + border-radius: 24px; + box-shadow: var(--shadow); +} + +.modal-card h2 { + margin: 8px 0; +} + +.modal-card p { + color: var(--muted); +} + +.trophy { + font-size: 4rem; +} + + +/* ---------- Tablet screens ---------- */ + +@media (max-width: 860px) { + .game-layout { + grid-template-columns: 1fr; + } + + .board { + width: min(94vw, 650px); + } + + .control-panel { + width: min(520px, 100%); + margin: auto; + } +} + + +/* ---------- Mobile screens ---------- */ + +@media (max-width: 520px) { + .app-shell { + padding-top: 20px; + } + + .setup-card { + padding: 22px 16px; + } + + .player-options { + grid-template-columns: 1fr; + } + + .board { + border-width: 4px; + border-radius: 16px; + } + + .home { + border-radius: 12px; + font-size: 0.65rem; + } + + .cell { + border-radius: 4px; + } + + .token { + border-width: 2px; + } +} \ No newline at end of file