...
This commit is contained in:
parent
543b1209e6
commit
4d44a4838b
@ -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 ->
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
40
src/main/kotlin/kr/lunaticbum/back/lun/model/Rank.kt
Normal file
40
src/main/kotlin/kr/lunaticbum/back/lun/model/Rank.kt
Normal 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?> // 👈 수정
|
||||||
|
}
|
||||||
136
src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt
Normal file
136
src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/main/resources/static/css/2048.css
Normal file
156
src/main/resources/static/css/2048.css
Normal 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; }
|
||||||
|
}
|
||||||
73
src/main/resources/static/css/admin-sudoku.css
Normal file
73
src/main/resources/static/css/admin-sudoku.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
238
src/main/resources/static/css/sudoku.css
Normal file
238
src/main/resources/static/css/sudoku.css
Normal 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;
|
||||||
|
}
|
||||||
233
src/main/resources/static/js/2048.js
Normal file
233
src/main/resources/static/js/2048.js
Normal 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();
|
||||||
|
});
|
||||||
53
src/main/resources/static/js/admin-sudoku.js
Normal file
53
src/main/resources/static/js/admin-sudoku.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = '/' }
|
|
||||||
// ]
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 힌트 버튼 클릭 이벤트 처리
|
// 힌트 버튼 클릭 이벤트 처리
|
||||||
|
|||||||
356
src/main/resources/static/js/sudoku.js
Normal file
356
src/main/resources/static/js/sudoku.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/main/resources/templates/content/puzzle/2048.html
Normal file
41
src/main/resources/templates/content/puzzle/2048.html
Normal 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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
80
src/main/resources/templates/content/puzzle/sudoku.html
Normal file
80
src/main/resources/templates/content/puzzle/sudoku.html
Normal 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>
|
||||||
31
src/main/resources/templates/content/puzzle/sudoku_gen.html
Normal file
31
src/main/resources/templates/content/puzzle/sudoku_gen.html
Normal 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>
|
||||||
@ -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>-->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user