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>
|