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(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/blog/post/images/**","/puzzle/upload.bjx"
"/blog/post/images/**","/puzzle/**","/puzzle/play/**"
) // 여기 예외 추가
}.authorizeHttpRequests { auth ->
auth
@ -84,7 +84,7 @@ class SecurityConfig(
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/imageUpload.bjx",
"/blog/post/images/**",
"/puzzle/upload.bjx",
"/puzzle/play","/puzzle/play/**",
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
.anyRequest().authenticated()
}.formLogin { form ->

View File

@ -1,9 +1,13 @@
package kr.lunaticbum.back.lun.controllers
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.PuzzleService
import kr.lunaticbum.back.lun.model.ResultMV
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.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
@ -25,6 +29,57 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
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")
suspend fun uploadPuzzle() : ResultMV {

View File

@ -1,18 +1,24 @@
package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
import org.springframework.data.annotation.Id
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.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.util.Base64
import javax.imageio.ImageIO
@ -23,26 +29,49 @@ data class NonogramPuzzle(
val solutionGrid: List<List<Int>>,
val rowClues: 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
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
/**
* ( Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는
* 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다.
*/
@Aggregation(pipeline = [
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
"{ \$sample: { size: 1 } }"
])
fun findRandom(): Flux<NonogramPuzzle>
}
@Service
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 fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle {
val puzzleData = withContext(Dispatchers.IO) {
val originalImage = ImageIO.read(file.inputStream)
// 1. (★중요) 투명 배경을 흰색으로 먼저 변환합니다.
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 {
createGraphics().run {
drawImage(imageWithBackground, 0, 0, size, size, null)
@ -50,26 +79,49 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
}
}
// 3. 평균 밝기를 계산하여 동적 임계치 결정
val averageBrightness = calculateAverageBrightness(grayImage)
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
// 4. 결정된 임계치로 최종 그리드 생성
val solutionGrid = List(size) { y ->
List(size) { x ->
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
}
}
// 5. 힌트 추출 및 객체 생성
val rowClues = 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()
}
// 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를 흰색 배경으로 변환합니다.

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 {
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")
}
function gotoPuzzleUpload() {
document.location.replace(getMainPath()+"/puzzle/upload.bs")
}
function gotoWhere() {
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) {
const container = document.getElementById('puzzle-container');
container.innerHTML = ''; // Clear previous puzzle
@ -50,6 +77,9 @@ document.addEventListener('DOMContentLoaded', () => {
const uploader = document.getElementById('imageUploader');
const statusDiv = document.getElementById('status');
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) {
statusDiv.textContent = '이미지 파일을 선택해주세요.';
@ -76,6 +106,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Call the new function to draw the puzzle!
drawPuzzle(puzzleData);
currentPuzzleData = puzzleData;
testSuccessBtn.addEventListener('click', showSuccessAnimation);
// 성공/삭제 버튼 모두 표시
testSuccessBtn.style.display = 'inline-block';
deleteBtn.style.display = 'inline-block';
playBtn.style.display = 'inline-block';
} else {
const errorMessage = await response.text();
statusDiv.textContent = `생성 실패: ${errorMessage}`;
@ -84,5 +120,47 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('네트워크 오류:', error);
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">
<div id="puzzle-container">
</div>
<div id="success-animation-container">
<img id="grayscale-reveal" src="" alt="Grayscale version">
<img id="original-reveal" src="" alt="Original version">
</div>
<hr>
<h1>이미지로 네모로직 문제 만들기</h1>
<input type="file" id="imageUploader" accept="image/*">
@ -25,6 +28,8 @@
<hr>
<h2>결과</h2>
<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>
</html>

View File

@ -15,7 +15,7 @@
<ul>
<li id="menu_home" ><a th:href="@{/}">Home</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_thr"><a href="right-sidebar">Right Sidebar</a></li>-->
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>-->
@ -31,6 +31,7 @@
<th:block sec:authorize="isAuthenticated()">
<li><a href="javascript:gotoWrite()">글쓰기</a></li>
<li><a href="javascript:gotoModify()">수정하기</a></li>
<li><a href="javascript:gotoPuzzleUpload()">퍼즐 문제 생성</a></li>
</th:block>
<li><a href="#">Phasellus magna</a></li>
<!-- <li><a href="#">Magna phasellus</a></li>-->