This commit is contained in:
lunaticbum 2025-09-01 17:23:40 +09:00
parent 373964146c
commit 90f0ef1cce
11 changed files with 901 additions and 13 deletions

View File

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

View File

@ -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 {

View File

@ -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를 흰색 배경으로 변환합니다.

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

View File

@ -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 */
}

View File

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

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

View File

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

View 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>

View File

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

View File

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