| author | Alan Dipert
<alan@dipert.org> 2025-11-16 23:37:02 UTC |
| committer | Alan Dipert
<alan@dipert.org> 2025-11-16 23:37:02 UTC |
| parent | 88ed3252cc666a7c1b5fbb5bb1150ce24e29cc18 |
| Makefile | +1 | -1 |
| blossom-blocks/game.js | +51 | -0 |
| fairy-finder/game.js | +57 | -0 |
| index.html | +13 | -1 |
| moonlit-garden-match/game.js | +64 | -8 |
| starlit-stacker/game.js | +401 | -0 |
| starlit-stacker/index.html | +68 | -0 |
| starlit-stacker/styles.css | +197 | -0 |
diff --git a/Makefile b/Makefile index d9605a4..e547dc2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: deploy clean check-git-clean -DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match +DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match starlit-stacker DEPLOY_TARGET ?= arsien23i2@dreamhost:tailrecursion.com/arcade/ RSYNC_FLAGS ?= -av --delete --chmod=F644,D755 --exclude='.DS_Store' diff --git a/blossom-blocks/game.js b/blossom-blocks/game.js index e86ff6b..14ca702 100644 --- a/blossom-blocks/game.js +++ b/blossom-blocks/game.js @@ -53,6 +53,7 @@ let dragAnchor = { row: 0, col: 0 }; let ghostSnapped = false; let boardMetrics = null; + const audio = createAudio(); function parsePixels(value) { if (!value) return 0; @@ -406,6 +407,7 @@ board[targetRow][targetCol] = { emoji: piece.emoji }; placedCells += 1; }); + playSound('place'); updateBoardUI(); const clearedLines = clearLines(); const gained = placedCells * 5 + clearedLines * 15; @@ -461,6 +463,7 @@ if (rowsToClear.length || colsToClear.length) { setTimeout(updateBoardUI, 200); showFloatingBonus(rowsToClear.length + colsToClear.length); + playSound('clear'); } return rowsToClear.length + colsToClear.length; } @@ -485,6 +488,7 @@ ], { duration: 900 } ); + playSound('bonus'); setTimeout(() => { floatingScore.style.opacity = '0'; }, 900); @@ -515,6 +519,7 @@ } function startGame() { + audio.resume(); isActive = true; score = 0; board = createEmptyBoard(); @@ -530,6 +535,9 @@ startOverlay.querySelector('p').textContent = message; startOverlay.querySelector('h2').textContent = 'Blossom Blocks'; startOverlay.classList.remove('hidden'); + if (!fromNewGame) { + playSound('over'); + } } newGameBtn.addEventListener('click', () => { @@ -542,4 +550,47 @@ initBoard(); board = createEmptyBoard(); updateBoardUI(); + + function playSound(type) { + if (audio) audio.play(type); + } + + function createAudio() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + const ctx = new AudioCtx(); + const master = ctx.createGain(); + master.gain.value = 0.14; + master.connect(ctx.destination); + ctx.suspend(); + const tones = { + place: 820, + clear: 560, + bonus: 960, + over: 280, + }; + function chirp(freq, duration = 0.2) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + osc.connect(gain); + gain.connect(master); + const start = ctx.currentTime; + gain.gain.setValueAtTime(0.001, start); + gain.gain.linearRampToValueAtTime(0.22, start + 0.03); + gain.gain.exponentialRampToValueAtTime(0.001, start + duration); + osc.start(start); + osc.stop(start + duration + 0.02); + } + return { + resume() { + if (ctx.state === 'suspended') ctx.resume(); + }, + play(event) { + const freq = tones[event]; + if (freq) chirp(freq); + }, + }; + } })(); diff --git a/fairy-finder/game.js b/fairy-finder/game.js index b6ce557..2d7481b 100644 --- a/fairy-finder/game.js +++ b/fairy-finder/game.js @@ -26,6 +26,7 @@ timer: 0, timerInterval: null, }; + const audio = createAudio(); function stopTimer() { if (game.timerInterval) { @@ -152,11 +153,13 @@ if (cell.hasFairy) { revealCellElement(row, col, cell); revealAllFairies(); + playSound('fairy'); finishGame(false); return; } game.revealedSafe += 1; revealCellElement(row, col, cell); + playSound(cell.neighborCount === 0 ? 'open' : 'chime'); if (cell.neighborCount === 0) { floodReveal(row, col); } @@ -184,6 +187,7 @@ neighbor.revealed = true; game.revealedSafe += 1; revealCellElement(nr, nc, neighbor); + playSound(neighbor.neighborCount === 0 ? 'open' : 'chime'); if (neighbor.neighborCount === 0) { queue.push([nr, nc]); } @@ -227,6 +231,7 @@ button.classList.add('cell--hidden'); } updateStats(); + playSound(cell.flagged ? 'flag' : 'unflag'); } function revealAllFairies(isWin = false) { @@ -252,6 +257,7 @@ : 'A shy fairy fluttered out! Try again for a perfect sweep?'; overlayButton.textContent = hasWon ? 'Play again' : 'Try again'; overlay.classList.remove('hidden'); + playSound(hasWon ? 'win' : 'lose'); } function updateStats() { @@ -278,6 +284,7 @@ } function startNewRound() { + audio.resume(); prepareGame(); overlay.classList.add('hidden'); } @@ -286,4 +293,54 @@ overlayButton.addEventListener('click', startNewRound); prepareGame(); + + function playSound(type) { + if (audio) audio.play(type); + } + + function createAudio() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + const ctx = new AudioCtx(); + const master = ctx.createGain(); + master.gain.value = 0.18; + master.connect(ctx.destination); + ctx.suspend(); + const tones = { + chime: 880, + open: 640, + fairy: 520, + flag: 720, + unflag: 420, + win: 990, + lose: 300, + }; + function ping(freq, duration = 0.2) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + osc.connect(gain); + gain.connect(master); + const start = ctx.currentTime; + gain.gain.setValueAtTime(0.001, start); + gain.gain.linearRampToValueAtTime(0.25, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.002, start + duration); + osc.start(start); + osc.stop(start + duration + 0.02); + } + return { + resume() { + if (ctx.state === 'suspended') ctx.resume(); + }, + play(event) { + if (event === 'fairy') { + [520, 640, 760].forEach((freq, i) => ping(freq, 0.15, i * 0.04)); + return; + } + const freq = tones[event]; + if (freq) ping(freq); + }, + }; + } })(); diff --git a/index.html b/index.html index 922f372..044f57b 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ <header class="hero"> <p class="subhead">tailrecursion.com/arcade</p> <h1>Viviana's Sparkle Arcade</h1> - <p class="intro">Four cozy browser games crafted for Viviana’s magical playtime. Click a card to jump straight in—no downloads, no ads, just pure pastel fun.</p> + <p class="intro">Five cozy browser games crafted for Viviana’s magical playtime. Click a card to jump straight in—no downloads, no ads, just pure pastel fun.</p> </header> <section class="game-grid"> @@ -55,6 +55,18 @@ <a class="btn" href="moonlit-garden-match/" aria-label="Play Moonlit Garden Match">Play Moonlit Garden Match</a> </article> + <article class="game-card" role="article"> + <div class="badge">Stacker ⭐</div> + <h2>Starlit Stacker</h2> + <p>Tetris-style constellations drift down from the night sky. Rotate and slide them to clear entire rows of stars.</p> + <ul> + <li>Classic 10×20 board</li> + <li>Next-piece peek and hard drop</li> + <li>Local best-score tracking</li> + </ul> + <a class="btn" href="starlit-stacker/" aria-label="Play Starlit Stacker">Play Starlit Stacker</a> + </article> + <article class="game-card" role="article"> <div class="badge">Puzzle 🧚</div> <h2>Fairy Finder</h2> diff --git a/moonlit-garden-match/game.js b/moonlit-garden-match/game.js index a860464..cdd7f2c 100644 --- a/moonlit-garden-match/game.js +++ b/moonlit-garden-match/game.js @@ -14,6 +14,7 @@ let score = 0; let moves = 25; let busy = false; + const audio = createAudio(); function createBoard() { const newGrid = Array.from({ length: size }, () => Array(size).fill(null)); @@ -90,19 +91,17 @@ busy = true; swap(a, b); const matches = findMatches(); + moves = Math.max(0, moves - 1); + updateHUD(); + selected = null; + renderBoard(); if (matches.length === 0) { - moves = Math.max(0, moves - 1); - updateHUD(); - selected = null; - renderBoard(); busy = false; + playSound('bump'); if (moves === 0) endGame(); return; } - moves = Math.max(0, moves - 1); - updateHUD(); - selected = null; - renderBoard(); + playSound('swap'); resolveMatches(matches, 1); } @@ -181,6 +180,7 @@ score += matchCells.length * 60 * chain; updateHUD(); animateMatches(matchCells); + playSound(chain > 1 ? 'cascade' : 'match'); setTimeout(() => { applyGravity(); fillNew(); @@ -242,6 +242,7 @@ } function startGame() { + audio.resume(); grid = createBoard(); score = 0; moves = 25; @@ -255,8 +256,63 @@ function endGame() { overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`; overlay.classList.remove('hidden'); + playSound('end'); } newGameBtn.addEventListener('click', startGame); startBtn.addEventListener('click', startGame); + + function playSound(type) { + if (audio) audio.play(type); + } + + function createAudio() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + const ctx = new AudioCtx(); + const master = ctx.createGain(); + master.gain.value = 0.18; + master.connect(ctx.destination); + ctx.suspend(); + function blip(freq, duration = 0.18, delay = 0) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + osc.connect(gain); + gain.connect(master); + const start = ctx.currentTime + delay; + gain.gain.setValueAtTime(0.001, start); + gain.gain.linearRampToValueAtTime(0.25, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.002, start + duration); + osc.start(start); + osc.stop(start + duration + 0.02); + } + return { + resume() { + if (ctx.state === 'suspended') ctx.resume(); + }, + play(event) { + switch (event) { + case 'bump': + blip(320); + break; + case 'swap': + blip(640); + break; + case 'match': + blip(900); + break; + case 'cascade': + [900, 1080].forEach((freq, i) => blip(freq, 0.2, i * 0.05)); + break; + case 'end': + blip(280, 0.4); + break; + default: + break; + } + }, + }; + } })(); diff --git a/starlit-stacker/game.js b/starlit-stacker/game.js new file mode 100644 index 0000000..e1fcb44 --- /dev/null +++ b/starlit-stacker/game.js @@ -0,0 +1,401 @@ +(() => { + const rows = 20; + const cols = 10; + const boardEl = document.getElementById('board'); + const previewEl = document.getElementById('preview'); + const scoreEl = document.getElementById('score'); + const linesEl = document.getElementById('lines'); + const bestEl = document.getElementById('best'); + const newGameBtn = document.getElementById('new-game'); + const overlay = document.getElementById('overlay'); + const startBtn = document.getElementById('start'); + const touchControls = document.getElementById('touch-controls'); + + const bestKey = 'starlitBestScore'; + + const shapes = [ + [[1, 1, 1, 1]], + [[1, 0, 0], [1, 1, 1]], + [[0, 0, 1], [1, 1, 1]], + [[1, 1], [1, 1]], + [[0, 1, 1], [1, 1, 0]], + [[0, 1, 0], [1, 1, 1]], + [[1, 1, 0], [0, 1, 1]], + ]; + + const colors = ['#f9b5ff', '#9dd1ff', '#a3ffde', '#ffd4a8', '#ff9bd1', '#c4a5ff', '#a5ffe3']; + + let board = createBoard(); + let current = null; + let nextPiece = null; + let dropTimer = null; + let dropInterval = 800; + let score = 0; + let lines = 0; + let bestScore = Number(localStorage.getItem(bestKey)) || 0; + let isActive = false; + const audio = createAudio(); + + const cells = []; + + function initGrid() { + boardEl.innerHTML = ''; + for (let r = 0; r < rows; r += 1) { + const rowCells = []; + for (let c = 0; c < cols; c += 1) { + const cell = document.createElement('div'); + cell.className = 'cell'; + boardEl.appendChild(cell); + rowCells.push(cell); + } + cells.push(rowCells); + } + } + + function createBoard() { + return Array.from({ length: rows }, () => Array(cols).fill(null)); + } + + function randomPiece() { + const idx = Math.floor(Math.random() * shapes.length); + return { + shape: shapes[idx].map((row) => row.slice()), + row: 0, + col: Math.floor(cols / 2) - 2, + color: colors[idx], + }; + } + + function spawnPiece() { + current = nextPiece || randomPiece(); + current.row = -1; + current.col = Math.floor(cols / 2) - Math.ceil(current.shape[0].length / 2); + nextPiece = randomPiece(); + renderPreview(); + if (!canMove(current.shape, current.row + 1, current.col)) { + endGame(); + return; + } + current.row += 1; + } + + function renderPreview() { + previewEl.innerHTML = ''; + const grid = Array.from({ length: 4 }, () => Array(4).fill(false)); + nextPiece.shape.forEach((row, r) => { + row.forEach((val, c) => { + if (val && r < 4 && c < 4) grid[r][c] = true; + }); + }); + grid.forEach((row) => { + row.forEach((filled) => { + const cell = document.createElement('div'); + cell.className = 'preview-cell'; + if (filled) { + cell.style.background = nextPiece.color; + } + previewEl.appendChild(cell); + }); + }); + } + + function canMove(shape, targetRow, targetCol) { + for (let r = 0; r < shape.length; r += 1) { + for (let c = 0; c < shape[r].length; c += 1) { + if (!shape[r][c]) continue; + const newRow = targetRow + r; + const newCol = targetCol + c; + if (newCol < 0 || newCol >= cols || newRow >= rows) return false; + if (newRow >= 0 && board[newRow][newCol]) return false; + } + } + return true; + } + + function placePiece() { + const piece = current; + piece.shape.forEach((row, r) => { + row.forEach((val, c) => { + if (val) { + const boardRow = piece.row + r; + const boardCol = piece.col + c; + if (boardRow >= 0) { + board[boardRow][boardCol] = current.color; + } + } + }); + }); + current = null; + playSound('lock'); + const cleared = clearLines(); + if (cleared === 0) { + spawnPiece(); + } else { + playSound(cleared >= 4 ? 'tetris' : 'clear'); + setTimeout(spawnPiece, 60); + } + } + + function rotate(shape) { + const size = shape.length; + const width = shape[0].length; + const newShape = Array.from({ length: width }, () => Array(size).fill(0)); + for (let r = 0; r < size; r += 1) { + for (let c = 0; c < width; c += 1) { + newShape[c][size - 1 - r] = shape[r][c]; + } + } + return newShape; + } + + function tryRotate() { + const nextShape = rotate(current.shape); + if (canMove(nextShape, current.row, current.col)) { + current.shape = nextShape; + } + } + + function clearLines() { + let cleared = 0; + for (let r = rows - 1; r >= 0; r -= 1) { + if (board[r].every(Boolean)) { + board.splice(r, 1); + board.unshift(Array(cols).fill(null)); + cleared += 1; + r += 1; + } + } + if (cleared > 0) { + score += cleared * 100; + lines += cleared; + dropInterval = Math.max(200, 800 - lines * 10); + updateHUD(); + restartDropTimer(); + } + return cleared; + } + + function moveDown() { + if (!current) return; + if (canMove(current.shape, current.row + 1, current.col)) { + current.row += 1; + } else { + placePiece(); + } + render(); + } + + function hardDrop() { + if (!current) return; + while (canMove(current.shape, current.row + 1, current.col)) { + current.row += 1; + } + placePiece(); + render(); + playSound('drop'); + } + + function render() { + for (let r = 0; r < rows; r += 1) { + for (let c = 0; c < cols; c += 1) { + const cell = cells[r][c]; + const color = board[r][c]; + if (color) { + cell.classList.add('filled'); + cell.style.background = color; + } else { + cell.classList.remove('filled'); + cell.style.background = 'var(--cell-empty)'; + } + } + } + if (current) { + current.shape.forEach((row, rIndex) => { + row.forEach((val, cIndex) => { + if (val) { + const br = current.row + rIndex; + const bc = current.col + cIndex; + if (br >= 0 && br < rows && bc >= 0 && bc < cols) { + const cell = cells[br][bc]; + cell.classList.add('filled'); + cell.style.background = current.color; + } + } + }); + }); + } + } + + function updateHUD() { + scoreEl.textContent = score; + linesEl.textContent = lines; + if (score > bestScore) { + bestScore = score; + localStorage.setItem(bestKey, String(bestScore)); + } + bestEl.textContent = bestScore; + } + + function startGame() { + if (audio) audio.resume(); + board = createBoard(); + score = 0; + lines = 0; + dropInterval = 800; + updateHUD(); + if (dropTimer) clearInterval(dropTimer); + current = null; + nextPiece = randomPiece(); + spawnPiece(); + render(); + dropTimer = setInterval(moveDown, dropInterval); + isActive = true; + overlay.classList.add('hidden'); + } + + function endGame() { + isActive = false; + clearInterval(dropTimer); + dropTimer = null; + overlay.querySelector('p').textContent = `Final score: ${score} ⭐`; + overlay.classList.remove('hidden'); + } + + function restartDropTimer() { + if (dropTimer) { + clearInterval(dropTimer); + } + dropTimer = setInterval(moveDown, dropInterval); + } + + function move(direction) { + if (!isActive) return; + const offset = direction === 'left' ? -1 : 1; + if (canMove(current.shape, current.row, current.col + offset)) { + current.col += offset; + render(); + playSound('move'); + } + } + + function softDrop() { + if (canMove(current.shape, current.row + 1, current.col)) { + current.row += 1; + score += 1; + updateHUD(); + render(); + playSound('soft'); + } + } + + document.addEventListener('keydown', (event) => { + if (!isActive && event.code !== 'Space') return; + switch (event.code) { + case 'ArrowLeft': + event.preventDefault(); + move('left'); + break; + case 'ArrowRight': + event.preventDefault(); + move('right'); + break; + case 'ArrowUp': + event.preventDefault(); + tryRotate(); + render(); + playSound('rotate'); + break; + case 'ArrowDown': + event.preventDefault(); + softDrop(); + break; + case 'Space': + event.preventDefault(); + hardDrop(); + break; + default: + break; + } + }); + + touchControls.addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button) return; + const action = button.dataset.action; + if (!isActive && action !== 'drop') return; + switch (action) { + case 'left': + move('left'); + break; + case 'right': + move('right'); + break; + case 'rotate': + tryRotate(); + render(); + break; + case 'drop': + softDrop(); + break; + default: + break; + } + }); + + newGameBtn.addEventListener('click', startGame); + startBtn.addEventListener('click', startGame); + + bestEl.textContent = bestScore; + initGrid(); + render(); + + function playSound(type) { + if (audio) audio.play(type); + } + + function createAudio() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + const ctx = new AudioCtx(); + const master = ctx.createGain(); + master.gain.value = 0.18; + master.connect(ctx.destination); + ctx.suspend(); + const tones = { + move: 760, + rotate: 840, + lock: 520, + clear: 960, + tetris: 0, + drop: 620, + soft: 700, + }; + function schedule(freq, duration = 0.18, delay = 0) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + osc.connect(gain); + gain.connect(master); + const start = ctx.currentTime + delay; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.linearRampToValueAtTime(0.25, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.002, start + duration); + osc.start(start); + osc.stop(start + duration + 0.02); + } + return { + resume() { + if (ctx.state === 'suspended') ctx.resume(); + }, + play(type) { + if (type === 'tetris') { + [880, 980, 1100, 1300].forEach((freq, i) => schedule(freq, 0.2, i * 0.05)); + return; + } + const freq = tones[type] || 600; + schedule(freq); + }, + }; + } +})(); diff --git a/starlit-stacker/index.html b/starlit-stacker/index.html new file mode 100644 index 0000000..cf0477a --- /dev/null +++ b/starlit-stacker/index.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Starlit Stacker</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600&family=Nunito:wght@400;600&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="styles.css" /> + <script src="game.js" defer></script> +</head> +<body> + <div class="starfield" aria-hidden="true">✨ 🌙 ⭐ 💜 💖</div> + <main class="layout"> + <header class="hero"> + <div> + <h1>Starlit Stacker</h1> + <p class="tagline">Drop glowing constellations into neat rows to keep the night sky tidy.</p> + </div> + <div class="panel"> + <div>Score: <span id="score">0</span> ⭐</div> + <div>Lines: <span id="lines">0</span> ✨</div> + <div>Best: <span id="best">0</span> 🌙</div> + <button class="btn" id="new-game">New Game</button> + </div> + </header> + + <section class="play"> + <div class="board" id="board" aria-label="Tetris board" role="grid"></div> + <aside class="info"> + <div class="next-box"> + <h2>Next Shape</h2> + <div class="preview" id="preview"></div> + </div> + <div class="controls"> + <h2>Controls</h2> + <ul> + <li>Left / Right: Move</li> + <li>Up: Rotate</li> + <li>Down: Soft drop</li> + <li>Space: Hard drop</li> + </ul> + </div> + <div class="instructions"> + <h2>How to play</h2> + <p>Stack dreamy blocks to fill entire rows. Completed rows vanish in sparkles. Keep the night sky clear as long as you can!</p> + </div> + </aside> + </section> + </main> + + <div class="overlay" id="overlay"> + <div class="card"> + <h2>Starlit Stacker</h2> + <p>Use the arrow keys or on-screen buttons to guide the glowing pieces. Fill rows to earn stars!</p> + <button class="btn btn-lg" id="start">Play</button> + </div> + </div> + + <div class="touch-controls" id="touch-controls"> + <button data-action="left">⟵</button> + <button data-action="rotate">⟳</button> + <button data-action="right">⟶</button> + <button data-action="drop">⬇</button> + </div> +</body> +</html> diff --git a/starlit-stacker/styles.css b/starlit-stacker/styles.css new file mode 100644 index 0000000..509ed7e --- /dev/null +++ b/starlit-stacker/styles.css @@ -0,0 +1,197 @@ +:root { + --bg-top: #0b0530; + --bg-bottom: #2d1766; + --grid-border: rgba(255, 255, 255, 0.2); + --cell-empty: rgba(255, 255, 255, 0.08); + --accent: #ff95df; + --accent-2: #8cc7ff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: 'Nunito', system-ui, sans-serif; + background: radial-gradient(circle at top, var(--bg-top), var(--bg-bottom)); + min-height: 100vh; + color: #f8f0ff; +} + +.starfield { + position: fixed; + inset: 0; + font-size: min(7rem, 15vw); + opacity: 0.05; + text-align: center; + pointer-events: none; +} + +.layout { + max-width: 1000px; + margin: 0 auto; + padding: 1.5rem clamp(1rem, 4vw, 3rem) 2.5rem; + position: relative; + z-index: 1; +} + +h1, h2 { + font-family: 'Baloo 2', 'Nunito', sans-serif; + color: #ff98e0; + margin: 0 0 0.5rem; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.tagline { color: #d2c0ff; margin: 0; } + +.panel { + background: rgba(255, 255, 255, 0.08); + padding: 1rem 1.2rem; + border-radius: 1.2rem; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 0.5rem; + font-weight: 600; +} + +.btn { + border: none; + border-radius: 999px; + padding: 0.5rem 1.5rem; + background: linear-gradient(120deg, var(--accent), var(--accent-2)); + color: white; + font-weight: 700; + cursor: pointer; + box-shadow: 0 15px 35px rgba(255, 149, 223, 0.4); +} + +.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.1rem; } + +.play { + margin-top: 1.5rem; + display: grid; + grid-template-columns: minmax(240px, 2fr) minmax(200px, 1fr); + gap: 1.5rem; +} + +.board { + width: min(340px, 80vw); + aspect-ratio: 10 / 20; + background: rgba(255, 255, 255, 0.04); + border-radius: 1rem; + padding: 0.5rem; + display: grid; + grid-template-columns: repeat(10, 1fr); + grid-template-rows: repeat(20, 1fr); + gap: 0.2rem; + border: 2px solid var(--grid-border); + box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.6); +} + +.cell { + border-radius: 0.3rem; + background: var(--cell-empty); + transition: background 0.2s ease; +} + +.cell.filled { + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.4); +} + +.info { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.next-box, .controls, .instructions { + background: rgba(255, 255, 255, 0.07); + border-radius: 1.2rem; + padding: 1rem 1.2rem; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); +} + +.preview { + margin-top: 0.6rem; + width: 140px; + height: 140px; + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(4, 1fr); + gap: 0.25rem; +} + +.preview-cell { + border-radius: 0.3rem; + background: rgba(255, 255, 255, 0.15); +} + +.controls ul { + margin: 0; + padding-left: 1rem; +} + +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(9, 2, 30, 0.8); + z-index: 10; +} + +.overlay.hidden { opacity: 0; pointer-events: none; } + +.card { + background: #1a0c34; + border-radius: 1.8rem; + padding: 2rem 3rem; + text-align: center; + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6); +} + +.touch-controls { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.5rem; + z-index: 5; +} + +.touch-controls button { + border: none; + border-radius: 0.8rem; + padding: 0.8rem 1rem; + background: rgba(255, 255, 255, 0.15); + color: white; + font-size: 1.2rem; + backdrop-filter: blur(8px); +} + +@media (max-width: 900px) { + .play { + grid-template-columns: 1fr; + } + .info { + flex-direction: row; + flex-wrap: wrap; + } +} + +@media (max-width: 600px) { + .touch-controls { display: flex; } +} + +@media (min-width: 601px) { + .touch-controls { display: none; } +}