....
This commit is contained in:
parent
373964146c
commit
90f0ef1cce
@ -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 ->
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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를 흰색 배경으로 변환합니다.
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
function gotoPuzzleUpload() {
|
||||
document.location.replace(getMainPath()+"/puzzle/upload.bs")
|
||||
}
|
||||
|
||||
function gotoWhere() {
|
||||
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) {
|
||||
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}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
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">
|
||||
<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>
|
||||
|
||||
@ -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>-->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user