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 a7fe94b..164a73c 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,9 @@ class SecurityConfig( csrf.ignoringRequestMatchers( "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx", - "/blog/post/images/**","/puzzle/**","/puzzle/play/**" + "/blog/post/images/**","/puzzle/**","/puzzle/play/**", + "/rank/**", + "/sudoku/**", ) // 여기 예외 추가 }.authorizeHttpRequests { auth -> auth @@ -84,7 +86,8 @@ class SecurityConfig( "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", // "/blog/post/imageUpload.bjx", "/blog/post/images/**", - "/puzzle/play","/puzzle/play/**", + "/rank/**","/sudoku/**", + "/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku", "/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 8dbaafb..94211fd 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -21,7 +21,7 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 @PostMapping("upload.bjx") suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity { return try { - val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile, 20) + val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile) ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환 } catch (e: Exception) { e.printStackTrace() @@ -80,6 +80,33 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 } } + /** + * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + */ + @GetMapping("/2048") + suspend fun play2048(): ResultMV { + val vm = ResultMV("content/puzzle/2048") + return vm + } + + /** + * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + */ + @GetMapping("/sudoku") + suspend fun sudoku(): ResultMV { + val vm = ResultMV("content/puzzle/sudoku") + return vm + + } + + @GetMapping("/sudoku_gen.bs") + suspend fun sudoku_gen(): ResultMV { + val vm = ResultMV("content/puzzle/sudoku_gen") + return vm + + } + + @GetMapping("/","/upload.bs") suspend fun uploadPuzzle() : ResultMV { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt new file mode 100644 index 0000000..81ee354 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt @@ -0,0 +1,39 @@ +package kr.lunaticbum.back.lun.controllers + +import kr.lunaticbum.back.lun.model.Rank +import kr.lunaticbum.back.lun.model.RankRepository +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + + +@RestController +@RequestMapping("/rank") +class RankController(val rankRepository: RankRepository) { +// private val rankRepository: RankRepository +// +// init { +// this.rankRepository = rankRepository +// } + + /** + * 새로운 랭킹을 저장합니다. + * 요청 Body에 gameId가 포함되어야 합니다. + * @param rank 저장할 랭크 정보 (gameId, name, score) + * @return Mono + */ + @PostMapping("/ranks") + fun saveRank(@RequestBody rank: Rank): Mono { // 👈 요청 Body는 Rank 모델을 그대로 사용 + return rankRepository.save(rank) + } + + /** + * 특정 게임의 상위 10개 랭킹 리스트를 조회합니다. + * @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다. + * @return Flux + */ + @GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가 + fun getRankingsByGameId(@PathVariable gameId: String): Flux { + return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt new file mode 100644 index 0000000..dfef078 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt @@ -0,0 +1,36 @@ +package kr.lunaticbum.back.lun.controllers +import kr.lunaticbum.back.lun.model.GameRecord +import kr.lunaticbum.back.lun.model.SudokuPuzzle +import kr.lunaticbum.back.lun.model.SudokuService +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/sudoku") +class SudokuController(private val sudokuService: SudokuService) { + + @GetMapping("/start") + suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto { + return sudokuService.startGame(difficulty) + } + + @PostMapping("/complete") + suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) { + sudokuService.saveRecord(recordDto) + } + + @GetMapping("/ranking/{puzzleId}") + suspend fun getRankings(@PathVariable puzzleId: Long): List { + return sudokuService.getRankings(puzzleId) + } + + @PostMapping("/generate") + suspend fun generateSinglePuzzle(): SudokuPuzzle { + return sudokuService.generateAndSavePuzzle() + } + + @PostMapping("/validate") + suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map { + val isCorrect = sudokuService.validateSolution(validateDto) + return mapOf("correct" to isCorrect) + } +} \ No newline at end of file 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 47edf66..681e9fd 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -53,6 +53,13 @@ interface NonogramPuzzleRepository : ReactiveMongoRepository - List(size) { x -> + // 결정된 puzzleSize를 사용하여 solutionGrid 생성 + val solutionGrid = List(puzzleSize) { y -> + List(puzzleSize) { x -> if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 } } @@ -106,6 +121,35 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // return puzzleRepository.save(puzzleData).awaitSingle() } + + /** + * (★ 새로 추가된 함수) + * 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다. + * @param image 분석할 원본 이미지 + * @return 계산된 퍼즐 크기 (정수) + */ + private fun determinePuzzleSize(image: BufferedImage): Int { + val width = image.width.toDouble() + val height = image.height.toDouble() + + // 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다. + val maxDimension = maxOf(width, height) + val minDimension = minOf(width, height) + + // 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상) + val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0 + + // 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다. + // - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다. + // - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다. + val baseSize = 15.0 + val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치 + val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor) + + // 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다. + return calculatedSize.toInt().coerceIn(MIN_PUZZLE_SIZE, MAX_PUZZLE_SIZE) + } + // Helper function to resize images private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage { return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Rank.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Rank.kt new file mode 100644 index 0000000..025bc46 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Rank.kt @@ -0,0 +1,40 @@ +package kr.lunaticbum.back.lun.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + + +@Document(collection = "ranks") +class Rank { + // Getters and Setters + @Id + var id: String? = null + + // 👈 Getter/Setter 추가 + var gameId: String? = null // 👈 게임 ID 필드 추가 + var name: String? = null + var score: Int = 0 + + // Constructors + constructor() + + constructor(gameId: String?, name: String?, score: Int) { + this.gameId = gameId + this.name = name + this.score = score + } +} + +@Repository +interface RankRepository : ReactiveMongoRepository { + /** + * 특정 gameId에 대해 점수가 높은 순서대로 상위 10개의 랭킹을 조회합니다. + * @param gameId 조회할 게임의 ID + * @return Flux + */ + // 쿼리 메소드 이름 변경 및 파라미터 추가 + fun findTop10ByGameIdOrderByScoreDesc(gameId: String): Flux // 👈 수정 +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt new file mode 100644 index 0000000..f271137 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt @@ -0,0 +1,136 @@ +package kr.lunaticbum.back.lun.model + +import com.mongodb.DuplicateKeyException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kr.lunaticbum.back.lun.utils.SudokuGenerator +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository +import org.springframework.stereotype.Service +import kotlin.random.Random + +@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정 +data class SudokuPuzzle( + @Id + val id: String? = null, // MongoDB의 고유 _id 필드 + val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용) + @Indexed(unique = true) + val puzzle: String? // 81자리 완성된 퍼즐 데이터 +) + + + +@Document(collection = "records") +data class GameRecord( + @Id + val id: String? = null, + val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조 + val userName: String, + val completionTime: Long // 완료 시간 (초) +) + +@Repository +interface SudokuPuzzleRepository : CoroutineCrudRepository { + // 전체 퍼즐 개수를 반환하는 suspend 함수 + override suspend fun count(): Long + // puzzleKey로 퍼즐을 찾는 suspend 함수 + suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? + // 👇 이 함수 선언을 추가해주세요. + suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? +} + +@Repository +interface GameRecordRepository : CoroutineCrudRepository { + // 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림) + fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow +} + +@Service +class SudokuService( + private val puzzleRepository: SudokuPuzzleRepository, + private val recordRepository: GameRecordRepository +) { + // DTO 정의 (파일 하단 또는 별도 파일) + data class GameDto(val puzzleId: Long, val question: String, val solution: String) + + data class RecordDto(val puzzleId: Long, val userName: String, val completionTime: Long) + + suspend fun startGame(difficulty: String): GameDto { + val puzzleCount = puzzleRepository.count() + if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.") + + val randomKey = Random.nextLong(1, puzzleCount - 1) + val solvedPuzzle = puzzleRepository.findByPuzzleKey(randomKey) + ?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.") + + val holes = when (difficulty.lowercase()) { + "medium" -> 45 + "hard" -> 55 + else -> 35 // easy + } + + val question = createQuestion(solvedPuzzle.puzzle!!, holes) + return GameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!) + } + + suspend fun saveRecord(recordDto: RecordDto) { + val record = GameRecord( + puzzleId = recordDto.puzzleId, + userName = recordDto.userName, + completionTime = recordDto.completionTime + ) + recordRepository.save(record) + } + + suspend fun getRankings(puzzleId: Long): List { + // Flow를 최종적으로 List로 변환하여 반환 + return recordRepository.findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId).toList() + } + + private fun createQuestion(puzzle: String, holes: Int): String { + val chars = puzzle.toMutableList() + var remainingHoles = holes + while (remainingHoles > 0) { + val randomIndex = Random.nextInt(chars.size) + if (chars[randomIndex] != '0') { + chars[randomIndex] = '0' + remainingHoles-- + } + } + return chars.joinToString("") + } + + suspend fun generateAndSavePuzzle(): SudokuPuzzle { + var attempts = 0 + val maxAttempts = 10 // 중복 시 최대 10번 재시도 + + while (attempts < maxAttempts) { + try { + val puzzleString = SudokuGenerator().generate() + println("puzzleString >>> ${puzzleString}") + // DB에 저장하기 전에 가장 큰 puzzleKey를 찾아 +1 + val lastPuzzle = puzzleRepository.findTopByOrderByPuzzleKeyDesc() + val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L + + val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString) + return puzzleRepository.save(newPuzzle) + } catch (e: DuplicateKeyException) { + attempts++ + println("중복 퍼즐 생성됨, 재시도... ($attempts/$maxAttempts)") + } + } + throw IllegalStateException("새로운 고유 퍼즐 생성에 실패했습니다.") + } + + data class ValidateDto(val puzzleId: Long, val answer: String) + + suspend fun validateSolution(validateDto: ValidateDto): Boolean { + val originalPuzzle = puzzleRepository.findByPuzzleKey(validateDto.puzzleId) + ?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.") + + return originalPuzzle.puzzle == validateDto.answer + } +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt new file mode 100644 index 0000000..63ff6f9 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/SudokuGenerator.kt @@ -0,0 +1,91 @@ +package kr.lunaticbum.back.lun.utils +import kotlin.random.Random + +class SudokuGenerator { + + // 9x9 스도쿠 보드 + private val board = Array(9) { IntArray(9) } + + /** + * 완성된 스도쿠 퍼즐 하나를 생성합니다. + * @return 81자리 문자열로 된 퍼즐 데이터 + */ + fun generate(): String { + // 보드 초기화 + for (i in 0..8) { + for (j in 0..8) { + board[i][j] = 0 + } + } + // 백트래킹으로 퍼즐 풀기 시작 + solve() + // 2D 배열을 1D 문자열로 변환하여 반환 + return board.joinToString("") { row -> row.joinToString("") } + } + + /** + * 백트래킹을 사용하여 보드를 채우는 재귀 함수 + */ + private fun solve(): Boolean { + val emptyCell = findEmpty() ?: return true // 빈 칸이 없으면 성공 + + val row = emptyCell.first + val col = emptyCell.second + + // 1~9 숫자를 무작위로 섞어서 시도하면 매번 다른 패턴의 퍼즐이 생성됨 + val numbers = (1..9).shuffled(Random) + + for (num in numbers) { + if (isValid(num, row, col)) { + // 유효한 숫자를 찾으면 칸에 배치 + board[row][col] = num + + // 재귀적으로 다음 칸 풀이 시도 + if (solve()) { + return true // 끝까지 해결되면 성공 + } + + // 다음 칸 풀이에 실패하면, 현재 칸을 비우고 다른 숫자로 다시 시도 (백트래킹) + board[row][col] = 0 + } + } + return false // 1~9까지 모든 숫자를 시도해도 해결 못하면 실패 + } + + /** + * 특정 위치에 숫자를 놓는 것이 유효한지 검사 + */ + private fun isValid(num: Int, row: Int, col: Int): Boolean { + // 1. 가로줄 검사 + for (c in 0..8) { + if (board[row][c] == num) return false + } + // 2. 세로줄 검사 + for (r in 0..8) { + if (board[r][col] == num) return false + } + // 3. 3x3 박스 검사 + val boxStartRow = row - row % 3 + val boxStartCol = col - col % 3 + for (r in 0..2) { + for (c in 0..2) { + if (board[boxStartRow + r][boxStartCol + c] == num) return false + } + } + return true // 모든 검사를 통과하면 유효함 + } + + /** + * 보드에서 비어있는 첫 번째 칸의 좌표를 찾습니다. (row, col) + */ + private fun findEmpty(): Pair? { + for (r in 0..8) { + for (c in 0..8) { + if (board[r][c] == 0) { + return Pair(r, c) + } + } + } + return null + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/2048.css b/src/main/resources/static/css/2048.css new file mode 100644 index 0000000..d4a0632 --- /dev/null +++ b/src/main/resources/static/css/2048.css @@ -0,0 +1,156 @@ +/* ================================= + 기본 및 전체 레이아웃 + ================================= */ +body { + font-family: Arial, sans-serif; + text-align: center; + background-color: #faf8ef; + color: #776e65; + margin: 0; + padding: 10px; + box-sizing: border-box; +} + +h1 { + font-size: 15vw; + margin: 20px 0; +} + +.score-container { + font-size: 24px; + margin-bottom: 20px; +} + +/* ================================= + 게임 보드 (가장 중요한 부분) + ================================= */ +#game-board { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-gap: 2vw; + width: 95vw; + max-width: 400px; + margin: 0 auto; + background-color: #bbada0; + padding: 2vw; + border-radius: 6px; + box-sizing: border-box; + aspect-ratio: 1 / 1; /* 정사각형 비율 유지 */ + touch-action: none; /* 모바일에서 터치 시 화면 확대/이동 방지 */ +} + +/* ================================= + 타일 공통 스타일 + ================================= */ +.tile { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + border-radius: 3px; + background-color: #cdc1b4; + font-size: 7vw; +} + +/* ================================= + 타일 색상 + ================================= */ +.tile-2 { background-color: #eee4da; color: #776e65; } +.tile-4 { background-color: #ede0c8; color: #776e65; } +.tile-8 { background-color: #f2b179; color: #f9f6f2; } +.tile-16 { background-color: #f59563; color: #f9f6f2; } +.tile-32 { background-color: #f67c5f; color: #f9f6f2; } +.tile-64 { background-color: #f65e3b; color: #f9f6f2; } +.tile-128 { background-color: #edcf72; color: #f9f6f2; } +.tile-256 { background-color: #edcc61; color: #f9f6f2; } +.tile-512 { background-color: #edc850; color: #f9f6f2; } +.tile-1024 { background-color: #edc53f; color: #f9f6f2; } +.tile-2048 { background-color: #edc22e; color: #f9f6f2; } +.tile-4096 { background-color: #3c3a32; color: #f9f6f2; } +.tile-8192 { background-color: #ff3333; color: #f9f6f2; } +.tile-16384 { background-color: #0077cc; color: #f9f6f2; } +.tile-32768 { background-color: #9900cc; color: #f9f6f2; } +.tile-4096, .tile-8192 { font-size: 6vw; } +.tile-16384, .tile-32768 { font-size: 5vw; } + + +/* ================================= + 게임 오버 팝업 + ================================= */ +.popup-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} +.popup { + background-color: #faf8ef; + padding: 20px; + border-radius: 10px; + text-align: center; + width: 80vw; + max-width: 300px; +} +.popup input { + width: 100%; + padding: 10px; + margin: 10px 0; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; +} +.popup button { + padding: 10px 20px; + background-color: #8f7a66; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; +} + +/* ================================= + 랭킹 리스트 + ================================= */ +.ranking-container { + width: 100%; + max-width: 400px; + margin: 30px auto; + text-align: left; +} +.ranking-container h3 { + text-align: center; +} +#ranking-list { + list-style-type: none; + padding: 0; +} +#ranking-list li { + background-color: #eee4da; + margin-bottom: 5px; + padding: 10px; + border-radius: 5px; + display: flex; + justify-content: space-between; +} + +/* ================================= + 반응형: PC & 태블릿 (화면이 481px 이상일 때) + ================================= */ +@media (min-width: 481px) { + h1 { font-size: 80px; } + #game-board { + grid-gap: 10px; + padding: 10px; + } + .tile { font-size: 45px; } + .tile-4096, .tile-8192 { font-size: 35px; } + .tile-16384, .tile-32768 { font-size: 30px; } +} \ No newline at end of file diff --git a/src/main/resources/static/css/admin-sudoku.css b/src/main/resources/static/css/admin-sudoku.css new file mode 100644 index 0000000..dc5de10 --- /dev/null +++ b/src/main/resources/static/css/admin-sudoku.css @@ -0,0 +1,73 @@ + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + display: flex; + justify-content: center; + align-items: center; + background-color: #f4f7f9; + padding: 20px; +} + +.container { + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + text-align: center; +} + +#generate-btn { + padding: 12px 24px; + font-size: 16px; + cursor: pointer; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + transition: background-color 0.2s; +} + +#generate-btn:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +#generate-btn:hover:not(:disabled) { + background-color: #0056b3; +} + +#status-message { + margin-top: 15px; + font-size: 14px; + color: #555; + height: 20px; +} + +#sudoku-board { + display: grid; + grid-template-columns: repeat(9, 40px); + grid-template-rows: repeat(9, 40px); + border: 3px solid #333; + margin: 20px auto 0; + width: 366px; /* (40px * 9) + 6px border */ +} + +.cell { + width: 40px; + height: 40px; + border: 1px solid #ccc; + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; + font-weight: bold; + color: #333; +} + +/* 3x3 구역을 위한 굵은 선 */ +.cell:nth-child(3n) { border-right: 2px solid #333; } +.cell:nth-child(9n) { border-right: none; } +.cell:nth-of-type(n+19):nth-of-type(-n+27), +.cell:nth-of-type(n+46):nth-of-type(-n+54) { + border-bottom: 2px solid #333; +} \ No newline at end of file diff --git a/src/main/resources/static/css/play.css b/src/main/resources/static/css/play.css index fc3666b..f6a4d52 100644 --- a/src/main/resources/static/css/play.css +++ b/src/main/resources/static/css/play.css @@ -42,41 +42,51 @@ body { /* transform-origin은 그대로 유지합니다. */ transform-origin: top; } -/* (★ 추가) 게임 컨트롤 영역 스타일 */ +/* (★ 수정) 게임 컨트롤 영역 스타일 */ #game-controls { display: flex; justify-content: space-between; align-items: center; width: 100%; - max-width: 500px; /* 보드와 비슷한 너비로 설정 */ + max-width: 500px; margin-bottom: 15px; font-size: 1.2em; - flex-wrap: wrap; /* Allow controls to wrap on small screens */ + flex-wrap: wrap; gap: 15px; } -/* (★ ADD) Styles for the mode selector */ +/* (★ 수정) 모드 선택 버튼 스타일 */ #mode-selector { display: flex; - gap: 10px; - background-color: #eee; - padding: 8px; + gap: 5px; + border: 1px solid #ccc; border-radius: 8px; + padding: 4px; + background-color: #f0f0f0; } + #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 { + +#mode-selector span { + padding: 8px 15px; + border-radius: 5px; + display: block; + transition: background-color 0.2s, color 0.2s; +} + +/* 실제 라디오 버튼은 숨김 */ +#mode-selector input[type="radio"] { + display: none; +} + +/* (★ 핵심) 선택된 라디오 버튼의 span 스타일 */ +#mode-selector input[type="radio"]:checked + span { background-color: #007bff; color: white; -} -/* Hide the actual radio buttons */ -#mode-selector input { - display: none; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); } #hint-btn { padding: 8px 15px; diff --git a/src/main/resources/static/css/sudoku.css b/src/main/resources/static/css/sudoku.css new file mode 100644 index 0000000..f522cd8 --- /dev/null +++ b/src/main/resources/static/css/sudoku.css @@ -0,0 +1,238 @@ +/* 기본 스타일 초기화 및 폰트 설정 */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 20px; + background-color: #f4f7f9; + display: flex; + justify-content: center; + min-height: 100vh; +} + +#sudoku-game-app { + width: 100%; + margin: 20px 0; +} + +.container { + background: white; + padding: clamp(15px, 4vw, 30px); + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + text-align: center; + max-width: 500px; + width: 100%; + box-sizing: border-box; + margin: 0 auto; +} + +h1 { + font-size: 1.8em; + color: #333; + margin-top: 0; + margin-bottom: 20px; +} + +button { + padding: 10px 20px; + font-size: 1em; + cursor: pointer; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + transition: background-color 0.2s; +} +button:hover:not(:disabled) { + background-color: #0056b3; +} +button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +/* 게임 컨테이너 */ +#game-container { + display: flex; + flex-direction: column; + align-items: center; + max-width: 500px; + margin: 0 auto; +} + +/* 게임 정보 (점수, 타이머) */ +.game-info { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding: 0 10px; + box-sizing: border-box; + font-size: 1.5em; + font-weight: bold; +} +#score { color: #007bff; } +#timer { color: #333; } + +/* 스도쿠 보드 */ +#sudoku-board { + display: grid; + grid-template-columns: repeat(9, 1fr); + grid-template-rows: repeat(9, 1fr); + width: 100%; + border: 3px solid #333; + aspect-ratio: 1 / 1; +} + +.cell { + display: flex; + justify-content: center; + align-items: center; + font-size: clamp(1em, 4vw, 1.8em); + font-weight: bold; + color: #333; + border: 1px solid #ddd; + box-sizing: border-box; + cursor: pointer; +} + +.cell:nth-child(3n) { border-right: 2px solid #333; } +.cell:nth-child(9n) { border-right-width: 1px; } +.cell:nth-child(n+19):nth-child(-n+27), +.cell:nth-child(n+46):nth-child(-n+54) { + border-bottom: 2px solid #333; +} + +.cell:not(.editable) { + background-color: #f0f0f0; + color: #222; + cursor: default; +} + +/* 하이라이트 & 오답 스타일 */ +.cell.incorrect { + background-color: #ffdddd !important; + color: #d8000c !important; +} +.highlight-focused { + background-color: #dbeeff !important; +} +.highlight-same-number { + background-color: #e6e6e6 !important; +} +.highlight-selected-number { + background-color: #b3d7ff !important; +} + +/* 숫자 입력 버튼 */ +#number-input-buttons { + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 15px; + gap: 1%; +} + +#number-input-buttons .num-btn, +#number-input-buttons #undo-btn { + line-height: unset; + min-width: unset; + width: 9%; + aspect-ratio: 1/1; + font-size: clamp(1em, 4vw, 1.8em); + font-weight: bold; + border-radius: 8px; + background-color: #f0f0f0; + color: #333; + border: 1px solid #ccc; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + padding: 0; + transition: background-color 0.2s, transform 0.1s, opacity 0.2s; +} + +#number-input-buttons .num-btn.selected { + background-color: #007bff; + color: white; + border-color: #007bff; +} + +#number-input-buttons .num-btn.completed { + opacity: 0.4; + background-color: #e9ecef; + pointer-events: none; +} + +#number-input-buttons #undo-btn { + background-color: #f8f9fa; + color: #dc3545; +} + +/* 액션 버튼 (힌트, 정답확인) */ +.action-buttons { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 15px; + width: 100%; +} +.action-buttons button { + flex-grow: 1; + max-width: 200px; +} + +/* 모달 및 숨김 처리 */ +.hidden { + display: none !important; +} +#modal-overlay, #game-over-modal { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-color: rgba(0,0,0,0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +#modal-content { + background: white; + padding: 30px; + border-radius: 10px; + text-align: center; + width: 90%; + max-width: 400px; + box-shadow: 0 8px 20px rgba(0,0,0,0.2); +} +#modal-content h2, #modal-content h3 { + color: #333; + margin-bottom: 15px; +} +#username-input { + width: calc(100% - 24px); + padding: 10px; + margin-bottom: 15px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 1em; +} +#ranking-list { + list-style-type: decimal; + list-style-position: inside; + padding: 0; + text-align: left; + margin-top: 20px; +} +#ranking-list li { + padding: 8px 0; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} +#ranking-list li:last-child { + border-bottom: none; +} \ No newline at end of file diff --git a/src/main/resources/static/js/2048.js b/src/main/resources/static/js/2048.js new file mode 100644 index 0000000..52b06d6 --- /dev/null +++ b/src/main/resources/static/js/2048.js @@ -0,0 +1,233 @@ +document.addEventListener('DOMContentLoaded', () => { + // DOM 요소 가져오기 + const gameBoard = document.getElementById('game-board'); + const scoreDisplay = document.getElementById('score'); + const gameOverPopup = document.getElementById('game-over-popup'); + const finalScoreDisplay = document.getElementById('final-score'); + const playerNameInput = document.getElementById('player-name'); + const saveScoreButton = document.getElementById('save-score'); + const rankingList = document.getElementById('ranking-list'); + + // 게임 설정 및 변수 + const currentGameId = '2048'; + const gridSize = 4; + let board = []; + let score = 0; + let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0; + + // ----- 게임 핵심 로직 ----- + function initializeBoard() { + gameBoard.innerHTML = ''; // 기존 타일 초기화 + for (let i = 0; i < gridSize * gridSize; i++) { + const tile = document.createElement('div'); + tile.className = 'tile'; + gameBoard.appendChild(tile); + } + board = Array(gridSize * gridSize).fill(0); + addNumber(); + addNumber(); + updateBoard(); + } + + function updateBoard() { + const tiles = gameBoard.children; + for (let i = 0; i < board.length; i++) { + const value = board[i]; + const tile = tiles[i]; + tile.textContent = value === 0 ? '' : value; + tile.className = 'tile' + (value > 0 ? ' tile-' + value : ''); + } + scoreDisplay.textContent = score; + } + + function addNumber() { + const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1); + if (available.length > 0) { + const spot = available[Math.floor(Math.random() * available.length)]; + board[spot] = Math.random() < 0.9 ? 2 : 4; + } + } + + // ----- 타일 이동 및 병합 로직 ----- + function moveRow(row) { + let arr = row.filter(val => val); + for (let i = 0; i < arr.length - 1; i++) { + if (arr[i] === arr[i + 1]) { + arr[i] *= 2; + score += arr[i]; + arr[i + 1] = 0; + } + } + arr = arr.filter(val => val); + const missing = gridSize - arr.length; + const zeros = Array(missing).fill(0); + return arr.concat(zeros); + } + + function moveLeft() { + let changed = false; + for (let i = 0; i < gridSize; i++) { + const rowStart = i * gridSize; + const row = board.slice(rowStart, rowStart + gridSize); + const newRow = moveRow(row); + if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true; + board.splice(rowStart, gridSize, ...newRow); + } + return changed; + } + + function moveRight() { + let changed = false; + for (let i = 0; i < gridSize; i++) { + const rowStart = i * gridSize; + const row = board.slice(rowStart, rowStart + gridSize).reverse(); + const newRow = moveRow(row).reverse(); + if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true; + board.splice(rowStart, gridSize, ...newRow); + } + return changed; + } + + function moveUp() { + let changed = false; + for (let i = 0; i < gridSize; i++) { + const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]; + const newCol = moveRow(col); + if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true; + for (let j = 0; j < gridSize; j++) { + board[i + j * gridSize] = newCol[j]; + } + } + return changed; + } + + function moveDown() { + let changed = false; + for (let i = 0; i < gridSize; i++) { + const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse(); + const newCol = moveRow(col).reverse(); + if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true; + for (let j = 0; j < gridSize; j++) { + board[i + j * gridSize] = newCol[j]; + } + } + return changed; + } + + // ----- 게임 상태 관리 ----- + function isGameOver() { + if (!board.includes(0)) { + for (let i = 0; i < gridSize; i++) { + for (let j = 0; j < gridSize; j++) { + const current = board[i * gridSize + j]; + if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) || + (i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) { + return false; + } + } + } + return true; + } + return false; + } + + function handleMove(moveFunction) { + if (moveFunction()) { + addNumber(); + updateBoard(); + if (isGameOver()) { + finalScoreDisplay.textContent = score; + gameOverPopup.style.display = 'flex'; + } + } + } + + // ----- 이벤트 리스너 ----- + document.addEventListener('keydown', (e) => { + switch (e.key) { + case 'ArrowUp': handleMove(moveUp); break; + case 'ArrowDown': handleMove(moveDown); break; + case 'ArrowLeft': handleMove(moveLeft); break; + case 'ArrowRight': handleMove(moveRight); break; + } + }); + + gameBoard.addEventListener('touchstart', (e) => { + touchStartX = e.changedTouches[0].screenX; + touchStartY = e.changedTouches[0].screenY; + }); + + gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); + + gameBoard.addEventListener('touchend', (e) => { + touchEndX = e.changedTouches[0].screenX; + touchEndY = e.changedTouches[0].screenY; + handleSwipe(); + }); + + function handleSwipe() { + const deltaX = touchEndX - touchStartX; + const deltaY = touchEndY - touchStartY; + const swipeThreshold = 30; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + if (Math.abs(deltaX) > swipeThreshold) { + handleMove(deltaX > 0 ? moveRight : moveLeft); + } + } else { + if (Math.abs(deltaY) > swipeThreshold) { + handleMove(deltaY > 0 ? moveDown : moveUp); + } + } + } + + // ----- 랭킹 API 연동 ----- + saveScoreButton.addEventListener('click', async () => { + const playerName = playerNameInput.value.trim(); + if (playerName === "") return alert("이름을 입력해주세요."); + + const newScore = { gameId: currentGameId, name: playerName, score: score }; + try { + const response = await fetch(getMainPath() + '/rank/ranks', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(newScore), + }); + if (!response.ok) throw new Error('점수 저장 실패'); + + gameOverPopup.style.display = 'none'; + playerNameInput.value = ''; + score = 0; // 점수 초기화 + updateRankingList(); + initializeBoard(); + } catch (error) { + console.error('Error:', error); + alert('서버와 통신 중 오류가 발생했습니다.'); + } + }); + + async function updateRankingList() { + rankingList.innerHTML = ''; + try { + const response = await fetch(getMainPath() +`/rank/ranks/${currentGameId}`); + if (!response.ok) throw new Error('랭킹 로딩 실패'); + const rankings = await response.json(); + if (rankings.length === 0) { + rankingList.innerHTML = '
  • 등록된 랭킹이 없습니다.
  • '; + return; + } + rankings.forEach((rank, index) => { + const li = document.createElement('li'); + li.innerHTML = `${index + 1}. ${rank.name}${rank.score}점`; + rankingList.appendChild(li); + }); + } catch (error) { + console.error('Error:', error); + rankingList.innerHTML = '
  • 랭킹을 불러올 수 없습니다.
  • '; + } + } + + // ----- 게임 시작 ----- + updateRankingList(); + initializeBoard(); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin-sudoku.js b/src/main/resources/static/js/admin-sudoku.js new file mode 100644 index 0000000..a2e70e8 --- /dev/null +++ b/src/main/resources/static/js/admin-sudoku.js @@ -0,0 +1,53 @@ +document.addEventListener('DOMContentLoaded', () => { + const generateBtn = document.getElementById('generate-btn'); + const boardElement = document.getElementById('sudoku-board'); + const statusMessage = document.getElementById('status-message'); + + // 스도쿠 보드를 화면에 그리는 함수 + function renderBoard(puzzleString) { + boardElement.innerHTML = ''; // 기존 보드 초기화 + if (!puzzleString || puzzleString.length !== 81) { + statusMessage.textContent = '잘못된 퍼즐 데이터입니다.'; + return; + } + + for (const char of puzzleString) { + const cell = document.createElement('div'); + cell.classList.add('cell'); + cell.textContent = char; + boardElement.appendChild(cell); + } + } + + // 생성 버튼 클릭 이벤트 리스너 + generateBtn.addEventListener('click', async () => { + // 버튼 비활성화 및 상태 메시지 업데이트 + generateBtn.disabled = true; + statusMessage.textContent = '새로운 스도쿠 퍼즐을 생성 중입니다... 🧠'; + boardElement.innerHTML = ''; // 보드 비우기 + + try { + // 백엔드 API 호출 (POST 요청) + const response = await fetch('/sudoku/generate', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`서버 오류: ${response.statusText}`); + } + + const result = await response.json(); + + // 성공 시 보드 렌더링 + renderBoard(result.puzzle); + statusMessage.textContent = `✅ 퍼즐 생성 완료! (ID: ${result.puzzleKey})`; + + } catch (error) { + console.error('Error generating Sudoku:', error); + statusMessage.textContent = `❌ 생성 실패: ${error.message}`; + } finally { + // 성공/실패 여부와 관계없이 버튼 다시 활성화 + generateBtn.disabled = false; + } + }); +}); \ 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 3af7a19..eaa06fd 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -266,6 +266,11 @@ function gotoPuzzleUpload() { document.location.replace(getMainPath()+"/puzzle/upload.bs") } + +function gotoPuzzleUpload() { + document.location.replace(getMainPath()+"/puzzle/sudoku_gen.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 index 93e30d0..80a896c 100644 --- a/src/main/resources/static/js/play.js +++ b/src/main/resources/static/js/play.js @@ -26,6 +26,7 @@ document.addEventListener('DOMContentLoaded', () => { const modalMessage = document.getElementById('modal-message'); const modalButtons = document.getElementById('modal-buttons'); + // --- 게임 상태를 관리하는 변수 --- let currentMode = 'fill'; let points = 5; @@ -213,14 +214,28 @@ document.addEventListener('DOMContentLoaded', () => { 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'; + // --- 하이브리드 로직 --- + if (e.type === 'mousedown') { // 마우스 이벤트일 경우 + if (e.button === 0) { // 좌클릭 + dragAction = (currentState === 1) ? 'clear' : 'fill'; + // UI를 실제 행동에 맞춰 동기화 + document.querySelector('input[name="play-mode"][value="fill"]').checked = true; + } else if (e.button === 2) { // 우클릭 + dragAction = (currentState === -1) ? 'clear' : 'mark'; + // UI를 실제 행동에 맞춰 동기화 + document.querySelector('input[name="play-mode"][value="mark"]').checked = true; + } + } else { // 터치 이벤트일 경우 ('touchstart') + // 현재 선택된 라디오 버튼 모드를 읽어옴 + const currentMode = document.querySelector('input[name="play-mode"]:checked').value; + if (currentMode === 'fill') { + dragAction = (currentState === 1) ? 'clear' : 'fill'; + } else { // 'mark' 모드 + dragAction = (currentState === -1) ? 'clear' : 'mark'; + } } - // Update selection visuals + // 드래그 시작점 기록 및 시각적 피드백 시작 startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) }; lastHoveredCell = startCell; updateSelectionVisuals(); @@ -409,9 +424,9 @@ document.addEventListener('DOMContentLoaded', () => { function triggerGameSuccess() { if (isGameFinished) return; isGameFinished = true; - document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none'; - hintBtn.disabled = true; + // --- 요소 참조 --- + const viewport = document.getElementById('board-viewport'); const puzzleGridContainer = document.querySelector('.puzzle-grid-container'); const grayscaleImg = document.getElementById('grayscale-reveal'); const originalImg = document.getElementById('original-reveal'); @@ -420,31 +435,35 @@ document.addEventListener('DOMContentLoaded', () => { puzzleGridContainer.style.pointerEvents = 'none'; hintBtn.disabled = true; - // --- 애니메이션 준비 --- - // 1. 이미지 소스 설정 - // (puzzleData에 저장해 둔 Base64 인코딩된 이미지 데이터를 사용합니다) - grayscaleImg.src = puzzleData.grayscaleImage; - originalImg.src = puzzleData.originalImage; + // --- (★ 핵심) 애니메이션 위치 및 크기 계산 --- + // 1. 렌더링된 퍼즐 격자와 뷰포트의 실제 위치/크기 정보를 가져옵니다. + const gridRect = puzzleGridContainer.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); - // 2. 현재 보드의 transform(스케일) 값을 이미지에도 동일하게 적용하여 정렬 - // 이렇게 해야 반응형으로 크기가 조절된 보드 위에도 이미지가 정확히 겹칩니다. - const boardTransform = gameBoard.style.transform; - grayscaleImg.style.transform = boardTransform; - originalImg.style.transform = boardTransform; + // 2. 뷰포트를 기준으로 퍼즐 격자의 상대적인 위치(top, left)를 계산합니다. + const top = gridRect.top - viewportRect.top; + const left = gridRect.left - viewportRect.left; - // --- 애니메이션 순차 실행 --- + // 3. 애니메이션 이미지들에 계산된 위치와 크기를 적용합니다. + [grayscaleImg, originalImg].forEach(img => { + img.style.top = `${top}px`; + img.style.left = `${left}px`; + img.style.width = `${gridRect.width}px`; + img.style.height = `${gridRect.height}px`; + + // 이미지 소스 설정 + img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage; + }); + + // --- 애니메이션 순차 실행 (기존과 동일) --- setTimeout(() => { - // 3. 그레이스케일 이미지 페이드 인 - // (CSS transition 속성에 의해 부드럽게 나타납니다) - grayscaleImg.style.opacity = '1'; + grayscaleImg.style.opacity = '1'; // 그레이스케일 페이드 인 setTimeout(() => { - // 4. 컬러 이미지 페이드 인 (크로스페이드 효과) - // (그레이스케일 이미지 위에 컬러 이미지가 겹쳐지며 나타납니다) - originalImg.style.opacity = '1'; + originalImg.style.opacity = '1'; // 컬러 이미지 페이드 인 setTimeout(() => { - // 5. 애니메이션이 모두 끝난 후 최종 결과 모달 표시 + // 최종 결과 모달 표시 showResultModal({ title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', @@ -453,17 +472,11 @@ document.addEventListener('DOMContentLoaded', () => { { text: '홈으로 (Home)', action: () => window.location.href = '/' } ] }); - }, 2000); // 컬러 이미지가 나타나고 2초 후 + }, 2000); - }, 2000); // 그레이스케일 이미지가 나타나고 2초 후 + }, 2000); - }, 500); // 마지막 셀을 채우고 0.5초 후 시작 - // showResultModal({ - // title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [ - // { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' }, - // { text: '홈으로 (Home)', action: () => window.location.href = '/' } - // ] - // }); + }, 500); } // 힌트 버튼 클릭 이벤트 처리 diff --git a/src/main/resources/static/js/sudoku.js b/src/main/resources/static/js/sudoku.js new file mode 100644 index 0000000..532666b --- /dev/null +++ b/src/main/resources/static/js/sudoku.js @@ -0,0 +1,356 @@ +document.addEventListener('DOMContentLoaded', () => { + // DOM 요소 + const setupContainer = document.getElementById('setup-container'); + const gameContainer = document.getElementById('game-container'); + const startBtn = document.getElementById('start-btn'); + const boardElement = document.getElementById('sudoku-board'); + const timerElement = document.getElementById('timer'); + const scoreElement = document.getElementById('score'); + const hintBtn = document.getElementById('hint-btn'); + const undoBtn = document.getElementById('undo-btn'); + const completeBtn = document.getElementById('complete-btn'); + const numberInputButtons = document.getElementById('number-input-buttons'); + const modalOverlay = document.getElementById('modal-overlay'); + const gameOverModal = document.getElementById('game-over-modal'); + const retryBtn = document.getElementById('retry-btn'); + const submitRankBtn = document.getElementById('submit-rank-btn'); + const rankingList = document.getElementById('ranking-list'); + const closeModalBtn = document.getElementById('close-modal-btn'); + + // 게임 상태 변수 + let currentPuzzleId = null; + let solvedPuzzle = null; + let timerInterval = null; + let secondsElapsed = 0; + let selectedNumber = null; + let focusedCell = null; + let score = 5; + let history = []; + + // --- 게임 초기화 및 시작 --- + startBtn.addEventListener('click', async () => { + const difficulty = document.getElementById('difficulty-select').value; + try { + const response = await fetch(`/sudoku/start?difficulty=${difficulty}`); + if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.'); + const gameData = await response.json(); + + currentPuzzleId = gameData.puzzleId; + solvedPuzzle = gameData.solution; + + history = []; + score = 5; + updateScoreDisplay(); + + renderBoard(gameData.question); + startTimer(); + updateButtonStates(); + + setupContainer.classList.add('hidden'); + gameContainer.classList.remove('hidden'); + numberInputButtons.classList.remove('hidden'); + gameOverModal.classList.add('hidden'); + } catch (error) { + alert('게임 로딩에 실패했습니다: ' + error.message); + console.error(error); + } + }); + + function renderBoard(puzzleString) { + boardElement.innerHTML = ''; + for (let i = 0; i < 81; i++) { + const cell = document.createElement('div'); + cell.classList.add('cell'); + cell.dataset.index = i; + + if (puzzleString[i] !== '0') { + cell.textContent = puzzleString[i]; + } else { + cell.classList.add('editable'); + } + boardElement.appendChild(cell); + } + } + + function startTimer() { + secondsElapsed = 0; + timerElement.textContent = '00:00'; + clearInterval(timerInterval); + timerInterval = setInterval(() => { + secondsElapsed++; + const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0'); + const seconds = (secondsElapsed % 60).toString().padStart(2, '0'); + timerElement.textContent = `${minutes}:${seconds}`; + }, 1000); + } + + function updateScoreDisplay() { + scoreElement.textContent = `SCORE: ${score}`; + if (score <= 0) { + clearInterval(timerInterval); + gameOverModal.classList.remove('hidden'); + } + } + + function updateButtonStates() { + const counts = {}; + for (let i = 1; i <= 9; i++) counts[i] = 0; + boardElement.querySelectorAll('.cell').forEach(cell => { + const num = cell.textContent; + if (num && counts[num] !== undefined) counts[num]++; + }); + for (let i = 1; i <= 9; i++) { + const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`); + if (btn) { + if (counts[i] >= 9) { + btn.classList.add('completed'); + if (selectedNumber == i) { + selectedNumber = null; + btn.classList.remove('selected'); + } + } else { + btn.classList.remove('completed'); + } + } + } + } + + // --- 게임 플레이 이벤트 핸들링 --- + numberInputButtons.addEventListener('click', (event) => { + const target = event.target.closest('button'); + if (!target) return; + + if (target === undoBtn) { + undoAction(); + return; + } + + if (target.classList.contains('completed')) return; + document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected')); + + if (target.classList.contains('num-btn')) { + const num = target.dataset.number; + selectedNumber = (selectedNumber === num) ? null : num; + if (selectedNumber) target.classList.add('selected'); + } + highlightCells(); + }); + + boardElement.addEventListener('click', (event) => { + const targetCell = event.target.closest('.cell.editable'); + if (!targetCell) { + if (focusedCell) focusedCell = null; + highlightCells(); + return; + } + focusedCell = targetCell; + + if (selectedNumber) { + const previousValue = targetCell.textContent; + let newValue = (previousValue === selectedNumber) ? '' : selectedNumber; + targetCell.textContent = newValue; + + recordAction(targetCell, previousValue, newValue); + validateCell(targetCell); + updateButtonStates(); + checkIfBoardIsFull(); + } + + highlightCells(); + }); + + hintBtn.addEventListener('click', () => { + if (score <= 0) return; + const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent); + if (emptyCells.length === 0) { + alert('모든 칸이 채워져 있습니다.'); + return; + } + + const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; + const cellIndex = parseInt(randomCell.dataset.index); + const correctAnswer = solvedPuzzle[cellIndex]; + const previousValue = randomCell.textContent; + + score--; + updateScoreDisplay(); + recordAction(randomCell, previousValue, correctAnswer, true); + + randomCell.textContent = correctAnswer; + randomCell.classList.remove('editable', 'incorrect'); + + updateButtonStates(); + highlightCells(); + checkIfBoardIsFull(); + }); + + function undoAction() { + if (history.length === 0) return; + const lastAction = history.pop(); + const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`); + + if (cell) { + cell.textContent = lastAction.previousValue; + if (lastAction.wasHint) { + cell.classList.add('editable'); + } + validateCell(cell, false); + updateButtonStates(); + highlightCells(); + } + } + + function recordAction(cell, previousValue, newValue, wasHint = false) { + history.push({ index: cell.dataset.index, previousValue, newValue, wasHint }); + } + + function validateCell(cell, deductPoint = true) { + if (!cell.textContent) { + cell.classList.remove('incorrect'); + return; + } + const cellIndex = parseInt(cell.dataset.index); + const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]); + if (!isCorrect) { + cell.classList.add('incorrect'); + if (deductPoint && score > 0) { + score--; + updateScoreDisplay(); + } + } else { + cell.classList.remove('incorrect'); + } + } + + // --- 하이라이트 기능 --- + function highlightCells() { + document.querySelectorAll('.cell').forEach(cell => { + cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); + }); + if (focusedCell) { + focusedCell.classList.add('highlight-focused'); + const focusedValue = focusedCell.textContent; + if (focusedValue) { + document.querySelectorAll('.cell').forEach(cell => { + if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number'); + }); + } + } + if (selectedNumber) { + document.querySelectorAll('.cell').forEach(cell => { + if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number'); + }); + } + } + + // --- 게임 완료 및 모달 --- + async function checkSolution() { + let answerString = ""; + boardElement.childNodes.forEach(cell => { + answerString += cell.textContent || '0'; + }); + + if (answerString.includes('0')) { + alert('모든 칸을 채워주세요!'); + return; + } + + try { + const response = await fetch('/sudoku/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString }) + }); + const result = await response.json(); + if (result.correct) { + clearInterval(timerInterval); + alert('🎉 정답입니다!'); + showRankingModal(); + } else { + alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.'); + } + } catch (error) { + console.error('정답 확인 중 오류 발생:', error); + alert('정답 확인 중 오류가 발생했습니다.'); + } + } + + function checkIfBoardIsFull() { + const emptyEditableCells = boardElement.querySelector('.cell.editable:empty'); + if (!emptyEditableCells) { + checkSolution(); + } + } + + completeBtn.addEventListener('click', checkSolution); + + async function showRankingModal() { + modalOverlay.classList.remove('hidden'); + document.getElementById('username-input').value = ''; + submitRankBtn.disabled = false; + try { + const response = await fetch(`/sudoku/ranking/${currentPuzzleId}`); + const rankings = await response.json(); + rankingList.innerHTML = ''; + if (rankings.length === 0) { + rankingList.innerHTML = '
  • 아직 등록된 랭킹이 없습니다.
  • '; + } else { + rankings.forEach((rank, index) => { + const li = document.createElement('li'); + const minutes = Math.floor(rank.completionTime / 60).toString().padStart(2, '0'); + const seconds = (rank.completionTime % 60).toString().padStart(2, '0'); + li.innerHTML = `${index + 1}위: ${rank.userName} ${minutes}:${seconds}`; + rankingList.appendChild(li); + }); + } + } catch (error) { + console.error('랭킹 조회 중 오류 발생:', error); + rankingList.innerHTML = '
  • 랭킹을 불러올 수 없습니다.
  • '; + } + } + + submitRankBtn.addEventListener('click', async () => { + const userName = document.getElementById('username-input').value.trim(); + if (!userName) return alert('이름을 입력해주세요.'); + try { + await fetch('/sudoku/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + puzzleId: currentPuzzleId, + userName: userName, + completionTime: secondsElapsed + }) + }); + alert('랭킹이 성공적으로 등록되었습니다!'); + showRankingModal(); + submitRankBtn.disabled = true; + } catch (error) { + console.error('랭킹 등록 중 오류 발생:', error); + alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.'); + } + }); + + function resetGameView() { + setupContainer.classList.remove('hidden'); + gameContainer.classList.add('hidden'); + numberInputButtons.classList.add('hidden'); + clearInterval(timerInterval); + selectedNumber = null; + focusedCell = null; + document.querySelectorAll('.cell').forEach(cell => { + cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); + }); + document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed')); + } + + closeModalBtn.addEventListener('click', () => { + modalOverlay.classList.add('hidden'); + resetGameView(); + }); + + retryBtn.addEventListener('click', () => { + gameOverModal.classList.add('hidden'); + resetGameView(); + }); +}); \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/2048.html b/src/main/resources/templates/content/puzzle/2048.html new file mode 100644 index 0000000..57cbf35 --- /dev/null +++ b/src/main/resources/templates/content/puzzle/2048.html @@ -0,0 +1,41 @@ + + + + + + + + + + + +

    2048

    +

    화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!

    +
    +
    + 점수: 0 +
    +
    + + + +
    +

    🏆 랭킹

    +
      +
      + + diff --git a/src/main/resources/templates/content/puzzle/play.html b/src/main/resources/templates/content/puzzle/play.html index ff9be93..a545c42 100644 --- a/src/main/resources/templates/content/puzzle/play.html +++ b/src/main/resources/templates/content/puzzle/play.html @@ -21,10 +21,10 @@
      diff --git a/src/main/resources/templates/content/puzzle/sudoku.html b/src/main/resources/templates/content/puzzle/sudoku.html new file mode 100644 index 0000000..ea90c26 --- /dev/null +++ b/src/main/resources/templates/content/puzzle/sudoku.html @@ -0,0 +1,80 @@ + + + + + + + + + + + +
      +
      +

      스도쿠를 즐겨보세요!

      + +
      + + +
      + + +
      +
      + + + + + +
      + diff --git a/src/main/resources/templates/content/puzzle/sudoku_gen.html b/src/main/resources/templates/content/puzzle/sudoku_gen.html new file mode 100644 index 0000000..3a26a72 --- /dev/null +++ b/src/main/resources/templates/content/puzzle/sudoku_gen.html @@ -0,0 +1,31 @@ + + + + + + + + + + + +
      +

      스도쿠 퍼즐 생성기

      +

      버튼을 누르면 새로운 스도쿠 퍼즐을 생성하여 DB에 저장하고 화면에 보여줍니다.

      + +
      + +

      생성된 퍼즐

      +
      +
      +
      + +
      + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 4353e3e..b04953b 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -16,6 +16,8 @@ + + @@ -31,7 +33,8 @@
    1. 글쓰기
    2. 수정하기
    3. -
    4. 퍼즐 문제 생성
    5. +
    6. 네모로직 문제 생성
    7. +
    8. 스도쿠 문제 생성
    9. Phasellus magna