// { // ======================================= // 1. 상수 및 변수 선언 // ======================================= const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const SAVED_GAME_ID_KEY = 'spider_saved_game_id'; let isProcessing = false; // 서버 통신 및 중요 처리 상태 관리 변수 const UI_ELEMENTS = {}; const CARD_WIDTH_RATIO = 1 / 11.5, CARD_HEIGHT_RATIO = 1.4, CARD_GAP_X_RATIO = 0.15, CARD_OVERLAP_Y_RATIO = 0.3; const CARD_RANK_LEFT_PADDING = 0.1, CARD_RANK_TOP_PADDING = 0.1, CARD_SYMBOL_TOP_PADDING = 0.25, CARD_SYMBOL_BOTTOM_PADDING = 0.15; const FOUNDATION_CARD_SPACING = 0.2, FOUNDATION_WIDTH_RATIO = 0.45; let currentGame = null; let isGameCompleted = false; let gameStartTime = 0, completionTimeSeconds = 0; const currentGameType = 'SPIDER'; let currentContextId = ''; let cardWidth = 0, cardHeight = 0, cardGapX = 0, cardOverlapY = 0, totalTableauWidth = 0, tableauStartX = 0; let isDragging = false, draggedCards = [], dragOffsetX = 0, dragOffsetY = 0; let completedStackCards = [], isAnimatingCompletion = false; const BOTTOM_ROW_Y_RATIO = 0.9; let dpr = 1; const MAX_UNDO_COUNT = 5; const cardBackImage = new Image(); cardBackImage.src = '../css/images/card-back.png'; let assetsLoaded = false; cardBackImage.onload = () => { assetsLoaded = true; resizeCanvas(); }; const suitOptions = [{ value: 1, text: '1개' }, { value: 2, text: '2개' }, { value: 4, text: '4개' }]; const cardDistributionOptions = { '1': [{ value: '4,3', text: '쉬움' }, { value: '5,4', text: '보통' }, { value: '6,5', text: '어려움' }], '2': [{ value: '5,4', text: '쉬움' }, { value: '6,5', text: '보통' }, { value: '7,6', text: '어려움' }], '4': [{ value: '6,5', text: '쉬움' }, { value: '7,6', text: '보통' }, { value: '8,7', text: '어려움' }] }; let selectedSuit = 1; let selectedCardCount = '4,3'; // ======================================= // 2. 렌더링 (그리기) 관련 함수 // ======================================= function getCssVar(varName) { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); } function resizeCanvas() { const size = Math.min(window.innerWidth, window.innerHeight) * 0.95; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; dpr = window.devicePixelRatio || 1; canvas.width = size * dpr; canvas.height = size * dpr; ctx.scale(dpr, dpr); const logicalWidth = size, logicalHeight = size; cardWidth = logicalWidth * CARD_WIDTH_RATIO; cardHeight = cardWidth * CARD_HEIGHT_RATIO; cardGapX = cardWidth * CARD_GAP_X_RATIO; cardOverlapY = cardHeight * CARD_OVERLAP_Y_RATIO; totalTableauWidth = cardWidth * 10 + cardGapX * 9; tableauStartX = (logicalWidth - totalTableauWidth) / 2; const buttonWidth = logicalWidth * 0.2, buttonHeight = logicalHeight * 0.05, buttonGap = 10; const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2; const startY = logicalHeight * 0.05; UI_ELEMENTS.suitSelect = { x: startX, y: startY, width: buttonWidth, height: buttonHeight }; UI_ELEMENTS.cardCountSelect = { x: startX + buttonWidth + buttonGap, y: startY, width: buttonWidth, height: buttonHeight }; UI_ELEMENTS.startButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY, width: buttonWidth, height: buttonHeight }; UI_ELEMENTS.loadButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY + buttonHeight + buttonGap, width: buttonWidth, height: buttonHeight }; const bottomY = logicalHeight * BOTTOM_ROW_Y_RATIO; const itemSpacing = 20; const foundationX = logicalWidth * 0.05; const foundationAreaWidth = logicalWidth * FOUNDATION_WIDTH_RATIO; UI_ELEMENTS.foundationArea = { x: foundationX, y: bottomY, width: foundationAreaWidth, height: cardHeight }; const undoButtonWidth = cardWidth * 0.8, undoButtonHeight = cardHeight * 0.5; const undoCountDisplayWidth = cardWidth * 0.5; const saveButtonWidth = cardWidth * 0.8; const bottomControlsTotalWidth = undoButtonWidth + undoCountDisplayWidth + saveButtonWidth + (itemSpacing * 2); const undoButtonX = logicalWidth * 0.5 - bottomControlsTotalWidth / 2; UI_ELEMENTS.undoButton = { x: undoButtonX, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoButtonWidth, height: undoButtonHeight }; UI_ELEMENTS.undoCountDisplay = { x: undoButtonX + undoButtonWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoCountDisplayWidth, height: undoButtonHeight }; UI_ELEMENTS.saveButton = { x: UI_ELEMENTS.undoCountDisplay.x + undoCountDisplayWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: saveButtonWidth, height: undoButtonHeight }; const stockX = logicalWidth * 0.95 - cardWidth; UI_ELEMENTS.stockArea = { x: stockX, y: bottomY, width: cardWidth, height: cardHeight }; UI_ELEMENTS.restartButton = { x: logicalWidth / 2 - buttonWidth / 2, y: logicalHeight / 2 + 50, width: buttonWidth, height: buttonHeight }; } window.addEventListener('resize', resizeCanvas); function draw() { if (!assetsLoaded) return; ctx.clearRect(0, 0, canvas.width, canvas.height); if (currentGame) drawGame(currentGame); drawUI(); if (isProcessing) { const logicalWidth = canvas.width / dpr, logicalHeight = canvas.height / dpr; ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, logicalWidth, logicalHeight); ctx.fillStyle = '#ffffff'; ctx.font = '24px Arial'; ctx.textAlign = 'center'; ctx.fillText('처리 중...', logicalWidth / 2, logicalHeight / 2); } } function drawUI() { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if (!currentGame) { const { suitSelect, cardCountSelect, startButton, loadButton } = UI_ELEMENTS; ctx.fillStyle = '#f0f0f0'; ctx.fillRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); ctx.strokeStyle = '#333'; ctx.strokeRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); ctx.fillStyle = '#000'; ctx.font = '16px Arial'; ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2); ctx.fillStyle = '#f0f0f0'; ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); ctx.fillStyle = '#000'; ctx.fillText(`카드: ${cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount).text}`, cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2); ctx.fillStyle = getCssVar('--color-success') || '#4CAF50'; ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height); ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height); ctx.fillStyle = '#fff'; ctx.fillText('새 게임 시작', startButton.x + startButton.width / 2, startButton.y + startButton.height / 2); if (localStorage.getItem(SAVED_GAME_ID_KEY)) { ctx.fillStyle = getCssVar('--color-info') || '#2196F3'; ctx.fillRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height); ctx.strokeRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height); ctx.fillStyle = '#fff'; ctx.fillText('이어하기', loadButton.x + loadButton.width / 2, loadButton.y + loadButton.height / 2); } } else { const { undoButton, undoCountDisplay, saveButton } = UI_ELEMENTS; const isUndoPossible = currentGame.undoHistory.length > 0; const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible; const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT; if (isUndoEnabled) { ctx.fillStyle = getCssVar('--color-warning') || '#ff9800'; ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('실행 취소', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); ctx.fillText(`${MAX_UNDO_COUNT - currentGame.undoCount}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2); } else if (isSurrender) { ctx.fillStyle = getCssVar('--color-danger') || '#f44336'; ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 포기', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); } ctx.fillStyle = getCssVar('--color-primary') || '#007bff'; ctx.fillRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height); ctx.strokeStyle = '#333'; ctx.strokeRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height); ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 저장', saveButton.x + saveButton.width / 2, saveButton.y + saveButton.height / 2); } } function drawGame(game) { drawBackground(); drawTableau(game.tableau); drawStockAndFoundation(game.stock, game.foundation); drawDraggedCards(draggedCards); drawCompletionAnimation(); if (isGameCompleted) { } } function drawBackground() { ctx.fillStyle = getCssVar('--color-felt-green') || '#008000'; ctx.fillRect(0, 0, canvas.width, canvas.height); } function drawTableau(tableau) { const startY = cardHeight * 0.5; const draggingCards = isDragging ? new Set(draggedCards) : null; tableau.forEach((stack, stackIndex) => { stack.forEach((card, cardIndex) => { if (draggingCards && draggingCards.has(card)) return; const x = tableauStartX + stackIndex * (cardWidth + cardGapX); const y = startY + cardIndex * cardOverlapY; card.touchHeight = (cardIndex === stack.length - 1) ? cardHeight : cardOverlapY; drawSingleCard(card, x, y); }); }); } function drawDraggedCards(cards) { if (!isDragging || !Array.isArray(cards) || cards.length === 0) return; cards.forEach((card, index) => { const x = cards[0].x, y = cards[0].y + index * cardOverlapY; drawSingleCard(card, x, y); }); } function drawCompletionAnimation() { if (isAnimatingCompletion) { const now = Date.now(); completedStackCards = completedStackCards.filter(card => { if (now < card.animEndTime) { const progress = (now - (card.animEndTime - 500)) / 500; const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress; const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress; drawSingleCard(card, currentX, currentY); return true; } return false; }); if (completedStackCards.length === 0) isAnimatingCompletion = false; } } function drawSingleCard(card, x, y) { card.x = x; card.y = y; card.width = cardWidth; card.height = cardHeight; if (card.isFaceUp) { ctx.fillStyle = '#ffffff'; ctx.fillRect(x, y, cardWidth, cardHeight); ctx.strokeStyle = '#333333'; ctx.strokeRect(x, y, cardWidth, cardHeight); const isRed = (card.suit === 'heart' || card.suit === 'diamond'); ctx.fillStyle = isRed ? '#ff0000' : '#000000'; ctx.font = `${cardWidth * 0.25}px Arial`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING); drawSuitSymbols(card, x, y); } else { ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); } } function drawSuitSymbols(card, x, y) { const symbol = getSuitSymbol(card.suit); let symbolSize = card.rank >= 2 && card.rank <= 5 ? cardWidth * 0.2 : cardWidth * 0.15; ctx.font = `${symbolSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = (card.suit === 'heart' || card.suit === 'diamond') ? '#ff0000' : '#000000'; const symbolAreaY = y + cardHeight * CARD_SYMBOL_TOP_PADDING; const symbolAreaHeight = cardHeight * (1 - CARD_SYMBOL_TOP_PADDING - CARD_SYMBOL_BOTTOM_PADDING); const symbolAreaMiddleY = symbolAreaY + symbolAreaHeight / 2; const symbolAreaLeftX = x + cardWidth * 0.25; const symbolAreaRightX = x + cardWidth * 0.75; const symbolGapY = symbolAreaHeight / 3; const positions = { top: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, bottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, center: { x: x + cardWidth / 2, y: symbolAreaMiddleY }, leftTop: { x: symbolAreaLeftX, y: symbolAreaY + symbolGapY * 0.5 }, rightTop: { x: symbolAreaRightX, y: symbolAreaY + symbolGapY * 0.5 }, leftCenter: { x: symbolAreaLeftX, y: symbolAreaMiddleY }, rightCenter: { x: symbolAreaRightX, y: symbolAreaMiddleY }, leftBottom: { x: symbolAreaLeftX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, rightBottom: { x: symbolAreaRightX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, middleTop: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, middleBottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 } }; switch (card.rank) { case 1: case 11: case 12: case 13: ctx.font = `${cardWidth * 0.6}px Arial`; ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2); break; case 2: ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); break; case 3: ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); ctx.fillText(symbol, positions.center.x, positions.center.y); ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); break; case 4: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); break; case 5: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.center.x, positions.center.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); break; case 6: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); break; case 7: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); ctx.fillText(symbol, positions.center.x, positions.center.y); break; case 8: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); break; case 9: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); ctx.fillText(symbol, positions.center.x, positions.center.y); ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); break; case 10: ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); ctx.fillText(symbol, positions.top.x, positions.top.y); ctx.fillText(symbol, positions.bottom.x, positions.bottom.y); ctx.fillText(symbol, positions.middleTop.x, (positions.top.y + symbolSize + positions.center.y) / 2 ); ctx.fillText(symbol, positions.middleBottom.x, ((positions.bottom.y + positions.center.y) - symbolSize) / 2); break; } } function drawStockAndFoundation(stock, foundation) { const stockArea = UI_ELEMENTS.stockArea; const foundationArea = UI_ELEMENTS.foundationArea; ctx.strokeStyle = getCssVar('--color-felt-border') || '#004d00'; ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; ctx.fillRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); ctx.strokeRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); foundation.forEach((stack, index) => { const foundationX = foundationArea.x + index * (cardWidth * FOUNDATION_CARD_SPACING); if (stack.length > 0) { drawSingleCard(stack[stack.length - 1], foundationX, UI_ELEMENTS.foundationArea.y); } }); if (stock.length > 0) { ctx.drawImage(cardBackImage, stockArea.x, stockArea.y, cardWidth, cardHeight); ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); const remainingDeals = Math.floor(stock.length / 10); ctx.fillStyle = '#fff'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`${remainingDeals}`, stockArea.x + cardWidth / 2, stockArea.y + cardHeight / 2); } else { ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); } } // ======================================= // 3. 이벤트 핸들러 및 유틸리티 함수 // ======================================= canvas.addEventListener('mousedown', handlePointerDown); canvas.addEventListener('mousemove', handlePointerMove); canvas.addEventListener('mouseup', handlePointerUp); canvas.addEventListener('dblclick', handleDoubleClick); // ▼▼▼ [추가] 터치 이벤트 리스너 등록 ▼▼▼ canvas.addEventListener('touchstart', handlePointerDown); canvas.addEventListener('touchmove', (e) => { e.preventDefault(); // 모바일에서 드래그 시 화면이 스크롤되는 것을 방지 handlePointerMove(e); }); canvas.addEventListener('touchend', handlePointerUp); function getCanvasCoordinates(event) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height; // 터치 이벤트와 마우스 이벤트를 모두 처리하기 위한 좌표 변수 let clientX, clientY; if (event.touches && event.touches.length > 0) { // 'touchstart', 'touchmove' 이벤트 처리 clientX = event.touches[0].clientX; clientY = event.touches[0].clientY; } else if (event.changedTouches && event.changedTouches.length > 0) { // 'touchend' 이벤트 처리 clientX = event.changedTouches[0].clientX; clientY = event.changedTouches[0].clientY; } else { // 마우스 이벤트 처리 ('mousedown', 'mousemove', 'mouseup') clientX = event.clientX; clientY = event.clientY; } // clientX 또는 clientY가 undefined인 경우 오류 방지 if (typeof clientX === 'undefined' || typeof clientY === 'undefined') return null; return { x: (clientX - rect.left) * scaleX / dpr, y: (clientY - rect.top) * scaleY / dpr }; } function findElementAt(x, y) { if (isGameCompleted) { if (isInside(x, y, UI_ELEMENTS.restartButton)) return { type: 'ui', name: 'submitButton' }; } if (currentGame) { if (isInside(x, y, UI_ELEMENTS.stockArea)) return { type: 'stock' }; if (isInside(x, y, UI_ELEMENTS.undoButton)) return { type: 'ui', name: 'undoButton' }; if (isInside(x, y, UI_ELEMENTS.saveButton)) return { type: 'ui', name: 'saveButton' }; } if (!currentGame) { if (isInside(x, y, UI_ELEMENTS.suitSelect)) return { type: 'ui', name: 'suitSelect' }; if (isInside(x, y, UI_ELEMENTS.cardCountSelect)) return { type: 'ui', name: 'cardCountSelect' }; if (isInside(x, y, UI_ELEMENTS.startButton)) return { type: 'ui', name: 'startButton' }; if (isInside(x, y, UI_ELEMENTS.loadButton) && localStorage.getItem(SAVED_GAME_ID_KEY)) return { type: 'ui', name: 'loadButton' }; } if (currentGame) { for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { const stackCards = currentGame.tableau[stackIndex]; for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { const card = stackCards[cardIndex]; if (!card.isFaceUp) continue; if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { return { type: 'card', card, stackIndex, cardIndex }; } } } } return null; } function isInside(x, y, rect) { return rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; } // ======================================= // 4. 게임 로직 및 상호작용 (★ 클라이언트 중심으로 재구성) // ======================================= function handlePointerDown(event) { if (isProcessing || isAnimatingCompletion) return; if (event.type.startsWith('touch')) { event.preventDefault(); } const coords = getCanvasCoordinates(event); const element = findElementAt(coords.x, coords.y); if (!element) return; if (element.type === 'ui') { switch (element.name) { case 'startButton': startNewGame(false); break; case 'loadButton': startNewGame(true); break; case 'saveButton': saveGameToServer(); break; case 'undoButton': handleUndo(); break; case 'submitButton': handleRankSubmit(); break; case 'suitSelect': selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1; selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value; break; case 'cardCountSelect': const opts = cardDistributionOptions[selectedSuit.toString()]; const curIdx = opts.findIndex(o => o.value === selectedCardCount); selectedCardCount = opts[(curIdx + 1) % opts.length].value; break; } } else if (element.type === 'card' && !isGameCompleted) { const { card, stackIndex, cardIndex } = element; const movableStack = getCardStackForMove(card, stackIndex, cardIndex); if (movableStack && movableStack.length > 0) { draggedCards = movableStack; draggedCards.sourceStackIndex = stackIndex; dragOffsetX = coords.x - card.x; dragOffsetY = coords.y - card.y; } } else if (element.type === 'stock') { dealFromStock(); } } function handlePointerMove(event) { if (!isDragging && draggedCards.length > 0) { isDragging = true; } if (isDragging) { event.preventDefault(); const coords = getCanvasCoordinates(event); draggedCards[0].x = coords.x - dragOffsetX; draggedCards[0].y = coords.y - dragOffsetY; } } function handlePointerUp(event) { if (!isDragging) { draggedCards = []; return; } const coords = getCanvasCoordinates(event); if (!coords) { // coords가 null일 경우를 대비한 방어 코드 isDragging = false; draggedCards = []; return; } const dropTargetStackId = findStackAt(coords.x, coords.y); const sourceStackIndex = draggedCards.sourceStackIndex; if (dropTargetStackId) { const destIndex = parseInt(dropTargetStackId.split('-')[1]) - 1; if (isValidMove(draggedCards, destIndex)) { addUndoState(); moveCardLocally(draggedCards, sourceStackIndex, destIndex); checkCompletedStacks(); } } isDragging = false; draggedCards = []; } function handleDoubleClick(event) { if (isProcessing || isGameCompleted) return; const coords = getCanvasCoordinates(event); const clicked = findCardAt(coords.x, coords.y); if (clicked) { const movable = getCardStackForMove(clicked.card, clicked.stackIndex, clicked.cardIndex); if (movable) { const destId = getBestMoveForStack(movable); if (destId) { addUndoState(); moveCardLocally(movable, clicked.stackIndex, parseInt(destId.split('-')[1])-1); checkCompletedStacks(); } } } } function handleUndo() { if (currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) { if (currentGame.undoCount >= MAX_UNDO_COUNT) { const giveUp = showConfirm("확인",'실행 취소 횟수를 모두 사용했습니다. 게임을 포기하시겠습니까?'); if(giveUp) currentGame = null; } return; } const prevState = currentGame.undoHistory.pop(); currentGame.tableau = prevState.tableau; currentGame.stock = prevState.stock; currentGame.foundation = prevState.foundation; currentGame.moves = prevState.moves; currentGame.undoCount++; } function dealFromStock() { if (currentGame.stock.length === 0 || isGameCompleted) return; addUndoState(); const cardsToDeal = currentGame.stock.splice(0, 10); cardsToDeal.forEach((card, index) => { card.isFaceUp = true; currentGame.tableau[index].push(card); }); currentGame.moves++; checkCompletedStacks(); } function addUndoState() { const stateToSave = { tableau: JSON.parse(JSON.stringify(currentGame.tableau)), stock: JSON.parse(JSON.stringify(currentGame.stock)), foundation: JSON.parse(JSON.stringify(currentGame.foundation)), moves: currentGame.moves }; currentGame.undoHistory.push(stateToSave); if(currentGame.undoHistory.length > 10) { // Undo 기록은 넉넉하게 currentGame.undoHistory.shift(); } } function moveCardLocally(cards, fromIndex, toIndex) { const sourceStack = currentGame.tableau[fromIndex]; sourceStack.splice(sourceStack.length - cards.length, cards.length); currentGame.tableau[toIndex].push(...cards); if (sourceStack.length > 0) sourceStack[sourceStack.length-1].isFaceUp = true; currentGame.moves++; } function checkCompletedStacks() { for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) { const stack = currentGame.tableau[stackIndex]; if (stack.length < 13) continue; const last13Cards = stack.slice(stack.length - 13); let isCompleted = true; for (let i = 0; i < 12; i++) { if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { isCompleted = false; break; } } if (isCompleted) { isAnimatingCompletion = true; const cardsToRemove = stack.slice(stack.length - 13); // 애니메이션 관련 로직 (기존과 동일) const originalStackLength = stack.length; cardsToRemove.forEach((card, index) => { const cardIndexInStack = originalStackLength - 13 + index; card.animStartX = tableauStartX + stackIndex * (cardWidth + cardGapX); card.animStartY = (cardHeight * 0.5) + cardIndexInStack * cardOverlapY; card.animEndTime = Date.now() + 500; card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING)); card.animTargetY = UI_ELEMENTS.foundationArea.y; completedStackCards.push(card); }); stack.splice(stack.length - 13, 13); // 보드에서 카드 제거 if (stack.length > 0) { stack[stack.length - 1].isFaceUp = true; } // ▼▼▼ [핵심 수정] 이 라인을 추가하세요! ▼▼▼ currentGame.foundation.push(cardsToRemove); } } // 이제 foundation에 카드가 제대로 쌓여서 totalFoundationCards가 104가 될 수 있습니다. const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0); if (totalFoundationCards === 104 && !isGameCompleted) { isGameCompleted = true; completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000); const timeMessage = `${Math.floor(completionTimeSeconds / 60)}분 ${completionTimeSeconds % 60}초`; showGameSuccessModal({ gameType: 'SPIDER', contextId: currentContextId, successMessage: `게임 완료! (이동: ${currentGame.moves}회, 시간: ${timeMessage})`, primaryScore: currentGame.moves, secondaryScore: completionTimeSeconds }); } } function isValidMove(cardsToMove, destIndex) { if (cardsToMove.length === 0) return false; const firstCard = cardsToMove[0]; const destStack = currentGame.tableau[destIndex]; if (destStack.length === 0) return true; const destTopCard = destStack[destStack.length - 1]; return firstCard.rank === destTopCard.rank - 1; } function getCardStackForMove(card, stackIndex, cardIndex) { const stack = currentGame.tableau[stackIndex]; if (cardIndex === -1 || !card.isFaceUp) return null; const movableStack = []; for (let i = cardIndex; i < stack.length; i++) { if (stack[i].isFaceUp) { movableStack.push(stack[i]); } else { break; } } if (movableStack.length === 0) return null; for (let i = 0; i < movableStack.length - 1; i++) { if (movableStack[i].rank !== movableStack[i + 1].rank + 1 || movableStack[i].suit !== movableStack[i + 1].suit) { return null; } } return movableStack; } // ======================================= // 5. 서버 통신 함수 (★ 저장/로드/랭킹 전용) // ======================================= async function startNewGame(loadFromSaved) { isProcessing = true; try { let gameData; if (loadFromSaved) { const savedId = localStorage.getItem(SAVED_GAME_ID_KEY); if (!savedId) throw new Error("저장된 게임이 없습니다."); gameData = await loadGameFromServer(savedId); } else { const numSuits = selectedSuit, numCards = selectedCardCount; currentContextId = `${numSuits}_SUITS_${numCards.replace(',', '-')}`; updateGameRanking('SPIDER', currentContextId); const response = await fetch(`/puzzle/spider/new?numSuits=${numSuits}&numCards=${numCards}`); if (!response.ok) throw new Error('새 게임 생성 실패'); gameData = await response.json(); } currentGame = gameData; if (!currentGame.undoHistory) currentGame.undoHistory = []; if (typeof currentGame.undoCount !== 'number') currentGame.undoCount = 0; isGameCompleted = false; gameStartTime = Date.now(); } catch (error) { console.error("게임 시작 중 오류:", error); showAlert("알림",error.message); currentGame = null; } finally { isProcessing = false; } } async function loadGameFromServer(gameId) { const response = await fetch(`/puzzle/spider/${gameId}`); if (!response.ok) throw new Error("저장된 게임을 불러오지 못했습니다."); return await response.json(); } async function saveGameToServer() { if (!currentGame || isProcessing) return; isProcessing = true; try { const response = await fetch(`/puzzle/spider/update`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(currentGame) }); if (!response.ok) throw new Error('저장 실패'); const savedGame = await response.json(); currentGame.id = savedGame.id; localStorage.setItem(SAVED_GAME_ID_KEY, currentGame.id); showAlert("알림","게임이 저장되었습니다."); } catch (error) { console.error("게임 저장 중 오류:", error); showAlert("알림","게임 저장에 실패했습니다."); } finally { isProcessing = false; } } // ======================================= // 6. 기타 유틸리티 함수 // ======================================= function findStackAt(x, y) { const startY = cardHeight * 0.5; for (let i = 0; i < 10; i++) { const stackX = tableauStartX + i * (cardWidth + cardGapX); const stackCards = currentGame.tableau[i]; if (stackCards.length === 0) { if (x >= stackX && x <= stackX + cardWidth && y >= startY) { return `tableau-${i + 1}`; } } else { const lastCardIndex = stackCards.length - 1; const lastCardY = startY + lastCardIndex * cardOverlapY; if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) { return `tableau-${i + 1}`; } } } return null; } function findCardAt(x, y) { if (!currentGame) return null; for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { const stackCards = currentGame.tableau[stackIndex]; for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { const card = stackCards[cardIndex]; if (!card.isFaceUp) continue; if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { return { card, stackIndex, cardIndex }; } } } return null; } function getRankText(rank) { if (rank === 1) return 'A'; if (rank === 11) return 'J'; if (rank === 12) return 'Q'; if (rank === 13) return 'K'; return String(rank); } function getSuitSymbol(suit) { if (suit === 'spade') return '♠️'; if (suit === 'heart') return '♥️'; if (suit === 'club') return '♣️'; if (suit === 'diamond') return '♦️'; } function getBestMoveForStack(cardsToMove) { if (cardsToMove.length === 0) return null; const firstCardToMove = cardsToMove[0]; for (let i = 0; i < 10; i++) { const destStackCards = currentGame.tableau[i]; if (destStackCards.length === 0) { return `tableau-${i + 1}`; } else { const destTopCard = destStackCards[destStackCards.length - 1]; if (firstCardToMove.rank === destTopCard.rank - 1) { return `tableau-${i + 1}`; } } } return null; } // --- 초기화 --- resizeCanvas(); function gameLoop() { draw(); requestAnimationFrame(gameLoop); } gameLoop(); // 게임 루프 시작 }); //]]>