diff options
Diffstat (limited to 'html/cards/cards.js')
-rw-r--r-- | html/cards/cards.js | 441 |
1 files changed, 441 insertions, 0 deletions
diff --git a/html/cards/cards.js b/html/cards/cards.js new file mode 100644 index 0000000..98aa0e1 --- /dev/null +++ b/html/cards/cards.js @@ -0,0 +1,441 @@ +/** + * @fileOverview Trying to make an easily extensible bit of code to handle + * creating and drawing any number of decks of cards. + * + * @author: eli_oat + * @license: no gods, no masters + */ + +/** + * @typedef {Object} Card + * @property {number} x + * @property {number} y + * @property {CardData} card + * @property {boolean} isFaceUp + */ + +/** + * @typedef {Object} CardData + * @property {string} suit + * @property {string} value + */ + +/** + * @typedef {Object} GameState + * @property {Card[]} cards + * @property {Card|null} draggingCard + * @property {CardData[]} deck + * @property {{x: number, y: number}} stackPosition + */ + +const CARD_WIDTH = 100; +const CARD_HEIGHT = 150; +const PADDING = 10; +const SUITS = ['❤️', '♦️', '♣️', '♠️']; +const VALUES = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; +const PATTERN_SIZE = 10; +const INITIAL_CARD_X = 20; +const INITIAL_CARD_Y = 20; +const FONT_SIZE = '34px "pokemon-font", monospace'; +const CARD_BORDER_COLOR = '#000000'; +const CARD_FACE_COLOR = '#FFFFFF'; +const DECK_COUNT = 4; // Can be changed to any number +const BASE_COLORS = [ + { primary: '#FF9900', secondary: '#FFCC00' }, // Original orange deck + { primary: '#6B8E23', secondary: '#9ACD32' }, // Olive green deck + { primary: '#4169E1', secondary: '#87CEEB' }, // Royal blue deck + { primary: '#8B008B', secondary: '#DA70D6' }, // Purple deck + { primary: '#CD853F', secondary: '#DEB887' } // Brown deck +]; + + +// Pile layout +const PILE_SPACING = CARD_WIDTH + PADDING * 4; // Space between piles +const PILE_OFFSET = 5; // Vertical offset for stacked cards + + +// Setting up the canvas +const canvas = document.getElementById('cards'); +const ctx = canvas.getContext('2d'); +canvas.width = window.innerWidth; +canvas.height = window.innerHeight; + + +/** + * Shuffles an array in place and returns a new shuffled array. + * @param {Array} array - The array to shuffle. + * @returns {Array} A new array containing the shuffled elements. + */ +const shuffle = array => { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +}; + + +/** + * Creates a deck of cards for a given deck index. + * @param {number} deckIndex - The index of the deck being created. + * @returns {Array} An array of card objects, each containing suit, value, and deckId. + */ +const createDeck = (deckIndex) => SUITS.flatMap(suit => + VALUES.map(value => ({ + suit, + value, + deckId: deckIndex // Add deckId to track which deck a card belongs to + })) +); + +/** + * Creates multiple decks of cards based on the specified count. + * If the count exceeds the number of unique deck colors defined, + * some decks will repeat colors. + * + * @param {number} count - The number of decks to create. + * @returns {Array} An array of card objects, each containing suit, value, and deckId. + */ +const createDecks = (count) => { + if (count > BASE_COLORS.length) { + console.warn(`Only ${BASE_COLORS.length} unique deck colors are defined. Some decks will repeat colors.`); + } + return Array.from({ length: count }, (_, i) => createDeck(i)).flat(); +}; + +/** + * Creates a card object with a known position and some card data. + * @param {number} x - The x-coordinate of the card. + * @param {number} y - The y-coordinate of the card. + * @param {CardData} cardData - The data for the card, including suit and value. + * @returns {Card} A new card object. + */ +const createCard = (x, y, cardData) => Object.freeze({ + x: x + PADDING, + y: y + PADDING, + card: Object.freeze({ ...cardData }), + isFaceUp: false +}); + +/** + * Determines if a point is within a card. + * Used to determine where to hold a card when dragging. + * @param {number} x - The x-coordinate of the point. + * @param {number} y - The y-coordinate of the point. + * @param {Card} card - The card to check by card object reference. + * @returns {boolean} True if the point is within the card. + */ +const isPointInCard = (x, y, card) => + x >= card.x && x <= card.x + CARD_WIDTH && y >= card.y && y <= card.y + CARD_HEIGHT; + +const clearCanvas = () => { + ctx.fillStyle = 'beige'; + ctx.fillRect(0, 0, canvas.width, canvas.height); +}; + +const drawCardBack = card => { + ctx.fillRect(card.x, card.y, CARD_WIDTH, CARD_HEIGHT); + drawRetroPattern(card); + ctx.strokeStyle = CARD_BORDER_COLOR; + ctx.strokeRect(card.x, card.y, CARD_WIDTH, CARD_HEIGHT); +}; + +const drawRetroPattern = card => { + const checkeredSize = 10; + const deckColors = BASE_COLORS[card.card.deckId % BASE_COLORS.length]; + + for (let i = 0; i < CARD_WIDTH; i += checkeredSize) { + for (let j = 0; j < CARD_HEIGHT; j += checkeredSize) { + ctx.fillStyle = (Math.floor(i / checkeredSize) + Math.floor(j / checkeredSize)) % 2 === 0 + ? deckColors.primary + : deckColors.secondary; + ctx.fillRect(card.x + i, card.y + j, checkeredSize, checkeredSize); + } + } +}; + +const drawCardFront = card => { + ctx.fillStyle = CARD_FACE_COLOR; + ctx.fillRect(card.x, card.y, CARD_WIDTH, CARD_HEIGHT); + ctx.fillStyle = CARD_BORDER_COLOR; + ctx.font = FONT_SIZE; + ctx.strokeRect(card.x, card.y, CARD_WIDTH, CARD_HEIGHT); + + drawCardValue(card.card.value, card.x + 12, card.y + 42, 'left'); + drawCardSuit(card.card.suit, card.x + CARD_WIDTH / 2, card.y + CARD_HEIGHT / 2 + 20); +}; + +const drawCardValue = (value, x, y, alignment) => { + ctx.textAlign = alignment; + ctx.fillStyle = CARD_BORDER_COLOR; + ctx.fillText(value, x, y); +}; + +const drawCardSuit = (suit, x, y) => { + ctx.textAlign = 'center'; + ctx.fillStyle = CARD_BORDER_COLOR; + ctx.fillText(suit, x, y); +}; + +/** + * Renders a card, determining which side to draw based on its face-up state. + * @param {Card} card - The card to render by card object reference. + */ +const renderCard = card => { + card.isFaceUp ? drawCardFront(card) : drawCardBack(card); +}; + +const renderAllCards = cards => { + clearCanvas(); + cards.forEach(renderCard); +}; + +let gameState; + +const initializeGameState = () => ({ + cards: [], + draggingCard: null, + deck: shuffle(createDecks(DECK_COUNT)), + stackPosition: { x: 0, y: 0 } +}); + +const initializeGame = () => { + try { + gameState = initializeGameState(); + + // Group cards by deck + const cardsByDeck = gameState.deck.reduce((acc, cardData) => { + const deckId = cardData.deckId; + if (!acc[deckId]) acc[deckId] = []; + acc[deckId].push(cardData); + return acc; + }, {}); + + // Calculate starting X position to center all piles + // FIXME: How can I make the deck position be dynamic? + const totalWidth = PILE_SPACING * DECK_COUNT; + const startX = (canvas.width - totalWidth) / 2; + + // Create cards for each deck in its own pile + gameState.cards = Object.entries(cardsByDeck).flatMap(([deckId, deckCards]) => { + const pileX = startX + (parseInt(deckId) * PILE_SPACING); + + return deckCards.map((cardData, indexInDeck) => + createCard( + pileX, + INITIAL_CARD_Y + (indexInDeck * PILE_OFFSET), + cardData + ) + ); + }); + + // TODO: Consider adding another level Box > Deck > Pile > Card + + clearCanvas(); + renderAllCards(gameState.cards); + setupEventListeners(); + + } catch (error) { + console.error('Failed to initialize game:', error); + alert('Failed to initialize game. Please refresh the page.'); + } +}; + +const setupEventListeners = () => { + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('contextmenu', e => e.preventDefault()); + document.addEventListener('keydown', e => { + if (e.key === 'q') handleResetGame(); + }); +}; + +const handleMouseMove = e => { + if (!gameState.draggingCard) return; + + const rect = canvas.getBoundingClientRect(); + const newX = e.clientX - rect.left - dragOffset.x; + const newY = e.clientY - rect.top - dragOffset.y; + + const updatedCard = moveCard(gameState.draggingCard, newX, newY); + gameState.cards = gameState.cards.map(card => + card === gameState.draggingCard ? updatedCard : card + ); + gameState.draggingCard = updatedCard; + + renderAllCards(gameState.cards); +}; + +const handleMouseUp = e => { + if (!gameState.draggingCard) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Was the card clicked? + const clickedCard = gameState.cards.slice().reverse().find(card => isPointInCard(x, y, card)); + if (clickedCard) { + // Move the clicked card to the top of the stack + gameState.cards = gameState.cards.filter(card => card !== clickedCard); + gameState.cards.push(clickedCard); + renderAllCards(gameState.cards); // Re-render all cards + } + } + + gameState.draggingCard = null; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); +}; + +let dragOffset = { x: 0, y: 0 }; // To store the offset of the click position + +/** + * Finds the card that was clicked. + * @param {number} x - The x-coordinate of the click. + * @param {number} y - The y-coordinate of the click. + * @param {Card[]} cards - The list of cards to search through by card object reference. + * @returns {Card|null} The card that was clicked, or null if no card was clicked. + */ +const findClickedCard = (x, y, cards) => + cards.slice().reverse().find(card => isPointInCard(x, y, card)); + +/** + * Moves a card to the top of the stack. + * @param {Card} targetCard - The card to move to the top. + * @param {Card[]} cards - The list of cards to search through by card object reference. + * @returns {Card[]} A new array with the target card moved to the top. + */ +const moveCardToTop = (targetCard, cards) => [ + ...cards.filter(card => card !== targetCard), + targetCard +]; + +const handleMouseDown = e => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (e.button === 2) { + e.preventDefault(); + const clickedCard = findClickedCard(x, y, gameState.cards); + if (clickedCard) { + const updatedCard = toggleCardFace(clickedCard); + gameState.cards = gameState.cards.map(card => + card === clickedCard ? updatedCard : card + ); + renderAllCards(gameState.cards); + } + return; + } + + const clickedCard = findClickedCard(x, y, gameState.cards); + if (clickedCard) { + gameState.draggingCard = clickedCard; + dragOffset = { + x: x - clickedCard.x, + y: y - clickedCard.y + }; + gameState.cards = moveCardToTop(clickedCard, gameState.cards); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } +}; + +const handleResetGame = () => { + if (confirm("Would you like to reset the cards?")) { + resetCardsToOriginalPiles(); + } +}; + +/** + * Moves a card to a new position. + * @param {Card} card - The card to move. + * @param {number} newX - The new x-coordinate for the card. + * @param {number} newY - The new y-coordinate for the card. + * @returns {Card} A new card object with updated position. + */ +const moveCard = (card, newX, newY) => ({ + ...card, + x: newX, + y: newY +}); + +const toggleCardFace = card => ({ + ...card, + isFaceUp: !card.isFaceUp +}); + +/** + * Calculates some stats for each deck, including total and face-up counts. + * @returns {Map} A map containing the total and face-up counts for each deck. + * Useful for debugging information to the canvas. + */ +const getDeckStats = () => { + const stats = new Map(); + gameState.cards.forEach(card => { + const deckId = card.card.deckId; + const current = stats.get(deckId) || { total: 0, faceUp: 0 }; + stats.set(deckId, { + total: current.total + 1, + faceUp: current.faceUp + (card.isFaceUp ? 1 : 0) + }); + }); + return stats; +}; + +const renderDeckStats = () => { + const stats = getDeckStats(); + ctx.font = '16px "pokemon-font", monospace'; + + // Calculate the same starting X position as the piles + const totalWidth = PILE_SPACING * DECK_COUNT; + const startX = (canvas.width - totalWidth) / 2; + + stats.forEach((stat, deckId) => { + const colors = BASE_COLORS[deckId % BASE_COLORS.length]; + const pileX = startX + (deckId * PILE_SPACING); + + ctx.fillStyle = colors.primary; + ctx.textAlign = 'center'; + ctx.fillText( + `Deck ${deckId + 1}: ${stat.faceUp}/${stat.total}`, + pileX + CARD_WIDTH / 2, + INITIAL_CARD_Y - 10 + ); + }); +}; + +// FIXME: this is too complicated, and would probably work better if I had a better way of handling state. +const resetCardsToOriginalPiles = () => { + const totalWidth = PILE_SPACING * DECK_COUNT; + const startX = (canvas.width - totalWidth) / 2; + + // Group cards by deck + const cardsByDeck = gameState.cards.reduce((acc, card) => { + const deckId = card.card.deckId; + if (!acc[deckId]) acc[deckId] = []; + acc[deckId].push(card); + return acc; + }, {}); + + // Reset position for each deck + Object.entries(cardsByDeck).forEach(([deckId, deckCards]) => { + const pileX = startX + (parseInt(deckId) * PILE_SPACING); + + deckCards.forEach((card, index) => { + card.x = pileX; + card.y = INITIAL_CARD_Y + (index * PILE_OFFSET); + card.isFaceUp = false; + }); + }); + + renderAllCards(gameState.cards); +}; + +initializeGame(); + +window.addEventListener('unload', () => { + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('contextmenu', e => e.preventDefault()); +}); \ No newline at end of file |