diff --git a/src/main/resources/static/css/play.css b/src/main/resources/static/css/play.css index b9935c0..fc3666b 100644 --- a/src/main/resources/static/css/play.css +++ b/src/main/resources/static/css/play.css @@ -51,6 +51,32 @@ body { max-width: 500px; /* 보드와 비슷한 너비로 설정 */ margin-bottom: 15px; font-size: 1.2em; + flex-wrap: wrap; /* Allow controls to wrap on small screens */ + gap: 15px; +} + +/* (★ ADD) Styles for the mode selector */ +#mode-selector { + display: flex; + gap: 10px; + background-color: #eee; + padding: 8px; + border-radius: 8px; +} +#mode-selector label { + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + user-select: none; +} +/* Style for the selected radio button's label */ +#mode-selector input:checked + span { + background-color: #007bff; + color: white; +} +/* Hide the actual radio buttons */ +#mode-selector input { + display: none; } #hint-btn { padding: 8px 15px; diff --git a/src/main/resources/static/js/play.js b/src/main/resources/static/js/play.js index 1dd1754..93e30d0 100644 --- a/src/main/resources/static/js/play.js +++ b/src/main/resources/static/js/play.js @@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { } // --- DOM 요소 참조 --- + const modeSelector = document.getElementById('mode-selector'); const gameBoard = document.getElementById('game-board'); const pointsDisplay = document.getElementById('points-display'); const hintBtn = document.getElementById('hint-btn'); @@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => { const modalButtons = document.getElementById('modal-buttons'); // --- 게임 상태를 관리하는 변수 --- + let currentMode = 'fill'; let points = 5; let isGameFinished = false; let isDragging = false; @@ -44,6 +46,13 @@ document.addEventListener('DOMContentLoaded', () => { let lockedRows = Array(numRows).fill(false); let lockedCols = Array(numCols).fill(false); + /** + * Updates the currentMode based on the selected radio button + */ + function updateMode() { + currentMode = document.querySelector('input[name="play-mode"]:checked').value; + } + /** * 폰트 높이와 두 자릿수 숫자 너비를 기준으로 최적의 셀 크기를 계산합니다. * @returns {number} 계산된 각 그리드 셀의 크기 (px) @@ -173,47 +182,88 @@ document.addEventListener('DOMContentLoaded', () => { } /** - * 상호작용 그리드에 마우스 이벤트 리스너를 등록합니다. + * (★ REVISED) Event Listeners for Mouse and Touch */ function attachEventListeners(grid) { - grid.addEventListener('mousedown', (e) => { - if (isGameFinished || !e.target.classList.contains('grid-cell')) return; - isDragging = true; - e.preventDefault(); - const cell = e.target; - const row = parseInt(cell.dataset.row); - const col = parseInt(cell.dataset.col); - startCell = { row, col }; - lastHoveredCell = startCell; - const currentState = playerGrid[row][col]; - if (e.button === 0) { - dragAction = (currentState === 1) ? 'clear' : 'fill'; - } else if (e.button === 2) { - dragAction = (currentState === -1) ? 'clear' : 'mark'; - } - updateSelectionVisuals(); - }); - grid.addEventListener('mouseover', (e) => { - if (!isDragging || !e.target.classList.contains('grid-cell')) return; - const row = parseInt(e.target.dataset.row); - const col = parseInt(e.target.dataset.col); + // --- MOUSE EVENTS --- + grid.addEventListener('mousedown', (e) => handleDragStart(e)); + grid.addEventListener('mouseover', (e) => handleDragMove(e)); + grid.addEventListener('contextmenu', (e) => e.preventDefault()); + + // --- TOUCH EVENTS --- + grid.addEventListener('touchstart', (e) => handleDragStart(e), { passive: false }); + grid.addEventListener('touchmove', (e) => handleDragMove(e), { passive: false }); + } + + // End drag for both mouse and touch + window.addEventListener('mouseup', () => handleDragEnd()); + window.addEventListener('touchend', () => handleDragEnd()); + + // Listen for changes on the mode selector + modeSelector.addEventListener('change', updateMode); + + /** + * (★ NEW) Handles the start of a drag (mousedown or touchstart) + */ + function handleDragStart(e) { + if (isGameFinished || !e.target.classList.contains('grid-cell')) return; + isDragging = true; + e.preventDefault(); + + const cell = e.target; + const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)]; + + // Determine action based on mode, not button + if (currentMode === 'fill') { + dragAction = (currentState === 1) ? 'clear' : 'fill'; + } else { // currentMode === 'mark' + dragAction = (currentState === -1) ? 'clear' : 'mark'; + } + + // Update selection visuals + startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) }; + lastHoveredCell = startCell; + updateSelectionVisuals(); + } + + /** + * (★ NEW) Handles movement during a drag (mouseover or touchmove) + */ + function handleDragMove(e) { + if (!isDragging) return; + e.preventDefault(); + + // For touch events, we need to find the element under the finger + const target = (e.touches) + ? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY) + : e.target; + + if (target && target.classList.contains('grid-cell')) { + const row = parseInt(target.dataset.row); + const col = parseInt(target.dataset.col); + if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) { lastHoveredCell = { row, col }; updateSelectionVisuals(); } - }); - grid.addEventListener('contextmenu', (e) => e.preventDefault()); + } } - // 드래그가 화면 어디에서 끝나든 감지하기 위해 window에 등록 - window.addEventListener('mouseup', () => { + /** + * (★ NEW) Handles the end of a drag (mouseup or touchend) + */ + function handleDragEnd() { if (!isDragging) return; + currentSelection.forEach(cell => updateCellState(cell, dragAction)); clearSelectionVisuals(); + if (dragAction === 'fill' || dragAction === 'clear') { checkAndLockCompletedLines(affectedRows, affectedCols); } checkWinCondition(); + + // Reset state isDragging = false; dragAction = null; startCell = null; @@ -221,7 +271,7 @@ document.addEventListener('DOMContentLoaded', () => { currentSelection.clear(); affectedRows.clear(); affectedCols.clear(); - }); + } /** * 드래그 중인 사각형 영역을 계산하고 시각적으로 업데이트하는 함수 @@ -361,13 +411,59 @@ document.addEventListener('DOMContentLoaded', () => { isGameFinished = true; document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none'; hintBtn.disabled = true; - // ... (성공 애니메이션 로직은 여기에 추가될 수 있습니다) - showResultModal({ - title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [ - { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' }, - { text: '홈으로 (Home)', action: () => window.location.href = '/' } - ] - }); + // --- 요소 참조 --- + const puzzleGridContainer = document.querySelector('.puzzle-grid-container'); + const grayscaleImg = document.getElementById('grayscale-reveal'); + const originalImg = document.getElementById('original-reveal'); + + // --- 상호작용 비활성화 --- + puzzleGridContainer.style.pointerEvents = 'none'; + hintBtn.disabled = true; + + // --- 애니메이션 준비 --- + // 1. 이미지 소스 설정 + // (puzzleData에 저장해 둔 Base64 인코딩된 이미지 데이터를 사용합니다) + grayscaleImg.src = puzzleData.grayscaleImage; + originalImg.src = puzzleData.originalImage; + + // 2. 현재 보드의 transform(스케일) 값을 이미지에도 동일하게 적용하여 정렬 + // 이렇게 해야 반응형으로 크기가 조절된 보드 위에도 이미지가 정확히 겹칩니다. + const boardTransform = gameBoard.style.transform; + grayscaleImg.style.transform = boardTransform; + originalImg.style.transform = boardTransform; + + // --- 애니메이션 순차 실행 --- + setTimeout(() => { + // 3. 그레이스케일 이미지 페이드 인 + // (CSS transition 속성에 의해 부드럽게 나타납니다) + grayscaleImg.style.opacity = '1'; + + setTimeout(() => { + // 4. 컬러 이미지 페이드 인 (크로스페이드 효과) + // (그레이스케일 이미지 위에 컬러 이미지가 겹쳐지며 나타납니다) + originalImg.style.opacity = '1'; + + setTimeout(() => { + // 5. 애니메이션이 모두 끝난 후 최종 결과 모달 표시 + showResultModal({ + title: 'Success! 🎉', + message: '퍼즐을 완성했습니다!', + buttons: [ + { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' }, + { text: '홈으로 (Home)', action: () => window.location.href = '/' } + ] + }); + }, 2000); // 컬러 이미지가 나타나고 2초 후 + + }, 2000); // 그레이스케일 이미지가 나타나고 2초 후 + + }, 500); // 마지막 셀을 채우고 0.5초 후 시작 + // showResultModal({ + // title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [ + // { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' }, + // { text: '홈으로 (Home)', action: () => window.location.href = '/' } + // ] + // }); } // 힌트 버튼 클릭 이벤트 처리 @@ -409,6 +505,7 @@ document.addEventListener('DOMContentLoaded', () => { const optimalCellSize = calculateCellSize(); drawBoard(optimalCellSize); updatePointsDisplay(); + updateMode(); // Set initial mode requestAnimationFrame(() => { fitBoardToScreen(); diff --git a/src/main/resources/templates/content/puzzle/play.html b/src/main/resources/templates/content/puzzle/play.html index 67195da..ff9be93 100644 --- a/src/main/resources/templates/content/puzzle/play.html +++ b/src/main/resources/templates/content/puzzle/play.html @@ -19,6 +19,15 @@