| author | Alan Dipert
<alan@dipert.org> 2025-11-16 17:23:29 UTC |
| committer | Alan Dipert
<alan@dipert.org> 2025-11-16 17:23:29 UTC |
| parent | 79f0ce6061a314adaa4e1ed167e735a9c4a10c63 |
| Makefile | +1 | -1 |
| crystal-cloud-carousel/game.js | +272 | -0 |
| crystal-cloud-carousel/index.html | +60 | -0 |
| crystal-cloud-carousel/styles.css | +199 | -0 |
| index.html | +25 | -1 |
| moonlit-garden-match/game.js | +262 | -0 |
| moonlit-garden-match/index.html | +46 | -0 |
| moonlit-garden-match/styles.css | +156 | -0 |
diff --git a/Makefile b/Makefile index 7531491..d9605a4 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 +DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match DEPLOY_TARGET ?= arsien23i2@dreamhost:tailrecursion.com/arcade/ RSYNC_FLAGS ?= -av --delete --chmod=F644,D755 --exclude='.DS_Store' diff --git a/crystal-cloud-carousel/game.js b/crystal-cloud-carousel/game.js new file mode 100644 index 0000000..f9c3465 --- /dev/null +++ b/crystal-cloud-carousel/game.js @@ -0,0 +1,272 @@ +(() => { + const lanesContainer = document.getElementById('lanes'); + const scoreEl = document.getElementById('score'); + const comboEl = document.getElementById('combo'); + const timeEl = document.getElementById('time'); + const newGameBtn = document.getElementById('new-game'); + const overlay = document.getElementById('overlay'); + const startBtn = document.getElementById('start'); + const hitButtons = document.getElementById('hit-buttons'); + + const laneColors = ['#ff9adf', '#c7a6ff', '#8fcbff', '#ffaccd']; + const laneEmojis = ['π', 'π', 'π', 'π']; + const lanes = Array.from(lanesContainer.querySelectorAll('.lane')); + + const NOTE_SPEED = 180; // px per second + const SONG_DURATION = 45; // seconds + const TARGET_PAD = 90; + const BEAT_INTERVAL = 0.55; // seconds per beat + const lanePattern = [0, 1, 2, 1, 3, 2, 0, 2, 1, 3, 0, 2, 1, 0, 3, 1]; + + let notes = []; + let score = 0; + let combo = 0; + let isActive = false; + let remaining = SONG_DURATION; + let timerInterval = null; + let beatTimer = 0; + let patternIndex = 0; + let lastTimestamp = null; + let laneHeight = 0; + let targetStart = 0; + let targetEnd = 0; + let songPlayer = null; + + function measureStage() { + const rect = lanes[0].getBoundingClientRect(); + laneHeight = rect.height; + targetEnd = laneHeight - 70; + targetStart = targetEnd - TARGET_PAD; + } + + function spawnNote(laneIndex) { + const lane = typeof laneIndex === 'number' ? laneIndex : Math.floor(Math.random() * lanes.length); + const noteEl = document.createElement('div'); + noteEl.className = 'note'; + noteEl.textContent = laneEmojis[lane]; + noteEl.style.background = `linear-gradient(135deg, ${laneColors[lane]}, rgba(255,255,255,0.9))`; + lanes[lane].appendChild(noteEl); + notes.push({ lane, y: -30, el: noteEl }); + } + + function removeNote(note) { + note.el.remove(); + notes = notes.filter((n) => n !== note); + } + + function hitLane(lane) { + if (!isActive) return; + flashLane(lane); + const targetNote = notes.find((note) => note.lane === lane && note.y >= targetStart && note.y <= targetEnd); + if (targetNote) { + const distance = Math.abs(targetNote.y - targetEnd + TARGET_PAD / 2); + const bonus = distance < 20 ? 120 : 90; + score += bonus + combo * 2; + combo += 1; + updateHUD(); + if (songPlayer) songPlayer.hitTone(lane); + showPop(`+${bonus}`, targetNote.el); + removeNote(targetNote); + } else { + combo = 0; + updateHUD(); + } + } + + function flashLane(lane) { + const laneEl = lanes[lane]; + laneEl.classList.add('lane--active'); + setTimeout(() => laneEl.classList.remove('lane--active'), 120); + } + + function showPop(text, anchor) { + const pop = document.createElement('span'); + pop.textContent = `${text} β¨`; + pop.style.position = 'absolute'; + pop.style.left = '50%'; + pop.style.transform = 'translate(-50%, -50%)'; + pop.style.top = `${anchor.offsetTop}px`; + pop.style.color = '#ff5fb1'; + pop.style.fontWeight = '700'; + pop.style.pointerEvents = 'none'; + anchor.parentElement.appendChild(pop); + pop.animate( + [ + { opacity: 1, transform: 'translate(-50%, -20px)' }, + { opacity: 0, transform: 'translate(-50%, -60px)' } + ], + { duration: 600 } + ).onfinish = () => pop.remove(); + } + + function updateHUD() { + scoreEl.textContent = score; + comboEl.textContent = combo; + } + + function startSong() { + measureStage(); + notes.forEach((note) => note.el.remove()); + notes = []; + score = 0; + combo = 0; + remaining = SONG_DURATION; + beatTimer = 0; + patternIndex = 0; + lastTimestamp = null; + updateHUD(); + timeEl.textContent = remaining; + if (timerInterval) clearInterval(timerInterval); + timerInterval = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + remaining = 0; + endSong(); + } + timeEl.textContent = remaining; + }, 1000); + isActive = true; + overlay.classList.add('hidden'); + if (!songPlayer) songPlayer = createSongPlayer(); + songPlayer.start(); + } + + function endSong() { + if (!isActive) return; + isActive = false; + clearInterval(timerInterval); + timerInterval = null; + overlay.querySelector('p').textContent = `Final score: ${score} β¨ Combo: ${combo}`; + overlay.classList.remove('hidden'); + if (songPlayer) songPlayer.stop(); + } + + function update(timestamp) { + if (!lastTimestamp) lastTimestamp = timestamp; + const delta = (timestamp - lastTimestamp) / 1000; + lastTimestamp = timestamp; + if (isActive) { + beatTimer += delta; + while (beatTimer >= BEAT_INTERVAL) { + const lane = lanePattern[patternIndex % lanePattern.length]; + patternIndex += 1; + spawnNote(lane); + beatTimer -= BEAT_INTERVAL; + } + notes.slice().forEach((note) => { + note.y += NOTE_SPEED * delta; + note.el.style.transform = `translateY(${note.y}px)`; + if (note.y > laneHeight + 40) { + removeNote(note); + combo = 0; + updateHUD(); + } else if (note.y > targetEnd + 40) { + removeNote(note); + combo = 0; + updateHUD(); + } + }); + if (remaining <= 0 && notes.length === 0) { + endSong(); + } + } + requestAnimationFrame(update); + } + + function handleKey(event) { + if (event.repeat) return; + const keyMap = ['1', '2', '3', '4']; + const index = keyMap.indexOf(event.key); + if (index !== -1) { + hitLane(index); + } + } + + hitButtons.addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button) return; + const lane = Number(button.dataset.index); + hitLane(lane); + }); + + lanes.forEach((laneEl) => { + laneEl.addEventListener('pointerdown', () => { + const lane = Number(laneEl.dataset.index); + hitLane(lane); + }); + }); + + document.addEventListener('keydown', handleKey); + newGameBtn.addEventListener('click', startSong); + startBtn.addEventListener('click', startSong); + + requestAnimationFrame(update); + + function createSongPlayer() { + const pattern = [523.25, 659.25, 587.33, 659.25, 783.99, 659.25, 587.33, 523.25]; + const totalDuration = pattern.length * BEAT_INTERVAL; + return { + ctx: null, + gain: null, + nextTime: 0, + timeoutId: null, + isPlaying: false, + ensureContext() { + if (!this.ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioCtx(); + this.gain = this.ctx.createGain(); + this.gain.gain.value = 0.16; + this.gain.connect(this.ctx.destination); + } + }, + start() { + this.ensureContext(); + if (this.isPlaying) return; + this.ctx.resume(); + this.isPlaying = true; + this.nextTime = this.ctx.currentTime + 0.05; + this.loop(); + }, + loop() { + if (!this.isPlaying) return; + let time = this.nextTime; + pattern.forEach((freq) => { + this.scheduleNote(freq, time, BEAT_INTERVAL * 0.75); + time += BEAT_INTERVAL; + }); + this.nextTime = time; + this.timeoutId = setTimeout(() => this.loop(), totalDuration * 1000); + }, + scheduleNote(freq, start, duration) { + const osc = this.ctx.createOscillator(); + const gainNode = this.ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + osc.connect(gainNode); + gainNode.connect(this.gain); + gainNode.gain.setValueAtTime(0.0001, start); + gainNode.gain.linearRampToValueAtTime(0.25, start + 0.03); + gainNode.gain.exponentialRampToValueAtTime(0.001, start + duration); + osc.start(start); + osc.stop(start + duration + 0.02); + }, + hitTone(lane) { + this.ensureContext(); + const freq = 440 + lane * 80; + this.scheduleNote(freq, this.ctx.currentTime, 0.25); + }, + stop() { + if (!this.isPlaying) return; + this.isPlaying = false; + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + if (this.ctx && this.ctx.state !== 'closed') { + this.ctx.suspend(); + } + }, + }; + } +})(); diff --git a/crystal-cloud-carousel/index.html b/crystal-cloud-carousel/index.html new file mode 100644 index 0000000..81116ef --- /dev/null +++ b/crystal-cloud-carousel/index.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Crystal Cloud Carousel</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="sky-charms" aria-hidden="true">π β¨ βοΈ π β π</div> + <main class="wrap"> + <header class="top"> + <div> + <h1>Crystal Cloud Carousel</h1> + <p class="tag">Tap sparkles in time to keep Vivianaβs carousel twirling through the clouds!</p> + </div> + <div class="stats"> + <div>Score: <span id="score">0</span> β¨</div> + <div>Combo: <span id="combo">0</span> π</div> + <div>Time: <span id="time">45</span>s β±οΈ</div> + <button class="btn" id="new-game">New Song</button> + </div> + </header> + + <section class="stage"> + <div class="lanes" id="lanes" aria-label="Music lanes"> + <div class="lane" data-index="0"><span class="label">1</span></div> + <div class="lane" data-index="1"><span class="label">2</span></div> + <div class="lane" data-index="2"><span class="label">3</span></div> + <div class="lane" data-index="3"><span class="label">4</span></div> + <div class="target-line" aria-hidden="true"></div> + </div> + <div class="hit-buttons" id="hit-buttons"> + <button data-index="0">π</button> + <button data-index="1">π</button> + <button data-index="2">π</button> + <button data-index="3">π</button> + </div> + </section> + + <aside class="instructions"> + <h2>How to play</h2> + <p>Notes glide down the sky lanes. Tap the matching button when a note touches the sparkle line. Keep the beat to build your combo and spin the carousel faster!</p> + <p>Use the number keys 1-4 or click the heart buttons. Try to finish the 45-second song with a dazzling score.</p> + </aside> + </main> + + <div class="overlay" id="overlay"> + <div class="card"> + <h2>Crystal Cloud Carousel</h2> + <p>Ready to ride? Tap the sparkles with the beat to earn stars for Vivianaβs sky pony.</p> + <button class="btn btn-lg" id="start">Play Song</button> + </div> + </div> +</body> +</html> diff --git a/crystal-cloud-carousel/styles.css b/crystal-cloud-carousel/styles.css new file mode 100644 index 0000000..5b56e3e --- /dev/null +++ b/crystal-cloud-carousel/styles.css @@ -0,0 +1,199 @@ +:root { + --bg-top: #f9e2ff; + --bg-bottom: #cfe8ff; + --lane-bg: rgba(255, 255, 255, 0.2); + --lane-active: rgba(255, 255, 255, 0.5); + --accent: #ff9edc; + --accent-2: #a4c8ff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: 'Nunito', system-ui, sans-serif; + min-height: 100vh; + background: linear-gradient(180deg, var(--bg-top), var(--bg-bottom)); + color: #4b2d62; + overflow-x: hidden; +} + +.sky-charms { + position: fixed; + inset: 0; + font-size: 6rem; + opacity: 0.05; + text-align: center; + padding-top: 2rem; + letter-spacing: 1.5rem; + pointer-events: none; +} + +.wrap { + max-width: 1100px; + margin: 0 auto; + padding: 2rem clamp(1rem, 5vw, 4rem) 4rem; + position: relative; + z-index: 1; +} + +h1, h2 { + font-family: 'Baloo 2', 'Nunito', sans-serif; + margin: 0; + color: #ff62c3; +} + +.top { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.tag { + margin-top: 0.4rem; + color: #745483; +} + +.stats { + background: rgba(255, 255, 255, 0.7); + padding: 0.8rem 1.2rem; + border-radius: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + font-weight: 600; + box-shadow: 0 15px 35px rgba(173, 137, 255, 0.35); +} + +.btn { + border: none; + border-radius: 999px; + padding: 0.5rem 1.5rem; + font-weight: 700; + font-size: 1rem; + background: linear-gradient(120deg, var(--accent), var(--accent-2)); + color: white; + cursor: pointer; + box-shadow: 0 15px 30px rgba(168, 198, 255, 0.45); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn:hover { transform: translateY(-2px); } + +.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.2rem; } + +.stage { + margin-top: 2.5rem; + background: rgba(255, 255, 255, 0.4); + border-radius: 2rem; + padding: 2rem; + box-shadow: inset 0 0 30px rgba(255, 255, 255, 0.6), 0 25px 50px rgba(136, 160, 255, 0.4); +} + +.lanes { + position: relative; + display: grid; + grid-template-columns: repeat(4, minmax(60px, 1fr)); + gap: 1rem; + height: 380px; +} + +.lane { + position: relative; + background: var(--lane-bg); + border-radius: 1.5rem; + overflow: hidden; + border: 2px dashed rgba(255, 255, 255, 0.4); +} + +.lane--active { + background: var(--lane-active); + box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.6); +} + +.lane .label { + position: absolute; + top: 0.4rem; + left: 50%; + transform: translateX(-50%); + font-weight: 700; + color: #fff; + opacity: 0.7; +} + +.target-line { + position: absolute; + left: 0; + right: 0; + bottom: 70px; + border-bottom: 6px solid rgba(255, 255, 255, 0.8); + pointer-events: none; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.7); +} + +.note { + position: absolute; + width: 70%; + left: 15%; + padding: 0.4rem 0; + border-radius: 999px; + text-align: center; + font-size: 1.4rem; + box-shadow: 0 8px 20px rgba(255, 130, 210, 0.45); +} + +.hit-buttons { + display: grid; + grid-template-columns: repeat(4, minmax(70px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.hit-buttons button { + border: none; + border-radius: 1rem; + font-size: 1.7rem; + padding: 0.6rem; + cursor: pointer; + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 12px 25px rgba(143, 154, 255, 0.4); + transition: transform 0.15s ease; +} + +.hit-buttons button:active { transform: translateY(2px); } + +.instructions { + margin-top: 2rem; + background: rgba(255, 255, 255, 0.8); + border-radius: 1.5rem; + padding: 1.5rem 2rem; + box-shadow: 0 18px 45px rgba(150, 180, 255, 0.35); +} + +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(233, 208, 255, 0.8); + z-index: 20; + transition: opacity 0.3s ease; +} + +.overlay.hidden { opacity: 0; pointer-events: none; } + +.card { + background: white; + border-radius: 2rem; + padding: 2rem 3rem; + text-align: center; + box-shadow: 0 30px 60px rgba(175, 140, 255, 0.45); +} + +@media (max-width: 700px) { + .lanes { height: 320px; } + .target-line { bottom: 60px; } +} diff --git a/index.html b/index.html index f575097..922f372 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">Two 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">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> </header> <section class="game-grid"> @@ -31,6 +31,30 @@ <a class="btn" href="blossom-blocks/" aria-label="Play Blossom Blocks">Play Blossom Blocks</a> </article> + <article class="game-card" role="article"> + <div class="badge">Rhythm β¨</div> + <h2>Crystal Cloud Carousel</h2> + <p>Keep the beat by tapping sparkles as they slide down four glowing lanes. Build combos to spin Vivianaβs sky pony faster.</p> + <ul> + <li>45-second kid-friendly song</li> + <li>Click hearts or press 1-4 keys</li> + <li>Combo bonuses & pop-up sparkles</li> + </ul> + <a class="btn" href="crystal-cloud-carousel/" aria-label="Play Crystal Cloud Carousel">Play Crystal Cloud Carousel</a> + </article> + + <article class="game-card" role="article"> + <div class="badge">Match-3 π</div> + <h2>Moonlit Garden Match</h2> + <p>Swap glowing mushrooms, butterflies, and fairy lights to free sleepy critters before you run out of moonlit moves.</p> + <ul> + <li>6Γ6 dreamy board</li> + <li>Chain reactions for big scores</li> + <li>25-move bedtime challenge</li> + </ul> + <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">Puzzle π§</div> <h2>Fairy Finder</h2> diff --git a/moonlit-garden-match/game.js b/moonlit-garden-match/game.js new file mode 100644 index 0000000..a860464 --- /dev/null +++ b/moonlit-garden-match/game.js @@ -0,0 +1,262 @@ +(() => { + const size = 6; + const scoreEl = document.getElementById('score'); + const movesEl = document.getElementById('moves'); + const boardEl = document.getElementById('board'); + const newGameBtn = document.getElementById('new-game'); + const overlay = document.getElementById('overlay'); + const startBtn = document.getElementById('start'); + + const tiles = ['π', 'π', 'π¦', 'πΈ', 'π«', 'πΏ']; + + let grid = []; + let selected = null; + let score = 0; + let moves = 25; + let busy = false; + + function createBoard() { + const newGrid = Array.from({ length: size }, () => Array(size).fill(null)); + for (let r = 0; r < size; r += 1) { + for (let c = 0; c < size; c += 1) { + let value; + do { + value = randomTile(); + newGrid[r][c] = value; + } while (createsMatch(newGrid, r, c)); + } + } + return newGrid; + } + + function randomTile() { + return tiles[Math.floor(Math.random() * tiles.length)]; + } + + function createsMatch(gridRef, r, c) { + const value = gridRef[r][c]; + if (!value) return false; + const horiz = c >= 2 && gridRef[r][c - 1] === value && gridRef[r][c - 2] === value; + const vert = r >= 2 && gridRef[r - 1][c] === value && gridRef[r - 2][c] === value; + return horiz || vert; + } + + function renderBoard() { + boardEl.innerHTML = ''; + for (let r = 0; r < size; r += 1) { + for (let c = 0; c < size; c += 1) { + const tile = document.createElement('button'); + tile.type = 'button'; + tile.className = 'tile'; + tile.textContent = grid[r][c]; + tile.dataset.row = r; + tile.dataset.col = c; + if (selected && selected.row === r && selected.col === c) { + tile.classList.add('selected'); + } + tile.addEventListener('click', () => handleSelect(r, c)); + boardEl.appendChild(tile); + } + } + } + + function handleSelect(row, col) { + if (busy) return; + if (!selected) { + selected = { row, col }; + renderBoard(); + return; + } + if (selected.row === row && selected.col === col) { + selected = null; + renderBoard(); + return; + } + if (!areNeighbors(selected, { row, col })) { + selected = { row, col }; + renderBoard(); + return; + } + attemptSwap(selected, { row, col }); + } + + function areNeighbors(a, b) { + const dr = Math.abs(a.row - b.row); + const dc = Math.abs(a.col - b.col); + return (dr === 1 && dc === 0) || (dr === 0 && dc === 1); + } + + function attemptSwap(a, b) { + busy = true; + swap(a, b); + const matches = findMatches(); + if (matches.length === 0) { + moves = Math.max(0, moves - 1); + updateHUD(); + selected = null; + renderBoard(); + busy = false; + if (moves === 0) endGame(); + return; + } + moves = Math.max(0, moves - 1); + updateHUD(); + selected = null; + renderBoard(); + resolveMatches(matches, 1); + } + + function swap(a, b) { + const temp = grid[a.row][a.col]; + grid[a.row][a.col] = grid[b.row][b.col]; + grid[b.row][b.col] = temp; + } + + function findMatches() { + const matches = []; + // horizontal + for (let r = 0; r < size; r += 1) { + let run = 1; + for (let c = 1; c < size; c += 1) { + if (grid[r][c] && grid[r][c] === grid[r][c - 1]) { + run += 1; + } else { + if (run >= 3) { + matches.push(...rangeCells(r, c - run, run, 'row')); + } + run = 1; + } + } + if (run >= 3) { + matches.push(...rangeCells(r, size - run, run, 'row')); + } + } + // vertical + for (let c = 0; c < size; c += 1) { + let run = 1; + for (let r = 1; r < size; r += 1) { + if (grid[r][c] && grid[r][c] === grid[r - 1][c]) { + run += 1; + } else { + if (run >= 3) { + matches.push(...rangeCells(r - run, c, run, 'col')); + } + run = 1; + } + } + if (run >= 3) { + matches.push(...rangeCells(size - run, c, run, 'col')); + } + } + const seen = new Set(); + return matches.filter(({ row, col }) => { + const key = `${row}-${col}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + function rangeCells(startRowOrCol, fixed, length, mode) { + const cells = []; + for (let i = 0; i < length; i += 1) { + if (mode === 'row') { + cells.push({ row: startRowOrCol, col: fixed + i }); + } else { + cells.push({ row: startRowOrCol + i, col: fixed }); + } + } + return cells; + } + + function resolveMatches(matchCells, chain) { + if (matchCells.length === 0) { + busy = false; + if (moves === 0) endGame(); + return; + } + matchCells.forEach(({ row, col }) => { + grid[row][col] = null; + }); + score += matchCells.length * 60 * chain; + updateHUD(); + animateMatches(matchCells); + setTimeout(() => { + applyGravity(); + fillNew(); + renderBoard(); + const nextMatches = findMatches(); + if (nextMatches.length > 0) { + resolveMatches(nextMatches, chain + 1); + } else { + busy = false; + if (moves === 0) endGame(); + } + }, 350); + } + + function animateMatches(cells) { + const map = new Map(); + Array.from(boardEl.children).forEach((tile) => { + const r = Number(tile.dataset.row); + const c = Number(tile.dataset.col); + map.set(`${r}-${c}`, tile); + }); + cells.forEach(({ row, col }) => { + const tile = map.get(`${row}-${col}`); + if (tile) tile.classList.add('matching'); + }); + } + + function applyGravity() { + for (let c = 0; c < size; c += 1) { + let writeRow = size - 1; + for (let r = size - 1; r >= 0; r -= 1) { + if (grid[r][c]) { + grid[writeRow][c] = grid[r][c]; + if (writeRow !== r) { + grid[r][c] = null; + } + writeRow -= 1; + } + } + for (let r = writeRow; r >= 0; r -= 1) { + grid[r][c] = null; + } + } + } + + function fillNew() { + for (let r = 0; r < size; r += 1) { + for (let c = 0; c < size; c += 1) { + if (!grid[r][c]) { + grid[r][c] = randomTile(); + } + } + } + } + + function updateHUD() { + scoreEl.textContent = score; + movesEl.textContent = moves; + } + + function startGame() { + grid = createBoard(); + score = 0; + moves = 25; + selected = null; + busy = false; + updateHUD(); + renderBoard(); + overlay.classList.add('hidden'); + } + + function endGame() { + overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`; + overlay.classList.remove('hidden'); + } + + newGameBtn.addEventListener('click', startGame); + startBtn.addEventListener('click', startGame); +})(); diff --git a/moonlit-garden-match/index.html b/moonlit-garden-match/index.html new file mode 100644 index 0000000..9d7e329 --- /dev/null +++ b/moonlit-garden-match/index.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Moonlit Garden Match</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="moon-glow" aria-hidden="true">π β¨ πΏ π π π</div> + <main class="shell"> + <header class="hero"> + <div> + <h1>Moonlit Garden Match</h1> + <p class="tagline">Swap glowing mushrooms and fairy lights to rescue sleepy critters.</p> + </div> + <div class="stats"> + <div>Score: <span id="score">0</span> π</div> + <div>Moves left: <span id="moves">25</span> β¨</div> + <button class="btn" id="new-game">New Garden</button> + </div> + </header> + + <section class="board-wrap"> + <div class="board" id="board" aria-label="Match 3 board" role="grid"></div> + <aside class="instructions"> + <h2>How to play</h2> + <p>Tap two neighbors to swap them. Match 3 or more identical charms to collect them. Matches free the critters and add moves to your glowing score bar.</p> + <p>Use up the 25 moves wisely. Clear several matches in one turn for bonus sparkles!</p> + </aside> + </section> + </main> + + <div class="overlay" id="overlay"> + <div class="panel"> + <h2>Moonlit Garden Match</h2> + <p>Swap glowing tiles to tuck the critters into bed under the moonlight.</p> + <button class="btn btn-lg" id="start">Start Matching</button> + </div> + </div> +</body> +</html> diff --git a/moonlit-garden-match/styles.css b/moonlit-garden-match/styles.css new file mode 100644 index 0000000..640db0e --- /dev/null +++ b/moonlit-garden-match/styles.css @@ -0,0 +1,156 @@ +:root { + --bg1: #0f0428; + --bg2: #2c1b54; + --board-bg: rgba(255, 255, 255, 0.15); + --text: #f9ecff; + --accent: #ff8ad5; + --accent-2: #83d8ff; + --cell-size: min(70px, 12vw); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + font-family: 'Nunito', system-ui, sans-serif; + background: radial-gradient(circle at top, #3d216d, #150428); + color: var(--text); +} + +.moon-glow { + position: fixed; + inset: 0; + font-size: 6rem; + opacity: 0.05; + text-align: center; + pointer-events: none; + padding-top: 2rem; +} + +.shell { + max-width: 1100px; + margin: 0 auto; + padding: 2rem clamp(1rem, 5vw, 4rem) 4rem; + position: relative; + z-index: 1; +} + +h1, h2 { + font-family: 'Baloo 2', 'Nunito', sans-serif; + margin: 0; + color: #ff9ae3; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.tagline { margin-top: 0.4rem; color: #d7c2f6; } + +.stats { + background: rgba(255, 255, 255, 0.15); + padding: 0.8rem 1.2rem; + border-radius: 1rem; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + gap: 0.4rem; + font-weight: 600; +} + +.btn { + padding: 0.5rem 1.5rem; + border-radius: 999px; + border: none; + background: linear-gradient(120deg, var(--accent), var(--accent-2)); + color: white; + font-weight: 700; + cursor: pointer; + box-shadow: 0 15px 35px rgba(255, 138, 213, 0.4); +} + +.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.1rem; } + +.board-wrap { + margin-top: 2.5rem; + display: grid; + grid-template-columns: minmax(300px, 1fr) minmax(240px, 0.8fr); + gap: 1.5rem; +} + +.board { + background: var(--board-bg); + border-radius: 1.8rem; + padding: 1rem; + display: grid; + grid-template-columns: repeat(6, var(--cell-size)); + grid-template-rows: repeat(6, var(--cell-size)); + gap: 0.5rem; + box-shadow: inset 0 0 35px rgba(255, 255, 255, 0.1), 0 30px 50px rgba(3, 5, 35, 0.6); +} + +.tile { + width: 100%; + height: 100%; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.18); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.2s ease; + border: 2px solid transparent; +} + +.tile.selected { + transform: scale(1.08); + border-color: rgba(255, 255, 255, 0.7); + box-shadow: 0 0 15px rgba(255, 255, 255, 0.4); +} + +.tile.matching { + animation: twinkle 0.6s ease infinite alternate; +} + +@keyframes twinkle { + from { transform: scale(1); opacity: 1; } + to { transform: scale(1.1); opacity: 0.6; } +} + +.instructions { + background: rgba(255, 255, 255, 0.08); + border-radius: 1.5rem; + padding: 1.5rem 2rem; + box-shadow: 0 20px 40px rgba(2, 0, 24, 0.7); +} + +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 3, 30, 0.8); + z-index: 10; +} + +.overlay.hidden { opacity: 0; pointer-events: none; } + +.panel { + background: #1c0e38; + border-radius: 1.8rem; + padding: 2rem 3rem; + text-align: center; + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.6); +} + +@media (max-width: 900px) { + .board-wrap { grid-template-columns: 1fr; } + .board { justify-self: center; } +}