(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;
if (v === '10') return 10;
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);
// Set initial position (will be updated by renderBoard)
el.style.top = '0px';
el.style.left = '0px';
});
// Fill Pyramid
for (let i = 0; i < 28; i++) {
pyramid[i] = deck.pop();
pyramid[i].faceUp = true;
}
stock = deck;
updateScoreDisplay();
requestAnimationFrame(renderBoard);
saveGame();
}
function renderBoard() {
if (!isActive) return;
const boardRect = gameBoard.getBoundingClientRect();
const layoutRect = pyramidLayout.getBoundingClientRect();
// If the board isn't visible yet, wait for the next frame
if (boardRect.width === 0) {
requestAnimationFrame(renderBoard);
return;
}
const rootStyle = getComputedStyle(document.documentElement);
let cardWidth = parseFloat(rootStyle.getPropertyValue('--card-width'));
// Fallback for cardWidth if parsing fails
if (isNaN(cardWidth) || cardWidth === 0) {
cardWidth = 90;
}
const cardHeight = cardWidth * 1.4;
const rowOverlap = 0.45;
const horizontalGap = 10;
// Position Pyramid Cards
let index = 0;
const boardTop = boardRect.top;
const boardLeft = boardRect.left;
for (let row = 0; row < 7; row++) {
const rowWidth = (row + 1) * cardWidth + row * horizontalGap;
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];
// Calculate final positions relative to the board
const finalTop = y + (layoutRect.top - boardTop);
const finalLeft = startX + i * (cardWidth + horizontalGap) + (layoutRect.left - boardLeft);
el.style.top = `${finalTop}px`;
el.style.left = `${finalLeft}px`;
el.style.zIndex = 100 + row;
el.classList.add('is-flipped');
el.draggable = false;
}
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');
el.classList.remove('is-selected'); // Selection only on top of waste/pyramid
});
// 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');
el.classList.remove('is-selected');
});
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();
requestAnimationFrame(renderBoard);
}
}
});
// Handle New Game button from topbar
document.getElementById('restart-btn').addEventListener('click', () => {
if (isActive) initPyramid();
});
window.addEventListener('resize', () => {
if (isActive) renderBoard();
});
// Initial check
if (localStorage.getItem('solitaire-mode') === 'pyramid') {
isActive = true;
if (!loadGame()) initPyramid();
}
})();