....
This commit is contained in:
parent
373964146c
commit
90f0ef1cce
@ -71,7 +71,7 @@ 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/upload.bjx"
|
"/blog/post/images/**","/puzzle/**","/puzzle/play/**"
|
||||||
) // 여기 예외 추가
|
) // 여기 예외 추가
|
||||||
}.authorizeHttpRequests { auth ->
|
}.authorizeHttpRequests { auth ->
|
||||||
auth
|
auth
|
||||||
@ -84,7 +84,7 @@ 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/upload.bjx",
|
"/puzzle/play","/puzzle/play/**",
|
||||||
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
|
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
}.formLogin { form ->
|
}.formLogin { form ->
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
package kr.lunaticbum.back.lun.controllers
|
package kr.lunaticbum.back.lun.controllers
|
||||||
|
|
||||||
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
import kr.lunaticbum.back.lun.model.PuzzleService
|
import kr.lunaticbum.back.lun.model.PuzzleService
|
||||||
import kr.lunaticbum.back.lun.model.ResultMV
|
import kr.lunaticbum.back.lun.model.ResultMV
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.ui.Model
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
@ -25,6 +29,57 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
|
|||||||
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
|
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 특정 ID의 퍼즐을 삭제하는 엔드포인트
|
||||||
|
* @param id URL 경로에서 추출한 퍼즐의 고유 ID
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}.bjx")
|
||||||
|
suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity<Void> {
|
||||||
|
return try {
|
||||||
|
puzzleService.deletePuzzle(id)
|
||||||
|
// 성공적으로 삭제되면 204 No Content 응답을 보냅니다.
|
||||||
|
ResponseEntity.noContent().build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 실패 시 500 에러 응답
|
||||||
|
ResponseEntity.internalServerError().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID가 지정된 경우 특정 퍼즐을 로드합니다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/play/{id}")
|
||||||
|
suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV {
|
||||||
|
val puzzle = puzzleService.findById(id).awaitSingleOrNull()
|
||||||
|
val vm = ResultMV("content/puzzle/play")
|
||||||
|
return if (puzzle != null) {
|
||||||
|
vm.model.put("puzzle", puzzle)
|
||||||
|
vm
|
||||||
|
} else {
|
||||||
|
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
|
||||||
|
vm.viewName = "redirect:/"
|
||||||
|
vm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/play")
|
||||||
|
suspend fun playRandomPuzzlePage(): ResultMV {
|
||||||
|
val puzzle = puzzleService.findRandomPuzzle()
|
||||||
|
val vm = ResultMV("content/puzzle/play")
|
||||||
|
return if (puzzle != null) {
|
||||||
|
vm.model.put("puzzle", puzzle)
|
||||||
|
vm
|
||||||
|
} else {
|
||||||
|
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
|
||||||
|
vm.viewName = "redirect:/"
|
||||||
|
vm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/","/upload.bs")
|
@GetMapping("/","/upload.bs")
|
||||||
suspend fun uploadPuzzle() : ResultMV {
|
suspend fun uploadPuzzle() : ResultMV {
|
||||||
|
|||||||
@ -1,18 +1,24 @@
|
|||||||
package kr.lunaticbum.back.lun.model
|
package kr.lunaticbum.back.lun.model
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
||||||
import org.springframework.data.annotation.Id
|
import org.springframework.data.annotation.Id
|
||||||
import org.springframework.data.mongodb.core.mapping.Document
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
import org.springframework.data.mongodb.repository.Aggregation
|
||||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
import reactor.core.publisher.Mono
|
||||||
import java.awt.Color
|
import java.awt.Color
|
||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Base64
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
|
||||||
@ -23,26 +29,49 @@ data class NonogramPuzzle(
|
|||||||
val solutionGrid: List<List<Int>>,
|
val solutionGrid: List<List<Int>>,
|
||||||
val rowClues: List<List<Int>>,
|
val rowClues: List<List<Int>>,
|
||||||
val colClues: List<List<Int>>,
|
val colClues: List<List<Int>>,
|
||||||
val createdAt: LocalDateTime = LocalDateTime.now() // 생성 시점의 기본값 설정
|
// Add these two fields
|
||||||
|
val grayscaleImage: String, // Base64 encoded grayscale image
|
||||||
|
val originalImage: String, // Base64 encoded original color image
|
||||||
|
|
||||||
|
val createdAt: LocalDateTime = LocalDateTime.now()
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
||||||
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
|
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
|
||||||
|
/**
|
||||||
|
* (★ Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는
|
||||||
|
* 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다.
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = [
|
||||||
|
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
|
||||||
|
"{ \$sample: { size: 1 } }"
|
||||||
|
])
|
||||||
|
fun findRandom(): Flux<NonogramPuzzle>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
|
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
|
||||||
|
/**
|
||||||
|
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다.
|
||||||
|
* @return 찾은 퍼즐 또는 DB가 비어있으면 null
|
||||||
|
*/
|
||||||
|
suspend fun findRandomPuzzle(): NonogramPuzzle? {
|
||||||
|
return puzzleRepository.findRandom().awaitFirstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findById(id: String) = puzzleRepository.findById(id)
|
||||||
|
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
|
||||||
// suspend 함수로 비동기 작업을 선언
|
// suspend 함수로 비동기 작업을 선언
|
||||||
suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle {
|
suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle {
|
||||||
val puzzleData = withContext(Dispatchers.IO) {
|
val puzzleData = withContext(Dispatchers.IO) {
|
||||||
val originalImage = ImageIO.read(file.inputStream)
|
val originalImage = ImageIO.read(file.inputStream)
|
||||||
|
|
||||||
// 1. (★중요) 투명 배경을 흰색으로 먼저 변환합니다.
|
|
||||||
val imageWithBackground = convertTransparentToWhite(originalImage)
|
val imageWithBackground = convertTransparentToWhite(originalImage)
|
||||||
|
|
||||||
// 2. 그레이스케일 변환 (흰색 배경이 적용된 이미지를 사용)
|
// Create a resized color version for the final reveal
|
||||||
|
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
|
||||||
|
|
||||||
val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply {
|
val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply {
|
||||||
createGraphics().run {
|
createGraphics().run {
|
||||||
drawImage(imageWithBackground, 0, 0, size, size, null)
|
drawImage(imageWithBackground, 0, 0, size, size, null)
|
||||||
@ -50,26 +79,49 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 평균 밝기를 계산하여 동적 임계치 결정
|
|
||||||
val averageBrightness = calculateAverageBrightness(grayImage)
|
val averageBrightness = calculateAverageBrightness(grayImage)
|
||||||
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
|
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
|
||||||
|
|
||||||
// 4. 결정된 임계치로 최종 그리드 생성
|
|
||||||
val solutionGrid = List(size) { y ->
|
val solutionGrid = List(size) { y ->
|
||||||
List(size) { x ->
|
List(size) { x ->
|
||||||
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
|
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 힌트 추출 및 객체 생성
|
|
||||||
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
||||||
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
|
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
|
||||||
|
|
||||||
NonogramPuzzle(solutionGrid = solutionGrid, rowClues = rowClues, colClues = colClues)
|
// Convert images to Base64 strings
|
||||||
|
val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300))
|
||||||
|
val originalBase64 = imageToBase64(resizedOriginal)
|
||||||
|
|
||||||
|
NonogramPuzzle(
|
||||||
|
solutionGrid = solutionGrid,
|
||||||
|
rowClues = rowClues,
|
||||||
|
colClues = colClues,
|
||||||
|
grayscaleImage = grayscaleBase64,
|
||||||
|
originalImage = originalBase64
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return puzzleRepository.save(puzzleData).awaitSingle()
|
return puzzleRepository.save(puzzleData).awaitSingle()
|
||||||
}
|
}
|
||||||
|
// Helper function to resize images
|
||||||
|
private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
|
||||||
|
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {
|
||||||
|
createGraphics().run {
|
||||||
|
drawImage(sourceImage, 0, 0, size, size, null)
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert a BufferedImage to a Base64 String
|
||||||
|
private fun imageToBase64(image: BufferedImage): String {
|
||||||
|
val os = ByteArrayOutputStream()
|
||||||
|
ImageIO.write(image, "png", os)
|
||||||
|
return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
|
* (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
|
||||||
|
|||||||
204
src/main/resources/static/css/play.css
Normal file
204
src/main/resources/static/css/play.css
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#board-viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 95vw; /* 화면 너비에 좀 더 맞춤 */
|
||||||
|
margin: 20px auto;
|
||||||
|
/* (★ 핵심 수정) Flexbox로 자식 요소를 중앙 정렬합니다. */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* 자식 요소를 수평 중앙 정렬 */
|
||||||
|
align-items: flex-start; /* 위쪽에 정렬 */
|
||||||
|
}
|
||||||
|
/* (★ ADD) Styles for the reveal images */
|
||||||
|
.reveal-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0; /* Hidden by default */
|
||||||
|
pointer-events: none; /* Make them unclickable */
|
||||||
|
transition: opacity 1.5s ease-in-out; /* Fade animation */
|
||||||
|
transform-origin: top left; /* Align with the game board's scaling */
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-line-right {
|
||||||
|
border-right: 2px solid #999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-line-bottom {
|
||||||
|
border-bottom: 2px solid #999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
background-color: #999;
|
||||||
|
border: 2px solid #333;
|
||||||
|
/* transform-origin은 그대로 유지합니다. */
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
/* (★ 추가) 게임 컨트롤 영역 스타일 */
|
||||||
|
#game-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px; /* 보드와 비슷한 너비로 설정 */
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
#hint-btn {
|
||||||
|
padding: 8px 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
#hint-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-clues-container, .row-clues-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.row-clues-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-grid-container {
|
||||||
|
display: grid;
|
||||||
|
border: 2px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-cell {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-clue {
|
||||||
|
justify-content: flex-end; /* 힌트 오른쪽 정렬 */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-clue {
|
||||||
|
justify-content: center; /* 힌트 가운데 정렬 */
|
||||||
|
align-items: flex-end; /* 힌트 아래쪽 정렬 */
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2; /* 줄 간격 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell.filled {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell.marked::after {
|
||||||
|
content: 'X';
|
||||||
|
color: #ff5c5c;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (★ 추가) 실수했을 때 셀 스타일 */
|
||||||
|
.grid-cell.incorrect {
|
||||||
|
background-color: #ffcccc;
|
||||||
|
animation: shake 0.5s;
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-5px); }
|
||||||
|
75% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#result-overlay {
|
||||||
|
position: fixed; /* 화면 전체에 고정 */
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw; /* 뷰포트 너비 100% */
|
||||||
|
height: 100vh; /* 뷰포트 높이 100% */
|
||||||
|
background-color: rgba(0, 0, 0, 0.75); /* 반투명 검은 배경 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
#result-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
/* (★ 추가) 모달 팝업 스타일 */
|
||||||
|
#result-modal {
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
#modal-title {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 2.5em;
|
||||||
|
}
|
||||||
|
#modal-buttons button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
#modal-buttons button.primary {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기존 .hidden 클래스는 이제 overlay의 visible/hidden 상태 관리에 사용됩니다. */
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* play.css */
|
||||||
|
|
||||||
|
/* (★ 추가) 완성된 힌트 스타일 */
|
||||||
|
.clue-cell.completed {
|
||||||
|
color: #999; /* 색상을 회색으로 */
|
||||||
|
text-decoration: line-through; /* 취소선 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (★ 추가) 잠긴 셀 스타일 */
|
||||||
|
.grid-cell.locked {
|
||||||
|
opacity: 0.8; /* 약간 투명하게 */
|
||||||
|
/* 잠긴 셀은 더 이상 클릭할 수 없다는 것을 시각적으로 보여줌 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell.selecting {
|
||||||
|
background-color: rgba(0, 123, 255, 0.3); /* 반투명 파란색 배경 */
|
||||||
|
border-color: rgba(0, 123, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
@ -39,3 +39,26 @@
|
|||||||
.empty {
|
.empty {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#puzzle-wrapper {
|
||||||
|
position: relative; /* Needed for absolute positioning of children */
|
||||||
|
}
|
||||||
|
|
||||||
|
#success-animation-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none; /* Allows clicking through the container */
|
||||||
|
}
|
||||||
|
|
||||||
|
#success-animation-container img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0; /* Hidden by default */
|
||||||
|
transition: opacity 1.0s ease-in-out; /* Fade animation */
|
||||||
|
}
|
||||||
@ -262,6 +262,10 @@ function gotoModify() {
|
|||||||
document.location.replace(getMainPath()+"/blog/modify.bs")
|
document.location.replace(getMainPath()+"/blog/modify.bs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gotoPuzzleUpload() {
|
||||||
|
document.location.replace(getMainPath()+"/puzzle/upload.bs")
|
||||||
|
}
|
||||||
|
|
||||||
function gotoWhere() {
|
function gotoWhere() {
|
||||||
document.location.replace(getMainPath()+"/bums/where.bs")
|
document.location.replace(getMainPath()+"/bums/where.bs")
|
||||||
}
|
}
|
||||||
|
|||||||
417
src/main/resources/static/js/play.js
Normal file
417
src/main/resources/static/js/play.js
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* Nonogram Game Logic - 최종 통합 및 수정 완료 버전
|
||||||
|
* * ## 포함된 모든 기능:
|
||||||
|
* 1. 동적 셀 크기 계산 및 가독성을 위한 넓은 힌트 영역
|
||||||
|
* 2. 게임 보드, 힌트, 상호작용 그리드 렌더링
|
||||||
|
* 3. 화면 크기에 맞는 반응형 보드 크기 조절
|
||||||
|
* 4. 사각형 선택 드래그 기능 등 모든 사용자 상호작용 처리
|
||||||
|
* 5. 게임 규칙 (포인트, 힌트, 실수 페널티, 승리/패배 조건)
|
||||||
|
* 6. '칠한 칸' 기준으로만 라인 완성 및 잠금 처리
|
||||||
|
* 7. 사용자가 마지막으로 수정한 라인만 검사하는 최적화
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 백엔드(Thymeleaf)로부터 puzzleData가 제대로 전달되었는지 확인
|
||||||
|
if (typeof puzzleData === 'undefined' || !puzzleData) {
|
||||||
|
document.getElementById('game-board').innerHTML = '<h2>오류: 퍼즐 데이터를 불러올 수 없습니다.</h2>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOM 요소 참조 ---
|
||||||
|
const gameBoard = document.getElementById('game-board');
|
||||||
|
const pointsDisplay = document.getElementById('points-display');
|
||||||
|
const hintBtn = document.getElementById('hint-btn');
|
||||||
|
const resultOverlay = document.getElementById('result-overlay');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const modalMessage = document.getElementById('modal-message');
|
||||||
|
const modalButtons = document.getElementById('modal-buttons');
|
||||||
|
|
||||||
|
// --- 게임 상태를 관리하는 변수 ---
|
||||||
|
let points = 5;
|
||||||
|
let isGameFinished = false;
|
||||||
|
let isDragging = false;
|
||||||
|
let dragAction = null; // 'fill', 'mark', 'clear'
|
||||||
|
let startCell = null; // 드래그 시작 셀 좌표 {row, col}
|
||||||
|
let lastHoveredCell = null; // 마지막으로 마우스가 지나간 셀 좌표
|
||||||
|
let currentSelection = new Set(); // 현재 드래그로 선택된 셀들의 Set
|
||||||
|
let affectedRows = new Set(); // 마지막 행동으로 영향을 받은 행
|
||||||
|
let affectedCols = new Set(); // 마지막 행동으로 영향을 받은 열
|
||||||
|
|
||||||
|
// --- 퍼즐 데이터 및 플레이어 진행 상황 ---
|
||||||
|
const solution = puzzleData.solutionGrid;
|
||||||
|
const numRows = solution.length;
|
||||||
|
const numCols = solution[0].length;
|
||||||
|
let playerGrid = Array(numRows).fill(0).map(() => Array(numCols).fill(0));
|
||||||
|
let lockedRows = Array(numRows).fill(false);
|
||||||
|
let lockedCols = Array(numCols).fill(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폰트 높이와 두 자릿수 숫자 너비를 기준으로 최적의 셀 크기를 계산합니다.
|
||||||
|
* @returns {number} 계산된 각 그리드 셀의 크기 (px)
|
||||||
|
*/
|
||||||
|
function calculateCellSize() {
|
||||||
|
const tempContainer = document.createElement('div');
|
||||||
|
tempContainer.style.position = 'absolute';
|
||||||
|
tempContainer.style.visibility = 'hidden';
|
||||||
|
const tempCell = document.createElement('div');
|
||||||
|
tempCell.className = 'clue-cell';
|
||||||
|
tempCell.textContent = '0';
|
||||||
|
tempContainer.appendChild(tempCell);
|
||||||
|
document.body.appendChild(tempContainer);
|
||||||
|
const fontHeight = tempCell.offsetHeight;
|
||||||
|
tempCell.textContent = '10';
|
||||||
|
const doubleDigitWidth = tempCell.offsetWidth;
|
||||||
|
document.body.removeChild(tempContainer);
|
||||||
|
const baseSize = Math.max(fontHeight, doubleDigitWidth, 30);
|
||||||
|
return baseSize + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계산된 셀 크기를 사용하여 전체 게임 보드를 그립니다.
|
||||||
|
* @param {number} cellSize - 계산된 셀의 통일된 크기
|
||||||
|
*/
|
||||||
|
function drawBoard(cellSize) {
|
||||||
|
const rowClueAreaWidth = cellSize * 2;
|
||||||
|
const colClueAreaHeight = cellSize * 2;
|
||||||
|
gameBoard.style.gridTemplateColumns = `${rowClueAreaWidth}px 1fr`;
|
||||||
|
gameBoard.style.gridTemplateRows = `${colClueAreaHeight}px 1fr`;
|
||||||
|
const corner = document.createElement('div');
|
||||||
|
const colCluesContainer = document.createElement('div');
|
||||||
|
colCluesContainer.className = 'col-clues-container';
|
||||||
|
const rowCluesContainer = document.createElement('div');
|
||||||
|
rowCluesContainer.className = 'row-clues-container';
|
||||||
|
const puzzleGridContainer = document.createElement('div');
|
||||||
|
puzzleGridContainer.className = 'puzzle-grid-container';
|
||||||
|
puzzleGridContainer.style.gridTemplateColumns = `repeat(${numCols}, ${cellSize}px)`;
|
||||||
|
puzzleGridContainer.style.gridTemplateRows = `repeat(${numRows}, ${cellSize}px)`;
|
||||||
|
puzzleData.colClues.forEach((clues, index) => {
|
||||||
|
const clueCell = document.createElement('div');
|
||||||
|
clueCell.className = 'clue-cell col-clue';
|
||||||
|
clueCell.id = `col-clue-${index}`;
|
||||||
|
clueCell.style.width = `${cellSize}px`;
|
||||||
|
clueCell.innerHTML = clues.join('<br>');
|
||||||
|
if ((index + 1) % 5 === 0 && index < numCols - 1) clueCell.classList.add('guide-line-right');
|
||||||
|
colCluesContainer.appendChild(clueCell);
|
||||||
|
});
|
||||||
|
puzzleData.rowClues.forEach((clues, index) => {
|
||||||
|
const clueCell = document.createElement('div');
|
||||||
|
clueCell.className = 'clue-cell row-clue';
|
||||||
|
clueCell.id = `row-clue-${index}`;
|
||||||
|
clueCell.style.height = `${cellSize}px`;
|
||||||
|
clueCell.textContent = clues.join(' ');
|
||||||
|
if ((index + 1) % 5 === 0 && index < numRows - 1) clueCell.classList.add('guide-line-bottom');
|
||||||
|
rowCluesContainer.appendChild(clueCell);
|
||||||
|
});
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'grid-cell';
|
||||||
|
cell.dataset.row = r;
|
||||||
|
cell.dataset.col = c;
|
||||||
|
if ((c + 1) % 5 === 0 && c < numCols - 1) cell.classList.add('guide-line-right');
|
||||||
|
if ((r + 1) % 5 === 0 && r < numRows - 1) cell.classList.add('guide-line-bottom');
|
||||||
|
puzzleGridContainer.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameBoard.appendChild(corner);
|
||||||
|
gameBoard.appendChild(colCluesContainer);
|
||||||
|
gameBoard.appendChild(rowCluesContainer);
|
||||||
|
gameBoard.appendChild(puzzleGridContainer);
|
||||||
|
attachEventListeners(puzzleGridContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 너비에 맞춰 게임 보드 전체를 비율 그대로 축소/확대합니다.
|
||||||
|
*/
|
||||||
|
function fitBoardToScreen() {
|
||||||
|
const viewport = document.getElementById('board-viewport');
|
||||||
|
const board = document.getElementById('game-board');
|
||||||
|
board.style.transform = 'scale(1)';
|
||||||
|
const boardRect = board.getBoundingClientRect();
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
if (boardRect.width > viewportRect.width) {
|
||||||
|
const scale = viewportRect.width / boardRect.width;
|
||||||
|
board.style.transform = `scale(${scale})`;
|
||||||
|
viewport.style.height = `${boardRect.height * scale}px`;
|
||||||
|
} else {
|
||||||
|
board.style.transform = 'scale(1)';
|
||||||
|
viewport.style.height = `${boardRect.height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 셀의 상태를 변경하는 유일한 함수. 모든 사용자 입력은 이 함수를 거칩니다.
|
||||||
|
*/
|
||||||
|
function updateCellState(cell, action) {
|
||||||
|
if (isGameFinished) return;
|
||||||
|
const row = parseInt(cell.dataset.row);
|
||||||
|
const col = parseInt(cell.dataset.col);
|
||||||
|
if (lockedRows[row] || lockedCols[col]) return;
|
||||||
|
affectedRows.add(row);
|
||||||
|
affectedCols.add(col);
|
||||||
|
const currentState = playerGrid[row][col];
|
||||||
|
let newState = currentState;
|
||||||
|
if (action === 'fill') {
|
||||||
|
if (solution[row][col] === 0) {
|
||||||
|
points--;
|
||||||
|
updatePointsDisplay();
|
||||||
|
cell.classList.add('incorrect');
|
||||||
|
setTimeout(() => cell.classList.remove('incorrect'), 500);
|
||||||
|
if (points <= 0) triggerGameOver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newState = 1;
|
||||||
|
} else if (action === 'mark') {
|
||||||
|
newState = -1;
|
||||||
|
} else if (action === 'clear') {
|
||||||
|
newState = 0;
|
||||||
|
}
|
||||||
|
if (currentState !== newState) {
|
||||||
|
playerGrid[row][col] = newState;
|
||||||
|
cell.classList.toggle('filled', newState === 1);
|
||||||
|
cell.classList.toggle('marked', newState === -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호작용 그리드에 마우스 이벤트 리스너를 등록합니다.
|
||||||
|
*/
|
||||||
|
function attachEventListeners(grid) {
|
||||||
|
grid.addEventListener('mousedown', (e) => {
|
||||||
|
if (isGameFinished || !e.target.classList.contains('grid-cell')) return;
|
||||||
|
isDragging = true;
|
||||||
|
e.preventDefault();
|
||||||
|
const cell = e.target;
|
||||||
|
const row = parseInt(cell.dataset.row);
|
||||||
|
const col = parseInt(cell.dataset.col);
|
||||||
|
startCell = { row, col };
|
||||||
|
lastHoveredCell = startCell;
|
||||||
|
const currentState = playerGrid[row][col];
|
||||||
|
if (e.button === 0) {
|
||||||
|
dragAction = (currentState === 1) ? 'clear' : 'fill';
|
||||||
|
} else if (e.button === 2) {
|
||||||
|
dragAction = (currentState === -1) ? 'clear' : 'mark';
|
||||||
|
}
|
||||||
|
updateSelectionVisuals();
|
||||||
|
});
|
||||||
|
grid.addEventListener('mouseover', (e) => {
|
||||||
|
if (!isDragging || !e.target.classList.contains('grid-cell')) return;
|
||||||
|
const row = parseInt(e.target.dataset.row);
|
||||||
|
const col = parseInt(e.target.dataset.col);
|
||||||
|
if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) {
|
||||||
|
lastHoveredCell = { row, col };
|
||||||
|
updateSelectionVisuals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
grid.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그가 화면 어디에서 끝나든 감지하기 위해 window에 등록
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
currentSelection.forEach(cell => updateCellState(cell, dragAction));
|
||||||
|
clearSelectionVisuals();
|
||||||
|
if (dragAction === 'fill' || dragAction === 'clear') {
|
||||||
|
checkAndLockCompletedLines(affectedRows, affectedCols);
|
||||||
|
}
|
||||||
|
checkWinCondition();
|
||||||
|
isDragging = false;
|
||||||
|
dragAction = null;
|
||||||
|
startCell = null;
|
||||||
|
lastHoveredCell = null;
|
||||||
|
currentSelection.clear();
|
||||||
|
affectedRows.clear();
|
||||||
|
affectedCols.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 중인 사각형 영역을 계산하고 시각적으로 업데이트하는 함수
|
||||||
|
*/
|
||||||
|
function updateSelectionVisuals() {
|
||||||
|
const newSelection = new Set();
|
||||||
|
if (!startCell || !lastHoveredCell) return;
|
||||||
|
const r1 = Math.min(startCell.row, lastHoveredCell.row);
|
||||||
|
const r2 = Math.max(startCell.row, lastHoveredCell.row);
|
||||||
|
const c1 = Math.min(startCell.col, lastHoveredCell.col);
|
||||||
|
const c2 = Math.max(startCell.col, lastHoveredCell.col);
|
||||||
|
for (let r = r1; r <= r2; r++) {
|
||||||
|
for (let c = c1; c <= c2; c++) {
|
||||||
|
const cell = document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`);
|
||||||
|
if (cell) newSelection.add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSelection.forEach(cell => {
|
||||||
|
if (!newSelection.has(cell)) cell.classList.remove('selecting');
|
||||||
|
});
|
||||||
|
newSelection.forEach(cell => {
|
||||||
|
if (!currentSelection.has(cell)) cell.classList.add('selecting');
|
||||||
|
});
|
||||||
|
currentSelection = newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 시각적 피드백을 제거하는 함수
|
||||||
|
*/
|
||||||
|
function clearSelectionVisuals() {
|
||||||
|
currentSelection.forEach(cell => cell.classList.remove('selecting'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 행이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||||
|
*/
|
||||||
|
function isRowComplete(rowIndex) {
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
if (solution[rowIndex][c] === 1 && playerGrid[rowIndex][c] !== 1) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 특정 열이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||||
|
*/
|
||||||
|
function isColComplete(colIndex) {
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
if (solution[r][colIndex] === 1 && playerGrid[r][colIndex] !== 1) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완성된 라인을 확인하고 잠금 처리 및 스타일 변경 (X 자동 완성 없음)
|
||||||
|
*/
|
||||||
|
function checkAndLockCompletedLines(rowsToCheck, colsToCheck) {
|
||||||
|
rowsToCheck.forEach(r => {
|
||||||
|
if (!lockedRows[r] && isRowComplete(r)) {
|
||||||
|
lockedRows[r] = true;
|
||||||
|
document.getElementById(`row-clue-${r}`).classList.add('completed');
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
colsToCheck.forEach(c => {
|
||||||
|
if (!lockedCols[c] && isColComplete(c)) {
|
||||||
|
lockedCols[c] = true;
|
||||||
|
document.getElementById(`col-clue-${c}`).classList.add('completed');
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게임 승리 조건 (모든 셀이 정답과 완벽하게 일치)을 확인
|
||||||
|
*/
|
||||||
|
function checkWinCondition() {
|
||||||
|
if (isGameFinished) return;
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
const playerState = (playerGrid[r][c] === 1) ? 1 : 0;
|
||||||
|
if (playerState !== solution[r][c]) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
triggerGameSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면에 현재 포인트를 업데이트
|
||||||
|
*/
|
||||||
|
function updatePointsDisplay() {
|
||||||
|
pointsDisplay.textContent = points;
|
||||||
|
hintBtn.disabled = (points <= 0 || isGameFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공/실패 모달을 동적으로 생성하여 표시
|
||||||
|
*/
|
||||||
|
function showResultModal(config) {
|
||||||
|
modalTitle.textContent = config.title;
|
||||||
|
modalMessage.textContent = config.message;
|
||||||
|
modalButtons.innerHTML = '';
|
||||||
|
config.buttons.forEach(btnInfo => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = btnInfo.text;
|
||||||
|
button.className = btnInfo.class || '';
|
||||||
|
button.onclick = btnInfo.action;
|
||||||
|
modalButtons.appendChild(button);
|
||||||
|
});
|
||||||
|
resultOverlay.classList.remove('hidden');
|
||||||
|
setTimeout(() => resultOverlay.classList.add('visible'), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게임 실패 처리
|
||||||
|
*/
|
||||||
|
function triggerGameOver() {
|
||||||
|
if (isGameFinished) return;
|
||||||
|
isGameFinished = true;
|
||||||
|
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
||||||
|
hintBtn.disabled = true;
|
||||||
|
showResultModal({
|
||||||
|
title: 'Failure', message: '포인트를 모두 사용했습니다.', buttons: [
|
||||||
|
{ text: '재시도 (Retry)', class: 'primary', action: () => window.location.reload() },
|
||||||
|
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 게임 성공 처리
|
||||||
|
*/
|
||||||
|
function triggerGameSuccess() {
|
||||||
|
if (isGameFinished) return;
|
||||||
|
isGameFinished = true;
|
||||||
|
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
||||||
|
hintBtn.disabled = true;
|
||||||
|
// ... (성공 애니메이션 로직은 여기에 추가될 수 있습니다)
|
||||||
|
showResultModal({
|
||||||
|
title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [
|
||||||
|
{ text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' },
|
||||||
|
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 힌트 버튼 클릭 이벤트 처리
|
||||||
|
hintBtn.addEventListener('click', () => {
|
||||||
|
if (points <= 0 || isGameFinished) return;
|
||||||
|
points--;
|
||||||
|
updatePointsDisplay();
|
||||||
|
const hintCandidates = [];
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
if (solution[r][c] === 1 && playerGrid[r][c] !== 1) {
|
||||||
|
hintCandidates.push({ r, c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hintCandidates.length > 0) {
|
||||||
|
const hint = hintCandidates[Math.floor(Math.random() * hintCandidates.length)];
|
||||||
|
const cellToReveal = document.querySelector(`.grid-cell[data-row='${hint.r}'][data-col='${hint.c}']`);
|
||||||
|
|
||||||
|
// 힌트 사용 시에도 updateCellState를 통해 상태를 변경합니다.
|
||||||
|
updateCellState(cellToReveal, 'fill');
|
||||||
|
|
||||||
|
// 힌트로 변경된 셀의 행과 열만 확인합니다.
|
||||||
|
const hintAffectedRows = new Set([hint.r]);
|
||||||
|
const hintAffectedCols = new Set([hint.c]);
|
||||||
|
checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols);
|
||||||
|
checkWinCondition();
|
||||||
|
} else {
|
||||||
|
alert("더 이상 사용할 힌트가 없습니다!");
|
||||||
|
points++;
|
||||||
|
updatePointsDisplay();
|
||||||
|
}
|
||||||
|
if (points <= 0 && !isGameFinished) {
|
||||||
|
triggerGameOver();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 초기 실행 ---
|
||||||
|
const optimalCellSize = calculateCellSize();
|
||||||
|
drawBoard(optimalCellSize);
|
||||||
|
updatePointsDisplay();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitBoardToScreen();
|
||||||
|
window.addEventListener('resize', fitBoardToScreen);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,3 +1,30 @@
|
|||||||
|
let currentPuzzleData = null;
|
||||||
|
|
||||||
|
// The success animation function
|
||||||
|
function showSuccessAnimation() {
|
||||||
|
if (!currentPuzzleData) return;
|
||||||
|
|
||||||
|
const puzzleContainer = document.getElementById('puzzle-container');
|
||||||
|
const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||||
|
const originalImg = document.getElementById('original-reveal');
|
||||||
|
|
||||||
|
// 1. Set the image sources from the saved Base64 data
|
||||||
|
grayscaleImg.src = currentPuzzleData.grayscaleImage;
|
||||||
|
originalImg.src = currentPuzzleData.originalImage;
|
||||||
|
|
||||||
|
// 2. Hide the puzzle grid and fade in the grayscale image
|
||||||
|
puzzleContainer.style.transition = 'opacity 0.5s';
|
||||||
|
puzzleContainer.style.opacity = '0';
|
||||||
|
grayscaleImg.style.opacity = '1';
|
||||||
|
|
||||||
|
// 3. After a delay, fade out the grayscale and fade in the color image
|
||||||
|
setTimeout(() => {
|
||||||
|
grayscaleImg.style.opacity = '0';
|
||||||
|
originalImg.style.opacity = '1';
|
||||||
|
}, 2000); // 2-second delay
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function drawPuzzle(puzzleData) {
|
function drawPuzzle(puzzleData) {
|
||||||
const container = document.getElementById('puzzle-container');
|
const container = document.getElementById('puzzle-container');
|
||||||
container.innerHTML = ''; // Clear previous puzzle
|
container.innerHTML = ''; // Clear previous puzzle
|
||||||
@ -50,6 +77,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const uploader = document.getElementById('imageUploader');
|
const uploader = document.getElementById('imageUploader');
|
||||||
const statusDiv = document.getElementById('status');
|
const statusDiv = document.getElementById('status');
|
||||||
const puzzleContainer = document.getElementById('puzzle-container');
|
const puzzleContainer = document.getElementById('puzzle-container');
|
||||||
|
const testSuccessBtn = document.getElementById('test-success-btn');
|
||||||
|
const deleteBtn = document.getElementById('delete-btn');
|
||||||
|
const playBtn = document.getElementById('play-btn'); // Get the new button
|
||||||
|
|
||||||
if (uploader.files.length === 0) {
|
if (uploader.files.length === 0) {
|
||||||
statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
||||||
@ -76,6 +106,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Call the new function to draw the puzzle!
|
// Call the new function to draw the puzzle!
|
||||||
drawPuzzle(puzzleData);
|
drawPuzzle(puzzleData);
|
||||||
|
|
||||||
|
currentPuzzleData = puzzleData;
|
||||||
|
testSuccessBtn.addEventListener('click', showSuccessAnimation);
|
||||||
|
// 성공/삭제 버튼 모두 표시
|
||||||
|
testSuccessBtn.style.display = 'inline-block';
|
||||||
|
deleteBtn.style.display = 'inline-block';
|
||||||
|
playBtn.style.display = 'inline-block';
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = await response.text();
|
const errorMessage = await response.text();
|
||||||
statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
||||||
@ -84,5 +120,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
console.error('네트워크 오류:', error);
|
console.error('네트워크 오류:', error);
|
||||||
statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
||||||
}
|
}
|
||||||
|
// 삭제 버튼에 클릭 이벤트 리스너 추가
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPuzzleData || !currentPuzzleData.id) {
|
||||||
|
alert('삭제할 퍼즐이 선택되지 않았습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자에게 삭제 여부 확인
|
||||||
|
if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 백엔드에 DELETE 요청 보내기
|
||||||
|
const response = await fetch(getMainPath() + `/puzzle/${currentPuzzleData.id}.bjx`, {
|
||||||
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.ok) { // 204 No Content 포함
|
||||||
|
statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.';
|
||||||
|
// 화면 정리
|
||||||
|
puzzleContainer.innerHTML = '';
|
||||||
|
document.getElementById('success-animation-container').innerHTML = ''; // 이미지 컨테이너도 비움
|
||||||
|
testSuccessBtn.style.display = 'none';
|
||||||
|
deleteBtn.style.display = 'none';
|
||||||
|
currentPuzzleData = null;
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 중 네트워크 오류:', error);
|
||||||
|
statusDiv.textContent = '삭제 중 오류가 발생했습니다.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
playBtn.addEventListener('click', () => {
|
||||||
|
if (currentPuzzleData && currentPuzzleData.id) {
|
||||||
|
// Navigate to the play page with the puzzle ID
|
||||||
|
window.location.href = `/puzzle/play/${currentPuzzleData.id}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
49
src/main/resources/templates/content/puzzle/play.html
Normal file
49
src/main/resources/templates/content/puzzle/play.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<!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/play.js}"></script>
|
||||||
|
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||||
|
<link th:href="@{/css/play.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>Solve the Puzzle! 🧩</h1>
|
||||||
|
|
||||||
|
<div id="game-controls">
|
||||||
|
<div id="points-info">
|
||||||
|
❤️ Points: <span id="points-display">5</span>
|
||||||
|
</div>
|
||||||
|
<button id="hint-btn">Hint (-1 Point)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="board-viewport">
|
||||||
|
<div id="game-board">
|
||||||
|
</div>
|
||||||
|
<img id="grayscale-reveal" class="reveal-img" src="" alt="Grayscale version">
|
||||||
|
<img id="original-reveal" class="reveal-img" src="" alt="Original version">
|
||||||
|
|
||||||
|
<div id="result-overlay" class="hidden">
|
||||||
|
<div id="result-modal">
|
||||||
|
<h2 id="modal-title"></h2>
|
||||||
|
<p id="modal-message"></p>
|
||||||
|
<div id="modal-buttons">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script th:inline="javascript">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const puzzleData = /*[[${puzzle}]]*/ null;
|
||||||
|
/*]]>*/
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</html>
|
||||||
@ -16,7 +16,10 @@
|
|||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
<div id="puzzle-container">
|
<div id="puzzle-container">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="success-animation-container">
|
||||||
|
<img id="grayscale-reveal" src="" alt="Grayscale version">
|
||||||
|
<img id="original-reveal" src="" alt="Original version">
|
||||||
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<h1>이미지로 네모로직 문제 만들기</h1>
|
<h1>이미지로 네모로직 문제 만들기</h1>
|
||||||
<input type="file" id="imageUploader" accept="image/*">
|
<input type="file" id="imageUploader" accept="image/*">
|
||||||
@ -25,6 +28,8 @@
|
|||||||
<hr>
|
<hr>
|
||||||
<h2>결과</h2>
|
<h2>결과</h2>
|
||||||
<div id="result"></div>
|
<div id="result"></div>
|
||||||
|
<button id="test-success-btn" style="display:none;">Test Success</button>
|
||||||
|
<button id="delete-btn" style="display:none; background-color: #ffcccc;">Delete Puzzle</button>
|
||||||
|
<button id="play-btn" style="display:none; background-color: #ccffcc;">Play Puzzle 🎮</button>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<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_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,6 +31,7 @@
|
|||||||
<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>
|
||||||
</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