1073 lines
46 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가 전체 화면(100vw/vh)을 차지하는 대신,
common_game_theme의 body(#f4f7f9 배경) 위에 떠 있는
'게임 테이블(카드)' 역할을 하도록 변경합니다. */
#game-container {
/* (★ 남김) 내부 캔버스를 정렬하는 로직은 유지 */
display: flex;
justify-content: center;
align-items: flex-start;
/* (★ 유지/수정) '펠트' 배경색은 유지하되, 공통 '카드' UI 요소를 추가 */
background-color: #008000;
border-radius: 8px; /* (★ 추가) 공통 테마 둥근 모서리 */
box-shadow: 0 4px 10px rgba(0,0,0,0.08); /* (★ 추가) 공통 테마 그림자 */
padding: 15px; /* (★ 추가) 캔버스 주변 여백 */
box-sizing: border-box;
/* (★ 삭제) 100vw, 100vh 속성을 삭제하여 body의 중앙 정렬이 동작하도록 함 */
/* width: 100vw; (삭제) */
/* height: 100vh; (삭제) */
/* (★ 수정) 너비 관리: 다른 게임(500px)보다 넓은 반응형 최대 너비를 가짐 */
width: 95%; /* 뷰포트의 95%를 사용 */
max-width: 1200px; /* 단, 스파이더 게임에 맞게 1200px까지 허용 */
}
#gameCanvas {
/* (★ 삭제) 컨테이너가 이미 녹색이므로 캔버스 자체의 배경은 불필요 */
/* background-color: #008000; (삭제) */
/* (★ 수정) 흰색 테두리보다 펠트 색과 대비되는 어두운 테두리로 변경 */
border: 2px solid #004d00; /* #fff (흰색) -> #004d00 (어두운 녹색) */
/* (★ 수정) 너비: 부모(#game-container) 패딩 영역의 100%를 차지 */
width: 100%;
height: auto; /* 너비에 맞춰 캔버스 비율(JS가 설정한)을 따름 */
/* (★ 삭제) 뷰포트 기준 max-height 삭제. 컨테이너 너비와 캔버스 비율로 크기가 결정됨. */
/* max-height: min(95vw, 95vh); (삭제) */
box-sizing: border-box; /* 유지 */
}
</style>
<script type="text/javascript">
//<![CDATA[
/**
* ==============================================
* spider.js (Canvas 렌더링 게임)
* (★ 리팩토링: CSS 변수 연동, 타이머 및 랭킹 API 기능 추가, API 경로 통합)
* ==============================================
*/
document.addEventListener('DOMContentLoaded', () => {
// =======================================
// 1. 상수 및 변수 선언
// =======================================
console.log("DOM 콘텐츠가 로드되었습니다. 초기 설정 시작.");
// HTML 요소
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// ** UI 버튼 및 영역의 논리적 위치 정의 (캔버스 내 좌표) **
const UI_ELEMENTS = {};
// ** 비율 상수 정의 **
const CARD_WIDTH_RATIO = 1 / 11.5;
const CARD_HEIGHT_RATIO = 1.4;
const CARD_GAP_X_RATIO = 0.15;
const CARD_OVERLAP_Y_RATIO = 0.3;
const CARD_RANK_LEFT_PADDING = 0.1;
const CARD_RANK_TOP_PADDING = 0.1;
const CARD_SYMBOL_TOP_PADDING = 0.25;
const CARD_SYMBOL_BOTTOM_PADDING = 0.15;
const FOUNDATION_CARD_SPACING = 0.2; // 카드 겹침 비율 (20%만 보이게)
const FOUNDATION_WIDTH_RATIO = 0.45; // 파운데이션 영역 최대 너비
// 게임 상태 및 데이터
let gameId = null;
let currentGame = null;
let isGameCompleted = false;
// (★ 신규) 게임 타이머 및 랭킹 관련 변수
let gameStartTime = 0; // 게임 시작 시간 (ms)
let completionTimeSeconds = 0; // 게임 완료 시간 (초)
const currentGameType = 'SPIDER'; // 통합 랭킹용 GameType
let currentContextId = ''; // 예: "1_SUITS_4,3" (난이도 저장용)
// 동적으로 계산될 레이아웃 변수
let cardWidth = 0;
let cardHeight = 0;
let cardGapX = 0;
let cardOverlapY = 0;
let totalTableauWidth = 0;
let tableauStartX = 0;
// 드래그 및 애니메이션 관련 변수
let isAnimating = false;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
const DRAG_THRESHOLD = 5;
let draggedCards = [];
let dragOffsetX = 0;
let dragOffsetY = 0;
let animatedCard = null;
let animationProgress = 0;
let completedStackCards = [];
let isAnimatingCompletion = false;
// 하단 정렬을 위한 Y 좌표 기준
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();
draw();
console.log("카드 뒷면 이미지가 로드되었습니다.");
};
// UI 옵션 데이터
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. 렌더링 (그리기) 관련 함수 (★ CSS 변수 적용)
// =======================================
console.log("렌더링 관련 함수 정의 시작.");
/**
* (★ 신규) CSS :root에서 정의된 변수 값을 읽어오는 헬퍼 함수
* @param {string} varName - 읽어올 CSS 변수 이름 (예: '--color-primary')
* @returns {string} 변수의 값 (trim된 문자열)
*/
function getCssVar(varName) {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
}
// 캔버스 크기 조정 및 레이아웃 변수 계산
function resizeCanvas() {
console.log("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;
const 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;
const buttonHeight = logicalHeight * 0.05;
const buttonGap = 10;
const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2;
const startY = logicalHeight * 0.05;
UI_ELEMENTS.difficultyUI = { x: logicalWidth / 2 - (buttonWidth * 1.5 + buttonGap), y: logicalHeight * 0.45, width: (buttonWidth * 3 + buttonGap * 2), height: buttonHeight };
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 };
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;
const undoButtonHeight = cardHeight * 0.5;
const undoCountDisplayWidth = cardWidth * 0.5;
const undoButtonX = logicalWidth * 0.5 - (undoButtonWidth + undoCountDisplayWidth + itemSpacing) / 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 };
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) {
requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (currentGame) {
drawGame(currentGame);
}
drawUI();
requestAnimationFrame(draw);
}
/**
* (★ 수정) UI 요소를 캔버스에 직접 그리는 함수
* 하드코딩된 색상 대신 CSS 변수 값을 읽어와서 사용합니다.
*/
function drawUI() {
if (!currentGame) {
// --- 게임 시작 전 난이도 선택 UI ---
const suitSelect = UI_ELEMENTS.suitSelect;
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.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2);
const cardCountSelect = UI_ELEMENTS.cardCountSelect;
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);
const startButton = UI_ELEMENTS.startButton;
ctx.fillStyle = getCssVar('--color-success') || '#4CAF50';
ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height);
ctx.strokeStyle = '#333';
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);
} else {
// --- 게임 중 하단 UI (Undo 버튼 등) ---
const undoButton = UI_ELEMENTS.undoButton;
const undoCountDisplay = UI_ELEMENTS.undoCountDisplay;
const isUndoPossible = currentGame.undoHistory.length > 0;
const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible;
const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT;
if (isUndoEnabled) {
const buttonColor = getCssVar('--color-warning') || '#ff9800';
const buttonText = '실행 취소';
ctx.fillStyle = buttonColor;
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(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2);
const remainingUndos = MAX_UNDO_COUNT - currentGame.undoCount;
ctx.fillText(`${remainingUndos}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2);
} else if (isSurrender) {
const buttonColor = getCssVar('--color-danger') || '#f44336';
const buttonText = '게임 포기';
ctx.fillStyle = buttonColor;
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(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2);
}
}
}
// 전체 게임 화면을 그리는 메인 함수
function drawGame(game) {
drawBackground();
drawTableau(game.tableau);
drawStockAndFoundation(game.stock, game.foundation);
drawDraggedCards(draggedCards);
drawCompletionAnimation();
if (isGameCompleted) {
drawCompletionMessage();
}
}
/**
* (★ 수정) 게임 완료 메시지 그리기 (제출 버튼 로직 추가)
*/
function drawCompletionMessage() {
const logicalWidth = canvas.width / dpr;
const logicalHeight = canvas.height / dpr;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, logicalWidth, logicalHeight);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('게임 완료! 축하합니다!', logicalWidth / 2, logicalHeight / 2);
const submitButton = UI_ELEMENTS.restartButton;
ctx.fillStyle = getCssVar('--color-success') || '#4CAF50';
ctx.fillRect(submitButton.x, submitButton.y, submitButton.width, submitButton.height);
ctx.strokeStyle = '#fff';
ctx.strokeRect(submitButton.x, submitButton.y, submitButton.width, submitButton.height);
ctx.fillStyle = '#fff';
ctx.font = '20px Arial';
ctx.fillText('랭킹 등록', submitButton.x + submitButton.width / 2, submitButton.y + submitButton.height / 2);
}
/**
* (★ 수정) 게임 배경 그리기 (CSS 변수 사용)
*/
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;
const 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;
} else {
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;
if (card.rank >= 2 && card.rank <= 5) {
symbolSize = cardWidth * 0.2;
} else {
symbolSize = 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;
}
}
2025-09-03 18:00:39 +09:00
2025-09-12 16:55:21 +09:00
/**
* (★ 수정) 스톡 및 파운데이션 그리기 (CSS 변수 사용)
*/
function drawStockAndFoundation(stock, foundation) {
const stockArea = UI_ELEMENTS.stockArea;
const foundationArea = UI_ELEMENTS.foundationArea;
2025-09-03 18:00:39 +09:00
2025-09-12 16:55:21 +09:00
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) {
const topCard = stack[stack.length - 1];
drawSingleCard(topCard, foundationX, bottomY);
}
});
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 + stockArea.width / 2, stockArea.y + stockArea.height / 2);
} else {
ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight);
}
}
// =======================================
// 3. 이벤트 핸들러 및 유틸리티 함수
// =======================================
console.log("이벤트 핸들러 등록 시작.");
canvas.addEventListener('mousedown', handlePointerDown);
canvas.addEventListener('mousemove', handlePointerMove);
canvas.addEventListener('mouseup', handlePointerUp);
canvas.addEventListener('touchstart', handlePointerDown);
canvas.addEventListener('touchmove', handlePointerMove);
canvas.addEventListener('touchend', handlePointerUp);
canvas.addEventListener('dblclick', handleDoubleClick);
function getCanvasCoordinates(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
return {
x: (clientX - rect.left) * scaleX / dpr,
y: (clientY - rect.top) * scaleY / dpr
};
}
/**
* (★ 수정) 클릭된 위치 찾기 (게임 완료 시 'submitButton' 처리)
*/
function findElementAt(x, y) {
if (isGameCompleted) {
const restartButton = UI_ELEMENTS.restartButton;
if (x >= restartButton.x && x <= restartButton.x + restartButton.width && y >= restartButton.y && y <= restartButton.y + restartButton.height) {
return { type: 'ui', name: 'submitButton' };
}
}
if (currentGame) {
const stockArea = UI_ELEMENTS.stockArea;
if (x >= stockArea.x && x <= stockArea.x + stockArea.width && y >= stockArea.y && y <= stockArea.y + stockArea.height) {
return { type: 'stock' };
}
const undoButton = UI_ELEMENTS.undoButton;
if (x >= undoButton.x && x <= undoButton.x + undoButton.width && y >= undoButton.y && y <= undoButton.y + undoButton.height) {
return { type: 'ui', name: 'undoButton' };
}
}
if (!currentGame) {
const suitSelect = UI_ELEMENTS.suitSelect;
if (x >= suitSelect.x && x <= suitSelect.x + suitSelect.width && y >= suitSelect.y && y <= suitSelect.y + suitSelect.height) {
return { type: 'ui', name: 'suitSelect' };
}
const cardCountSelect = UI_ELEMENTS.cardCountSelect;
if (x >= cardCountSelect.x && x <= cardCountSelect.x + cardCountSelect.width && y >= cardCountSelect.y && y <= cardCountSelect.y + cardCountSelect.height) {
return { type: 'ui', name: 'cardCountSelect' };
}
const startButton = UI_ELEMENTS.startButton;
if (x >= startButton.x && x <= startButton.x + startButton.width && y >= startButton.y && y <= startButton.y + startButton.height) {
return { type: 'ui', name: 'startButton' };
}
}
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 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;
}
// =======================================
// 4. 게임 로직 및 상호작용
// =======================================
let touchStart = {};
/**
* (★ 수정) handlePointerDown (랭킹 등록 로직 추가)
*/
function handlePointerDown(event) {
if (isAnimating || isAnimatingCompletion) return;
const coords = getCanvasCoordinates(event);
touchStart = { x: coords.x, y: coords.y, time: Date.now() };
const element = findElementAt(coords.x, coords.y);
if (!element) return;
if (element.type === 'ui') {
switch (element.name) {
case 'startButton':
startNewGame();
break;
case 'undoButton':
if (currentGame.undoCount < MAX_UNDO_COUNT) {
handleUndo();
} else {
currentGame = null;
draw();
}
break;
case 'suitSelect':
selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1;
selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value;
draw();
break;
case 'cardCountSelect':
const currentOptions = cardDistributionOptions[selectedSuit.toString()];
const currentIndex = currentOptions.findIndex(opt => opt.value === selectedCardCount);
const nextIndex = (currentIndex + 1) % currentOptions.length;
selectedCardCount = currentOptions[nextIndex].value;
draw();
break;
case 'submitButton':
handleRankSubmit();
break;
}
} else if (element.type === 'card') {
const { card, stackIndex, cardIndex } = element;
const movableStack = getCardStackForMove(card, stackIndex, cardIndex);
if (movableStack && movableStack.length > 0) {
draggedCards = movableStack;
draggedCards.sourceStackIndex = stackIndex;
const cardPos = getCardPosition(card, stackIndex);
dragOffsetX = coords.x - cardPos.x;
dragOffsetY = coords.y - cardPos.y;
}
} else if (element.type === 'stock') {
handleStockClick();
}
}
/**
* (★ 신규) 랭킹 등록 처리 함수
* user.js의 공통 submitRank 함수를 호출합니다.
*/
async function handleRankSubmit() {
const playerName = prompt("랭킹에 등록할 이름을 입력하세요:", "Player");
if (!playerName || playerName.trim() === "") return;
try {
await submitRank(
currentGameType,
currentContextId,
playerName.trim(),
currentGame.moves,
completionTimeSeconds
);
alert("랭킹이 등록되었습니다!");
currentGame = null;
isGameCompleted = false;
draw();
} catch (error) {
console.error("Rank submission failed:", error);
alert("랭킹 등록에 실패했습니다: " + error.message);
}
}
function handlePointerMove(event) {
if (!draggedCards || draggedCards.length === 0) return;
event.preventDefault();
const coords = getCanvasCoordinates(event);
if (!isDragging) {
const dx = coords.x - touchStart.x;
const dy = coords.y - touchStart.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_THRESHOLD) {
isDragging = true;
}
}
if (isDragging) {
draggedCards[0].x = coords.x - dragOffsetX;
draggedCards[0].y = coords.y - dragOffsetY;
}
draw();
}
function handlePointerUp(event) {
if (!isDragging || draggedCards.length === 0) {
returnToOriginalPosition();
return;
}
const coords = getCanvasCoordinates(event);
const dropTargetStackId = findStackAt(coords.x, coords.y);
const sourceStackIndex = draggedCards.sourceStackIndex;
if (dropTargetStackId) {
const destinationStackIndex = (parseInt(dropTargetStackId.split('-')[1]) || 1) - 1;
const isValid = isValidMove(draggedCards, destinationStackIndex);
if (isValid) {
moveCardLocally(draggedCards, sourceStackIndex, destinationStackIndex);
checkCompletedStacks();
updateGameOnServer(currentGame);
} else {
returnToOriginalPosition();
}
} else {
returnToOriginalPosition();
}
isDragging = false;
draggedCards = [];
draw();
}
function returnToOriginalPosition() {
isDragging = false;
draggedCards = [];
}
function handleStockClick() {
if (!currentGame || isAnimating || currentGame.stock.length === 0) return;
dealFromStock();
}
function handleDoubleClick(event) {
if (!currentGame || isGameCompleted) {
return;
}
const coords = getCanvasCoordinates(event);
const clickedCardData = findCardAt(coords.x, coords.y);
if (clickedCardData) {
const { card, stackIndex, cardIndex } = clickedCardData;
const movableStack = getCardStackForMove(card, stackIndex, cardIndex);
if (movableStack) {
const destinationStackId = getBestMoveForStack(movableStack);
if (destinationStackId) {
const destinationStackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1;
moveCardLocally(movableStack, stackIndex, destinationStackIndex);
checkCompletedStacks();
updateGameOnServer(currentGame);
}
}
}
}
/**
* (★ 수정) checkCompletedStacks (게임 완료 시 타이머 중지)
*/
function checkCompletedStacks() {
let completedCount = 0;
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) {
completedCount++;
isAnimatingCompletion = true;
const cardsToRemove = stack.splice(stack.length - 13, 13);
cardsToRemove.forEach(card => {
const cardPos = getCardPosition(card, stackIndex);
card.animStartX = cardPos.x;
card.animStartY = cardPos.y;
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);
});
if (stack.length > 0) {
stack[stack.length - 1].isFaceUp = true;
}
currentGame.foundation.push(cardsToRemove);
}
}
const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0);
if (totalFoundationCards === 104 && !isGameCompleted) {
isGameCompleted = true;
completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000);
console.log(`Game Won! Moves: ${currentGame.moves}, Time: ${completionTimeSeconds}s`);
}
}
function isValidMove(cardsToMove, destinationStackIndex) {
if (cardsToMove.length === 0) return false;
const firstCardToMove = cardsToMove[0];
const destStackCards = currentGame.tableau[destinationStackIndex];
if (destStackCards.length === 0) {
return true;
}
const destTopCard = destStackCards[destStackCards.length - 1];
return firstCardToMove.rank === destTopCard.rank - 1;
}
function moveCardLocally(cardsToMove, sourceStackIndex, destinationStackIndex) {
const sourceStack = currentGame.tableau[sourceStackIndex];
const newSourceStack = sourceStack.slice(0, sourceStack.length - cardsToMove.length);
const destinationStack = currentGame.tableau[destinationStackIndex];
const newDestinationStack = [...destinationStack, ...cardsToMove];
const newTableau = [...currentGame.tableau];
newTableau[sourceStackIndex] = newSourceStack;
newTableau[destinationStackIndex] = newDestinationStack;
if (newSourceStack.length > 0 && !newSourceStack[newSourceStack.length - 1].isFaceUp) {
newSourceStack[newSourceStack.length - 1].isFaceUp = true;
}
currentGame.tableau = newTableau;
currentGame.moves++;
}
/**
* (★ 수정) handleUndo (통합 API 경로로 변경)
*/
async function handleUndo() {
if (!currentGame || isAnimating || currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) {
console.log("실행 취소 불가");
return;
}
try {
const response = await fetch(`/puzzle/spider/undo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameId: currentGame.id })
});
if (!response.ok) throw new Error('Undo failed on server');
currentGame = await response.json();
draw();
} catch (error) {
console.error("실행 취소 중 오류 발생:", error);
}
}
// =======================================
// 5. 서버 통신 함수 (★ API 경로 전체 수정)
// =======================================
/**
* (★ 수정) API 경로 변경 -> /puzzle/spider/deal
*/
async function dealFromStock() {
if (!currentGame || isAnimating || currentGame.stock.length === 0) return;
isAnimating = true;
try {
const response = await fetch(`/puzzle/spider/deal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameId: currentGame.id })
});
if (!response.ok) throw new Error('Deal failed on server');
currentGame = await response.json();
draw();
} catch (error) {
console.error("카드 분배 중 오류 발생:", error);
} finally {
isAnimating = false;
}
}
/**
* (★ 수정) API 경로 변경 -> /puzzle/spider/update
*/
async function updateGameOnServer(updatedGame) {
try {
const response = await fetch(`/puzzle/spider/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedGame)
});
if (!response.ok) throw new Error('Update failed on server');
currentGame = await response.json();
isDragging = false;
draggedCards = [];
draw();
} catch (error) {
console.error("게임 상태 업데이트 중 오류 발생:", error);
}
}
/**
* (★ 수정) API 경로 변경 및 타이머/ContextId 설정
*/
async function startNewGame() {
if (!assetsLoaded) return;
const numSuits = selectedSuit;
const numCards = selectedCardCount;
currentContextId = `${numSuits}_SUITS_${numCards.replace(',', '-')}`;
try {
const response = await fetch(`/puzzle/spider/new?numSuits=${numSuits}&numCards=${numCards}`);
if (!response.ok) throw new Error('Failed to start new game');
currentGame = await response.json();
gameId = currentGame.id;
isDragging = false;
draggedCards = [];
isGameCompleted = false;
gameStartTime = Date.now();
completionTimeSeconds = 0;
draw();
} catch (error) {
console.error("새 게임 시작 중 오류 발생:", error);
}
}
// =======================================
// 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}`;
}
}
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 getCardPosition(card, stackIndex) {
const startY = cardHeight * 0.5;
const stackCards = currentGame.tableau[stackIndex];
const cardIndexInStack = stackCards.findIndex(c => c.suit === card.suit && c.rank === card.rank);
const x = tableauStartX + stackIndex * (cardWidth + cardGapX);
const y = startY + cardIndexInStack * cardOverlapY;
return { x, y };
}
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 destStackId = `tableau-${i + 1}`;
const destStackCards = currentGame.tableau[i];
if (destStackCards.length === 0) {
return destStackId;
} else {
const destTopCard = destStackCards[destStackCards.length - 1];
if (firstCardToMove.rank === destTopCard.rank - 1) {
return destStackId;
}
}
}
return null;
}
// --- 초기화 ---
resizeCanvas();
draw();
});
//]]>
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-03 18:00:39 +09:00
<div id="game-container">
<canvas id="gameCanvas"></canvas>
</div>
2025-09-12 18:01:23 +09:00
</div>
2025-09-03 18:00:39 +09:00
</th:block>
2025-09-12 16:55:21 +09:00
</html>