423 lines
15 KiB
JavaScript
423 lines
15 KiB
JavaScript
(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 = `
|
|
<div class="card-value-display--top"><span class="card-rank">${cardData.value}</span><span class="card-suit">${cardData.suit}</span></div>
|
|
<div class="card-value-display--bottom"><span class="card-rank">${cardData.value}</span><span class="card-suit">${cardData.suit}</span></div>
|
|
<div class="card-watermark">${cardData.suit}</div>`;
|
|
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 or dimensions aren't ready, wait for the next frame
|
|
if (boardRect.width === 0 || layoutRect.width === 0) {
|
|
requestAnimationFrame(renderBoard);
|
|
return;
|
|
}
|
|
|
|
const rootStyle = getComputedStyle(document.documentElement);
|
|
let cardWidth = parseFloat(rootStyle.getPropertyValue('--card-width'));
|
|
if (isNaN(cardWidth) || cardWidth === 0) cardWidth = 80;
|
|
|
|
const cardHeight = cardWidth * 1.4;
|
|
const rowOverlap = 0.45;
|
|
const horizontalGap = cardWidth * 0.15; // Proportional gap
|
|
|
|
const boardTop = boardRect.top;
|
|
const boardLeft = boardRect.left;
|
|
|
|
// Position Pyramid Cards
|
|
let index = 0;
|
|
for (let row = 0; row < 7; row++) {
|
|
// Calculate total width of this row to center it
|
|
const numCards = row + 1;
|
|
const totalRowWidth = numCards * cardWidth + (numCards - 1) * horizontalGap;
|
|
const startX = (layoutRect.width - totalRowWidth) / 2;
|
|
const y = row * (cardHeight * rowOverlap);
|
|
|
|
for (let i = 0; i <= row; i++) {
|
|
const card = pyramid[index];
|
|
if (card) {
|
|
const el = cardElements[card.id];
|
|
// Absolute position relative to the gameBoard
|
|
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 + index;
|
|
el.classList.add('is-flipped');
|
|
|
|
// Mark if it's currently playable for visual feedback
|
|
const exposed = isExposed(card.id);
|
|
el.classList.toggle('is-exposed', exposed);
|
|
el.style.opacity = exposed ? '1' : '0.85';
|
|
// el.style.filter = exposed ? 'none' : 'brightness(0.8)'; // Optional: dim unexposed cards
|
|
}
|
|
index++;
|
|
}
|
|
}
|
|
|
|
// Position Stock
|
|
const sRect = stockEl.getBoundingClientRect();
|
|
stock.forEach((card, i) => {
|
|
const el = cardElements[card.id];
|
|
el.style.top = `${sRect.top - boardTop + PILE_BORDER_WIDTH}px`;
|
|
el.style.left = `${sRect.left - boardLeft + PILE_BORDER_WIDTH}px`;
|
|
el.style.zIndex = 10 + i;
|
|
el.classList.remove('is-flipped');
|
|
el.classList.remove('is-selected');
|
|
el.classList.remove('is-exposed');
|
|
});
|
|
|
|
// Position Waste
|
|
const wRect = wasteEl.getBoundingClientRect();
|
|
waste.forEach((card, i) => {
|
|
const el = cardElements[card.id];
|
|
const isTop = (i === waste.length - 1);
|
|
el.style.top = `${wRect.top - boardTop + PILE_BORDER_WIDTH}px`;
|
|
el.style.left = `${wRect.left - boardLeft + PILE_BORDER_WIDTH}px`;
|
|
el.style.zIndex = 50 + i;
|
|
el.classList.add('is-flipped');
|
|
el.classList.toggle('is-exposed', isTop);
|
|
});
|
|
|
|
// Position Discard
|
|
const dRect = discardEl.getBoundingClientRect();
|
|
discard.forEach((card, i) => {
|
|
const el = cardElements[card.id];
|
|
el.style.top = `${dRect.top - boardTop + PILE_BORDER_WIDTH}px`;
|
|
el.style.left = `${dRect.left - boardLeft + PILE_BORDER_WIDTH}px`;
|
|
el.style.zIndex = 10 + i;
|
|
el.classList.add('is-flipped');
|
|
el.classList.remove('is-selected');
|
|
el.classList.remove('is-exposed');
|
|
});
|
|
|
|
checkWin();
|
|
}
|
|
|
|
function handleCardClick(cardId) {
|
|
if (!isActive) return;
|
|
|
|
// Find card
|
|
let card = null;
|
|
let source = '';
|
|
let pIdx = pyramid.findIndex(c => c && c.id === cardId);
|
|
if (pIdx !== -1) {
|
|
card = pyramid[pIdx];
|
|
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) - remove immediately
|
|
if (getValueRank(card.value) === 13) {
|
|
if (selectedCard) {
|
|
cardElements[selectedCard.id].classList.remove('is-selected');
|
|
selectedCard = null;
|
|
}
|
|
removeCards([card]);
|
|
return;
|
|
}
|
|
|
|
if (!selectedCard) {
|
|
selectedCard = card;
|
|
el.classList.add('is-selected');
|
|
} else {
|
|
if (selectedCard.id === card.id) {
|
|
// Deselect if clicking the same card
|
|
el.classList.remove('is-selected');
|
|
selectedCard = null;
|
|
} else {
|
|
// Try to match
|
|
if (getValueRank(selectedCard.value) + getValueRank(card.value) === 13) {
|
|
removeCards([selectedCard, card]);
|
|
selectedCard = null;
|
|
} else {
|
|
// Invalid pair: switch selection to the 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) => {
|
|
if (!isActive) return;
|
|
|
|
const cardEl = e.target.closest('.card');
|
|
const pileEl = e.target.closest('.pile');
|
|
|
|
// Check if we clicked the stock pile OR a card that is currently in the stock
|
|
const isStockClick = (pileEl && pileEl.id === 'pyramid-stock') ||
|
|
(cardEl && stock.some(c => c.id === cardEl.dataset.id));
|
|
|
|
if (isStockClick) {
|
|
handleStockClick();
|
|
return;
|
|
}
|
|
|
|
if (cardEl) {
|
|
handleCardClick(cardEl.dataset.id);
|
|
return;
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
})();
|