837 lines
39 KiB
HTML
Raw Normal View History

2025-09-03 18:00:39 +09:00
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
2025-09-12 16:55:21 +09:00
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
<style>
#game-container {
display: flex;
justify-content: center;
align-items: flex-start;
2025-09-16 18:42:55 +09:00
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
padding: 15px;
2025-09-12 16:55:21 +09:00
box-sizing: border-box;
2025-09-16 18:42:55 +09:00
width: 95%;
max-width: 1200px;
2025-09-12 16:55:21 +09:00
}
#gameCanvas {
2025-09-16 18:42:55 +09:00
border: 1px solid #004d00;
border-radius: 8px;
2025-09-12 16:55:21 +09:00
width: 100%;
2025-09-16 18:42:55 +09:00
height: auto;
box-sizing: border-box;
2025-09-12 16:55:21 +09:00
}
</style>
<script type="text/javascript">
//<![CDATA[
2025-09-16 18:42:55 +09:00
window.pageContext = { pageType: 'game', gameType: 'SPIDER', contextId: undefined };
2025-09-12 16:55:21 +09:00
/**
* ==============================================
* spider.js (Canvas 렌더링 게임)
2025-09-16 18:42:55 +09:00
* (★ 하이브리드 모델 적용: 게임 로직은 클라이언트, 저장은 서버)
2025-09-12 16:55:21 +09:00
* ==============================================
*/
document.addEventListener('DOMContentLoaded', () => {
// =======================================
// 1. 상수 및 변수 선언
// =======================================
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
2025-09-16 18:42:55 +09:00
const SAVED_GAME_ID_KEY = 'spider_saved_game_id';
let isProcessing = false; // 서버 통신 및 중요 처리 상태 관리 변수
2025-09-12 16:55:21 +09:00
const UI_ELEMENTS = {};
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
let currentGame = null;
let isGameCompleted = false;
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
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;
2025-09-16 18:42:55 +09:00
cardBackImage.onload = () => { assetsLoaded = true; resizeCanvas(); };
2025-09-12 16:55:21 +09:00
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';
// =======================================
2025-09-16 18:42:55 +09:00
// 2. 렌더링 (그리기) 관련 함수
2025-09-12 16:55:21 +09:00
// =======================================
2025-09-16 18:42:55 +09:00
function getCssVar(varName) { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); }
2025-09-12 16:55:21 +09:00
function resizeCanvas() {
const size = Math.min(window.innerWidth, window.innerHeight) * 0.95;
2025-09-16 18:42:55 +09:00
canvas.style.width = `${size}px`; canvas.style.height = `${size}px`;
2025-09-12 16:55:21 +09:00
dpr = window.devicePixelRatio || 1;
2025-09-16 18:42:55 +09:00
canvas.width = size * dpr; canvas.height = size * dpr;
2025-09-12 16:55:21 +09:00
ctx.scale(dpr, dpr);
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2;
const startY = logicalHeight * 0.05;
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
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 };
2025-09-16 18:42:55 +09:00
UI_ELEMENTS.loadButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY + buttonHeight + buttonGap, width: buttonWidth, height: buttonHeight };
2025-09-12 16:55:21 +09:00
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 };
2025-09-16 18:42:55 +09:00
const undoButtonWidth = cardWidth * 0.8, undoButtonHeight = cardHeight * 0.5;
2025-09-12 16:55:21 +09:00
const undoCountDisplayWidth = cardWidth * 0.5;
2025-09-16 18:42:55 +09:00
const saveButtonWidth = cardWidth * 0.8;
const bottomControlsTotalWidth = undoButtonWidth + undoCountDisplayWidth + saveButtonWidth + (itemSpacing * 2);
const undoButtonX = logicalWidth * 0.5 - bottomControlsTotalWidth / 2;
2025-09-12 16:55:21 +09:00
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 };
2025-09-16 18:42:55 +09:00
UI_ELEMENTS.saveButton = { x: UI_ELEMENTS.undoCountDisplay.x + undoCountDisplayWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: saveButtonWidth, height: undoButtonHeight };
2025-09-12 16:55:21 +09:00
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() {
2025-09-16 18:42:55 +09:00
if (!assetsLoaded) return;
2025-09-12 16:55:21 +09:00
ctx.clearRect(0, 0, canvas.width, canvas.height);
2025-09-16 18:42:55 +09:00
if (currentGame) drawGame(currentGame);
2025-09-12 16:55:21 +09:00
drawUI();
2025-09-16 18:42:55 +09:00
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);
}
2025-09-12 16:55:21 +09:00
}
function drawUI() {
2025-09-16 18:42:55 +09:00
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
2025-09-12 16:55:21 +09:00
if (!currentGame) {
2025-09-16 18:42:55 +09:00
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';
2025-09-12 16:55:21 +09:00
ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2);
2025-09-16 18:42:55 +09:00
ctx.fillStyle = '#f0f0f0'; ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height);
2025-09-12 16:55:21 +09:00
ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height);
ctx.fillStyle = '#000';
2025-09-16 18:42:55 +09:00
ctx.fillText(`카드: ${cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount).text}`, cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2);
2025-09-12 16:55:21 +09:00
2025-09-16 18:42:55 +09:00
ctx.fillStyle = getCssVar('--color-success') || '#4CAF50'; ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height);
2025-09-12 16:55:21 +09:00
ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height);
2025-09-16 18:42:55 +09:00
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);
}
2025-09-12 16:55:21 +09:00
} else {
2025-09-16 18:42:55 +09:00
const { undoButton, undoCountDisplay, saveButton } = UI_ELEMENTS;
2025-09-12 16:55:21 +09:00
const isUndoPossible = currentGame.undoHistory.length > 0;
const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible;
const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT;
if (isUndoEnabled) {
2025-09-16 18:42:55 +09:00
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);
2025-09-12 16:55:21 +09:00
} else if (isSurrender) {
2025-09-16 18:42:55 +09:00
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);
2025-09-12 16:55:21 +09:00
}
2025-09-16 18:42:55 +09:00
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);
2025-09-12 16:55:21 +09:00
}
}
function drawGame(game) {
drawBackground();
drawTableau(game.tableau);
drawStockAndFoundation(game.stock, game.foundation);
drawDraggedCards(draggedCards);
drawCompletionAnimation();
if (isGameCompleted) {
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
}
}
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
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) => {
2025-09-16 18:42:55 +09:00
if (draggingCards && draggingCards.has(card)) return;
2025-09-12 16:55:21 +09:00
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) {
2025-09-16 18:42:55 +09:00
if (!isDragging || !Array.isArray(cards) || cards.length === 0) return;
2025-09-12 16:55:21 +09:00
cards.forEach((card, index) => {
2025-09-16 18:42:55 +09:00
const x = cards[0].x, y = cards[0].y + index * cardOverlapY;
2025-09-12 16:55:21 +09:00
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;
}
2025-09-16 18:42:55 +09:00
return false;
2025-09-12 16:55:21 +09:00
});
2025-09-16 18:42:55 +09:00
if (completedStackCards.length === 0) isAnimatingCompletion = false;
2025-09-12 16:55:21 +09:00
}
}
function drawSingleCard(card, x, y) {
2025-09-16 18:42:55 +09:00
card.x = x; card.y = y; card.width = cardWidth; card.height = cardHeight;
2025-09-12 16:55:21 +09:00
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);
2025-09-16 18:42:55 +09:00
let symbolSize = card.rank >= 2 && card.rank <= 5 ? cardWidth * 0.2 : cardWidth * 0.15;
2025-09-12 16:55:21 +09:00
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;
}
}
2025-09-03 18:00:39 +09:00
2025-09-12 16:55:21 +09:00
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) {
2025-09-16 18:42:55 +09:00
drawSingleCard(stack[stack.length - 1], foundationX, UI_ELEMENTS.foundationArea.y);
2025-09-12 16:55:21 +09:00
}
});
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);
2025-09-16 18:42:55 +09:00
ctx.fillStyle = '#fff'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(`${remainingDeals}`, stockArea.x + cardWidth / 2, stockArea.y + cardHeight / 2);
2025-09-12 16:55:21 +09:00
} 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);
function getCanvasCoordinates(event) {
const rect = canvas.getBoundingClientRect();
2025-09-16 18:42:55 +09:00
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
2025-09-12 16:55:21 +09:00
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
2025-09-16 18:42:55 +09:00
return { x: (clientX - rect.left) * scaleX / dpr, y: (clientY - rect.top) * scaleY / dpr };
2025-09-12 16:55:21 +09:00
}
function findElementAt(x, y) {
if (isGameCompleted) {
2025-09-16 18:42:55 +09:00
if (isInside(x, y, UI_ELEMENTS.restartButton)) return { type: 'ui', name: 'submitButton' };
2025-09-12 16:55:21 +09:00
}
if (currentGame) {
2025-09-16 18:42:55 +09:00
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' };
2025-09-12 16:55:21 +09:00
}
if (!currentGame) {
2025-09-16 18:42:55 +09:00
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' };
2025-09-12 16:55:21 +09:00
}
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;
}
2025-09-16 18:42:55 +09:00
function isInside(x, y, rect) {
return rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
2025-09-12 16:55:21 +09:00
}
// =======================================
2025-09-16 18:42:55 +09:00
// 4. 게임 로직 및 상호작용 (★ 클라이언트 중심으로 재구성)
2025-09-12 16:55:21 +09:00
// =======================================
function handlePointerDown(event) {
2025-09-16 18:42:55 +09:00
if (isProcessing || isAnimatingCompletion) return;
2025-09-12 16:55:21 +09:00
const coords = getCanvasCoordinates(event);
const element = findElementAt(coords.x, coords.y);
if (!element) return;
if (element.type === 'ui') {
switch (element.name) {
2025-09-16 18:42:55 +09:00
case 'startButton': startNewGame(false); break;
case 'loadButton': startNewGame(true); break;
case 'saveButton': saveGameToServer(); break;
case 'undoButton': handleUndo(); break;
case 'submitButton': handleRankSubmit(); break;
2025-09-12 16:55:21 +09:00
case 'suitSelect':
selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1;
selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value;
break;
case 'cardCountSelect':
2025-09-16 18:42:55 +09:00
const opts = cardDistributionOptions[selectedSuit.toString()];
const curIdx = opts.findIndex(o => o.value === selectedCardCount);
selectedCardCount = opts[(curIdx + 1) % opts.length].value;
2025-09-12 16:55:21 +09:00
break;
}
2025-09-16 18:42:55 +09:00
} else if (element.type === 'card' && !isGameCompleted) {
2025-09-12 16:55:21 +09:00
const { card, stackIndex, cardIndex } = element;
const movableStack = getCardStackForMove(card, stackIndex, cardIndex);
if (movableStack && movableStack.length > 0) {
draggedCards = movableStack;
draggedCards.sourceStackIndex = stackIndex;
2025-09-16 18:42:55 +09:00
dragOffsetX = coords.x - card.x; dragOffsetY = coords.y - card.y;
2025-09-12 16:55:21 +09:00
}
} else if (element.type === 'stock') {
2025-09-16 18:42:55 +09:00
dealFromStock();
2025-09-12 16:55:21 +09:00
}
}
function handlePointerMove(event) {
2025-09-16 18:42:55 +09:00
if (!isDragging && draggedCards.length > 0) {
isDragging = true;
2025-09-12 16:55:21 +09:00
}
if (isDragging) {
2025-09-16 18:42:55 +09:00
event.preventDefault();
const coords = getCanvasCoordinates(event);
2025-09-12 16:55:21 +09:00
draggedCards[0].x = coords.x - dragOffsetX;
draggedCards[0].y = coords.y - dragOffsetY;
}
}
function handlePointerUp(event) {
2025-09-16 18:42:55 +09:00
if (!isDragging) { draggedCards = []; return; }
2025-09-12 16:55:21 +09:00
const coords = getCanvasCoordinates(event);
const dropTargetStackId = findStackAt(coords.x, coords.y);
const sourceStackIndex = draggedCards.sourceStackIndex;
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
if (dropTargetStackId) {
2025-09-16 18:42:55 +09:00
const destIndex = parseInt(dropTargetStackId.split('-')[1]) - 1;
if (isValidMove(draggedCards, destIndex)) {
addUndoState();
moveCardLocally(draggedCards, sourceStackIndex, destIndex);
2025-09-12 16:55:21 +09:00
checkCompletedStacks();
}
}
isDragging = false;
draggedCards = [];
}
function handleDoubleClick(event) {
2025-09-16 18:42:55 +09:00
if (isProcessing || isGameCompleted) return;
2025-09-12 16:55:21 +09:00
const coords = getCanvasCoordinates(event);
2025-09-16 18:42:55 +09:00
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);
2025-09-12 16:55:21 +09:00
checkCompletedStacks();
}
}
}
}
2025-09-16 18:42:55 +09:00
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++;
}
2025-09-12 16:55:21 +09:00
function checkCompletedStacks() {
for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) {
const stack = currentGame.tableau[stackIndex];
if (stack.length < 13) continue;
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
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;
}
}
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
if (isCompleted) {
isAnimatingCompletion = true;
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
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);
});
2025-09-16 18:42:55 +09:00
stack.splice(stack.length - 13, 13); // 보드에서 카드 제거
2025-09-12 16:55:21 +09:00
if (stack.length > 0) {
stack[stack.length - 1].isFaceUp = true;
}
2025-09-16 18:42:55 +09:00
// ▼▼▼ [핵심 수정] 이 라인을 추가하세요! ▼▼▼
2025-09-12 16:55:21 +09:00
currentGame.foundation.push(cardsToRemove);
}
}
2025-09-16 18:42:55 +09:00
// 이제 foundation에 카드가 제대로 쌓여서 totalFoundationCards가 104가 될 수 있습니다.
2025-09-12 16:55:21 +09:00
const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0);
if (totalFoundationCards === 104 && !isGameCompleted) {
isGameCompleted = true;
completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000);
2025-09-16 18:42:55 +09:00
const timeMessage = `${Math.floor(completionTimeSeconds / 60)}분 ${completionTimeSeconds % 60}초`;
showGameSuccessModal({
gameType: 'SPIDER',
contextId: currentContextId,
successMessage: `게임 완료! (이동: ${currentGame.moves}회, 시간: ${timeMessage})`,
primaryScore: currentGame.moves,
secondaryScore: completionTimeSeconds
});
2025-09-12 16:55:21 +09:00
}
}
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
}
2025-09-16 18:42:55 +09:00
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;
}
2025-09-12 16:55:21 +09:00
}
2025-09-16 18:42:55 +09:00
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;
}
2025-09-12 16:55:21 +09:00
}
2025-09-16 18:42:55 +09:00
return movableStack;
2025-09-12 16:55:21 +09:00
}
2025-09-16 18:42:55 +09:00
2025-09-12 16:55:21 +09:00
// =======================================
2025-09-16 18:42:55 +09:00
// 5. 서버 통신 함수 (★ 저장/로드/랭킹 전용)
2025-09-12 16:55:21 +09:00
// =======================================
2025-09-16 18:42:55 +09:00
async function startNewGame(loadFromSaved) {
isProcessing = true;
2025-09-12 16:55:21 +09:00
try {
2025-09-16 18:42:55 +09:00
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();
2025-09-12 16:55:21 +09:00
} catch (error) {
2025-09-16 18:42:55 +09:00
console.error("게임 시작 중 오류:", error);
showAlert("알림",error.message);
currentGame = null;
2025-09-12 16:55:21 +09:00
} finally {
2025-09-16 18:42:55 +09:00
isProcessing = false;
2025-09-12 16:55:21 +09:00
}
}
2025-09-16 18:42:55 +09:00
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;
2025-09-12 16:55:21 +09:00
try {
const response = await fetch(`/puzzle/spider/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2025-09-16 18:42:55 +09:00
body: JSON.stringify(currentGame)
2025-09-12 16:55:21 +09:00
});
2025-09-16 18:42:55 +09:00
if (!response.ok) throw new Error('저장 실패');
const savedGame = await response.json();
currentGame.id = savedGame.id;
localStorage.setItem(SAVED_GAME_ID_KEY, currentGame.id);
showAlert("알림","게임이 저장되었습니다.");
2025-09-12 16:55:21 +09:00
} catch (error) {
2025-09-16 18:42:55 +09:00
console.error("게임 저장 중 오류:", error);
showAlert("알림","게임 저장에 실패했습니다.");
} finally {
isProcessing = false;
2025-09-12 16:55:21 +09:00
}
}
// =======================================
// 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}`;
}
2025-09-16 18:42:55 +09:00
} else {
const lastCardIndex = stackCards.length - 1;
const lastCardY = startY + lastCardIndex * cardOverlapY;
if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) {
return `tableau-${i + 1}`;
}
2025-09-12 16:55:21 +09:00
}
}
return null;
}
function findCardAt(x, y) {
2025-09-16 18:42:55 +09:00
if (!currentGame) return null;
2025-09-12 16:55:21 +09:00
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) {
2025-09-16 18:42:55 +09:00
return `tableau-${i + 1}`;
2025-09-12 16:55:21 +09:00
} else {
const destTopCard = destStackCards[destStackCards.length - 1];
if (firstCardToMove.rank === destTopCard.rank - 1) {
2025-09-16 18:42:55 +09:00
return `tableau-${i + 1}`;
2025-09-12 16:55:21 +09:00
}
}
}
return null;
}
// --- 초기화 ---
resizeCanvas();
2025-09-16 18:42:55 +09:00
function gameLoop() { draw(); requestAnimationFrame(gameLoop); }
gameLoop(); // 게임 루프 시작
2025-09-12 16:55:21 +09:00
});
//]]>
2025-09-03 18:00:39 +09:00
</script>
2025-09-12 16:55:21 +09:00
2025-09-03 18:00:39 +09:00
</th:block >
<th:block layout:fragment="content">
2025-09-12 18:01:23 +09:00
<div class="game-body-wrapper">
2025-09-16 18:42:55 +09:00
<div id="game-container">
<canvas id="gameCanvas"></canvas>
</div>
2025-09-12 18:01:23 +09:00
</div>
2025-09-23 17:37:22 +09:00
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
2025-09-03 18:00:39 +09:00
</th:block>
2025-09-12 16:55:21 +09:00
</html>