diff --git a/index.html b/index.html index d9ca9ef..1df7980 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ Klondike Solitaire +
@@ -14,21 +15,33 @@
High Score: 0
- +
+
+ +
+ - + -
+ -
+
@@ -51,20 +64,63 @@
-
+
- - - + + diff --git a/pyramid.css b/pyramid.css new file mode 100644 index 0000000..9315bd5 --- /dev/null +++ b/pyramid.css @@ -0,0 +1,53 @@ +.pyramid-board { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 20px; +} + +.pyramid-layout { + position: relative; + width: 100%; + /* 7 rows * (card height * overlap) + last card height */ + height: calc(var(--card-height) + (6 * (var(--card-height) * 0.45))); + margin-bottom: 40px; + max-width: 1000px; +} + +.pyramid-bottom-piles { + display: flex; + width: 100%; + justify-content: center; + gap: 100px; + align-items: flex-start; +} + +.discard-pile-container { + display: flex; + flex-direction: column; + align-items: center; +} + +.pyramid-layout .card { + transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); + z-index: 100; +} + +.card.is-selected { + outline: 4px solid var(--glow); + box-shadow: 0 0 20px var(--glow); + transform: scale(1.05) translateY(-5px); + z-index: 1000 !important; +} + +/* Specific to Pyramid Card layout */ +.pyramid-card { + /* These will be overridden by inline styles from pyramid.js */ +} + +@media (max-width: 600px) { + .pyramid-bottom-piles { + gap: 40px; + } +} diff --git a/pyramid.js b/pyramid.js new file mode 100644 index 0000000..9a1bb7a --- /dev/null +++ b/pyramid.js @@ -0,0 +1,373 @@ +(function() { + // ====== CONSTANTS & STATE ====== + const suits = ['♥', '♦', '♣', '♠']; + const values = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']; + const PILE_BORDER_WIDTH = 2; + + let deck, stock, waste, pyramid, discard; + let cardElements = {}; + let selectedCard = null; + let score = 0; + let highScore = 0; + let isActive = false; + + // DOM Elements + const gameBoard = document.getElementById('pyramid-board'); + const pyramidLayout = gameBoard.querySelector('.pyramid-layout'); + const stockEl = document.getElementById('pyramid-stock'); + const wasteEl = document.getElementById('pyramid-waste'); + const discardEl = document.getElementById('pyramid-discard'); + const scoreEl = document.getElementById('current-score'); + const highScoreEl = document.getElementById('high-score'); + const winMessage = document.getElementById('win-message'); + + // ====== HELPERS ====== + function getValueRank(v) { + if (v === 'A') return 1; + if (v === 'K') return 13; + if (v === 'Q') return 12; + if (v === 'J') return 11; + return parseInt(v, 10); + } + + function createCardElement(cardData) { + const el = document.createElement('div'); + el.classList.add('card'); + el.dataset.id = cardData.id; + const cardInner = document.createElement('div'); + cardInner.classList.add('card-inner'); + const color = (cardData.suit === '♥' || cardData.suit === '♦') ? 'red' : 'black'; + const front = document.createElement('div'); + front.className = `card-face card-face--front ${color}`; + front.innerHTML = ` +
${cardData.value}${cardData.suit}
+
${cardData.value}${cardData.suit}
+
${cardData.suit}
`; + const back = document.createElement('div'); + back.className = 'card-face card-face--back'; + cardInner.append(front, back); + el.appendChild(cardInner); + return el; + } + + function isExposed(cardId) { + // A card in the pyramid is exposed if no cards in the row below it are still present in the slots it covers. + // Pyramid indexing: + // Row 0: 0 + // Row 1: 1, 2 + // Row 2: 3, 4, 5 + // Row 3: 6, 7, 8, 9 + // Row 4: 10, 11, 12, 13, 14 + // Row 5: 15, 16, 17, 18, 19, 20 + // Row 6: 21, 22, 23, 24, 25, 26, 27 + + const index = pyramid.findIndex(c => c && c.id === cardId); + if (index === -1) return true; // Not in pyramid (waste/stock) + + // Find which row it's in + let row = 0; + let count = 0; + for (let r = 0; r < 7; r++) { + if (index >= count && index < count + r + 1) { + row = r; + break; + } + count += r + 1; + } + + if (row === 6) return true; // Bottom row is always exposed if present + + // Check the two cards below it + // Row r, index i (within row) covers Row r+1, index i and i+1 + const indexInRow = index - count; + const nextRowStart = count + row + 1; + const leftBelow = nextRowStart + indexInRow; + const rightBelow = nextRowStart + indexInRow + 1; + + return !pyramid[leftBelow] && !pyramid[rightBelow]; + } + + // ====== CORE LOGIC ====== + function initPyramid() { + score = 0; + highScore = parseInt(localStorage.getItem('pyramid-high-score'), 10) || 0; + winMessage.classList.add('hidden'); + + // Clean up + Object.values(cardElements).forEach(el => el.remove()); + cardElements = {}; + deck = []; + pyramid = Array(28).fill(null); + stock = []; + waste = []; + discard = []; + selectedCard = null; + + // Create deck + suits.forEach(suit => values.forEach(value => { + deck.push({ suit, value, faceUp: false, id: `p-${value}${suit}-${Math.random().toString(36).slice(2,8)}` }); + })); + + // Shuffle + for (let i = deck.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + + // Create elements + deck.forEach(cardData => { + const el = createCardElement(cardData); + cardElements[cardData.id] = el; + gameBoard.appendChild(el); + // Start all cards at stock position + const sRect = stockEl.getBoundingClientRect(); + const bRect = gameBoard.getBoundingClientRect(); + el.style.top = `${sRect.top - bRect.top + PILE_BORDER_WIDTH}px`; + el.style.left = `${sRect.left - bRect.left + PILE_BORDER_WIDTH}px`; + }); + + // Fill Pyramid + for (let i = 0; i < 28; i++) { + pyramid[i] = deck.pop(); + pyramid[i].faceUp = true; + } + + stock = deck; + updateScoreDisplay(); + renderBoard(); + saveGame(); + } + + function renderBoard() { + const boardRect = gameBoard.getBoundingClientRect(); + const layoutRect = pyramidLayout.getBoundingClientRect(); + const cardWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-width')); + const cardHeight = cardWidth * 1.4; + const rowOverlap = 0.45; + + // Position Pyramid Cards + let index = 0; + for (let row = 0; row < 7; row++) { + const rowWidth = (row + 1) * cardWidth + row * 10; + const startX = (layoutRect.width - rowWidth) / 2; + const y = row * (cardHeight * rowOverlap); + + for (let i = 0; i <= row; i++) { + const card = pyramid[index]; + if (card) { + const el = cardElements[card.id]; + el.style.top = `${y + layoutRect.top - boardRect.top}px`; + el.style.left = `${startX + i * (cardWidth + 10) + layoutRect.left - boardRect.left}px`; + el.style.zIndex = 100 + row; + el.classList.add('is-flipped'); + el.draggable = false; // Pyramid uses clicks + } + index++; + } + } + + // Position Stock + const sRect = stockEl.getBoundingClientRect(); + stock.forEach((card, i) => { + const el = cardElements[card.id]; + el.style.top = `${sRect.top - boardRect.top + PILE_BORDER_WIDTH}px`; + el.style.left = `${sRect.left - boardRect.left + PILE_BORDER_WIDTH}px`; + el.style.zIndex = 10 + i; + el.classList.remove('is-flipped'); + }); + + // Position Waste + const wRect = wasteEl.getBoundingClientRect(); + waste.forEach((card, i) => { + const el = cardElements[card.id]; + el.style.top = `${wRect.top - boardRect.top + PILE_BORDER_WIDTH}px`; + el.style.left = `${wRect.left - boardRect.left + PILE_BORDER_WIDTH}px`; + el.style.zIndex = 50 + i; + el.classList.add('is-flipped'); + }); + + // Position Discard + const dRect = discardEl.getBoundingClientRect(); + discard.forEach((card, i) => { + const el = cardElements[card.id]; + el.style.top = `${dRect.top - boardRect.top + PILE_BORDER_WIDTH}px`; + el.style.left = `${dRect.left - boardRect.left + PILE_BORDER_WIDTH}px`; + el.style.zIndex = 10 + i; + el.classList.add('is-flipped'); + }); + + checkWin(); + } + + function handleCardClick(cardId) { + if (!isActive) return; + + // Find card in pyramid or waste + let card = null; + let source = ''; + let index = pyramid.findIndex(c => c && c.id === cardId); + if (index !== -1) { + card = pyramid[index]; + source = 'pyramid'; + } else if (waste.length > 0 && waste[waste.length - 1].id === cardId) { + card = waste[waste.length - 1]; + source = 'waste'; + } + + if (!card) return; + if (source === 'pyramid' && !isExposed(cardId)) return; + + const el = cardElements[card.id]; + + // King is special (13) + if (getValueRank(card.value) === 13) { + removeCards([card]); + selectedCard = null; + return; + } + + if (!selectedCard) { + selectedCard = card; + el.classList.add('is-selected'); + } else { + if (selectedCard.id === card.id) { + // Deselect + el.classList.remove('is-selected'); + selectedCard = null; + } else { + if (getValueRank(selectedCard.value) + getValueRank(card.value) === 13) { + removeCards([selectedCard, card]); + selectedCard = null; + } else { + // Invalid pair - switch selection to new card + cardElements[selectedCard.id].classList.remove('is-selected'); + selectedCard = card; + el.classList.add('is-selected'); + } + } + } + } + + function removeCards(cards) { + cards.forEach(card => { + // Remove from pyramid + const pIdx = pyramid.findIndex(c => c && c.id === card.id); + if (pIdx !== -1) pyramid[pIdx] = null; + + // Remove from waste + const wIdx = waste.findIndex(c => c && c.id === card.id); + if (wIdx !== -1) waste.splice(wIdx, 1); + + discard.push(card); + cardElements[card.id].classList.remove('is-selected'); + score += 5; + }); + updateScoreDisplay(); + renderBoard(); + saveGame(); + } + + function handleStockClick() { + if (stock.length > 0) { + const card = stock.pop(); + card.faceUp = true; + waste.push(card); + } else if (waste.length > 0) { + // Recycle waste back to stock + stock = waste.reverse(); + stock.forEach(c => c.faceUp = false); + waste = []; + } + renderBoard(); + saveGame(); + } + + function updateScoreDisplay() { + if (!isActive) return; + scoreEl.textContent = score; + highScoreEl.textContent = highScore; + } + + function checkWin() { + if (pyramid.every(c => c === null)) { + if (score > highScore) { + highScore = score; + localStorage.setItem('pyramid-high-score', highScore); + updateScoreDisplay(); + } + winMessage.classList.remove('hidden'); + // Trigger bouncing cards - reuse logic if available or implement local + if (window.triggerBouncingCards) window.triggerBouncingCards(); + } + } + + function saveGame() { + const state = { pyramid, stock, waste, discard, score }; + localStorage.setItem('pyramid-save', JSON.stringify(state)); + } + + function loadGame() { + const saved = localStorage.getItem('pyramid-save'); + if (!saved) return false; + try { + const state = JSON.parse(saved); + pyramid = state.pyramid; + stock = state.stock; + waste = state.waste; + discard = state.discard; + score = state.score || 0; + + // Rebuild elements + Object.values(cardElements).forEach(el => el.remove()); + cardElements = {}; + const all = [...pyramid.filter(c => c), ...stock, ...waste, ...discard]; + all.forEach(c => { + const el = createCardElement(c); + cardElements[c.id] = el; + gameBoard.appendChild(el); + }); + renderBoard(); + updateScoreDisplay(); + return true; + } catch (e) { + return false; + } + } + + // ====== EVENT LISTENERS ====== + gameBoard.addEventListener('click', (e) => { + const cardEl = e.target.closest('.card'); + if (cardEl) { + handleCardClick(cardEl.dataset.id); + return; + } + + const pileEl = e.target.closest('.pile'); + if (pileEl && pileEl.id === 'pyramid-stock') { + handleStockClick(); + } + }); + + document.addEventListener('gameModeChanged', (e) => { + isActive = (e.detail === 'pyramid'); + if (isActive) { + if (!pyramid) { + if (!loadGame()) initPyramid(); + } else { + updateScoreDisplay(); + renderBoard(); + } + } + }); + + // Handle New Game button from topbar + document.getElementById('restart-btn').addEventListener('click', () => { + if (isActive) initPyramid(); + }); + + // Initial check + if (localStorage.getItem('solitaire-mode') === 'pyramid') { + isActive = true; + if (!loadGame()) initPyramid(); + } +})(); diff --git a/script.js b/script.js index 3cafe7d..7019fb8 100644 --- a/script.js +++ b/script.js @@ -18,6 +18,7 @@ document.addEventListener('DOMContentLoaded', () => { let isDealing = false; let score = 0; let highScore = 0; + let isActive = false; const CARD_BACKS = [ { id: 'waves', name: 'Blue Waves', className: 'card-back-waves' }, @@ -310,6 +311,7 @@ document.addEventListener('DOMContentLoaded', () => { // ====== HELPERS ====== function updateScoreDisplay() { + if (!isActive) return; scoreEl.textContent = score; highScoreEl.textContent = highScore; } @@ -495,6 +497,7 @@ document.addEventListener('DOMContentLoaded', () => { bounceNextCard(); } + window.triggerBouncingCards = triggerBouncingCards; // ====== ACTIONS ====== function handleStockClick() { @@ -699,7 +702,7 @@ document.addEventListener('DOMContentLoaded', () => { // ====== INPUT HANDLERS ====== gameBoard.addEventListener('click', e => { - if (isAutoCompleting) return; + if (!isActive || isAutoCompleting) return; const clickedCardEl = e.target.closest('.card'); const clickedPileEl = e.target.closest('.pile'); @@ -721,7 +724,7 @@ document.addEventListener('DOMContentLoaded', () => { // Custom drag and drop functionality gameBoard.addEventListener('dragstart', e => { - if (isAutoCompleting) { e.preventDefault(); return; } + if (!isActive || isAutoCompleting) { e.preventDefault(); return; } const cardEl = e.target.closest('.card'); if (!cardEl) { e.preventDefault(); return; } @@ -863,7 +866,7 @@ document.addEventListener('DOMContentLoaded', () => { }); document.addEventListener('keydown', (e)=>{ - if (isAutoCompleting) return; + if (!isActive || isAutoCompleting) return; if (e.code === 'Space') { e.preventDefault(); handleStockClick(); } if (e.key.toLowerCase() === 'u' || (e.ctrlKey && e.key.toLowerCase() === 'z')) { e.preventDefault(); @@ -872,7 +875,9 @@ document.addEventListener('DOMContentLoaded', () => { }); // ====== BUTTONS & MODAL LISTENERS ====== - restartBtn?.addEventListener('click', initState); + restartBtn?.addEventListener('click', () => { + if (isActive) initState(); + }); undoBtn?.addEventListener('click', undo); drawSelect?.addEventListener('change', () => { DRAW_COUNT = parseInt(drawSelect.value, 10) || 3; initState(); }); openModalBtn?.addEventListener('click', () => cardBackModal.classList.remove('hidden')); @@ -881,11 +886,23 @@ document.addEventListener('DOMContentLoaded', () => { checkMovesBtn?.addEventListener('click', handleStuckCheck); autoCompleteBtn?.addEventListener('click', autoComplete); + document.addEventListener('gameModeChanged', (e) => { + isActive = (e.detail === 'klondike'); + if (isActive) { + updateScoreDisplay(); + updateBoard(); + } + }); // ====== INIT ====== setupThemeSelector(); - if (!loadGame()) { - initState(); + isActive = (localStorage.getItem('solitaire-mode') !== 'pyramid'); + if (isActive) { + if (!loadGame()) { + initState(); + } + } else { + loadGame(); } - window.addEventListener('resize', () => updateBoard(true)); + window.addEventListener('resize', () => { if (isActive) updateBoard(true); }); }); \ No newline at end of file diff --git a/style.css b/style.css index d9888ef..eec0bf9 100644 --- a/style.css +++ b/style.css @@ -75,6 +75,35 @@ h1 { flex-wrap: wrap; } +.game-select { + display: flex; + gap: 6px; + align-items: center; + background: var(--control-bg); + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--line); + margin-right: 8px; +} + +.game-select select { + appearance: none; + background: transparent; + color: var(--text-color); + border: none; + outline: none; + font-weight: 600; + cursor: pointer; +} + +.game-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.hidden { display: none !important; } + .btn { background: var(--control-bg); color: var(--text-color);