diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index 1a2109b..a7fe94b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -71,7 +71,7 @@ class SecurityConfig( csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx", - "/blog/post/images/**","/puzzle/upload.bjx" + "/blog/post/images/**","/puzzle/**","/puzzle/play/**" ) // 여기 예외 추가 }.authorizeHttpRequests { auth -> auth @@ -84,7 +84,7 @@ class SecurityConfig( "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", // "/blog/post/imageUpload.bjx", "/blog/post/images/**", - "/puzzle/upload.bjx", + "/puzzle/play","/puzzle/play/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() .anyRequest().authenticated() }.formLogin { form -> diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index b1c273e..8dbaafb 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -1,9 +1,13 @@ package kr.lunaticbum.back.lun.controllers +import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.model.PuzzleService import kr.lunaticbum.back.lun.model.ResultMV import org.springframework.http.ResponseEntity +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -25,6 +29,57 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}") } } + /** + * 특정 ID의 퍼즐을 삭제하는 엔드포인트 + * @param id URL 경로에서 추출한 퍼즐의 고유 ID + */ + @DeleteMapping("/{id}.bjx") + suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity { + return try { + puzzleService.deletePuzzle(id) + // 성공적으로 삭제되면 204 No Content 응답을 보냅니다. + ResponseEntity.noContent().build() + } catch (e: Exception) { + // 실패 시 500 에러 응답 + ResponseEntity.internalServerError().build() + } + } + + + /** + * ID가 지정된 경우 특정 퍼즐을 로드합니다. + */ + @GetMapping("/play/{id}") + suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV { + val puzzle = puzzleService.findById(id).awaitSingleOrNull() + val vm = ResultMV("content/puzzle/play") + return if (puzzle != null) { + vm.model.put("puzzle", puzzle) + vm + } else { + // DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트 + vm.viewName = "redirect:/" + vm + } + } + + /** + * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + */ + @GetMapping("/play") + suspend fun playRandomPuzzlePage(): ResultMV { + val puzzle = puzzleService.findRandomPuzzle() + val vm = ResultMV("content/puzzle/play") + return if (puzzle != null) { + vm.model.put("puzzle", puzzle) + vm + } else { + // DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트 + vm.viewName = "redirect:/" + vm + } + } + @GetMapping("/","/upload.bs") suspend fun uploadPuzzle() : ResultMV { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index ac960f9..47edf66 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -1,18 +1,24 @@ package kr.lunaticbum.back.lun.model import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.withContext import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.repository.Aggregation import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.stereotype.Repository import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono import java.awt.Color import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream import java.time.LocalDateTime +import java.util.Base64 import javax.imageio.ImageIO @@ -23,26 +29,49 @@ data class NonogramPuzzle( val solutionGrid: List>, val rowClues: List>, val colClues: List>, - val createdAt: LocalDateTime = LocalDateTime.now() // 생성 시점의 기본값 설정 + // Add these two fields + val grayscaleImage: String, // Base64 encoded grayscale image + val originalImage: String, // Base64 encoded original color image + + val createdAt: LocalDateTime = LocalDateTime.now() + ) @Repository interface NonogramPuzzleRepository : ReactiveMongoRepository { // ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공 + /** + * (★ Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는 + * 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다. + */ + @Aggregation(pipeline = [ + "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }", + "{ \$sample: { size: 1 } }" + ]) + fun findRandom(): Flux } @Service class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입 + /** + * 랜덤으로 퍼즐 하나를 찾아서 반환합니다. + * @return 찾은 퍼즐 또는 DB가 비어있으면 null + */ + suspend fun findRandomPuzzle(): NonogramPuzzle? { + return puzzleRepository.findRandom().awaitFirstOrNull() + } + fun findById(id: String) = puzzleRepository.findById(id) + fun deletePuzzle(id : String) = puzzleRepository.deleteById(id) // suspend 함수로 비동기 작업을 선언 suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle { val puzzleData = withContext(Dispatchers.IO) { val originalImage = ImageIO.read(file.inputStream) - - // 1. (★중요) 투명 배경을 흰색으로 먼저 변환합니다. val imageWithBackground = convertTransparentToWhite(originalImage) - // 2. 그레이스케일 변환 (흰색 배경이 적용된 이미지를 사용) + // Create a resized color version for the final reveal + val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display + val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply { createGraphics().run { drawImage(imageWithBackground, 0, 0, size, size, null) @@ -50,26 +79,49 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // } } - // 3. 평균 밝기를 계산하여 동적 임계치 결정 val averageBrightness = calculateAverageBrightness(grayImage) val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness) - // 4. 결정된 임계치로 최종 그리드 생성 val solutionGrid = List(size) { y -> List(size) { x -> if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 } } - // 5. 힌트 추출 및 객체 생성 val rowClues = solutionGrid.map { getCluesForLine(it) } val colClues = transpose(solutionGrid).map { getCluesForLine(it) } - NonogramPuzzle(solutionGrid = solutionGrid, rowClues = rowClues, colClues = colClues) + // Convert images to Base64 strings + val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300)) + val originalBase64 = imageToBase64(resizedOriginal) + + NonogramPuzzle( + solutionGrid = solutionGrid, + rowClues = rowClues, + colClues = colClues, + grayscaleImage = grayscaleBase64, + originalImage = originalBase64 + ) } return puzzleRepository.save(puzzleData).awaitSingle() } + // Helper function to resize images + private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage { + return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply { + createGraphics().run { + drawImage(sourceImage, 0, 0, size, size, null) + dispose() + } + } + } + + // Helper function to convert a BufferedImage to a Base64 String + private fun imageToBase64(image: BufferedImage): String { + val os = ByteArrayOutputStream() + ImageIO.write(image, "png", os) + return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()) + } /** * (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다. diff --git a/src/main/resources/static/css/play.css b/src/main/resources/static/css/play.css new file mode 100644 index 0000000..b9935c0 --- /dev/null +++ b/src/main/resources/static/css/play.css @@ -0,0 +1,204 @@ +body { + font-family: sans-serif; + align-items: center; +} + +#board-viewport { + position: relative; + width: 100%; + max-width: 95vw; /* 화면 너비에 좀 더 맞춤 */ + margin: 20px auto; + /* (★ 핵심 수정) Flexbox로 자식 요소를 중앙 정렬합니다. */ + display: flex; + justify-content: center; /* 자식 요소를 수평 중앙 정렬 */ + align-items: flex-start; /* 위쪽에 정렬 */ +} +/* (★ ADD) Styles for the reveal images */ +.reveal-img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; /* Hidden by default */ + pointer-events: none; /* Make them unclickable */ + transition: opacity 1.5s ease-in-out; /* Fade animation */ + transform-origin: top left; /* Align with the game board's scaling */ +} + +.guide-line-right { + border-right: 2px solid #999 !important; +} + +.guide-line-bottom { + border-bottom: 2px solid #999 !important; +} + +#game-board { + display: grid; + gap: 1px; + background-color: #999; + border: 2px solid #333; + /* transform-origin은 그대로 유지합니다. */ + transform-origin: top; +} +/* (★ 추가) 게임 컨트롤 영역 스타일 */ +#game-controls { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + max-width: 500px; /* 보드와 비슷한 너비로 설정 */ + margin-bottom: 15px; + font-size: 1.2em; +} +#hint-btn { + padding: 8px 15px; + font-weight: bold; + cursor: pointer; + border-radius: 5px; + border: 1px solid #ccc; +} +#hint-btn:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.col-clues-container, .row-clues-container { + display: flex; +} +.row-clues-container { + flex-direction: column; +} + +.puzzle-grid-container { + display: grid; + border: 2px solid #333; +} + +.clue-cell { + background-color: #f0f0f0; + font-weight: bold; + font-size: 14px; + box-sizing: border-box; + display: flex; + padding: 5px; +} + +.row-clue { + justify-content: flex-end; /* 힌트 오른쪽 정렬 */ + align-items: center; +} + +.col-clue { + justify-content: center; /* 힌트 가운데 정렬 */ + align-items: flex-end; /* 힌트 아래쪽 정렬 */ + text-align: center; + line-height: 1.2; /* 줄 간격 */ +} + +.grid-cell { + background-color: #fff; + border: 1px solid #ddd; + box-sizing: border-box; + cursor: pointer; +} + +.grid-cell.filled { + background-color: #333; +} + +.grid-cell.marked::after { + content: 'X'; + color: #ff5c5c; + font-weight: bold; + font-size: 1.2em; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +/* (★ 추가) 실수했을 때 셀 스타일 */ +.grid-cell.incorrect { + background-color: #ffcccc; + animation: shake 0.5s; +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + + +#result-overlay { + position: fixed; /* 화면 전체에 고정 */ + top: 0; + left: 0; + width: 100vw; /* 뷰포트 너비 100% */ + height: 100vh; /* 뷰포트 높이 100% */ + background-color: rgba(0, 0, 0, 0.75); /* 반투명 검은 배경 */ + display: flex; + justify-content: center; + align-items: center; + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-in-out; +} +#result-overlay.visible { + opacity: 1; + pointer-events: auto; +} +/* (★ 추가) 모달 팝업 스타일 */ +#result-modal { + background-color: white; + padding: 20px 40px; + border-radius: 10px; + text-align: center; + box-shadow: 0 5px 15px rgba(0,0,0,0.3); +} +#modal-title { + margin-top: 0; + font-size: 2.5em; +} +#modal-buttons button { + padding: 10px 20px; + margin: 0 10px; + font-size: 1em; + cursor: pointer; + border-radius: 5px; + border: 1px solid #ccc; + min-width: 120px; +} +#modal-buttons button.primary { + background-color: #4CAF50; + color: white; + border-color: #4CAF50; +} + +/* 기존 .hidden 클래스는 이제 overlay의 visible/hidden 상태 관리에 사용됩니다. */ +.hidden { + display: none; +} + +/* play.css */ + +/* (★ 추가) 완성된 힌트 스타일 */ +.clue-cell.completed { + color: #999; /* 색상을 회색으로 */ + text-decoration: line-through; /* 취소선 */ +} + +/* (★ 추가) 잠긴 셀 스타일 */ +.grid-cell.locked { + opacity: 0.8; /* 약간 투명하게 */ + /* 잠긴 셀은 더 이상 클릭할 수 없다는 것을 시각적으로 보여줌 */ +} + +.grid-cell.selecting { + background-color: rgba(0, 123, 255, 0.3); /* 반투명 파란색 배경 */ + border-color: rgba(0, 123, 255, 0.5); +} + diff --git a/src/main/resources/static/css/puzzle.css b/src/main/resources/static/css/puzzle.css index 1ccfce8..3e7b973 100644 --- a/src/main/resources/static/css/puzzle.css +++ b/src/main/resources/static/css/puzzle.css @@ -38,4 +38,27 @@ .empty { background-color: #fff; +} + +#puzzle-wrapper { + position: relative; /* Needed for absolute positioning of children */ +} + +#success-animation-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; /* Allows clicking through the container */ +} + +#success-animation-container img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; /* Hidden by default */ + transition: opacity 1.0s ease-in-out; /* Fade animation */ } \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 309a70b..3af7a19 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -262,6 +262,10 @@ function gotoModify() { document.location.replace(getMainPath()+"/blog/modify.bs") } +function gotoPuzzleUpload() { + document.location.replace(getMainPath()+"/puzzle/upload.bs") +} + function gotoWhere() { document.location.replace(getMainPath()+"/bums/where.bs") } diff --git a/src/main/resources/static/js/play.js b/src/main/resources/static/js/play.js new file mode 100644 index 0000000..1dd1754 --- /dev/null +++ b/src/main/resources/static/js/play.js @@ -0,0 +1,417 @@ +/** + * Nonogram Game Logic - 최종 통합 및 수정 완료 버전 + * * ## 포함된 모든 기능: + * 1. 동적 셀 크기 계산 및 가독성을 위한 넓은 힌트 영역 + * 2. 게임 보드, 힌트, 상호작용 그리드 렌더링 + * 3. 화면 크기에 맞는 반응형 보드 크기 조절 + * 4. 사각형 선택 드래그 기능 등 모든 사용자 상호작용 처리 + * 5. 게임 규칙 (포인트, 힌트, 실수 페널티, 승리/패배 조건) + * 6. '칠한 칸' 기준으로만 라인 완성 및 잠금 처리 + * 7. 사용자가 마지막으로 수정한 라인만 검사하는 최적화 + */ +document.addEventListener('DOMContentLoaded', () => { + // 백엔드(Thymeleaf)로부터 puzzleData가 제대로 전달되었는지 확인 + if (typeof puzzleData === 'undefined' || !puzzleData) { + document.getElementById('game-board').innerHTML = '

오류: 퍼즐 데이터를 불러올 수 없습니다.

'; + return; + } + + // --- DOM 요소 참조 --- + const gameBoard = document.getElementById('game-board'); + const pointsDisplay = document.getElementById('points-display'); + const hintBtn = document.getElementById('hint-btn'); + const resultOverlay = document.getElementById('result-overlay'); + const modalTitle = document.getElementById('modal-title'); + const modalMessage = document.getElementById('modal-message'); + const modalButtons = document.getElementById('modal-buttons'); + + // --- 게임 상태를 관리하는 변수 --- + let points = 5; + let isGameFinished = false; + let isDragging = false; + let dragAction = null; // 'fill', 'mark', 'clear' + let startCell = null; // 드래그 시작 셀 좌표 {row, col} + let lastHoveredCell = null; // 마지막으로 마우스가 지나간 셀 좌표 + let currentSelection = new Set(); // 현재 드래그로 선택된 셀들의 Set + let affectedRows = new Set(); // 마지막 행동으로 영향을 받은 행 + let affectedCols = new Set(); // 마지막 행동으로 영향을 받은 열 + + // --- 퍼즐 데이터 및 플레이어 진행 상황 --- + const solution = puzzleData.solutionGrid; + const numRows = solution.length; + const numCols = solution[0].length; + let playerGrid = Array(numRows).fill(0).map(() => Array(numCols).fill(0)); + let lockedRows = Array(numRows).fill(false); + let lockedCols = Array(numCols).fill(false); + + /** + * 폰트 높이와 두 자릿수 숫자 너비를 기준으로 최적의 셀 크기를 계산합니다. + * @returns {number} 계산된 각 그리드 셀의 크기 (px) + */ + function calculateCellSize() { + const tempContainer = document.createElement('div'); + tempContainer.style.position = 'absolute'; + tempContainer.style.visibility = 'hidden'; + const tempCell = document.createElement('div'); + tempCell.className = 'clue-cell'; + tempCell.textContent = '0'; + tempContainer.appendChild(tempCell); + document.body.appendChild(tempContainer); + const fontHeight = tempCell.offsetHeight; + tempCell.textContent = '10'; + const doubleDigitWidth = tempCell.offsetWidth; + document.body.removeChild(tempContainer); + const baseSize = Math.max(fontHeight, doubleDigitWidth, 30); + return baseSize + 10; + } + + /** + * 계산된 셀 크기를 사용하여 전체 게임 보드를 그립니다. + * @param {number} cellSize - 계산된 셀의 통일된 크기 + */ + function drawBoard(cellSize) { + const rowClueAreaWidth = cellSize * 2; + const colClueAreaHeight = cellSize * 2; + gameBoard.style.gridTemplateColumns = `${rowClueAreaWidth}px 1fr`; + gameBoard.style.gridTemplateRows = `${colClueAreaHeight}px 1fr`; + const corner = document.createElement('div'); + const colCluesContainer = document.createElement('div'); + colCluesContainer.className = 'col-clues-container'; + const rowCluesContainer = document.createElement('div'); + rowCluesContainer.className = 'row-clues-container'; + const puzzleGridContainer = document.createElement('div'); + puzzleGridContainer.className = 'puzzle-grid-container'; + puzzleGridContainer.style.gridTemplateColumns = `repeat(${numCols}, ${cellSize}px)`; + puzzleGridContainer.style.gridTemplateRows = `repeat(${numRows}, ${cellSize}px)`; + puzzleData.colClues.forEach((clues, index) => { + const clueCell = document.createElement('div'); + clueCell.className = 'clue-cell col-clue'; + clueCell.id = `col-clue-${index}`; + clueCell.style.width = `${cellSize}px`; + clueCell.innerHTML = clues.join('
'); + if ((index + 1) % 5 === 0 && index < numCols - 1) clueCell.classList.add('guide-line-right'); + colCluesContainer.appendChild(clueCell); + }); + puzzleData.rowClues.forEach((clues, index) => { + const clueCell = document.createElement('div'); + clueCell.className = 'clue-cell row-clue'; + clueCell.id = `row-clue-${index}`; + clueCell.style.height = `${cellSize}px`; + clueCell.textContent = clues.join(' '); + if ((index + 1) % 5 === 0 && index < numRows - 1) clueCell.classList.add('guide-line-bottom'); + rowCluesContainer.appendChild(clueCell); + }); + for (let r = 0; r < numRows; r++) { + for (let c = 0; c < numCols; c++) { + const cell = document.createElement('div'); + cell.className = 'grid-cell'; + cell.dataset.row = r; + cell.dataset.col = c; + if ((c + 1) % 5 === 0 && c < numCols - 1) cell.classList.add('guide-line-right'); + if ((r + 1) % 5 === 0 && r < numRows - 1) cell.classList.add('guide-line-bottom'); + puzzleGridContainer.appendChild(cell); + } + } + gameBoard.appendChild(corner); + gameBoard.appendChild(colCluesContainer); + gameBoard.appendChild(rowCluesContainer); + gameBoard.appendChild(puzzleGridContainer); + attachEventListeners(puzzleGridContainer); + } + + /** + * 화면 너비에 맞춰 게임 보드 전체를 비율 그대로 축소/확대합니다. + */ + function fitBoardToScreen() { + const viewport = document.getElementById('board-viewport'); + const board = document.getElementById('game-board'); + board.style.transform = 'scale(1)'; + const boardRect = board.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + if (boardRect.width > viewportRect.width) { + const scale = viewportRect.width / boardRect.width; + board.style.transform = `scale(${scale})`; + viewport.style.height = `${boardRect.height * scale}px`; + } else { + board.style.transform = 'scale(1)'; + viewport.style.height = `${boardRect.height}px`; + } + } + + /** + * 셀의 상태를 변경하는 유일한 함수. 모든 사용자 입력은 이 함수를 거칩니다. + */ + function updateCellState(cell, action) { + if (isGameFinished) return; + const row = parseInt(cell.dataset.row); + const col = parseInt(cell.dataset.col); + if (lockedRows[row] || lockedCols[col]) return; + affectedRows.add(row); + affectedCols.add(col); + const currentState = playerGrid[row][col]; + let newState = currentState; + if (action === 'fill') { + if (solution[row][col] === 0) { + points--; + updatePointsDisplay(); + cell.classList.add('incorrect'); + setTimeout(() => cell.classList.remove('incorrect'), 500); + if (points <= 0) triggerGameOver(); + return; + } + newState = 1; + } else if (action === 'mark') { + newState = -1; + } else if (action === 'clear') { + newState = 0; + } + if (currentState !== newState) { + playerGrid[row][col] = newState; + cell.classList.toggle('filled', newState === 1); + cell.classList.toggle('marked', newState === -1); + } + } + + /** + * 상호작용 그리드에 마우스 이벤트 리스너를 등록합니다. + */ + 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); + if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) { + lastHoveredCell = { row, col }; + updateSelectionVisuals(); + } + }); + grid.addEventListener('contextmenu', (e) => e.preventDefault()); + } + + // 드래그가 화면 어디에서 끝나든 감지하기 위해 window에 등록 + window.addEventListener('mouseup', () => { + if (!isDragging) return; + currentSelection.forEach(cell => updateCellState(cell, dragAction)); + clearSelectionVisuals(); + if (dragAction === 'fill' || dragAction === 'clear') { + checkAndLockCompletedLines(affectedRows, affectedCols); + } + checkWinCondition(); + isDragging = false; + dragAction = null; + startCell = null; + lastHoveredCell = null; + currentSelection.clear(); + affectedRows.clear(); + affectedCols.clear(); + }); + + /** + * 드래그 중인 사각형 영역을 계산하고 시각적으로 업데이트하는 함수 + */ + function updateSelectionVisuals() { + const newSelection = new Set(); + if (!startCell || !lastHoveredCell) return; + const r1 = Math.min(startCell.row, lastHoveredCell.row); + const r2 = Math.max(startCell.row, lastHoveredCell.row); + const c1 = Math.min(startCell.col, lastHoveredCell.col); + const c2 = Math.max(startCell.col, lastHoveredCell.col); + for (let r = r1; r <= r2; r++) { + for (let c = c1; c <= c2; c++) { + const cell = document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`); + if (cell) newSelection.add(cell); + } + } + currentSelection.forEach(cell => { + if (!newSelection.has(cell)) cell.classList.remove('selecting'); + }); + newSelection.forEach(cell => { + if (!currentSelection.has(cell)) cell.classList.add('selecting'); + }); + currentSelection = newSelection; + } + + /** + * 모든 시각적 피드백을 제거하는 함수 + */ + function clearSelectionVisuals() { + currentSelection.forEach(cell => cell.classList.remove('selecting')); + } + + /** + * 특정 행이 '칠해야 할 칸'을 모두 만족했는지 검사 + */ + function isRowComplete(rowIndex) { + for (let c = 0; c < numCols; c++) { + if (solution[rowIndex][c] === 1 && playerGrid[rowIndex][c] !== 1) return false; + } + return true; + } + /** + * 특정 열이 '칠해야 할 칸'을 모두 만족했는지 검사 + */ + function isColComplete(colIndex) { + for (let r = 0; r < numRows; r++) { + if (solution[r][colIndex] === 1 && playerGrid[r][colIndex] !== 1) return false; + } + return true; + } + + /** + * 완성된 라인을 확인하고 잠금 처리 및 스타일 변경 (X 자동 완성 없음) + */ + function checkAndLockCompletedLines(rowsToCheck, colsToCheck) { + rowsToCheck.forEach(r => { + if (!lockedRows[r] && isRowComplete(r)) { + lockedRows[r] = true; + document.getElementById(`row-clue-${r}`).classList.add('completed'); + for (let c = 0; c < numCols; c++) { + document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked'); + } + } + }); + colsToCheck.forEach(c => { + if (!lockedCols[c] && isColComplete(c)) { + lockedCols[c] = true; + document.getElementById(`col-clue-${c}`).classList.add('completed'); + for (let r = 0; r < numRows; r++) { + document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked'); + } + } + }); + } + + /** + * 게임 승리 조건 (모든 셀이 정답과 완벽하게 일치)을 확인 + */ + function checkWinCondition() { + if (isGameFinished) return; + for (let r = 0; r < numRows; r++) { + for (let c = 0; c < numCols; c++) { + const playerState = (playerGrid[r][c] === 1) ? 1 : 0; + if (playerState !== solution[r][c]) return; + } + } + triggerGameSuccess(); + } + + /** + * 화면에 현재 포인트를 업데이트 + */ + function updatePointsDisplay() { + pointsDisplay.textContent = points; + hintBtn.disabled = (points <= 0 || isGameFinished); + } + + /** + * 성공/실패 모달을 동적으로 생성하여 표시 + */ + function showResultModal(config) { + modalTitle.textContent = config.title; + modalMessage.textContent = config.message; + modalButtons.innerHTML = ''; + config.buttons.forEach(btnInfo => { + const button = document.createElement('button'); + button.textContent = btnInfo.text; + button.className = btnInfo.class || ''; + button.onclick = btnInfo.action; + modalButtons.appendChild(button); + }); + resultOverlay.classList.remove('hidden'); + setTimeout(() => resultOverlay.classList.add('visible'), 10); + } + + /** + * 게임 실패 처리 + */ + function triggerGameOver() { + if (isGameFinished) return; + isGameFinished = true; + document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none'; + hintBtn.disabled = true; + showResultModal({ + title: 'Failure', message: '포인트를 모두 사용했습니다.', buttons: [ + { text: '재시도 (Retry)', class: 'primary', action: () => window.location.reload() }, + { text: '홈으로 (Home)', action: () => window.location.href = '/' } + ] + }); + } + /** + * 게임 성공 처리 + */ + function triggerGameSuccess() { + if (isGameFinished) return; + 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 = '/' } + ] + }); + } + + // 힌트 버튼 클릭 이벤트 처리 + hintBtn.addEventListener('click', () => { + if (points <= 0 || isGameFinished) return; + points--; + updatePointsDisplay(); + const hintCandidates = []; + for (let r = 0; r < numRows; r++) { + for (let c = 0; c < numCols; c++) { + if (solution[r][c] === 1 && playerGrid[r][c] !== 1) { + hintCandidates.push({ r, c }); + } + } + } + if (hintCandidates.length > 0) { + const hint = hintCandidates[Math.floor(Math.random() * hintCandidates.length)]; + const cellToReveal = document.querySelector(`.grid-cell[data-row='${hint.r}'][data-col='${hint.c}']`); + + // 힌트 사용 시에도 updateCellState를 통해 상태를 변경합니다. + updateCellState(cellToReveal, 'fill'); + + // 힌트로 변경된 셀의 행과 열만 확인합니다. + const hintAffectedRows = new Set([hint.r]); + const hintAffectedCols = new Set([hint.c]); + checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols); + checkWinCondition(); + } else { + alert("더 이상 사용할 힌트가 없습니다!"); + points++; + updatePointsDisplay(); + } + if (points <= 0 && !isGameFinished) { + triggerGameOver(); + } + }); + + // --- 초기 실행 --- + const optimalCellSize = calculateCellSize(); + drawBoard(optimalCellSize); + updatePointsDisplay(); + + requestAnimationFrame(() => { + fitBoardToScreen(); + window.addEventListener('resize', fitBoardToScreen); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js index 7f6e32a..183b645 100644 --- a/src/main/resources/static/js/upload.js +++ b/src/main/resources/static/js/upload.js @@ -1,3 +1,30 @@ +let currentPuzzleData = null; + +// The success animation function +function showSuccessAnimation() { + if (!currentPuzzleData) return; + + const puzzleContainer = document.getElementById('puzzle-container'); + const grayscaleImg = document.getElementById('grayscale-reveal'); + const originalImg = document.getElementById('original-reveal'); + + // 1. Set the image sources from the saved Base64 data + grayscaleImg.src = currentPuzzleData.grayscaleImage; + originalImg.src = currentPuzzleData.originalImage; + + // 2. Hide the puzzle grid and fade in the grayscale image + puzzleContainer.style.transition = 'opacity 0.5s'; + puzzleContainer.style.opacity = '0'; + grayscaleImg.style.opacity = '1'; + + // 3. After a delay, fade out the grayscale and fade in the color image + setTimeout(() => { + grayscaleImg.style.opacity = '0'; + originalImg.style.opacity = '1'; + }, 2000); // 2-second delay +} + + function drawPuzzle(puzzleData) { const container = document.getElementById('puzzle-container'); container.innerHTML = ''; // Clear previous puzzle @@ -50,6 +77,9 @@ document.addEventListener('DOMContentLoaded', () => { const uploader = document.getElementById('imageUploader'); const statusDiv = document.getElementById('status'); const puzzleContainer = document.getElementById('puzzle-container'); + const testSuccessBtn = document.getElementById('test-success-btn'); + const deleteBtn = document.getElementById('delete-btn'); + const playBtn = document.getElementById('play-btn'); // Get the new button if (uploader.files.length === 0) { statusDiv.textContent = '이미지 파일을 선택해주세요.'; @@ -76,6 +106,12 @@ document.addEventListener('DOMContentLoaded', () => { // Call the new function to draw the puzzle! drawPuzzle(puzzleData); + currentPuzzleData = puzzleData; + testSuccessBtn.addEventListener('click', showSuccessAnimation); +// 성공/삭제 버튼 모두 표시 + testSuccessBtn.style.display = 'inline-block'; + deleteBtn.style.display = 'inline-block'; + playBtn.style.display = 'inline-block'; } else { const errorMessage = await response.text(); statusDiv.textContent = `생성 실패: ${errorMessage}`; @@ -84,5 +120,47 @@ document.addEventListener('DOMContentLoaded', () => { console.error('네트워크 오류:', error); statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.'; } + // 삭제 버튼에 클릭 이벤트 리스너 추가 + deleteBtn.addEventListener('click', async () => { + if (!currentPuzzleData || !currentPuzzleData.id) { + alert('삭제할 퍼즐이 선택되지 않았습니다.'); + return; + } + + // 사용자에게 삭제 여부 확인 + if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) { + return; + } + + try { + // 백엔드에 DELETE 요청 보내기 + const response = await fetch(getMainPath() + `/puzzle/${currentPuzzleData.id}.bjx`, { + method: 'DELETE', + }); + + if (response.ok) { // 204 No Content 포함 + statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.'; + // 화면 정리 + puzzleContainer.innerHTML = ''; + document.getElementById('success-animation-container').innerHTML = ''; // 이미지 컨테이너도 비움 + testSuccessBtn.style.display = 'none'; + deleteBtn.style.display = 'none'; + currentPuzzleData = null; + } else { + statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`; + } + } catch (error) { + console.error('삭제 중 네트워크 오류:', error); + statusDiv.textContent = '삭제 중 오류가 발생했습니다.'; + } + }); + playBtn.addEventListener('click', () => { + if (currentPuzzleData && currentPuzzleData.id) { + // Navigate to the play page with the puzzle ID + window.location.href = `/puzzle/play/${currentPuzzleData.id}`; + } + }); }); + + }); \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/play.html b/src/main/resources/templates/content/puzzle/play.html new file mode 100644 index 0000000..67195da --- /dev/null +++ b/src/main/resources/templates/content/puzzle/play.html @@ -0,0 +1,49 @@ + + + + + + + + + + + +

Solve the Puzzle! 🧩

+ +
+
+ ❤️ Points: 5 +
+ +
+ +
+
+
+ Grayscale version + Original version + + +
+ +
+ diff --git a/src/main/resources/templates/content/puzzle/upload.html b/src/main/resources/templates/content/puzzle/upload.html index 02a6716..520030a 100644 --- a/src/main/resources/templates/content/puzzle/upload.html +++ b/src/main/resources/templates/content/puzzle/upload.html @@ -16,7 +16,10 @@
- +
+ Grayscale version + Original version +

이미지로 네모로직 문제 만들기

@@ -25,6 +28,8 @@

결과

- + + +
diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index c9e6264..4353e3e 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -15,7 +15,7 @@