This commit is contained in:
lunaticbum 2025-09-02 17:32:02 +09:00
parent 543b1209e6
commit 4d44a4838b
22 changed files with 1770 additions and 62 deletions

View File

@ -71,7 +71,9 @@ class SecurityConfig(
csrf.ignoringRequestMatchers( csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/blog/post/images/**","/puzzle/**","/puzzle/play/**" "/blog/post/images/**","/puzzle/**","/puzzle/play/**",
"/rank/**",
"/sudoku/**",
) // 여기 예외 추가 ) // 여기 예외 추가
}.authorizeHttpRequests { auth -> }.authorizeHttpRequests { auth ->
auth auth
@ -84,7 +86,8 @@ class SecurityConfig(
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/imageUpload.bjx", // "/blog/post/imageUpload.bjx",
"/blog/post/images/**", "/blog/post/images/**",
"/puzzle/play","/puzzle/play/**", "/rank/**","/sudoku/**",
"/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku",
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
}.formLogin { form -> }.formLogin { form ->

View File

@ -21,7 +21,7 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
@PostMapping("upload.bjx") @PostMapping("upload.bjx")
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> { suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
return try { return try {
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile, 20) val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile)
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환 ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() 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") @GetMapping("/","/upload.bs")
suspend fun uploadPuzzle() : ResultMV { suspend fun uploadPuzzle() : ResultMV {

View File

@ -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<Rank>
</Rank> */
@PostMapping("/ranks")
fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
return rankRepository.save(rank)
}
/**
* 특정 게임의 상위 10 랭킹 리스트를 조회합니다.
* @param gameId 경로 변수(Path Variable) 게임 ID를 받습니다.
* @return Flux<Rank>
</Rank> */
@GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
}
}

View File

@ -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<GameRecord> {
return sudokuService.getRankings(puzzleId)
}
@PostMapping("/generate")
suspend fun generateSinglePuzzle(): SudokuPuzzle {
return sudokuService.generateAndSavePuzzle()
}
@PostMapping("/validate")
suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
val isCorrect = sudokuService.validateSolution(validateDto)
return mapOf("correct" to isCorrect)
}
}

View File

@ -53,6 +53,13 @@ interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, Str
@Service @Service
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입 class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
// 퍼즐 크기의 최소/최대값을 상수로 정의하여 관리 용이성을 높입니다.
companion object {
private const val MIN_PUZZLE_SIZE = 10
private const val MAX_PUZZLE_SIZE = 30
}
/** /**
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다. * 랜덤으로 퍼즐 하나를 찾아서 반환합니다.
* @return 찾은 퍼즐 또는 DB가 비어있으면 null * @return 찾은 퍼즐 또는 DB가 비어있으면 null
@ -63,18 +70,25 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
fun findById(id: String) = puzzleRepository.findById(id) fun findById(id: String) = puzzleRepository.findById(id)
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id) fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
// suspend 함수로 비동기 작업을 선언
suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle { /**
* ( 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다.
*/
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
val puzzleData = withContext(Dispatchers.IO) { val puzzleData = withContext(Dispatchers.IO) {
val originalImage = ImageIO.read(file.inputStream) val originalImage = ImageIO.read(file.inputStream)
val imageWithBackground = convertTransparentToWhite(originalImage) val imageWithBackground = convertTransparentToWhite(originalImage)
// (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정
val puzzleSize = determinePuzzleSize(imageWithBackground)
// Create a resized color version for the final reveal // Create a resized color version for the final reveal
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply { // 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성
val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply {
createGraphics().run { createGraphics().run {
drawImage(imageWithBackground, 0, 0, size, size, null) drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null)
dispose() dispose()
} }
} }
@ -82,8 +96,9 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
val averageBrightness = calculateAverageBrightness(grayImage) val averageBrightness = calculateAverageBrightness(grayImage)
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness) val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
val solutionGrid = List(size) { y -> // 결정된 puzzleSize를 사용하여 solutionGrid 생성
List(size) { x -> val solutionGrid = List(puzzleSize) { y ->
List(puzzleSize) { x ->
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 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() 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 // Helper function to resize images
private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage { private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply { return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {

View File

@ -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<Rank, String> {
/**
* 특정 gameId에 대해 점수가 높은 순서대로 상위 10개의 랭킹을 조회합니다.
* @param gameId 조회할 게임의 ID
* @return Flux<Rank>
</Rank> */
// 쿼리 메소드 이름 변경 및 파라미터 추가
fun findTop10ByGameIdOrderByScoreDesc(gameId: String): Flux<Rank?> // 👈 수정
}

View File

@ -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<SudokuPuzzle, String> {
// 전체 퍼즐 개수를 반환하는 suspend 함수
override suspend fun count(): Long
// puzzleKey로 퍼즐을 찾는 suspend 함수
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
// 👇 이 함수 선언을 추가해주세요.
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
}
@Repository
interface GameRecordRepository : CoroutineCrudRepository<GameRecord, String> {
// 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림)
fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow<GameRecord>
}
@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<GameRecord> {
// 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
}
}

View File

@ -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<Int, Int>? {
for (r in 0..8) {
for (c in 0..8) {
if (board[r][c] == 0) {
return Pair(r, c)
}
}
}
return null
}
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -42,41 +42,51 @@ body {
/* transform-origin은 그대로 유지합니다. */ /* transform-origin은 그대로 유지합니다. */
transform-origin: top; transform-origin: top;
} }
/* (★ 추가) 게임 컨트롤 영역 스타일 */ /* (★ 수정) 게임 컨트롤 영역 스타일 */
#game-controls { #game-controls {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
max-width: 500px; /* 보드와 비슷한 너비로 설정 */ max-width: 500px;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 1.2em; font-size: 1.2em;
flex-wrap: wrap; /* Allow controls to wrap on small screens */ flex-wrap: wrap;
gap: 15px; gap: 15px;
} }
/* (★ ADD) Styles for the mode selector */ /* (★ 수정) 모드 선택 버튼 스타일 */
#mode-selector { #mode-selector {
display: flex; display: flex;
gap: 10px; gap: 5px;
background-color: #eee; border: 1px solid #ccc;
padding: 8px;
border-radius: 8px; border-radius: 8px;
padding: 4px;
background-color: #f0f0f0;
} }
#mode-selector label { #mode-selector label {
cursor: pointer; cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
user-select: none; 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; background-color: #007bff;
color: white; color: white;
} box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
/* Hide the actual radio buttons */
#mode-selector input {
display: none;
} }
#hint-btn { #hint-btn {
padding: 8px 15px; padding: 8px 15px;

View File

@ -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;
}

View File

@ -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 = '<li>등록된 랭킹이 없습니다.</li>';
return;
}
rankings.forEach((rank, index) => {
const li = document.createElement('li');
li.innerHTML = `<span>${index + 1}. ${rank.name}</span><strong>${rank.score}점</strong>`;
rankingList.appendChild(li);
});
} catch (error) {
console.error('Error:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
// ----- 게임 시작 -----
updateRankingList();
initializeBoard();
});

View File

@ -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;
}
});
});

View File

@ -266,6 +266,11 @@ function gotoPuzzleUpload() {
document.location.replace(getMainPath()+"/puzzle/upload.bs") document.location.replace(getMainPath()+"/puzzle/upload.bs")
} }
function gotoPuzzleUpload() {
document.location.replace(getMainPath()+"/puzzle/sudoku_gen.bs")
}
function gotoWhere() { function gotoWhere() {
document.location.replace(getMainPath()+"/bums/where.bs") document.location.replace(getMainPath()+"/bums/where.bs")
} }

View File

@ -26,6 +26,7 @@ document.addEventListener('DOMContentLoaded', () => {
const modalMessage = document.getElementById('modal-message'); const modalMessage = document.getElementById('modal-message');
const modalButtons = document.getElementById('modal-buttons'); const modalButtons = document.getElementById('modal-buttons');
// --- 게임 상태를 관리하는 변수 --- // --- 게임 상태를 관리하는 변수 ---
let currentMode = 'fill'; let currentMode = 'fill';
let points = 5; let points = 5;
@ -213,14 +214,28 @@ document.addEventListener('DOMContentLoaded', () => {
const cell = e.target; const cell = e.target;
const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)]; const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
// Determine action based on mode, not button // --- 하이브리드 로직 ---
if (currentMode === 'fill') { if (e.type === 'mousedown') { // 마우스 이벤트일 경우
dragAction = (currentState === 1) ? 'clear' : 'fill'; if (e.button === 0) { // 좌클릭
} else { // currentMode === 'mark' dragAction = (currentState === 1) ? 'clear' : 'fill';
dragAction = (currentState === -1) ? 'clear' : 'mark'; // 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) }; startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
lastHoveredCell = startCell; lastHoveredCell = startCell;
updateSelectionVisuals(); updateSelectionVisuals();
@ -409,9 +424,9 @@ document.addEventListener('DOMContentLoaded', () => {
function triggerGameSuccess() { function triggerGameSuccess() {
if (isGameFinished) return; if (isGameFinished) return;
isGameFinished = true; 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 puzzleGridContainer = document.querySelector('.puzzle-grid-container');
const grayscaleImg = document.getElementById('grayscale-reveal'); const grayscaleImg = document.getElementById('grayscale-reveal');
const originalImg = document.getElementById('original-reveal'); const originalImg = document.getElementById('original-reveal');
@ -420,31 +435,35 @@ document.addEventListener('DOMContentLoaded', () => {
puzzleGridContainer.style.pointerEvents = 'none'; puzzleGridContainer.style.pointerEvents = 'none';
hintBtn.disabled = true; hintBtn.disabled = true;
// --- 애니메이션 준비 --- // --- (★ 핵심) 애니메이션 위치 및 크기 계산 ---
// 1. 이미지 소스 설정 // 1. 렌더링된 퍼즐 격자와 뷰포트의 실제 위치/크기 정보를 가져옵니다.
// (puzzleData에 저장해 둔 Base64 인코딩된 이미지 데이터를 사용합니다) const gridRect = puzzleGridContainer.getBoundingClientRect();
grayscaleImg.src = puzzleData.grayscaleImage; const viewportRect = viewport.getBoundingClientRect();
originalImg.src = puzzleData.originalImage;
// 2. 현재 보드의 transform(스케일) 값을 이미지에도 동일하게 적용하여 정렬 // 2. 뷰포트를 기준으로 퍼즐 격자의 상대적인 위치(top, left)를 계산합니다.
// 이렇게 해야 반응형으로 크기가 조절된 보드 위에도 이미지가 정확히 겹칩니다. const top = gridRect.top - viewportRect.top;
const boardTransform = gameBoard.style.transform; const left = gridRect.left - viewportRect.left;
grayscaleImg.style.transform = boardTransform;
originalImg.style.transform = boardTransform;
// --- 애니메이션 순차 실행 --- // 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(() => { setTimeout(() => {
// 3. 그레이스케일 이미지 페이드 인 grayscaleImg.style.opacity = '1'; // 그레이스케일 페이드 인
// (CSS transition 속성에 의해 부드럽게 나타납니다)
grayscaleImg.style.opacity = '1';
setTimeout(() => { setTimeout(() => {
// 4. 컬러 이미지 페이드 인 (크로스페이드 효과) originalImg.style.opacity = '1'; // 컬러 이미지 페이드 인
// (그레이스케일 이미지 위에 컬러 이미지가 겹쳐지며 나타납니다)
originalImg.style.opacity = '1';
setTimeout(() => { setTimeout(() => {
// 5. 애니메이션이 모두 끝난 후 최종 결과 모달 표시 // 최종 결과 모달 표시
showResultModal({ showResultModal({
title: 'Success! 🎉', title: 'Success! 🎉',
message: '퍼즐을 완성했습니다!', message: '퍼즐을 완성했습니다!',
@ -453,17 +472,11 @@ document.addEventListener('DOMContentLoaded', () => {
{ text: '홈으로 (Home)', action: () => window.location.href = '/' } { text: '홈으로 (Home)', action: () => window.location.href = '/' }
] ]
}); });
}, 2000); // 컬러 이미지가 나타나고 2초 후 }, 2000);
}, 2000); // 그레이스케일 이미지가 나타나고 2초 후 }, 2000);
}, 500); // 마지막 셀을 채우고 0.5초 후 시작 }, 500);
// showResultModal({
// title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [
// { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' },
// { text: '홈으로 (Home)', action: () => window.location.href = '/' }
// ]
// });
} }
// 힌트 버튼 클릭 이벤트 처리 // 힌트 버튼 클릭 이벤트 처리

View File

@ -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 = '<li>아직 등록된 랭킹이 없습니다.</li>';
} 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 = `<span>${index + 1}위: ${rank.userName}</span> <span>${minutes}:${seconds}</span>`;
rankingList.appendChild(li);
});
}
} catch (error) {
console.error('랭킹 조회 중 오류 발생:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
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();
});
});

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/2048.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/2048.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<th:block layout:fragment="content">
<h1>2048</h1>
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
<div class="game-container">
<div class="score-container">
<strong>점수:</strong> <span id="score">0</span>
</div>
<div id="game-board"></div>
<div id="game-over-popup" class="popup-container" style="display:none;">
<div class="popup">
<h2>게임 오버!</h2>
<p>최종 점수: <span id="final-score">0</span></p>
<input type="text" id="player-name" placeholder="이름을 입력하세요">
<button id="save-score">점수 저장</button>
</div>
</div>
<div class="ranking-container">
<h3>🏆 랭킹</h3>
<ol id="ranking-list"></ol>
</div>
</th:block>
</html>

View File

@ -21,10 +21,10 @@
<div id="game-controls"> <div id="game-controls">
<div id="mode-selector"> <div id="mode-selector">
<label> <label>
<input type="radio" name="play-mode" value="fill" checked> Fill <input type="radio" name="play-mode" value="fill" checked><span> Fill</span>
</label> </label>
<label> <label>
<input type="radio" name="play-mode" value="mark"> Mark (X) <input type="radio" name="play-mode" value="mark"><span> Mark (X)</span>
</label> </label>
</div> </div>

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/sudoku.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<th:block layout:fragment="content">
<div id="sudoku-game-app">
<div class="container">
<h1>스도쿠를 즐겨보세요!</h1>
<div id="setup-container">
<select id="difficulty-select">
<option value="easy">쉬움</option>
<option value="medium">중간</option>
<option value="hard">어려움</option>
</select>
<button id="start-btn">게임 시작</button>
</div>
<div id="game-container" class="hidden">
<div class="game-info">
<div id="score">SCORE: 5</div>
<div id="timer">00:00</div>
</div>
<div id="sudoku-board"></div>
<div id="number-input-buttons">
<button class="num-btn" data-number="1">1</button>
<button class="num-btn" data-number="2">2</button>
<button class="num-btn" data-number="3">3</button>
<button class="num-btn" data-number="4">4</button>
<button class="num-btn" data-number="5">5</button>
<button class="num-btn" data-number="6">6</button>
<button class="num-btn" data-number="7">7</button>
<button class="num-btn" data-number="8">8</button>
<button class="num-btn" data-number="9">9</button>
<button id="undo-btn" class="clear-btn">실행취소</button>
</div>
<div class="action-buttons">
<button id="hint-btn">힌트 사용 (-1점)</button>
<button id="complete-btn">정답 확인</button>
</div>
</div>
</div>
</div>
<div id="modal-overlay" class="hidden">
<div id="modal-content">
<h2>🎉 성공! 기록을 남겨주세요.</h2>
<input type="text" id="username-input" placeholder="이름을 입력하세요" maxlength="10">
<button id="submit-rank-btn">랭킹 등록</button>
<hr>
<h3>🏆 명예의 전당</h3>
<ol id="ranking-list"></ol>
<button id="close-modal-btn">닫기</button>
</div>
</div>
<div id="game-over-modal" class="hidden">
<div id="modal-content">
<h2>GAME OVER</h2>
<p>포인트를 모두 사용했습니다.</p>
<button id="retry-btn">새 게임 시작</button>
</div>
</div>
</th:block>
</html>

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/admin-sudoku.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/admin-sudoku.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<th:block layout:fragment="content">
<div class="container">
<h1>스도쿠 퍼즐 생성기</h1>
<p>버튼을 누르면 새로운 스도쿠 퍼즐을 생성하여 DB에 저장하고 화면에 보여줍니다.</p>
<button id="generate-btn">새 스도쿠 생성</button>
<div id="status-message"></div>
<h2>생성된 퍼즐</h2>
<div id="sudoku-board">
</div>
</div>
</th:block>
</html>

View File

@ -16,6 +16,8 @@
<li id="menu_home" ><a th:href="@{/}">Home</a></li> <li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li> <li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li> <li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>--> <!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>-->
<!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>--> <!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>-->
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>--> <!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>-->
@ -31,7 +33,8 @@
<th:block sec:authorize="isAuthenticated()"> <th:block sec:authorize="isAuthenticated()">
<li><a href="javascript:gotoWrite()">글쓰기</a></li> <li><a href="javascript:gotoWrite()">글쓰기</a></li>
<li><a href="javascript:gotoModify()">수정하기</a></li> <li><a href="javascript:gotoModify()">수정하기</a></li>
<li><a href="javascript:gotoPuzzleUpload()">퍼즐 문제 생성</a></li> <li><a href="javascript:gotoPuzzleUpload()">네모로직 문제 생성</a></li>
<li><a href="javascript:gotoPuzzleUpload()">스도쿠 문제 생성</a></li>
</th:block> </th:block>
<li><a href="#">Phasellus magna</a></li> <li><a href="#">Phasellus magna</a></li>
<!-- <li><a href="#">Magna phasellus</a></li>--> <!-- <li><a href="#">Magna phasellus</a></li>-->