Add Pyramid Solitaire game mode and game switcher

This commit is contained in:
chris 2026-05-25 00:46:28 -04:00
parent f28b0fa62e
commit 84d444c0ae
5 changed files with 555 additions and 27 deletions

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Klondike Solitaire</title>
<link rel="stylesheet" href="style.css"/>
<link rel="stylesheet" href="pyramid.css"/>
</head>
<body>
<header class="topbar">
@ -14,21 +15,33 @@
<div class="score-display">High Score: <span id="high-score">0</span></div>
</div>
<div class="controls">
<label class="draw-toggle">
Draw:
<select id="draw-select" aria-label="Draw count">
<option value="1">1</option>
<option value="3" selected>3</option>
<div class="game-select">
<label for="game-mode-select">Game:</label>
<select id="game-mode-select">
<option value="klondike">Klondike</option>
<option value="pyramid">Pyramid</option>
</select>
</label>
</div>
<div id="klondike-controls" class="game-controls">
<label class="draw-toggle">
Draw:
<select id="draw-select" aria-label="Draw count">
<option value="1">1</option>
<option value="3" selected>3</option>
</select>
</label>
</div>
<div id="pyramid-controls" class="game-controls hidden">
<!-- Pyramid specific controls if any -->
</div>
<button id="change-back-btn" class="btn">Card Back</button>
<button id="undo-btn" class="btn">Undo</button>
<button id="check-moves-btn" class="btn">Check Moves</button>
<button id="check-moves-btn" class="btn">Check Moves</button>
<button id="restart-btn" class="btn btn-accent">New Game</button>
</div>
</header>
</header>
<main class="game-board" aria-label="Solitaire game board">
<main id="klondike-board" class="game-board" aria-label="Klondike Solitaire board">
<div class="top-piles">
<div class="stock-waste">
<div class="pile stock" id="stock" aria-label="Stock" tabindex="0"></div>
@ -51,20 +64,63 @@
<div class="pile tableau-pile" id="tableau-5" aria-label="Tableau 6" tabindex="0"></div>
<div class="pile tableau-pile" id="tableau-6" aria-label="Tableau 7" tabindex="0"></div>
</div>
</main>
</main>
<div id="win-message" class="hidden">You Won! 🎉</div>
<div id="card-back-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content">
<h2>Choose Card Back</h2>
<div class="card-back-options" id="card-back-options">
</div>
<button id="modal-close-btn" class="btn">Close</button>
<main id="pyramid-board" class="game-board hidden" aria-label="Pyramid Solitaire board">
<div class="pyramid-layout">
<!-- Pyramid rows will be rendered here by pyramid.js -->
</div>
<div class="pyramid-bottom-piles">
<div class="stock-waste">
<div class="pile stock" id="pyramid-stock" aria-label="Stock" tabindex="0"></div>
<div class="pile waste" id="pyramid-waste" aria-label="Waste" tabindex="0"></div>
</div>
<div class="discard-pile-container">
<div class="pile discard" id="pyramid-discard" aria-label="Discard Pile" tabindex="0"></div>
</div>
</div>
</main>
<div id="win-message" class="hidden">You Won! 🎉</div>
<div id="card-back-modal" class="modal hidden">
...
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="script.js"></script>
<script src="pyramid.js"></script>
<script>
// Global switcher logic
(function() {
const switcher = document.getElementById('game-mode-select');
const klondikeBoard = document.getElementById('klondike-board');
const pyramidBoard = document.getElementById('pyramid-board');
const klondikeControls = document.getElementById('klondike-controls');
const pyramidControls = document.getElementById('pyramid-controls');
function switchMode(mode) {
if (mode === 'pyramid') {
klondikeBoard.classList.add('hidden');
klondikeControls.classList.add('hidden');
pyramidBoard.classList.remove('hidden');
pyramidControls.classList.remove('hidden');
} else {
pyramidBoard.classList.add('hidden');
pyramidControls.classList.add('hidden');
klondikeBoard.classList.remove('hidden');
klondikeControls.classList.remove('hidden');
}
localStorage.setItem('solitaire-mode', mode);
document.dispatchEvent(new CustomEvent('gameModeChanged', { detail: mode }));
}
switcher.addEventListener('change', (e) => switchMode(e.target.value));
const savedMode = localStorage.getItem('solitaire-mode') || 'klondike';
switcher.value = savedMode;
switchMode(savedMode);
})();
</script>
</body>
</html>

53
pyramid.css Normal file
View File

@ -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;
}
}

373
pyramid.js Normal file
View File

@ -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 = `
<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);
// 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();
}
})();

View File

@ -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); });
});

View File

@ -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);