This commit is contained in:
lunaticbum 2025-09-12 16:55:21 +09:00
parent d62a4a3c15
commit 19c5d5473f
41 changed files with 8039 additions and 4149 deletions

View File

@ -29,6 +29,9 @@ import org.springframework.security.web.authentication.rememberme.JdbcTokenRepos
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository
import org.springframework.web.ErrorResponse
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import javax.sql.DataSource
@ -64,60 +67,86 @@ class SecurityConfig(
}
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf("*") // 모든 도메인 허용
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") // 모든 HTTP 메서드 허용
configuration.allowedHeaders = listOf("*") // 모든 헤더 허용
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration) // 모든 경로에 이 설정 적용
return source
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.csrf { csrf ->
// [수정]
// CSRF 보호 예외 목록에서 like/unlike 엔드포인트를 제거합니다.
// 이 엔드포인트들은 POST 요청이며 인증이 필요하므로, CSRF 보호를 받는 것이 올바릅니다.
// (common.js에서 X-CSRF-TOKEN 헤더를 정상적으로 보내고 있습니다.)
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
// "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
"/puzzle/**","/puzzle/play/**","/bums/save/**",
"/rank/**","/spider/**",
"/sudoku/**",
)
}.authorizeHttpRequests { auth ->
auth
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다.
.requestMatchers(HttpMethod.GET, "/blog/comments/{commentId}/replies.bjx").permitAll()
.requestMatchers(HttpMethod.GET, "/blog/posts/{postId}/comments.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/posts/{postId}/comments.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/bums/save/loc.api").permitAll()
// permitAll() 목록
.requestMatchers(
// ★★★ 1. CORS 설정을 적용하도록 .cors {} 를 추가합니다. ★★★
http.cors { }
.csrf { csrf ->
// [수정] 사용자의 요구사항대로 공개 POST API 목록을 CSRF 예외에 다시 추가합니다.
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/puzzle/**", // ★ 게임 관련 API (전체)
"/puzzle/play/**", // ★
"/bums/save/**", // ★ 위치 저장 API
"/rank/**", // ★ 랭킹 API
"/sudoku/**" // ★
)
}.authorizeHttpRequests { auth ->
auth
// 1. 정적 리소스 = permitAll (변경 없음)
.requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/images/**",
"/webjars/**", "/assets/**"
).permitAll()
"/",
"/home.bs",
"/bums/where.bs" ,
"/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/images/**", // WebSecurityCustomizer에서 ignoring 처리되었으므로 여기서 제외해도 됩니다.
"/spider/new**",
"/rank/**","/sudoku/**","/spider/**",
"/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider",
"/webfonts/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
// 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET,
"/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/signup.bs",
"/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
"/blog/comments/{commentId}/replies.bjx",
"/blog/posts/{postId}/comments.bjx",
"/puzzle/play", "/puzzle/2048", "/puzzle/sudoku", "/puzzle/spider",
"/puzzle/**", // ★ 게임 GET 요청 전체 허용
"/rank/**", // ★ 랭킹 GET 요청 전체 허용
"/sudoku/**" // ★
).permitAll()
// 나머지 모든 요청은 인증이 필요합니다.
.anyRequest().authenticated()
}.formLogin { form ->
form.loginPage("/user/login.bs")
.defaultSuccessUrl("/", true)
.permitAll()
}.rememberMe { rememberMe ->
rememberMe.rememberMeServices(rememberMeServices())
.key("remember-BsTs*!12@") // 보통 안전한 키 지정
.tokenRepository(tokenRepository)
.tokenValiditySeconds(60 * 60 * 24 * 7) // 7일간 유효
.userDetailsService(userManager) // 사용자 정보 서비스 지정
}.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}
// 3. 공개 POST API = permitAll (요구사항 반영)
.requestMatchers(HttpMethod.POST,
"/user/login.bjx",
"/user/joinUser.bjx",
"/tlg/repotToMe.bjx",
"/bums/save/loc.api", // ★ 위치 저장 POST 공개
"/puzzle/spider/**", // ★ 스파이더 POST API(deal, undo 등) 전체 공개
"/rank/**", // ★ 랭킹 제출 POST API 전체 공개
"/sudoku/**" // ★ 스도쿠 POST API 전체 공개
).permitAll()
// [중요] 블로그 '좋아요', '댓글 작성' 등은 위 permitAll 목록에 없으므로
// 여전히 '4. 나머지 요청'으로 분류되어 인증 + CSRF 보호를 받습니다.
// 4. 나머지 모든 요청 = authenticated (변경 없음)
.anyRequest().authenticated()
}.formLogin { form ->
// (이하 formLogin, rememberMe, logout 설정은 동일)
form.loginPage("/user/login.bs")
.defaultSuccessUrl("/", true)
.permitAll()
}.rememberMe { rememberMe ->
rememberMe.rememberMeServices(rememberMeServices())
.key("remember-BsTs*!12@")
.tokenRepository(tokenRepository)
.tokenValiditySeconds(60 * 60 * 24 * 7)
.userDetailsService(userManager)
}.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}
return http.build()
}

View File

@ -8,6 +8,8 @@ import com.google.maps.GeocodingApi
import com.google.maps.model.LatLng
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactive.awaitSingleOrNull
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
@ -42,7 +44,9 @@ import java.net.URLDecoder
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
import kr.lunaticbum.back.lun.model.ImageMeta // [신규 추가]
import kr.lunaticbum.back.lun.model.ImageMetaService // [신규 추가]
import javax.imageio.ImageIO // [신규 추가]
@RestController
@RequestMapping("/blog")
@ -50,6 +54,10 @@ class BlogController(private val commentService : CommentService) {
companion object {
val TEMPTOKEN = "TEMP_TOKEN_VIBUM"
}
@Autowired
private lateinit var imageMetaService: ImageMetaService
@Autowired
lateinit var globalEvv : GlobalEnvironment
@ -355,7 +363,7 @@ class BlogController(private val commentService : CommentService) {
* @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8 설정
*/
@GetMapping("posts")
fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
suspend fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
val vm = ResultMV("content/blog/posts")
try {
val postsList: List<Post>
@ -368,13 +376,15 @@ class BlogController(private val commentService : CommentService) {
if (isAdmin) {
// [관리자]: 모든 버전의 글을 조회합니다.
logService.log("User is ADMIN. Loading all post versions.")
postsList = postManager.findAllVersionsPaginated(pageable)
totalPosts = postManager.countAllVersions().block() ?: 0L
// 2. Use awaitSingleOrNull() instead of blocking assignment
postsList = postManager.findAllVersionsPaginated(pageable).awaitSingleOrNull() ?: emptyList()
totalPosts = postManager.countAllVersions().awaitSingleOrNull() ?: 0L // 3. Use awaitSingleOrNull()
} else {
// [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다.
logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.")
postsList = postManager.findLatestUniquePaginated(pageable)
totalPosts = postManager.countLatestUnique().block() ?: 0L
// 4. Use awaitSingleOrNull() - THIS FIXES THE TYPE MISMATCH ERROR
postsList = postManager.findLatestUniquePaginated(pageable).awaitSingleOrNull() ?: emptyList()
totalPosts = postManager.countLatestUnique().awaitSingleOrNull() ?: 0L // 5. Use awaitSingleOrNull()
}
// if (principal != null) {
// // [인증 사용자]: 모든 버전의 글을 조회합니다.
@ -556,25 +566,49 @@ class BlogController(private val commentService : CommentService) {
out.write(bytes)
out.flush()
// 썸네일 생성 및 저장
// --- [신규 로직 시작] ---
try {
// 1. 저장된 원본 파일에서 이미지 크기(dimensions) 추출
val savedFile = File(originalImagePath)
val bufferedImage = ImageIO.read(savedFile)
val imgWidth = bufferedImage.width
val imgHeight = bufferedImage.height
// 2. ImageMeta 객체 생성
val metadata = ImageMeta(
fileName = "$uuid.$extension",
originalFileName = upload.originalFilename,
fileType = upload.contentType,
fileSize = upload.size,
width = imgWidth,
height = imgHeight,
uploadTime = System.currentTimeMillis(),
path = "/blog/post/images/$uuid.$extension" // 이미지를 불러오는 GetMapping 경로 기준
)
// 3. 메타데이터를 DB에 저장 (Reactive 호출을 동기식으로 대기)
imageMetaService.save(metadata).block() // .block()은 실제 프로덕션에서는 권장되지 않으나, 현재 컨트롤러 구조(동기식 반환)에 맞춤
} catch (metaError: Exception) {
// 메타데이터 저장에 실패해도 원본 이미지 업로드는 성공한 것으로 처리 (로그만 남김)
logService.log("Failed to save image metadata: ${metaError.message}")
metaError.printStackTrace()
}
// --- [신규 로직 끝] ---
// 썸네일 생성 및 저장 (기존 로직)
Thumbnails.of(originalImagePath)
.width(200) // 가로 크기를 설정
.keepAspectRatio(true)
.toFile(thumbnailPath)
logService.log("Original image saved: ${File(originalImagePath).exists()}")
logService.log("Thumbnail saved: ${File(thumbnailPath).exists()}")
// --- [신규 추가 로직 시작] ---
// 업로드가 완료되었으므로, 백그라운드 동기화 작업을 호출합니다.
// (혹시 모를 다른 누락 파일을 찾기 위함. 이미 실행 중이면 무시됨)
imageMetaService.launchSyncTask()
// --- [신규 추가 로직 끝] ---
// 메타데이터 읽기 (원본 이미지에서)
val metadata: Metadata? = ImageMetadataReader.readMetadata(File(originalImagePath))
metadata?.let {
it.directories?.forEach { directory ->
logService.log(directory.name)
logService.log(directory.tags.map { tag ->
logService.log("tag.tagName >>> ${tag.tagName} || tag.description ${tag.description}")
}.joinToString(" \n"))
}
}
// ... (기존 메타데이터 읽기 로그) ...
} catch (e: IOException) {
e.printStackTrace()

View File

@ -0,0 +1,39 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.GameRank
import kr.lunaticbum.back.lun.model.GameRankService
import kr.lunaticbum.back.lun.model.GameType
import kr.lunaticbum.back.lun.model.UnifiedRankDto
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* 모든 게임을 위한 통합 랭킹 등록 엔드포인트
*/
@PostMapping("/submit")
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<GameRank>> {
return gameRankService.submitRank(rankDto)
.map { savedRank -> ResponseEntity.ok(savedRank) }
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* : /api/ranks/list?gameType=SUDOKU&contextId=123
* : /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
}

View File

@ -6,6 +6,10 @@ import com.google.gson.JsonObject
import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.ImageMeta
import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.Post
import kr.lunaticbum.back.lun.model.PostManager
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.utils.LogService
@ -29,6 +33,9 @@ import java.net.URLDecoder
@RequestMapping()
class Home {
@Autowired
private lateinit var imageMetaService: ImageMetaService
@Autowired
lateinit var logService: LogService
@ -71,7 +78,30 @@ class Home {
suspend fun home() : ResultMV {
val vm = ResultMV("content/home")
try {
vm.modelMap.put("Posts", postManager.find8().apply {
try {
// 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다.
// DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다.
val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() //
// 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다.
if (randomImage != null) {
vm.modelMap.put("randomBannerImage", randomImage.path)
}
// 3. else (null인 경우): 아무것도 하지 않습니다.
// 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다.
} catch (e: Exception) {
// 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다.
logService.log("CRITICAL Error during random banner image query: ${e.message}")
}
// === [FIXED LOGIC] ===
// 1. Asynchronously await the Mono result without blocking the thread.
// Use awaitSingleOrNull() just like the random image query.
val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
// 2. Apply the processing logic to the resulting list.
vm.modelMap.put("Posts", postsList.apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
@ -100,7 +130,8 @@ class Home {
}
it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
}
}.chunked(2))
})
// === [END FIXED LOGIC] ===
}catch (ex: Exception){ex.printStackTrace()}
vm.modelMap.put("path","/blog/viewer/")
return vm

View File

@ -1,87 +1,171 @@
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 kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
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
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
/**
* [통합 게임 API 허브 컨트롤러]
* 1. 모든 게임의 HTML 페이지 서빙
* 2. 모든 게임의 플레이 로직 API (게임 시작, 검증, 상태 업데이트 ) 제공
* (기존 SudokuController, SpiderController의 기능을 모두 통합)
*/
@RestController
@RequestMapping("/puzzle")
class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입
@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
class PuzzleController(
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
private val puzzleService: PuzzleService
) {
// ======================================================
// 1. NONOGRAM API (기존 엔드포인트 유지)
// ======================================================
/**
* 노노그램: 이미지 업로드 퍼즐 생성
*/
@PostMapping("upload.bjx")
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
return try {
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile)
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환
ResponseEntity.ok(savedPuzzle)
} catch (e: Exception) {
e.printStackTrace()
// 실패 시 500 에러와 메시지 반환
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
}
}
/**
* 특정 ID의 퍼즐을 삭제하는 엔드포인트
* @param id URL 경로에서 추출한 퍼즐의 고유 ID
* 노노그램: 퍼즐 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()
}
}
// ======================================================
// 2. SUDOKU API (★ SudokuController에서 마이그레이션됨)
// ======================================================
/**
* ID가 지정된 경우 특정 퍼즐을 로드합니다.
* 스도쿠: 게임 시작 (난이도별 문제 반환)
*/
@GetMapping("/sudoku/start")
suspend fun sudokuStartGame(@RequestParam(defaultValue = "easy") difficulty: String): PuzzleService.SudokuGameDto {
return puzzleService.sudoku_startGame(difficulty)
}
/**
* 스도쿠: 사용자가 제출한 답안 검증
*/
@PostMapping("/sudoku/validate")
suspend fun sudokuValidate(@RequestBody validateDto: PuzzleService.SudokuValidateDto): Map<String, Boolean> {
val isCorrect = puzzleService.sudoku_validateSolution(validateDto)
return mapOf("correct" to isCorrect)
}
/**
* 스도쿠: (관리용) 퍼즐 문제 생성 DB 저장
*/
@GetMapping("/sudoku/generate")
suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle {
return puzzleService.sudoku_generateAndSavePuzzle()
}
// ======================================================
// 3. SPIDER API (★ SpiderController에서 마이그레이션 및 Coroutine 변환됨)
// ======================================================
/**
* 스파이더: 게임 시작 (무늬 , 카드 기반)
*/
@GetMapping("/spider/new")
suspend fun spiderNewGame(@RequestParam numSuits: Int, @RequestParam numCards: String): SpiderGame {
return puzzleService.spider_newGame(numSuits, numCards)
}
/**
* 스파이더: ID로 기존 게임 불러오기
*/
@GetMapping("/spider/{id}")
suspend fun spiderGetGame(@PathVariable id: String): ResponseEntity<SpiderGame> {
val game = puzzleService.spider_getGame(id)
return if (game != null) ResponseEntity.ok(game) else ResponseEntity.notFound().build()
}
/**
* 스파이더: 게임 상태 업데이트 (카드 이동 )
*/
@PostMapping("/spider/update")
suspend fun spiderUpdateGame(@RequestBody game: SpiderGame): SpiderGame {
return puzzleService.spider_updateGame(game)
}
/**
* 스파이더: 스톡에서 카드 분배
*/
@PostMapping("/spider/deal")
suspend fun spiderDealCards(@RequestBody request: Map<String, String>): SpiderGame {
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
return puzzleService.spider_dealCardsFromStock(gameId)
}
/**
* 스파이더: 실행 취소 (Undo)
*/
@PostMapping("/spider/undo")
suspend fun spiderUndo(@RequestBody request: Map<String, String>): SpiderGame {
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
return puzzleService.spider_undoGame(gameId)
}
// ======================================================
// 4. 페이지 서빙 엔드포인트 (기존 로직 유지)
// ======================================================
/**
* 노노그램: 특정 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")
val vm = ResultMV("content/puzzle/nonogram")
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")
val vm = ResultMV("content/puzzle/nonogram")
return if (puzzle != null) {
vm.model.put("puzzle", puzzle)
vm
} else {
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
vm.viewName = "redirect:/"
vm
}
}
/**
* (추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
* 2048: 게임 페이지 서빙
*/
@GetMapping("/2048")
suspend fun play2048(): ResultMV {
@ -90,30 +174,27 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
}
/**
* (추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
* 스도쿠: 게임 페이지 서빙
*/
@GetMapping("/sudoku")
suspend fun sudoku(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku")
return vm
}
@GetMapping("/sudoku_gen.bs")
suspend fun sudoku_gen(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku_gen")
return vm
}
/**
* 스파이더: 게임 페이지 서빙
*/
@GetMapping("/spider")
suspend fun spider(): ResultMV {
val vm = ResultMV("content/puzzle/spider")
return vm
}
@GetMapping("/","/upload.bs")
/**
* 메인 페이지 (노노그램 업로드)
*/
@GetMapping("/", "/upload.bs")
suspend fun uploadPuzzle() : ResultMV {
val vm = ResultMV("content/puzzle/upload")
return vm

View File

@ -1,39 +1,39 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.Rank
import kr.lunaticbum.back.lun.model.RankRepository
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/rank")
class RankController(val rankRepository: RankRepository) {
// private val rankRepository: RankRepository
//package kr.lunaticbum.back.lun.controllers
//
// init {
// this.rankRepository = rankRepository
//import kr.lunaticbum.back.lun.model.Rank
//import kr.lunaticbum.back.lun.model.RankRepository
//import org.springframework.web.bind.annotation.*
//import reactor.core.publisher.Flux
//import reactor.core.publisher.Mono
//
//
//@RestController
//@RequestMapping("/rank")
//class RankController(val rankRepository: RankRepository) {
//// private val rankRepository: RankRepository
////
//// init {
//// this.rankRepository = rankRepository
//// }
//
// /**
// * 새로운 랭킹을 저장합니다.
// * 요청 Body에 gameId가 포함되어야 합니다.
// * @param rank 저장할 랭크 정보 (gameId, name, score)
// * @return Mono<Rank>
// </Rank> */
// @PostMapping("/ranks")
// fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
// return rankRepository.save(rank)
// }
/**
* 새로운 랭킹을 저장합니다.
* 요청 Body에 gameId가 포함되어야 합니다.
* @param rank 저장할 랭크 정보 (gameId, name, score)
* @return Mono<Rank>
</Rank> */
@PostMapping("/ranks")
fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
return rankRepository.save(rank)
}
/**
* 특정 게임의 상위 10 랭킹 리스트를 조회합니다.
* @param gameId 경로 변수(Path Variable) 게임 ID를 받습니다.
* @return Flux<Rank>
</Rank> */
@GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
}
}
//
// /**
// * 특정 게임의 상위 10개 랭킹 리스트를 조회합니다.
// * @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다.
// * @return Flux<Rank>
// </Rank> */
// @GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
// fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
// return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
// }
//}

View File

@ -1,64 +1,64 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.SpiderGame
import kr.lunaticbum.back.lun.model.SpiderRank
import kr.lunaticbum.back.lun.model.SpiderService
import org.springframework.http.ResponseEntity
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.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/spider")
class SpiderController(private val spiderService: SpiderService,) {
@GetMapping("/new")
fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono<SpiderGame> {
return spiderService.newGame(numSuits, numCards)
}
@GetMapping("/{id}")
fun getGame(@PathVariable id: String): Mono<SpiderGame> {
return spiderService.getGame(id)
}
@PostMapping("/update")
fun updateGame(@RequestBody game: SpiderGame): Mono<SpiderGame> {
return spiderService.updateGame(game)
}
// 랭킹 등록 엔드포인트
@PostMapping("/register")
fun registerRank(@RequestBody rank: SpiderRank): Mono<ResponseEntity<SpiderRank>> {
return spiderService.registerRank(rank)
.map { savedRank -> ResponseEntity.ok(savedRank) }
.onErrorResume(IllegalArgumentException::class.java) { e ->
Mono.just(ResponseEntity.badRequest().body(null))
}
}
// 게임 ID별 랭킹 조회 엔드포인트
@GetMapping("/list/{gameId}")
fun getRanks(@PathVariable gameId: String): Flux<SpiderRank> {
return spiderService.getRanksByGameId(gameId)
}
@PostMapping("/deal")
fun dealCards(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
return spiderService.dealCardsFromStock(gameId)
}
// 실행 취소 엔드포인트 추가
@PostMapping("/undo")
fun undo(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
return spiderService.undoGame(gameId)
}
}
//package kr.lunaticbum.back.lun.controllers
//
//import kr.lunaticbum.back.lun.model.SpiderGame
//import kr.lunaticbum.back.lun.model.SpiderRank
//import kr.lunaticbum.back.lun.model.SpiderService
//import org.springframework.http.ResponseEntity
//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.RequestBody
//import org.springframework.web.bind.annotation.RequestMapping
//import org.springframework.web.bind.annotation.RequestParam
//import org.springframework.web.bind.annotation.RestController
//import reactor.core.publisher.Flux
//import reactor.core.publisher.Mono
//
//@RestController
//@RequestMapping("/spider")
//class SpiderController(private val spiderService: SpiderService,) {
//
// @GetMapping("/new")
// fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono<SpiderGame> {
// return spiderService.newGame(numSuits, numCards)
// }
//
// @GetMapping("/{id}")
// fun getGame(@PathVariable id: String): Mono<SpiderGame> {
// return spiderService.getGame(id)
// }
//
// @PostMapping("/update")
// fun updateGame(@RequestBody game: SpiderGame): Mono<SpiderGame> {
// return spiderService.updateGame(game)
// }
//
// // 랭킹 등록 엔드포인트
// @PostMapping("/register")
// fun registerRank(@RequestBody rank: SpiderRank): Mono<ResponseEntity<SpiderRank>> {
// return spiderService.registerRank(rank)
// .map { savedRank -> ResponseEntity.ok(savedRank) }
// .onErrorResume(IllegalArgumentException::class.java) { e ->
// Mono.just(ResponseEntity.badRequest().body(null))
// }
// }
//
// // 게임 ID별 랭킹 조회 엔드포인트
// @GetMapping("/list/{gameId}")
// fun getRanks(@PathVariable gameId: String): Flux<SpiderRank> {
// return spiderService.getRanksByGameId(gameId)
// }
//
// @PostMapping("/deal")
// fun dealCards(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
// return spiderService.dealCardsFromStock(gameId)
// }
//
// // 실행 취소 엔드포인트 추가
// @PostMapping("/undo")
// fun undo(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
// return spiderService.undoGame(gameId)
// }
//}

View File

@ -1,36 +1,26 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.GameRecord
import kr.lunaticbum.back.lun.model.SudokuPuzzle
import kr.lunaticbum.back.lun.model.SudokuService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/sudoku")
class SudokuController(private val sudokuService: SudokuService) {
@GetMapping("/start")
suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
return sudokuService.startGame(difficulty)
}
@PostMapping("/complete")
suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) {
sudokuService.saveRecord(recordDto)
}
@GetMapping("/ranking/{puzzleId}")
suspend fun getRankings(@PathVariable puzzleId: Long): List<GameRecord> {
return sudokuService.getRankings(puzzleId)
}
@PostMapping("/generate")
suspend fun generateSinglePuzzle(): SudokuPuzzle {
return sudokuService.generateAndSavePuzzle()
}
@PostMapping("/validate")
suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
val isCorrect = sudokuService.validateSolution(validateDto)
return mapOf("correct" to isCorrect)
}
}
//package kr.lunaticbum.back.lun.controllers
//import kr.lunaticbum.back.lun.model.GameRecord
//import kr.lunaticbum.back.lun.model.SudokuPuzzle
//import kr.lunaticbum.back.lun.model.SudokuService
//import org.springframework.web.bind.annotation.*
//
//@RestController
//@RequestMapping("/sudoku")
//class SudokuController(private val sudokuService: SudokuService) {
//
// @GetMapping("/start")
// suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
// return sudokuService.startGame(difficulty)
// }
//
// @PostMapping("/generate")
// suspend fun generateSinglePuzzle(): SudokuPuzzle {
// return sudokuService.generateAndSavePuzzle()
// }
//
// @PostMapping("/validate")
// suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
// val isCorrect = sudokuService.validateSolution(validateDto)
// return mapOf("correct" to isCorrect)
// }
//}

View File

@ -0,0 +1,106 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
import org.springframework.data.repository.reactive.ReactiveSortingRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
/**
* 모든 게임의 랭킹을 저장하는 통합 모델
*/
@Document(collection = "game_ranks")
data class GameRank(
@Id
val id: String? = null,
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
val playerName: String, // 표준화된 플레이어 이름 필드
/** * 기본 점수 필드 (정렬 1순위).
* - 2048: 점수 (높을수록 좋음)
* - Sudoku: 완료 시간() (낮을수록 좋음)
* - Spider: 이동 횟수 (낮을수록 좋음)
*/
val primaryScore: Long,
/** * 보조 점수 필드 (정렬 2순위. : 스파이더의 완료 시간).
*/
val secondaryScore: Long? = null,
val timestamp: Instant = Instant.now()
)
/**
* 지원하는 게임 타입을 정의하는 Enum
*/
enum class GameType {
GAME_2048,
SUDOKU,
SPIDER,
NONOGRAM
}
/**
* 랭킹 등록 모든 프론트엔드에서 공통으로 사용할 DTO
*/
data class UnifiedRankDto(
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null
)
@Repository
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
fun save(gameRank: GameRank): Mono<GameRank>
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
}
@Service
class GameRankService(private val rankRepository: GameRankRepository) {
/**
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
*/
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
return when (gameType) {
// 점수가 높아야 하는 게임 (2048)
GameType.GAME_2048 ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
}
}
/**
* 공통 DTO를 받아 랭킹을 저장합니다.
*/
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
val gameRank = GameRank(
gameType = rankDto.gameType,
contextId = rankDto.contextId,
playerName = rankDto.playerName,
primaryScore = rankDto.primaryScore,
secondaryScore = rankDto.secondaryScore
)
return rankRepository.save(gameRank)
}
}

View File

@ -0,0 +1,180 @@
package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactor.awaitSingle
import kr.lunaticbum.back.lun.utils.LogService
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
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.Service
import reactor.core.publisher.Mono
import java.io.File
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
@Document(collection = "ImageMeta") // 이미지 메타데이터를 저장할 컬렉션
data class ImageMeta(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var fileName: String, // 저장된 파일명 (예: uuid.jpg)
var originalFileName: String?, // 원본 파일명
var fileType: String?, // MIME 타입 (예: image/jpeg)
var fileSize: Long, // 파일 크기 (bytes)
var width: Int, // 이미지 가로 픽셀
var height: Int, // 이미지 세로 픽셀
var uploadTime: Long, // 등록일시 (Timestamp)
var path: String // 이미지 접근 가능 URL 경로
)
/**
* ImageMeta 컬렉션을 위한 Spring Data Repository
*/
interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
/**
* MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다.
*/
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
fun findRandomImage(): Mono<ImageMeta>
// [신규 추가] 파일 이름으로 문서를 찾는 기능
fun findByFileName(fileName: String): Mono<ImageMeta>
}
/**
* 이미지 메타데이터 로직을 처리할 서비스
*/
@Service
class ImageMetaService(
private val repository: ImageMetaRepository,
private val logService: LogService, // LogService 주입
@Value("\${image.upload.path}") private val uploadPath: String // application.properties의 업로드 경로 주입
) {
// [신규 추가] 백그라운드 작업용 Coroutine Scope 정의
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// [신규 추가] 동기화 작업이 중복 실행되는 것을 방지하기 위한 Atomic Lock
private val isSyncRunning = AtomicBoolean(false)
/**
* 공개 메소드: 메타데이터 저장 (BlogController에서 사용)
*/
fun save(imageMeta: ImageMeta): Mono<ImageMeta> {
return repository.save(imageMeta)
}
/**
* 공개 메소드: 랜덤 이미지 가져오기 (Home 컨트롤러에서 사용)
*/
fun getRandomImage(): Mono<ImageMeta> {
return repository.findRandomImage()
}
/**
* [신규 추가] Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너
*/
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logService.log("Application ready. Launching initial image DB sync task...")
launchSyncTask()
}
/**
* [신규 추가] 동기화 작업을 비동기(Coroutine) 실행하는 런처 (잠금 처리)
* BlogController에서도 함수를 호출할 있습니다.
*/
fun launchSyncTask() {
// AtomicBoolean을 사용해 현재 동기화 작업이 실행 중이 아닐 때만 true로 설정하고 새 작업을 시작
if (isSyncRunning.compareAndSet(false, true)) {
logService.log("Starting background image sync...")
serviceScope.launch {
try {
// 실제 동기화 로직 실행
runFileSystemSync()
} catch (e: Exception) {
logService.log("Unhandled error in sync launcher: ${e.message}")
} finally {
// 작업이 성공하든, 실패(중단)하든 항상 잠금을 해제하여 다음 작업을 허용
isSyncRunning.set(false)
logService.log("Background image sync finished. Lock released.")
}
}
} else {
logService.log("Skipping sync launch: Task is already running.")
}
}
/**
* [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직
*/
private suspend fun runFileSystemSync() {
// 1. 실제 업로드 폴더에서 원본 파일 목록(썸네일 제외)을 가져옵니다.
val physicalFiles = File(uploadPath).listFiles { _, name ->
!name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif"))
} ?: emptyArray()
// 2. DB에서 모든 이미지 메타데이터 개수를 가져옵니다.
val dbCount = repository.count().awaitSingle()
// 3. (요청사항) 개수가 동일하면 작업을 수행할 필요가 없습니다.
if (physicalFiles.size.toLong() == dbCount) {
logService.log("Image sync check: Counts match (${dbCount} files). No sync needed.")
return
}
logService.log("Image sync: File count (${physicalFiles.size}) vs DB count ($dbCount). Syncing...")
// 4. DB에 존재하는 모든 파일 이름을 Set으로 변환하여 빠른 조회를 준비합니다.
val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet()
// 5. (요청사항) 에러 발생 시 즉시 중단되도록 전체 루프를 try-catch로 감쌉니다.
try {
// 6. 실제 파일 목록을 순회합니다.
for (file in physicalFiles) {
// 7. 파일 이름이 DB 파일 이름 Set에 없는 경우(누락된 경우)에만 처리합니다.
if (file.name !in dbFilenames) {
logService.log("Sync: Found missing file: ${file.name}. Adding to DB...")
// 8. 메타데이터 추출 (이 과정에서 파일이 손상되었으면 ImageIO.read가 Exception 발생)
val bufferedImage = ImageIO.read(file)
val width = bufferedImage.width
val height = bufferedImage.height
val metadata = ImageMeta(
fileName = file.name,
originalFileName = "Scanned from disk", // 원본 파일명은 알 수 없으므로 대체 텍스트 사용
fileType = Files.probeContentType(file.toPath()), // 파일 시스템 기반 MIME 타입 추측
fileSize = file.length(),
width = width,
height = height,
uploadTime = file.lastModified(), // 등록일시 대신 파일 최종 수정일시 사용
path = "/blog/post/images/${file.name}" // 저장된 경로
)
// 9. DB에 저장 (DB 연결 오류 시 Exception 발생)
repository.save(metadata).awaitSingle()
}
}
} catch (e: Exception) {
// [요청사항] IO 오류(손상된 파일) 또는 DB 오류 발생 시, 즉시 작업을 중단하고 로그를 남깁니다.
logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task immediately. Remaining files will not be processed.")
e.printStackTrace()
// 함수가 여기서 종료되며, launchSyncTask의 finally 블록이 실행되어 잠금이 해제됩니다.
}
}
}

View File

@ -28,10 +28,15 @@ import reactor.core.publisher.Mono
import java.net.URLDecoder
import java.time.Duration
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
class Post {
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
@ -56,10 +61,14 @@ class Post {
var firstAddress = ""
var modifyAddress = ""
// [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var modifyTime : Long = 0
var modifyLat : Double = 0.0
var modifyLon : Double = 0.0
// [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var readCount : Long = 0
var voteCount : Long = 0
var unlikeCount : Long = 0
@ -184,24 +193,25 @@ class PostManager(
/**
* [이름 변경] find20 -> findAllVersionsPaginated
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
* [FIX]: Change return type to Mono<List<Post>> and remove the blocking call.
*/
fun findAllVersionsPaginated(pageable :Pageable) : List<Post> {
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> { // <-- 1. Change return type
println("pageSize >>> ${pageable.pageSize}")
println("pageNumber >>> ${pageable.pageNumber}")
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
.doOnNext { println(it) } // map 대신 doOnNext로 로그 출력
.collectList() // Flux<Post> → Mono<List<Post>>
.block(Duration.ofSeconds(30)) // Mono<List<Post>> → List<Post>
?: listOf()
// .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK
// ?: listOf()
}
/**
* 익명 사용자를 위한 메서드 (고유 최신 페이지네이션 조회)
* [FIX]: This function should already be correct from the previous step.
*/
fun findLatestUniquePaginated(pageable: Pageable) : List<Post> {
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> { // <-- Should already return Mono
return postRepository.findLatestUniqueOriginPaginated(pageable)
.collectList()
.block(Duration.ofSeconds(30)) ?: listOf()
}
/**
@ -272,12 +282,13 @@ class PostManager(
/**
* [로직 수정]
* 화면은 이제 "익명 사용자용 최신 글" 0 페이지, 8 아이템을 명시적으로 요청합니다.
* [FIX]: Return Mono<List<Post>> to match the updated callee.
*/
fun find8() : List<Post> {
fun find8() : Mono<List<Post>> { // <-- 3. Change return type to Mono<List<Post>>
// 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다.
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
// 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다.
return this.findLatestUniquePaginated(pageRequest)
return this.findLatestUniquePaginated(pageRequest) // <-- 4. This now correctly returns the Mono
}
fun find20() : List<Post> {

View File

@ -3,8 +3,10 @@ package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
import kr.lunaticbum.back.lun.utils.SudokuGenerator
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Aggregation
@ -12,7 +14,7 @@ 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.Flux // (★ 오류 수정: 누락된 Flux import 추가)
import reactor.core.publisher.Mono
import java.awt.Color
import java.awt.image.BufferedImage
@ -20,93 +22,94 @@ import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.util.Base64
import javax.imageio.ImageIO
import kotlin.random.Random
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.mongodb.core.index.Indexed
@Document("puzzles") // "puzzles" 컬렉션에 매핑
/**
* ======================================================
* 1. NONOGRAM 모델 리포지토리
* ======================================================
*/
@Document("puzzles") // "puzzles" 컬렉션 (노노그램용)
data class NonogramPuzzle(
@Id
val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언
val id: String? = null,
val solutionGrid: List<List<Int>>,
val rowClues: List<List<Int>>,
val colClues: List<List<Int>>,
// Add these two fields
val grayscaleImage: String, // Base64 encoded grayscale image
val originalImage: String, // Base64 encoded original color image
val grayscaleImage: String,
val originalImage: String,
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>
fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
}
/**
* ======================================================
* [통합 게임 서비스]
* 모든 게임(Nonogram, Sudoku, Spider) 로직을 처리하는 단일 서비스.
* ======================================================
*/
@Service
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
class PuzzleService(
// 1. Nonogram 의존성
private val puzzleRepository: NonogramPuzzleRepository,
// 2. Sudoku 의존성
private val sudokuPuzzleRepository: SudokuPuzzleRepository,
// 3. Spider 의존성
private val spiderGameRepository: SpiderGameRepository
) {
// 퍼즐 크기의 최소/최대값을 상수로 정의하여 관리 용이성을 높입니다.
companion object {
private const val MIN_PUZZLE_SIZE = 10
private const val MAX_PUZZLE_SIZE = 30
}
/**
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다.
* @return 찾은 퍼즐 또는 DB가 비어있으면 null
*/
// ======================================================
// 1. NONOGRAM 서비스 로직 (기존 함수)
// ======================================================
suspend fun findRandomPuzzle(): NonogramPuzzle? {
return puzzleRepository.findRandom().awaitFirstOrNull()
}
fun findById(id: String) = puzzleRepository.findById(id)
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
fun findById(id: String): Mono<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
fun deletePuzzle(id : String): Mono<Void> = puzzleRepository.deleteById(id) // Nonogram 용
/**
* ( 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다.
*/
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
val puzzleData = withContext(Dispatchers.IO) {
val originalImage = ImageIO.read(file.inputStream)
val imageWithBackground = convertTransparentToWhite(originalImage)
// (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정
val puzzleSize = determinePuzzleSize(imageWithBackground)
// Create a resized color version for the final reveal
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
// 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성
val resizedOriginal = resizeImage(imageWithBackground, 300)
val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply {
createGraphics().run {
drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null)
dispose()
}
}
val averageBrightness = calculateAverageBrightness(grayImage)
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
// 결정된 puzzleSize를 사용하여 solutionGrid 생성
val solutionGrid = List(puzzleSize) { y ->
List(puzzleSize) { x ->
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
}
}
val rowClues = solutionGrid.map { getCluesForLine(it) }
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
// Convert images to Base64 strings
val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300))
val originalBase64 = imageToBase64(resizedOriginal)
@ -118,39 +121,23 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
originalImage = originalBase64
)
}
return puzzleRepository.save(puzzleData).awaitSingle()
}
/**
* ( 새로 추가된 함수)
* 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다.
* @param image 분석할 원본 이미지
* @return 계산된 퍼즐 크기 (정수)
*/
// --- (★ 오류 수정: 축약되었던 Nonogram 헬퍼 함수 본문 전체 복원) ---
private fun determinePuzzleSize(image: BufferedImage): Int {
val width = image.width.toDouble()
val height = image.height.toDouble()
// 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다.
val maxDimension = maxOf(width, height)
val minDimension = minOf(width, height)
// 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상)
val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0
// 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다.
// - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다.
// - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다.
val baseSize = 15.0
val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치
val factor = 5.0
val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor)
// 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다.
return calculatedSize.toInt().coerceIn(MIN_PUZZLE_SIZE, MAX_PUZZLE_SIZE)
}
// Helper function to resize images
private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().run {
@ -160,21 +147,16 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
}
}
// 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를 흰색 배경으로 변환합니다.
*/
private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
if (!sourceImage.colorModel.hasAlpha()) {
return sourceImage
}
return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().also { g2d ->
g2d.color = Color.WHITE
@ -185,9 +167,6 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
}
}
/**
* 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255)
*/
private fun calculateAverageBrightness(image: BufferedImage): Int {
var totalBrightness: Long = 0
val width = image.width
@ -200,21 +179,14 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
return (totalBrightness / (width * height)).toInt()
}
/**
* 평균 밝기에 따라 임계치를 조정합니다.
*/
private fun determineAdaptiveThreshold(averageBrightness: Int): Int {
return when {
// 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄
averageBrightness > 180 -> (averageBrightness * 0.9).toInt()
// 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄
averageBrightness < 80 -> (averageBrightness * 1.1).toInt()
// 보통 밝기의 이미지면 평균값을 그대로 사용
else -> averageBrightness
}
}
// --- 헬퍼 함수들 ---
private fun getCluesForLine(line: List<Int>): List<Int> {
val clues = mutableListOf<Int>()
var count = 0
@ -233,4 +205,262 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
private fun transpose(grid: List<List<Int>>): List<List<Int>> {
return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } }
}
// --- (헬퍼 함수 복원 끝) ---
// ======================================================
// 2. SUDOKU 서비스 로직 (통합됨)
// ======================================================
data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String)
data class SudokuValidateDto(val puzzleId: Long, val answer: String)
suspend fun sudoku_startGame(difficulty: String): SudokuGameDto {
val puzzleCount = sudokuPuzzleRepository.count()
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
val randomKey = Random.nextLong(1, puzzleCount - 1)
val solvedPuzzle = sudokuPuzzleRepository.findByPuzzleKey(randomKey)
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
val holes = when (difficulty.lowercase()) {
"medium" -> 45
"hard" -> 55
else -> 35 // easy
}
val question = sudoku_createQuestion(solvedPuzzle.puzzle!!, holes)
return SudokuGameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
}
suspend fun sudoku_validateSolution(validateDto: SudokuValidateDto): Boolean {
val originalPuzzle = sudokuPuzzleRepository.findByPuzzleKey(validateDto.puzzleId)
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
return originalPuzzle.puzzle == validateDto.answer
}
suspend fun sudoku_generateAndSavePuzzle(): SudokuPuzzle {
val puzzleString = SudokuGenerator().generate()
val lastPuzzle = sudokuPuzzleRepository.findTopByOrderByPuzzleKeyDesc()
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
return sudokuPuzzleRepository.save(newPuzzle)
}
private fun sudoku_createQuestion(puzzle: String, holes: Int): String {
val chars = puzzle.toMutableList()
var remainingHoles = holes
while (remainingHoles > 0) {
val randomIndex = Random.nextInt(chars.size)
if (chars[randomIndex] != '0') {
chars[randomIndex] = '0'
remainingHoles--
}
}
return chars.joinToString("")
}
// ======================================================
// 3. SPIDER 서비스 로직 (통합 및 Coroutine 변환됨)
// ======================================================
suspend fun spider_newGame(numSuits: Int, numCards: String): SpiderGame {
val allCards = spider_createDeck(numSuits)
val shuffledCards = allCards.shuffled(Random)
val (tableau, stock) = spider_dealCards(shuffledCards, numCards)
val initialGame = SpiderGame(
id = null,
tableau = tableau,
stock = stock,
foundation = emptyList(),
moves = 0,
isCompleted = false,
undoCount = 0,
undoHistory = emptyList()
)
return spiderGameRepository.save(initialGame).awaitSingle()
}
suspend fun spider_getGame(id: String): SpiderGame? {
return spiderGameRepository.findById(id).awaitSingleOrNull()
}
suspend fun spider_updateGame(game: SpiderGame): SpiderGame {
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedGame = game.copy(undoHistory = updatedHistory)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
suspend fun spider_dealCardsFromStock(gameId: String): SpiderGame {
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
?: throw IllegalArgumentException("Game not found: $gameId")
val stockCards = game.stock.toMutableList()
if (stockCards.size < 10) {
throw IllegalArgumentException("No more cards in stock.")
}
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedTableau = game.tableau.toMutableList()
val remainingStock = stockCards.drop(10)
updatedTableau.forEachIndexed { index, stack ->
val cardToDeal = stockCards[index]
cardToDeal.isFaceUp = true
(stack as MutableList).add(cardToDeal)
}
val updatedGame = game.copy(
tableau = updatedTableau,
stock = remainingStock,
moves = game.moves + 1,
undoHistory = updatedHistory
)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
suspend fun spider_undoGame(gameId: String): SpiderGame {
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
?: throw IllegalArgumentException("Game not found: $gameId")
if (game.undoHistory.isEmpty() || game.undoCount >= 5) {
throw IllegalArgumentException("Cannot undo. No more history or undo limit reached.")
}
val lastHistory = game.undoHistory.last()
val remainingHistory = game.undoHistory.dropLast(1)
val updatedGame = game.copy(
tableau = lastHistory.tableau,
stock = lastHistory.stock,
foundation = lastHistory.foundation,
moves = lastHistory.moves,
undoCount = game.undoCount + 1,
undoHistory = remainingHistory
)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
// --- (스파이더 헬퍼 함수들) ---
private fun spider_createDeck(numSuits: Int): List<SpiderCard> {
val allSuits = listOf("spade", "heart", "club", "diamond")
val suits = allSuits.take(numSuits)
val setsPerSuit = when (numSuits) {
1 -> 8
2 -> 4
4 -> 2
else -> throw IllegalArgumentException("Invalid number of suits: $numSuits")
}
val deck = mutableListOf<SpiderCard>()
repeat(setsPerSuit) {
for (suit in suits) {
for (rank in 1..13) {
deck.add(SpiderCard(suit, rank, isFaceUp = false))
}
}
}
return deck
}
private fun spider_dealCards(shuffledCards: List<SpiderCard>, numCards: String): Pair<List<List<SpiderCard>>, List<SpiderCard>> {
val initialCards = numCards.split(",").map { it.trim().toInt() }
val cardsPerStack = List(10) { index ->
if (index < 4) initialCards[0] else initialCards[1]
}
val cardsToDeal = shuffledCards.toMutableList()
val tableau = MutableList(10) { mutableListOf<SpiderCard>() }
cardsPerStack.forEachIndexed { stackIndex, count ->
repeat(count) {
if (cardsToDeal.isNotEmpty()) {
val card = cardsToDeal.removeFirst()
tableau[stackIndex].add(card)
}
}
}
tableau.forEach { stack ->
if (stack.isNotEmpty()) {
stack.last().isFaceUp = true
}
}
return Pair(tableau, cardsToDeal)
}
}
/**
* 스파이더 게임 상태 저장 모델
*/
@Document(collection = "spider_games")
data class SpiderGame(
@Id
val id: String? = null,
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int,
val isCompleted: Boolean,
val undoCount: Int = 0, // 실행 취소 횟수
val undoHistory: List<SpiderGameHistory> = emptyList(), // 게임 상태 히스토리
val timestamp: Long = System.currentTimeMillis()
)
data class SpiderCard(
val suit: String,
val rank: Int,
var isFaceUp: Boolean,
)
// 게임 상태 히스토리 모델
data class SpiderGameHistory(
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int
)
/**
* 스파이더 게임 상태 리포지토리 (PuzzleService에서 사용됨)
* (참고: 리포지토리는 Reactive 타입으로 유지하고,
* 서비스단에서 Coroutine으로 브리징하여 사용합니다.)
*/
interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
override fun findById(id: String): Mono<SpiderGame>
}
/**
* 스도쿠 퍼즐 원본 데이터를 저장하는 모델
*/
@Document(collection = "puzzles") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
data class SudokuPuzzle(
@Id
val id: String? = null,
val puzzleKey: Long? = null,
@Indexed(unique = true)
val puzzle: String? // 81자리 완성된 퍼즐 데이터
)
/**
* 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
*/
@Repository
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
override suspend fun count(): Long
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
}

View File

@ -1,219 +1,22 @@
// kr.lunaticbum.back.lun.model.Spider.kt
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import kotlin.random.Random
@Document(collection = "spider_games")
data class SpiderGame(
@Id
val id: String? = null,
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int,
val isCompleted: Boolean,
val undoCount: Int = 0, // 실행 취소 횟수: 최대 5회 제한
val undoHistory: List<SpiderGameHistory> = emptyList(), // 게임 상태 히스토리 저장
val timestamp: Long = System.currentTimeMillis()
)
data class SpiderCard(
val suit: String,
val rank: Int,
var isFaceUp: Boolean,
)
// 게임 상태 히스토리를 저장할 데이터 클래스
data class SpiderGameHistory(
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int
)
interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
override fun findById(id: String): Mono<SpiderGame>
}
@Service
class SpiderService(
private val spiderGameRepository: SpiderGameRepository,
private val spiderRankRepository: SpiderRankRepository
) {
// 덱 생성, 카드 분배 등 기존 로직은 그대로 유지
private fun createDeck(numSuits: Int): List<SpiderCard> {
val allSuits = listOf("spade", "heart", "club", "diamond")
val suits = allSuits.take(numSuits)
val setsPerSuit = when (numSuits) {
1 -> 8 // 13 ranks * 1 suit * 8 sets = 104 cards
2 -> 4 // 13 ranks * 2 suits * 4 sets = 104 cards
4 -> 2 // 13 ranks * 4 suits * 2 sets = 104 cards
else -> throw IllegalArgumentException("Invalid number of suits: $numSuits")
}
val deck = mutableListOf<SpiderCard>()
repeat(setsPerSuit) {
for (suit in suits) {
for (rank in 1..13) {
deck.add(SpiderCard(suit, rank, isFaceUp = false))
}
}
}
return deck
}
private fun dealCards(shuffledCards: List<SpiderCard>, numCards: String): Pair<List<List<SpiderCard>>, List<SpiderCard>> {
val initialCards = numCards.split(",").map { it.trim().toInt() }
val cardsPerStack = List(10) { index ->
if (index < 4) initialCards[0] else initialCards[1]
}
val cardsToDeal = shuffledCards.toMutableList()
val tableau = MutableList(10) { mutableListOf<SpiderCard>() }
// 각 스택에 초기 카드를 분배합니다.
cardsPerStack.forEachIndexed { stackIndex, count ->
repeat(count) {
if (cardsToDeal.isNotEmpty()) {
val card = cardsToDeal.removeFirst()
tableau[stackIndex].add(card)
}
}
}
// 각 스택의 맨 위 카드를 앞면으로 설정합니다.
tableau.forEach { stack ->
if (stack.isNotEmpty()) {
stack.last().isFaceUp = true
}
}
return Pair(tableau, cardsToDeal)
}
fun newGame(numSuits: Int, numCards: String): Mono<SpiderGame> {
val allCards = createDeck(numSuits)
val shuffledCards = allCards.shuffled(Random)
val (tableau, stock) = dealCards(shuffledCards, numCards)
val initialGame = SpiderGame(
id = null,
tableau = tableau,
stock = stock,
foundation = emptyList(),
moves = 0,
isCompleted = false,
undoCount = 0,
undoHistory = emptyList()
)
return spiderGameRepository.save(initialGame)
}
fun getGame(id: String): Mono<SpiderGame> {
return spiderGameRepository.findById(id)
}
// updateGame 메서드: 게임 상태를 업데이트하기 전에 히스토리를 저장합니다.
fun updateGame(game: SpiderGame): Mono<SpiderGame> {
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
// 기존 히스토리에 현재 상태를 추가하고, 최대 5개까지만 유지합니다.
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedGame = game.copy(undoHistory = updatedHistory)
return spiderGameRepository.save(updatedGame)
}
// dealCardsFromStock 메서드: 카드 분배 전에 히스토리를 저장합니다.
fun dealCardsFromStock(gameId: String): Mono<SpiderGame> {
return spiderGameRepository.findById(gameId)
.flatMap { game ->
val stockCards = game.stock.toMutableList()
if (stockCards.size >= 10) {
// 현재 게임 상태를 히스토리에 저장
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedTableau = game.tableau.toMutableList()
val remainingStock = stockCards.drop(10)
updatedTableau.forEachIndexed { index, stack ->
val cardToDeal = stockCards[index]
cardToDeal.isFaceUp = true // 카드를 추가할 때 앞면으로 뒤집기
(stack as MutableList).add(cardToDeal)
}
val updatedGame = game.copy(
tableau = updatedTableau,
stock = remainingStock,
moves = game.moves + 1,
undoHistory = updatedHistory // 히스토리 업데이트
)
spiderGameRepository.save(updatedGame)
} else {
Mono.error(IllegalArgumentException("No more cards in stock."))
}
}
}
// undoGame 메서드: 히스토리에서 마지막 상태를 불러와 게임을 되돌립니다.
fun undoGame(gameId: String): Mono<SpiderGame> {
return spiderGameRepository.findById(gameId)
.flatMap { game ->
if (game.undoHistory.isNotEmpty() && game.undoCount < 5) {
val lastHistory = game.undoHistory.last()
val remainingHistory = game.undoHistory.dropLast(1)
val updatedGame = game.copy(
tableau = lastHistory.tableau,
stock = lastHistory.stock,
foundation = lastHistory.foundation,
moves = lastHistory.moves,
undoCount = game.undoCount + 1, // 실행 취소 횟수 증가
undoHistory = remainingHistory // 마지막 히스토리는 제거
)
spiderGameRepository.save(updatedGame)
} else {
Mono.error(IllegalArgumentException("Cannot undo. No more history or undo limit reached."))
}
}
}
// 랭킹 관련 기존 메서드들은 그대로 유지
fun registerRank(rank: SpiderRank): Mono<SpiderRank> {
return spiderRankRepository.save(rank)
}
fun getRanksByGameId(gameId: String): Flux<SpiderRank> {
return spiderRankRepository.findByGameIdOrderByMovesAscCompletionTimeAsc(gameId)
}
}
@Document(collection = "spider_ranks")
data class SpiderRank(
@Id
val id: String? = null,
val gameId: String,
val playerName: String,
val moves: Int,
val completionTime: Long,
val timestamp: Long = System.currentTimeMillis()
)
interface SpiderRankRepository : ReactiveMongoRepository<SpiderRank, String> {
fun findByGameIdOrderByMovesAscCompletionTimeAsc(gameId: String): Flux<SpiderRank>
}
//package kr.lunaticbum.back.lun.model
//
//import org.springframework.data.annotation.Id
//import org.springframework.data.mongodb.core.mapping.Document
//import org.springframework.data.mongodb.repository.ReactiveMongoRepository
//import reactor.core.publisher.Mono
//// (★ 삭제) Service, Flux, Random 관련 import 제거
//
//
///*
// * (★ 삭제됨) @Service class SpiderService (...)
// * -> 모든 로직이 PuzzleService로 통합됨.
// */
//
///* * (★ 삭제됨) data class SpiderRank (...)
// * -> 통합 GameRank 모델로 대체됨.
// */
//
///*
// * (★ 삭제됨) interface SpiderRankRepository (...)
// * -> 통합 GameRankRepository로 대체됨.
// */

View File

@ -1,136 +1,45 @@
package kr.lunaticbum.back.lun.model
import com.mongodb.DuplicateKeyException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kr.lunaticbum.back.lun.utils.SudokuGenerator
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import kotlin.random.Random
@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정
data class SudokuPuzzle(
@Id
val id: String? = null, // MongoDB의 고유 _id 필드
val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용)
@Indexed(unique = true)
val puzzle: String? // 81자리 완성된 퍼즐 데이터
)
@Document(collection = "records")
data class GameRecord(
@Id
val id: String? = null,
val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조
val userName: String,
val completionTime: Long // 완료 시간 (초)
)
@Repository
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
// 전체 퍼즐 개수를 반환하는 suspend 함수
override suspend fun count(): Long
// puzzleKey로 퍼즐을 찾는 suspend 함수
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
// 👇 이 함수 선언을 추가해주세요.
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
}
@Repository
interface GameRecordRepository : CoroutineCrudRepository<GameRecord, String> {
// 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림)
fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow<GameRecord>
}
@Service
class SudokuService(
private val puzzleRepository: SudokuPuzzleRepository,
private val recordRepository: GameRecordRepository
) {
// DTO 정의 (파일 하단 또는 별도 파일)
data class GameDto(val puzzleId: Long, val question: String, val solution: String)
data class RecordDto(val puzzleId: Long, val userName: String, val completionTime: Long)
suspend fun startGame(difficulty: String): GameDto {
val puzzleCount = puzzleRepository.count()
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
val randomKey = Random.nextLong(1, puzzleCount - 1)
val solvedPuzzle = puzzleRepository.findByPuzzleKey(randomKey)
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
val holes = when (difficulty.lowercase()) {
"medium" -> 45
"hard" -> 55
else -> 35 // easy
}
val question = createQuestion(solvedPuzzle.puzzle!!, holes)
return GameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
}
suspend fun saveRecord(recordDto: RecordDto) {
val record = GameRecord(
puzzleId = recordDto.puzzleId,
userName = recordDto.userName,
completionTime = recordDto.completionTime
)
recordRepository.save(record)
}
suspend fun getRankings(puzzleId: Long): List<GameRecord> {
// Flow를 최종적으로 List로 변환하여 반환
return recordRepository.findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId).toList()
}
private fun createQuestion(puzzle: String, holes: Int): String {
val chars = puzzle.toMutableList()
var remainingHoles = holes
while (remainingHoles > 0) {
val randomIndex = Random.nextInt(chars.size)
if (chars[randomIndex] != '0') {
chars[randomIndex] = '0'
remainingHoles--
}
}
return chars.joinToString("")
}
suspend fun generateAndSavePuzzle(): SudokuPuzzle {
var attempts = 0
val maxAttempts = 10 // 중복 시 최대 10번 재시도
while (attempts < maxAttempts) {
try {
val puzzleString = SudokuGenerator().generate()
println("puzzleString >>> ${puzzleString}")
// DB에 저장하기 전에 가장 큰 puzzleKey를 찾아 +1
val lastPuzzle = puzzleRepository.findTopByOrderByPuzzleKeyDesc()
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
return puzzleRepository.save(newPuzzle)
} catch (e: DuplicateKeyException) {
attempts++
println("중복 퍼즐 생성됨, 재시도... ($attempts/$maxAttempts)")
}
}
throw IllegalStateException("새로운 고유 퍼즐 생성에 실패했습니다.")
}
data class ValidateDto(val puzzleId: Long, val answer: String)
suspend fun validateSolution(validateDto: ValidateDto): Boolean {
val originalPuzzle = puzzleRepository.findByPuzzleKey(validateDto.puzzleId)
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
return originalPuzzle.puzzle == validateDto.answer
}
}
//package kr.lunaticbum.back.lun.model
//
//// (★ 삭제) Service, Record 관련 import 모두 제거
//import org.springframework.data.annotation.Id
//import org.springframework.data.mongodb.core.index.Indexed
//import org.springframework.data.mongodb.core.mapping.Document
//import org.springframework.data.repository.kotlin.CoroutineCrudRepository
//import org.springframework.stereotype.Repository
//
///**
// * 스도쿠 퍼즐 원본 데이터를 저장하는 모델
// */
//@Document(collection = "puzzles") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
//data class SudokuPuzzle(
// @Id
// val id: String? = null,
// val puzzleKey: Long? = null,
// @Indexed(unique = true)
// val puzzle: String? // 81자리 완성된 퍼즐 데이터
//)
//
///**
// * 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
// */
//@Repository
//interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
// override suspend fun count(): Long
// suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
// suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
//}
//
//
///* * (★ 삭제됨) data class GameRecord (...)
// * -> 통합 GameRank 모델로 대체됨.
// */
//
///*
// * (★ 삭제됨) interface GameRecordRepository (...)
// * -> 통합 GameRankRepository로 대체됨.
// */
//
///*
// * (★ 삭제됨) @Service class SudokuService (...)
// * -> 모든 로직이 PuzzleService로 통합됨.
// */

View File

@ -1,156 +1,171 @@
/* =================================
기본 전체 레이아웃
================================= */
body {
font-family: Arial, sans-serif;
text-align: center;
background-color: #faf8ef;
color: #776e65;
margin: 0;
padding: 10px;
box-sizing: border-box;
}
/*!* =================================*/
/* 기본 및 전체 레이아웃 (수정됨)*/
/* ================================= *!*/
/*body {*/
/* !* (★ 삭제) font-family, text-align, background-color, color, margin, padding*/
/* -> 이 속성들은 모두 common_game_theme.css에서 관리합니다.*/
/* *!*/
/* box-sizing: border-box;*/
/*}*/
h1 {
font-size: 15vw;
margin: 20px 0;
}
/*h1 {*/
/* font-size: 15vw; !* 2048 고유의 큰 폰트 크기는 유지 *!*/
/* margin: 20px 0;*/
/* !* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 *!*/
/*}*/
.score-container {
font-size: 24px;
margin-bottom: 20px;
}
/*.score-container {*/
/* font-size: 24px;*/
/* margin-bottom: 20px;*/
/*}*/
/* =================================
게임 보드 (가장 중요한 부분)
================================= */
#game-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 2vw;
width: 95vw;
max-width: 400px;
margin: 0 auto;
background-color: #bbada0;
padding: 2vw;
border-radius: 6px;
box-sizing: border-box;
aspect-ratio: 1 / 1; /* 정사각형 비율 유지 */
touch-action: none; /* 모바일에서 터치 시 화면 확대/이동 방지 */
}
/*!* =================================*/
/* 게임 보드 (테마 적용)*/
/* ================================= *!*/
/*#game-board {*/
/* display: grid;*/
/* grid-template-columns: repeat(4, 1fr);*/
/* grid-gap: 2vw;*/
/* width: 95vw;*/
/* max-width: 500px; !* (★ 수정) 400px -> 500px (다른 게임과 통일) *!*/
/* margin: 0 auto;*/
/* =================================
타일 공통 스타일
================================= */
.tile {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
border-radius: 3px;
background-color: #cdc1b4;
font-size: 7vw;
}
/* !* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 *!*/
/* background-color: #b0bec5; !* #bbada0 (갈색) -> #b0bec5 (블루 그레이) *!*/
/* =================================
타일 색상
================================= */
.tile-2 { background-color: #eee4da; color: #776e65; }
.tile-4 { background-color: #ede0c8; color: #776e65; }
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
.tile-16 { background-color: #f59563; color: #f9f6f2; }
.tile-32 { background-color: #f67c5f; color: #f9f6f2; }
.tile-64 { background-color: #f65e3b; color: #f9f6f2; }
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
.tile-256 { background-color: #edcc61; color: #f9f6f2; }
.tile-512 { background-color: #edc850; color: #f9f6f2; }
.tile-1024 { background-color: #edc53f; color: #f9f6f2; }
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }
.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }
.tile-8192 { background-color: #ff3333; color: #f9f6f2; }
.tile-16384 { background-color: #0077cc; color: #f9f6f2; }
.tile-32768 { background-color: #9900cc; color: #f9f6f2; }
.tile-4096, .tile-8192 { font-size: 6vw; }
.tile-16384, .tile-32768 { font-size: 5vw; }
/* padding: 2vw;*/
/* border-radius: 6px;*/
/* box-sizing: border-box;*/
/* aspect-ratio: 1 / 1;*/
/* touch-action: none;*/
/* !* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 *!*/
/* box-shadow: 0 4px 10px rgba(0,0,0,0.08);*/
/*}*/
/*@media (min-width: 481px) {*/
/* #game-board {*/
/* grid-gap: 10px;*/
/* padding: 10px;*/
/* }*/
/*}*/
/* =================================
게임 오버 팝업
================================= */
.popup-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.popup {
background-color: #faf8ef;
padding: 20px;
border-radius: 10px;
text-align: center;
width: 80vw;
max-width: 300px;
}
.popup input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
}
.popup button {
padding: 10px 20px;
background-color: #8f7a66;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
/*!* =================================*/
/* 타일 공통 스타일 (테마 적용)*/
/* ================================= *!*/
/*.tile {*/
/* width: 100%;*/
/* height: 100%;*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* font-weight: bold;*/
/* border-radius: 3px;*/
/* =================================
랭킹 리스트
================================= */
.ranking-container {
width: 100%;
max-width: 400px;
margin: 30px auto;
text-align: left;
}
.ranking-container h3 {
text-align: center;
}
#ranking-list {
list-style-type: none;
padding: 0;
}
#ranking-list li {
background-color: #eee4da;
margin-bottom: 5px;
padding: 10px;
border-radius: 5px;
display: flex;
justify-content: space-between;
}
/* !* (★ 수정) 빈 타일 색상 변경 *!*/
/* background-color: #eceff1; !* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) *!*/
/* =================================
반응형: PC & 태블릿 (화면이 481px 이상일 )
================================= */
@media (min-width: 481px) {
h1 { font-size: 80px; }
#game-board {
grid-gap: 10px;
padding: 10px;
}
.tile { font-size: 45px; }
.tile-4096, .tile-8192 { font-size: 35px; }
.tile-16384, .tile-32768 { font-size: 30px; }
}
/* font-size: 5vw;*/
/*}*/
/*@media (min-width: 481px) {*/
/* .tile {*/
/* font-size: 30px;*/
/* }*/
/*}*/
/*!* =================================*/
/* 타일 색상 (테마 적용)*/
/* ================================= *!*/
/*!* (★ 수정) 2, 4 타일은 베이지색 계열이라 테마와 충돌하므로 파란색 계열로 변경 *!*/
/*.tile-2 { background-color: #e3f2fd; color: #333; } !* #eee4da (베이지) -> #e3f2fd (밝은 파랑) *!*/
/*.tile-4 { background-color: #bbdefb; color: #333; } !* #ede0c8 (노란 베이지) -> #bbdefb (파랑) *!*/
/*!* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) *!*/
/*.tile-8 { background-color: #f2b179; color: #f9f6f2; }*/
/*.tile-16 { background-color: #f59563; color: #f9f6f2; }*/
/*.tile-32 { background-color: #f67c5f; color: #f9f6f2; }*/
/*.tile-64 { background-color: #f65e3b; color: #f9f6f2; }*/
/*.tile-128 { background-color: #edcf72; color: #f9f6f2; }*/
/*.tile-256 { background-color: #edcc61; color: #f9f6f2; }*/
/*.tile-512 { background-color: #edc850; color: #f9f6f2; }*/
/*.tile-1024 { background-color: #edc53f; color: #f9f6f2; }*/
/*.tile-2048 { background-color: #edc22e; color: #f9f6f2; }*/
/*.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }*/
/*.tile-8192 { background-color: #ff3333; color: #f9f6f2; }*/
/*.tile-16384 { background-color: #0077cc; color: #f9f6f2; }*/
/*.tile-32768 { background-color: #9900cc; color: #f9f6f2; }*/
/*!* =================================*/
/* 게임 오버 팝업 (테마 적용)*/
/* ================================= *!*/
/*.popup-container {*/
/* position: fixed;*/
/* top: 0;*/
/* left: 0;*/
/* width: 100%;*/
/* height: 100%;*/
/* background-color: rgba(0, 0, 0, 0.5);*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* z-index: 100;*/
/*}*/
/*.popup {*/
/* !* (★ 수정) 배경색을 테마에 맞게 흰색으로 변경 *!*/
/* background-color: #ffffff; !* #faf8ef (베이지) -> #ffffff (흰색) *!*/
/* padding: 20px;*/
/* border-radius: 10px;*/
/* text-align: center;*/
/* width: 80vw;*/
/* max-width: 300px;*/
/* box-shadow: 0 4px 15px rgba(0,0,0,0.2); !* 흰색 배경이므로 그림자 추가 *!*/
/*}*/
/*.popup input {*/
/* width: 100%;*/
/* padding: 10px;*/
/* margin: 10px 0;*/
/* border: 1px solid #ccc;*/
/* border-radius: 5px;*/
/* box-sizing: border-box;*/
/*}*/
/*.popup button {*/
/* padding: 10px 20px;*/
/* !* (★ 삭제) background-color, color, border -> common_game_theme의 파란색 버튼 스타일을 상속받음 *!*/
/* border-radius: 5px;*/
/* cursor: pointer;*/
/*}*/
/*!* =================================*/
/* 랭킹 리스트 (테마 적용)*/
/* ================================= *!*/
/*.ranking-container {*/
/* !**/
/* (★ 참고) 이 컨테이너는 common_game_theme.css에서*/
/* .game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.*/
/* 여기서는 내부 정렬만 담당합니다.*/
/* *!*/
/* width: 100%;*/
/* max-width: 500px; !* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) *!*/
/* margin: 30px auto;*/
/* text-align: left;*/
/*}*/
/*.ranking-container h3 {*/
/* text-align: center;*/
/*}*/
/*#ranking-list {*/
/* list-style-type: none;*/
/* padding: 0;*/
/*}*/
/*#ranking-list li {*/
/* !* (★ 수정) 배경색 변경 *!*/
/* background-color: #f0f4f8; !* #eee4da (베이지) -> #f0f4f8 (밝은 회색) *!*/
/* margin-bottom: 5px;*/
/* padding: 10px;*/
/* border-radius: 5px;*/
/* display: flex;*/
/* justify-content: space-between;*/
/*}*/

View File

@ -535,4 +535,17 @@ a.btn_layerClose:hover {
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
}
@media screen and (max-width: 480px) {
.vote-controls {
display: flex; /* Flexbox 컨테이너로 변경 */
gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */
}
.vote-controls .button {
width: auto; /* main.css의 width: 100% 덮어쓰기 */
display: inline-block; /* main.css의 display: block 덮어쓰기 */
flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */
margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */
}
}

View File

@ -0,0 +1,100 @@
/* =================================
common_game_theme.css
(모든 게임이 공유하는 공통 테마)
================================= */
/* (★ 신규) 모든 테마의 기준이 되는 CSS 변수 정의 */
:root {
--font-main: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 기본 색상 */
--color-text-primary: #1a1a1a;
--color-text-secondary: #333;
--color-bg-page: #f4f7f9;
--color-bg-card: #ffffff;
/* 공통 UI 색상 */
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-disabled-bg: #cccccc;
--color-disabled-opacity: 0.7;
/* 게임 상태별 색상 */
--color-incorrect-bg: #ffdddd;
--color-incorrect-text: #d8000c;
--color-focus-bg: #dbeeff; /* 스도쿠 포커스 */
--color-highlight-bg: #e6e6e6; /* 스도쿠 동일 숫자 */
--color-selected-num-bg: #b3d7ff; /* 스도쿠 선택 숫자 */
/* 게임 고유 테마 색상 */
--color-felt-green: #008000; /* 스파이더: 테이블 배경 */
--color-felt-border: #004d00; /* 스파이더: 캔버스 테두리 */
--color-grid-bg-2048: #b0bec5; /* 2048: 보드 배경 */
--color-tile-empty: #eceff1; /* 2048: 빈 타일 */
--color-tile-2: #e3f2fd;
--color-tile-4: #bbdefb;
/* 공통 UI 속성 */
--border-radius-main: 8px;
--box-shadow-main: 0 4px 10px rgba(0,0,0,0.08);
}
/* (★ 통일) 기본 폰트 및 배경색 정의 (변수 사용) */
body {
font-family: var(--font-main);
background-color: var(--color-bg-page);
color: var(--color-text-secondary);
text-align: center;
margin: 0;
padding: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
/* (★ 통일) H1 (게임 제목) 스타일 통일 (변수 사용) */
h1 {
font-size: clamp(2.2em, 8vw, 3.2em);
color: var(--color-text-primary);
margin: 10px 0 20px 0;
}
/* (★ 통일) 모든 <button> 기본 스타일 통일 (변수 사용) */
button {
padding: 10px 20px;
font-size: 1em;
font-weight: bold;
cursor: pointer;
background-color: var(--color-primary);
color: var(--color-bg-card);
border: none;
border-radius: 5px;
transition: background-color 0.2s, opacity 0.2s;
}
button:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
button:disabled {
background-color: var(--color-disabled-bg);
cursor: not-allowed;
opacity: var(--color-disabled-opacity);
}
/* (★ 통일) 게임 컨트롤/랭킹 등을 감싸는 공통 '카드' UI (변수 사용) */
#sudoku-game-app .container,
.ranking-container,
#setup-container,
#game-controls {
background: var(--color-bg-card);
padding: clamp(15px, 4vw, 25px);
border-radius: var(--border-radius-main);
box-shadow: var(--box-shadow-main);
box-sizing: border-box;
width: 100%;
max-width: 500px; /* 최대 너비 통일 */
margin: 15px auto;
}

View File

@ -0,0 +1,314 @@
/*!* === nonogram.css (게임 플레이용) === *!*/
/*body {*/
/* !* (★ 삭제) font-family, align-items: center*/
/* -> common_game_theme.css에서 관리합니다.*/
/* *!*/
/*}*/
/*#board-viewport {*/
/* position: relative;*/
/* width: 100%;*/
/* max-width: 95vw; !* 화면 너비에 좀 더 맞춤 *!*/
/* margin: 20px auto;*/
/* !* (★ 핵심 수정) Flexbox로 자식 요소를 중앙 정렬합니다. *!*/
/* display: flex;*/
/* justify-content: center; !* 자식 요소를 수평 중앙 정렬 *!*/
/* align-items: flex-start; !* 위쪽에 정렬 *!*/
/*}*/
/*.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: top;*/
/*}*/
/*#game-controls {*/
/* display: flex;*/
/* justify-content: space-between;*/
/* align-items: center;*/
/* width: 100%;*/
/* !* (★ 삭제) 아래 속성들은 common_game_theme.css의 #game-controls 셀렉터가 관리합니다.*/
/* max-width: 500px;*/
/* margin-bottom: 15px;*/
/* *!*/
/* font-size: 1.2em;*/
/* flex-wrap: wrap;*/
/* gap: 15px;*/
/* !* (★ 참고) common_game_theme에서 이미 background: white, padding, box-shadow 등이 적용된 상태입니다.*/
/* 이 CSS는 그 내부의 flex 정렬만 담당하게 됩니다.*/
/* *!*/
/*}*/
/*#mode-selector {*/
/* display: flex;*/
/* gap: 5px;*/
/* border: 1px solid #ccc;*/
/* border-radius: 8px;*/
/* padding: 4px;*/
/* background-color: #f0f0f0;*/
/*}*/
/*#mode-selector label {*/
/* cursor: pointer;*/
/* user-select: none;*/
/*}*/
/*#mode-selector span {*/
/* padding: 8px 15px;*/
/* border-radius: 5px;*/
/* display: block;*/
/* transition: background-color 0.2s, color 0.2s;*/
/*}*/
/*#mode-selector input[type="radio"] {*/
/* display: none;*/
/*}*/
/*#mode-selector input[type="radio"]:checked + span {*/
/* background-color: #007bff; !* (★ 참고) 공통 테마의 파란색과 동일하므로 유지 *!*/
/* color: white;*/
/* box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);*/
/*}*/
/*#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;*/
/*}*/
/*!* nonogram.css의 .clue-cell (게임용) *!*/
/*.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; !* 줄 간격 *!*/
/*}*/
/*!* nonogram.css의 .grid-cell (게임용) *!*/
/*.grid-cell {*/
/* background-color: #fff;*/
/* border: 1px solid #ddd;*/
/* box-sizing: border-box;*/
/* cursor: pointer;*/
/*}*/
/*!* nonogram.css의 .filled (게임용) *!*/
/*.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 {*/
/* display: none;*/
/*}*/
/*.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);*/
/*}*/
/*!* === puzzle.css (업로드 미리보기용) - 충돌 해결됨 === *!*/
/*#puzzle-container {*/
/* display: grid;*/
/* !* We will set grid-template-columns/rows with JS *!*/
/* grid-gap: 2px;*/
/* margin-top: 20px;*/
/* background-color: #333;*/
/* border: 2px solid #333;*/
/* width: fit-content;*/
/*}*/
/*!* (★충돌 해결) #puzzle-container 내부의 .grid-cell (미리보기 코너 셀) *!*/
/*#puzzle-container .grid-cell {*/
/* width: 25px;*/
/* height: 25px;*/
/* background-color: #f0f0f0;*/
/* text-align: center;*/
/* line-height: 25px;*/
/* font-size: 14px;*/
/* !* nonogram.css의 .grid-cell 스타일과 겹치지 않음 *!*/
/* cursor: default;*/
/* border: none;*/
/*}*/
/*!* (★충돌 해결) #puzzle-container 내부의 .clue-cell (미리보기 힌트 셀) *!*/
/*#puzzle-container .clue-cell {*/
/* background-color: #cce7ff;*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* padding: 5px;*/
/* min-height: 25px;*/
/* font-weight: bold;*/
/* !* nonogram.css의 .clue-cell 스타일과 겹치지 않음 *!*/
/* font-size: 14px;*/
/*}*/
/*.solution-cell {*/
/* width: 25px;*/
/* height: 25px;*/
/*}*/
/*!* (★충돌 해결) .filled 대신 .solution-cell.filled 사용 *!*/
/*.solution-cell.filled {*/
/* background-color: #333;*/
/*}*/
/*!* .empty는 .solution-cell.empty로 사용 (upload.js 기준) *!*/
/*.solution-cell.empty {*/
/* background-color: #fff;*/
/*}*/
/*#puzzle-wrapper {*/
/* position: relative; !* Needed for absolute positioning of children *!*/
/*}*/
/*!* 이 ID는 nonogram.html에서 사용되지 않으므로 충돌 없음 *!*/
/*#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

@ -1,240 +0,0 @@
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;
flex-wrap: wrap;
gap: 15px;
}
/* (★ 수정) 모드 선택 버튼 스타일 */
#mode-selector {
display: flex;
gap: 5px;
border: 1px solid #ccc;
border-radius: 8px;
padding: 4px;
background-color: #f0f0f0;
}
#mode-selector label {
cursor: pointer;
user-select: none;
}
#mode-selector span {
padding: 8px 15px;
border-radius: 5px;
display: block;
transition: background-color 0.2s, color 0.2s;
}
/* 실제 라디오 버튼은 숨김 */
#mode-selector input[type="radio"] {
display: none;
}
/* (★ 핵심) 선택된 라디오 버튼의 span 스타일 */
#mode-selector input[type="radio"]:checked + span {
background-color: #007bff;
color: white;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
}
#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

@ -1,64 +0,0 @@
#puzzle-container {
display: grid;
/* We will set grid-template-columns/rows with JS */
grid-gap: 2px;
margin-top: 20px;
background-color: #333;
border: 2px solid #333;
width: fit-content;
}
.grid-cell {
width: 25px;
height: 25px;
background-color: #f0f0f0;
text-align: center;
line-height: 25px;
font-size: 14px;
}
.clue-cell {
background-color: #cce7ff;
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
min-height: 25px;
font-weight: bold;
}
.solution-cell {
width: 25px;
height: 25px;
}
.filled {
background-color: #333;
}
.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

@ -1,17 +1,41 @@
#game-container {
display: flex;
justify-content: center; /* 가로 중앙 정렬 */
align-items: flex-start; /* 세로 상단 정렬 */
background-color: #008000;
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
/*!* (★ 수정) #game-container가 전체 화면(100vw/vh)을 차지하는 대신,*/
/* common_game_theme의 body(#f4f7f9 배경) 위에 떠 있는*/
/* '게임 테이블(카드)' 역할을 하도록 변경합니다. *!*/
/*#game-container {*/
/* !* (★ 남김) 내부 캔버스를 정렬하는 로직은 유지 *!*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: flex-start;*/
#gameCanvas {
background-color: #008000;
border: 2px solid #fff;
width: 95%;
max-height: min(95vw, 95vh);
box-sizing: border-box;
}
/* !* (★ 유지/수정) '펠트' 배경색은 유지하되, 공통 '카드' UI 요소를 추가 *!*/
/* background-color: #008000;*/
/* border-radius: 8px; !* (★ 추가) 공통 테마 둥근 모서리 *!*/
/* box-shadow: 0 4px 10px rgba(0,0,0,0.08); !* (★ 추가) 공통 테마 그림자 *!*/
/* padding: 15px; !* (★ 추가) 캔버스 주변 여백 *!*/
/* box-sizing: border-box;*/
/* !* (★ 삭제) 100vw, 100vh 속성을 삭제하여 body의 중앙 정렬이 동작하도록 함 *!*/
/* !* width: 100vw; (삭제) *!*/
/* !* height: 100vh; (삭제) *!*/
/* !* (★ 수정) 너비 관리: 다른 게임(500px)보다 넓은 반응형 최대 너비를 가짐 *!*/
/* width: 95%; !* 뷰포트의 95%를 사용 *!*/
/* max-width: 1200px; !* 단, 스파이더 게임에 맞게 1200px까지 허용 *!*/
/*}*/
/*#gameCanvas {*/
/* !* (★ 삭제) 컨테이너가 이미 녹색이므로 캔버스 자체의 배경은 불필요 *!*/
/* !* background-color: #008000; (삭제) *!*/
/* !* (★ 수정) 흰색 테두리보다 펠트 색과 대비되는 어두운 테두리로 변경 *!*/
/* border: 2px solid #004d00; !* #fff (흰색) -> #004d00 (어두운 녹색) *!*/
/* !* (★ 수정) 너비: 부모(#game-container) 패딩 영역의 100%를 차지 *!*/
/* width: 100%;*/
/* height: auto; !* 너비에 맞춰 캔버스 비율(JS가 설정한)을 따름 *!*/
/* !* (★ 삭제) 뷰포트 기준 max-height 삭제. 컨테이너 너비와 캔버스 비율로 크기가 결정됨. *!*/
/* !* max-height: min(95vw, 95vh); (삭제) *!*/
/* box-sizing: border-box; !* 유지 *!*/
/*}*/

View File

@ -1,238 +1,260 @@
/* 기본 스타일 초기화 및 폰트 설정 */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f7f9;
display: flex;
justify-content: center;
min-height: 100vh;
}
/*!* 기본 스타일 초기화 및 폰트 설정 *!*/
/*body {*/
/* !**/
/* (★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.*/
/* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;*/
/* margin: 0;*/
/* padding: 20px;*/
/* background-color: #f4f7f9;*/
/* display: flex;*/
/* justify-content: center;*/
/* min-height: 100vh;*/
/* *!*/
/*}*/
#sudoku-game-app {
width: 100%;
margin: 20px 0;
}
/*#sudoku-game-app {*/
/* width: 100%;*/
/* margin: 20px 0;*/
/*}*/
.container {
background: white;
padding: clamp(15px, 4vw, 30px);
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
}
/*.container {*/
/* !**/
/* (★ 삭제) 아래 속성들은 common_game_theme.css에서*/
/* '#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.*/
h1 {
font-size: 1.8em;
color: #333;
margin-top: 0;
margin-bottom: 20px;
}
/* background: white;*/
/* padding: clamp(15px, 4vw, 30px);*/
/* border-radius: 8px;*/
/* box-shadow: 0 4px 10px rgba(0,0,0,0.1);*/
/* text-align: center;*/
/* max-width: 500px;*/
/* width: 100%;*/
/* box-sizing: border-box;*/
/* margin: 0 auto;*/
/* *!*/
button {
padding: 10px 20px;
font-size: 1em;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
/* !* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 *!*/
/* text-align: center;*/
/*}*/
/* 게임 컨테이너 */
#game-container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 500px;
margin: 0 auto;
}
/*h1 {*/
/* font-size: 1.8em;*/
/* color: #333;*/
/* margin-top: 0;*/
/* margin-bottom: 20px;*/
/* !* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.*/
/* 만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.*/
/* 현재는 공통 스타일이 적용됩니다. *!*/
/*}*/
/* 게임 정보 (점수, 타이머) */
.game-info {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 0 10px;
box-sizing: border-box;
font-size: 1.5em;
font-weight: bold;
}
#score { color: #007bff; }
#timer { color: #333; }
/*!* (★ 삭제) 아래의 'button' 공통 스타일은 common_game_theme.css가 처리합니다. *!*/
/*!**/
/*button {*/
/* padding: 10px 20px;*/
/* font-size: 1em;*/
/* cursor: pointer;*/
/* background-color: #007bff;*/
/* color: white;*/
/* border: none;*/
/* border-radius: 5px;*/
/* transition: background-color 0.2s;*/
/*}*/
/*button:hover:not(:disabled) {*/
/* background-color: #0056b3;*/
/*}*/
/*button:disabled {*/
/* background-color: #cccccc;*/
/* cursor: not-allowed;*/
/*}*/
/**!*/
/* 스도쿠 보드 */
#sudoku-board {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
width: 100%;
border: 3px solid #333;
aspect-ratio: 1 / 1;
}
.cell {
display: flex;
justify-content: center;
align-items: center;
font-size: clamp(1em, 4vw, 1.8em);
font-weight: bold;
color: #333;
border: 1px solid #ddd;
box-sizing: border-box;
cursor: pointer;
}
/*!* ======================================= *!*/
/*!* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) *!*/
/*!* ======================================= *!*/
.cell:nth-child(3n) { border-right: 2px solid #333; }
.cell:nth-child(9n) { border-right-width: 1px; }
.cell:nth-child(n+19):nth-child(-n+27),
.cell:nth-child(n+46):nth-child(-n+54) {
border-bottom: 2px solid #333;
}
/*!* 게임 컨테이너 *!*/
/*#game-container {*/
/* display: flex;*/
/* flex-direction: column;*/
/* align-items: center;*/
/* max-width: 500px;*/
/* margin: 0 auto;*/
/*}*/
.cell:not(.editable) {
background-color: #f0f0f0;
color: #222;
cursor: default;
}
/*!* 게임 정보 (점수, 타이머) *!*/
/*.game-info {*/
/* width: 100%;*/
/* display: flex;*/
/* justify-content: space-between;*/
/* align-items: center;*/
/* margin-bottom: 15px;*/
/* padding: 0 10px;*/
/* box-sizing: border-box;*/
/* font-size: 1.5em;*/
/* font-weight: bold;*/
/*}*/
/*#score { color: #007bff; }*/
/*#timer { color: #333; }*/
/* 하이라이트 & 오답 스타일 */
.cell.incorrect {
background-color: #ffdddd !important;
color: #d8000c !important;
}
.highlight-focused {
background-color: #dbeeff !important;
}
.highlight-same-number {
background-color: #e6e6e6 !important;
}
.highlight-selected-number {
background-color: #b3d7ff !important;
}
/*!* 스도쿠 보드 *!*/
/*#sudoku-board {*/
/* display: grid;*/
/* grid-template-columns: repeat(9, 1fr);*/
/* grid-template-rows: repeat(9, 1fr);*/
/* width: 100%;*/
/* border: 3px solid #333;*/
/* aspect-ratio: 1 / 1;*/
/*}*/
/* 숫자 입력 버튼 */
#number-input-buttons {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 15px;
gap: 1%;
}
/*.cell {*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* font-size: clamp(1em, 4vw, 1.8em);*/
/* font-weight: bold;*/
/* color: #333;*/
/* border: 1px solid #ddd;*/
/* box-sizing: border-box;*/
/* cursor: pointer;*/
/*}*/
#number-input-buttons .num-btn,
#number-input-buttons #undo-btn {
line-height: unset;
min-width: unset;
width: 9%;
aspect-ratio: 1/1;
font-size: clamp(1em, 4vw, 1.8em);
font-weight: bold;
border-radius: 8px;
background-color: #f0f0f0;
color: #333;
border: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.1s, opacity 0.2s;
}
/*.cell:nth-child(3n) { border-right: 2px solid #333; }*/
/*.cell:nth-child(9n) { border-right-width: 1px; }*/
/*.cell:nth-child(n+19):nth-child(-n+27),*/
/*.cell:nth-child(n+46):nth-child(-n+54) {*/
/* border-bottom: 2px solid #333;*/
/*}*/
#number-input-buttons .num-btn.selected {
background-color: #007bff;
color: white;
border-color: #007bff;
}
/*.cell:not(.editable) {*/
/* background-color: #f0f0f0;*/
/* color: #222;*/
/* cursor: default;*/
/*}*/
#number-input-buttons .num-btn.completed {
opacity: 0.4;
background-color: #e9ecef;
pointer-events: none;
}
/*!* 하이라이트 & 오답 스타일 *!*/
/*.cell.incorrect {*/
/* background-color: #ffdddd !important;*/
/* color: #d8000c !important;*/
/*}*/
/*.highlight-focused {*/
/* background-color: #dbeeff !important;*/
/*}*/
/*.highlight-same-number {*/
/* background-color: #e6e6e6 !important;*/
/*}*/
/*.highlight-selected-number {*/
/* background-color: #b3d7ff !important;*/
/*}*/
#number-input-buttons #undo-btn {
background-color: #f8f9fa;
color: #dc3545;
}
/*!* 숫자 입력 버튼 *!*/
/*#number-input-buttons {*/
/* display: flex;*/
/* justify-content: space-between;*/
/* width: 100%;*/
/* margin-top: 15px;*/
/* gap: 1%;*/
/*}*/
/* 액션 버튼 (힌트, 정답확인) */
.action-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 15px;
width: 100%;
}
.action-buttons button {
flex-grow: 1;
max-width: 200px;
}
/*#number-input-buttons .num-btn,*/
/*#number-input-buttons #undo-btn {*/
/* line-height: unset;*/
/* min-width: unset;*/
/* width: 9%;*/
/* aspect-ratio: 1/1;*/
/* font-size: clamp(1em, 4vw, 1.8em);*/
/* font-weight: bold;*/
/* border-radius: 8px;*/
/* background-color: #f0f0f0;*/
/* color: #333;*/
/* border: 1px solid #ccc;*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* cursor: pointer;*/
/* padding: 0;*/
/* transition: background-color 0.2s, transform 0.1s, opacity 0.2s;*/
/*}*/
/* 모달 및 숨김 처리 */
.hidden {
display: none !important;
}
#modal-overlay, #game-over-modal {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
#modal-content {
background: white;
padding: 30px;
border-radius: 10px;
text-align: center;
width: 90%;
max-width: 400px;
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
#modal-content h2, #modal-content h3 {
color: #333;
margin-bottom: 15px;
}
#username-input {
width: calc(100% - 24px);
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 1em;
}
#ranking-list {
list-style-type: decimal;
list-style-position: inside;
padding: 0;
text-align: left;
margin-top: 20px;
}
#ranking-list li {
padding: 8px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
#ranking-list li:last-child {
border-bottom: none;
}
/*#number-input-buttons .num-btn.selected {*/
/* background-color: #007bff;*/
/* color: white;*/
/* border-color: #007bff;*/
/*}*/
/*#number-input-buttons .num-btn.completed {*/
/* opacity: 0.4;*/
/* background-color: #e9ecef;*/
/* pointer-events: none;*/
/*}*/
/*#number-input-buttons #undo-btn {*/
/* background-color: #f8f9fa;*/
/* color: #dc3545;*/
/*}*/
/*!* 액션 버튼 (힌트, 정답확인) *!*/
/*.action-buttons {*/
/* display: flex;*/
/* justify-content: center;*/
/* gap: 10px;*/
/* margin-top: 15px;*/
/* width: 100%;*/
/*}*/
/*.action-buttons button {*/
/* flex-grow: 1;*/
/* max-width: 200px;*/
/*}*/
/*!* 모달 및 숨김 처리 *!*/
/*.hidden {*/
/* display: none !important;*/
/*}*/
/*#modal-overlay, #game-over-modal {*/
/* position: fixed;*/
/* top: 0; left: 0;*/
/* width: 100%; height: 100%;*/
/* background-color: rgba(0,0,0,0.7);*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* z-index: 1000;*/
/*}*/
/*#modal-content {*/
/* background: white;*/
/* padding: 30px;*/
/* border-radius: 10px;*/
/* text-align: center;*/
/* width: 90%;*/
/* max-width: 400px;*/
/* box-shadow: 0 8px 20px rgba(0,0,0,0.2);*/
/*}*/
/*#modal-content h2, #modal-content h3 {*/
/* color: #333;*/
/* margin-bottom: 15px;*/
/*}*/
/*#username-input {*/
/* width: calc(100% - 24px);*/
/* padding: 10px;*/
/* margin-bottom: 15px;*/
/* border: 1px solid #ccc;*/
/* border-radius: 5px;*/
/* font-size: 1em;*/
/*}*/
/*#ranking-list {*/
/* list-style-type: decimal;*/
/* list-style-position: inside;*/
/* padding: 0;*/
/* text-align: left;*/
/* margin-top: 20px;*/
/*}*/
/*#ranking-list li {*/
/* padding: 8px 0;*/
/* border-bottom: 1px solid #eee;*/
/* display: flex;*/
/* justify-content: space-between;*/
/* align-items: center;*/
/*}*/
/*#ranking-list li:last-child {*/
/* border-bottom: none;*/
/*}*/

View File

@ -1,233 +1,238 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM 요소 가져오기
const gameBoard = document.getElementById('game-board');
const scoreDisplay = document.getElementById('score');
const gameOverPopup = document.getElementById('game-over-popup');
const finalScoreDisplay = document.getElementById('final-score');
const playerNameInput = document.getElementById('player-name');
const saveScoreButton = document.getElementById('save-score');
const rankingList = document.getElementById('ranking-list');
// 게임 설정 및 변수
const currentGameId = '2048';
const gridSize = 4;
let board = [];
let score = 0;
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
// ----- 게임 핵심 로직 -----
function initializeBoard() {
gameBoard.innerHTML = ''; // 기존 타일 초기화
for (let i = 0; i < gridSize * gridSize; i++) {
const tile = document.createElement('div');
tile.className = 'tile';
gameBoard.appendChild(tile);
}
board = Array(gridSize * gridSize).fill(0);
addNumber();
addNumber();
updateBoard();
}
function updateBoard() {
const tiles = gameBoard.children;
for (let i = 0; i < board.length; i++) {
const value = board[i];
const tile = tiles[i];
tile.textContent = value === 0 ? '' : value;
tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
}
scoreDisplay.textContent = score;
}
function addNumber() {
const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
if (available.length > 0) {
const spot = available[Math.floor(Math.random() * available.length)];
board[spot] = Math.random() < 0.9 ? 2 : 4;
}
}
// ----- 타일 이동 및 병합 로직 -----
function moveRow(row) {
let arr = row.filter(val => val);
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] === arr[i + 1]) {
arr[i] *= 2;
score += arr[i];
arr[i + 1] = 0;
}
}
arr = arr.filter(val => val);
const missing = gridSize - arr.length;
const zeros = Array(missing).fill(0);
return arr.concat(zeros);
}
function moveLeft() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const rowStart = i * gridSize;
const row = board.slice(rowStart, rowStart + gridSize);
const newRow = moveRow(row);
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
board.splice(rowStart, gridSize, ...newRow);
}
return changed;
}
function moveRight() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const rowStart = i * gridSize;
const row = board.slice(rowStart, rowStart + gridSize).reverse();
const newRow = moveRow(row).reverse();
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
board.splice(rowStart, gridSize, ...newRow);
}
return changed;
}
function moveUp() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
const newCol = moveRow(col);
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
for (let j = 0; j < gridSize; j++) {
board[i + j * gridSize] = newCol[j];
}
}
return changed;
}
function moveDown() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
const newCol = moveRow(col).reverse();
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
for (let j = 0; j < gridSize; j++) {
board[i + j * gridSize] = newCol[j];
}
}
return changed;
}
// ----- 게임 상태 관리 -----
function isGameOver() {
if (!board.includes(0)) {
for (let i = 0; i < gridSize; i++) {
for (let j = 0; j < gridSize; j++) {
const current = board[i * gridSize + j];
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
return false;
}
}
}
return true;
}
return false;
}
function handleMove(moveFunction) {
if (moveFunction()) {
addNumber();
updateBoard();
if (isGameOver()) {
finalScoreDisplay.textContent = score;
gameOverPopup.style.display = 'flex';
}
}
}
// ----- 이벤트 리스너 -----
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp': handleMove(moveUp); break;
case 'ArrowDown': handleMove(moveDown); break;
case 'ArrowLeft': handleMove(moveLeft); break;
case 'ArrowRight': handleMove(moveRight); break;
}
});
gameBoard.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
});
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
gameBoard.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
handleSwipe();
});
function handleSwipe() {
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const swipeThreshold = 30;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (Math.abs(deltaX) > swipeThreshold) {
handleMove(deltaX > 0 ? moveRight : moveLeft);
}
} else {
if (Math.abs(deltaY) > swipeThreshold) {
handleMove(deltaY > 0 ? moveDown : moveUp);
}
}
}
// ----- 랭킹 API 연동 -----
saveScoreButton.addEventListener('click', async () => {
const playerName = playerNameInput.value.trim();
if (playerName === "") return alert("이름을 입력해주세요.");
const newScore = { gameId: currentGameId, name: playerName, score: score };
try {
const response = await fetch(getMainPath() + '/rank/ranks', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(newScore),
});
if (!response.ok) throw new Error('점수 저장 실패');
gameOverPopup.style.display = 'none';
playerNameInput.value = '';
score = 0; // 점수 초기화
updateRankingList();
initializeBoard();
} catch (error) {
console.error('Error:', error);
alert('서버와 통신 중 오류가 발생했습니다.');
}
});
async function updateRankingList() {
rankingList.innerHTML = '';
try {
const response = await fetch(getMainPath() +`/rank/ranks/${currentGameId}`);
if (!response.ok) throw new Error('랭킹 로딩 실패');
const rankings = await response.json();
if (rankings.length === 0) {
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
return;
}
rankings.forEach((rank, index) => {
const li = document.createElement('li');
li.innerHTML = `<span>${index + 1}. ${rank.name}</span><strong>${rank.score}점</strong>`;
rankingList.appendChild(li);
});
} catch (error) {
console.error('Error:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
// ----- 게임 시작 -----
updateRankingList();
initializeBoard();
});
// document.addEventListener('DOMContentLoaded', () => {
// // ... (DOM 요소 가져오기 - 동일)
// const gameBoard = document.getElementById('game-board');
// const scoreDisplay = document.getElementById('score');
// const gameOverPopup = document.getElementById('game-over-popup');
// const finalScoreDisplay = document.getElementById('final-score');
// const playerNameInput = document.getElementById('player-name');
// const saveScoreButton = document.getElementById('save-score');
// const rankingList = document.getElementById('ranking-list');
//
// // (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
// const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
// const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
//
// let gridSize = 4;
// let board = [];
// let score = 0;
// let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
//
// // ----- 게임 핵심 로직 -----
// function initializeBoard() {
// gameBoard.innerHTML = ''; // 기존 타일 초기화
// for (let i = 0; i < gridSize * gridSize; i++) {
// const tile = document.createElement('div');
// tile.className = 'tile';
// gameBoard.appendChild(tile);
// }
// board = Array(gridSize * gridSize).fill(0);
// addNumber();
// addNumber();
// updateBoard();
// }
//
// function updateBoard() {
// const tiles = gameBoard.children;
// for (let i = 0; i < board.length; i++) {
// const value = board[i];
// const tile = tiles[i];
// tile.textContent = value === 0 ? '' : value;
// tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
// }
// scoreDisplay.textContent = score;
// }
//
// function addNumber() {
// const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
// if (available.length > 0) {
// const spot = available[Math.floor(Math.random() * available.length)];
// board[spot] = Math.random() < 0.9 ? 2 : 4;
// }
// }
//
// // ----- 타일 이동 및 병합 로직 -----
// function moveRow(row) {
// let arr = row.filter(val => val);
// for (let i = 0; i < arr.length - 1; i++) {
// if (arr[i] === arr[i + 1]) {
// arr[i] *= 2;
// score += arr[i];
// arr[i + 1] = 0;
// }
// }
// arr = arr.filter(val => val);
// const missing = gridSize - arr.length;
// const zeros = Array(missing).fill(0);
// return arr.concat(zeros);
// }
//
// function moveLeft() {
// let changed = false;
// for (let i = 0; i < gridSize; i++) {
// const rowStart = i * gridSize;
// const row = board.slice(rowStart, rowStart + gridSize);
// const newRow = moveRow(row);
// if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
// board.splice(rowStart, gridSize, ...newRow);
// }
// return changed;
// }
//
// function moveRight() {
// let changed = false;
// for (let i = 0; i < gridSize; i++) {
// const rowStart = i * gridSize;
// const row = board.slice(rowStart, rowStart + gridSize).reverse();
// const newRow = moveRow(row).reverse();
// if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
// board.splice(rowStart, gridSize, ...newRow);
// }
// return changed;
// }
//
// function moveUp() {
// let changed = false;
// for (let i = 0; i < gridSize; i++) {
// const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
// const newCol = moveRow(col);
// if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
// for (let j = 0; j < gridSize; j++) {
// board[i + j * gridSize] = newCol[j];
// }
// }
// return changed;
// }
//
// function moveDown() {
// let changed = false;
// for (let i = 0; i < gridSize; i++) {
// const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
// const newCol = moveRow(col).reverse();
// if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
// for (let j = 0; j < gridSize; j++) {
// board[i + j * gridSize] = newCol[j];
// }
// }
// return changed;
// }
//
// // ----- 게임 상태 관리 -----
// function isGameOver() {
// if (!board.includes(0)) {
// for (let i = 0; i < gridSize; i++) {
// for (let j = 0; j < gridSize; j++) {
// const current = board[i * gridSize + j];
// if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
// (i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
// return false;
// }
// }
// }
// return true;
// }
// return false;
// }
//
// function handleMove(moveFunction) {
// if (moveFunction()) {
// addNumber();
// updateBoard();
// if (isGameOver()) {
// finalScoreDisplay.textContent = score;
// gameOverPopup.style.display = 'flex';
// }
// }
// }
//
// // ----- 이벤트 리스너 -----
// document.addEventListener('keydown', (e) => {
// switch (e.key) {
// case 'ArrowUp': handleMove(moveUp); break;
// case 'ArrowDown': handleMove(moveDown); break;
// case 'ArrowLeft': handleMove(moveLeft); break;
// case 'ArrowRight': handleMove(moveRight); break;
// }
// });
//
// gameBoard.addEventListener('touchstart', (e) => {
// touchStartX = e.changedTouches[0].screenX;
// touchStartY = e.changedTouches[0].screenY;
// });
//
// gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
//
// gameBoard.addEventListener('touchend', (e) => {
// touchEndX = e.changedTouches[0].screenX;
// touchEndY = e.changedTouches[0].screenY;
// handleSwipe();
// });
//
// function handleSwipe() {
// const deltaX = touchEndX - touchStartX;
// const deltaY = touchEndY - touchStartY;
// const swipeThreshold = 30;
//
// if (Math.abs(deltaX) > Math.abs(deltaY)) {
// if (Math.abs(deltaX) > swipeThreshold) {
// handleMove(deltaX > 0 ? moveRight : moveLeft);
// }
// } else {
// if (Math.abs(deltaY) > swipeThreshold) {
// handleMove(deltaY > 0 ? moveDown : moveUp);
// }
// }
// }
//
// // ----- 랭킹 API 연동 -----
// saveScoreButton.addEventListener('click', async () => {
// const playerName = playerNameInput.value.trim();
// if (playerName === "") return alert("이름을 입력해주세요.");
//
// try {
// // (★ 수정) user.js의 공통 submitRank 함수 호출
// // 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
// await submitRank(currentGameType, currentContextId, playerName, score, null);
//
// gameOverPopup.style.display = 'none';
// playerNameInput.value = '';
// score = 0;
// updateRankingList(); // 랭킹 리스트 새로고침
// initializeBoard(); // 새 게임 시작
//
// } catch (error) {
// console.error('Error submitting rank:', error);
// alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
// }
// });
//
// /**
// * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
// */
// async function updateRankingList() {
// rankingList.innerHTML = '<li>로딩 중...</li>';
// try {
// // (★ 수정) user.js의 공통 fetchRanks 함수 호출
// const rankings = await fetchRanks(currentGameType, currentContextId);
//
// rankingList.innerHTML = ''; // 리스트 비우기
// if (!rankings || rankings.length === 0) {
// rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
// return;
// }
//
// rankings.forEach((rank, index) => {
// const li = document.createElement('li');
// // (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
// li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
// rankingList.appendChild(li);
// });
// } catch (error) {
// console.error('Error fetching ranks:', error);
// rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
// }
// }
//
// // ----- 게임 시작 -----
// updateRankingList();
// initializeBoard();
// });

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,694 @@
// /**
// * ==============================================
// * nonogram.js (게임 플레이 로직)
// * (★ 리팩토링: 타이머 및 랭킹 등록 기능 추가)
// * ==============================================
// */
// document.addEventListener('DOMContentLoaded', () => {
// // 백엔드(Thymeleaf)로부터 puzzleData가 제대로 전달되었는지 확인
// // 이 변수는 nonogram.html에만 존재하므로, upload.html에서는 이 로직이 실행되지 않음.
// if (typeof puzzleData === 'undefined' || !puzzleData) {
// // game-board가 없는 upload.html에서는 오류를 뱉지 않고 조용히 종료됨.
// const gb = document.getElementById('game-board');
// if (gb) {
// gb.innerHTML = '<h2>오류: 퍼즐 데이터를 불러올 수 없습니다.</h2>';
// }
// return; // upload.html에서는 여기서 즉시 return됨.
// }
//
// // --- DOM 요소 참조 (게임 페이지 전용) ---
// const modeSelector = document.getElementById('mode-selector');
// 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 currentMode = 'fill';
// let points = 5;
// let isGameFinished = false;
// let gameStartTime = 0; // (★ 신규) 게임 시작 시간 (ms)
//
// let isDragging = false;
// let dragAction = null;
// let startCell = null;
// let lastHoveredCell = null;
// let currentSelection = new 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);
//
//
// function updateMode() {
// currentMode = document.querySelector('input[name="play-mode"]:checked').value;
// }
//
// 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;
// }
//
// /**
// * (★ 수정) drawBoard (타이머 시작점 추가)
// */
// function drawBoard(cellSize) {
// // ... (모든 보드 그리기 DOM 생성 로직 - 수정 없음) ...
// gameBoard.style.gridTemplateColumns = `${cellSize * 2}px 1fr`;
// gameBoard.style.gridTemplateRows = `${cellSize * 2}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);
//
// // (★ 신규) 보드가 그려지는 시점을 게임 시작 시간으로 기록
// gameStartTime = Date.now();
//
// 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);
// }
// }
// // --- (이벤트 리스너 및 드래그/터치 핸들러) ---
// // (★ 수정 없음) attachEventListeners, handleDragStart, handleDragMove, handleDragEnd
// // (★ 수정 없음) updateSelectionVisuals, clearSelectionVisuals
// // --- (모두 동일하게 유지) ---
// function attachEventListeners(grid) {
// grid.addEventListener('mousedown', (e) => handleDragStart(e));
// grid.addEventListener('mouseover', (e) => handleDragMove(e));
// grid.addEventListener('contextmenu', (e) => e.preventDefault());
// grid.addEventListener('touchstart', (e) => handleDragStart(e), { passive: false });
// grid.addEventListener('touchmove', (e) => handleDragMove(e), { passive: false });
// }
// window.addEventListener('mouseup', () => handleDragEnd());
// window.addEventListener('touchend', () => handleDragEnd());
// modeSelector.addEventListener('change', updateMode);
// function handleDragStart(e) {
// if (isGameFinished || !e.target.classList.contains('grid-cell')) return;
// isDragging = true;
// e.preventDefault();
// const cell = e.target;
// const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
// if (e.type === 'mousedown') {
// if (e.button === 0) {
// dragAction = (currentState === 1) ? 'clear' : 'fill';
// document.querySelector('input[name="play-mode"][value="fill"]').checked = true;
// } else if (e.button === 2) {
// dragAction = (currentState === -1) ? 'clear' : 'mark';
// document.querySelector('input[name="play-mode"][value="mark"]').checked = true;
// }
// } else {
// const currentMode = document.querySelector('input[name="play-mode"]:checked').value;
// if (currentMode === 'fill') {
// dragAction = (currentState === 1) ? 'clear' : 'fill';
// } else {
// dragAction = (currentState === -1) ? 'clear' : 'mark';
// }
// }
// startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
// lastHoveredCell = startCell;
// updateSelectionVisuals();
// }
// function handleDragMove(e) {
// if (!isDragging) return;
// e.preventDefault();
// const target = (e.touches)
// ? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)
// : e.target;
// if (target && target.classList.contains('grid-cell')) {
// const row = parseInt(target.dataset.row);
// const col = parseInt(target.dataset.col);
// if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) {
// lastHoveredCell = { row, col };
// updateSelectionVisuals();
// }
// }
// }
// function handleDragEnd() {
// 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);
// }
//
// /**
// * (★ 신규) 노노그램 랭킹 등록을 처리하는 함수
// * 이 함수는 user.js에 정의된 공통 submitRank 함수를 호출합니다.
// */
// async function submitNonogramRank(completionTime, hintsUsed) {
// const playerName = prompt("랭킹에 등록할 이름을 입력하세요:", "Player");
// if (!playerName || playerName.trim() === "") return;
//
// try {
// // (★ 신규) user.js의 공통 submitRank 함수 호출
// // 주 점수(primaryScore) = 완료 시간(초) (낮을수록 좋음)
// // 보조 점수(secondaryScore) = 사용한 힌트 수(5-남은포인트) (낮을수록 좋음)
// await submitRank(
// 'NONOGRAM', // GameType
// puzzleData.id, // ContextId (퍼즐 고유 ID)
// playerName.trim(), // playerName
// completionTime, // primaryScore (시간)
// hintsUsed // secondaryScore (힌트 사용 횟수)
// );
//
// alert("랭킹이 등록되었습니다!");
// // 랭킹 등록 버튼 비활성화 (중복 제출 방지)
// const submitBtn = document.getElementById('modal-submit-rank-btn');
// if (submitBtn) submitBtn.disabled = true;
//
// } catch (error) {
// console.error("Rank submission failed:", error);
// alert("랭킹 등록에 실패했습니다: " + error.message);
// }
// }
//
// /**
// * (★ 수정) 성공/실패 모달 (랭킹 등록 버튼 추가를 위해 ID 할당 기능 추가)
// */
// 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;
// if (btnInfo.id) button.id = btnInfo.id; // (★ 신규) 버튼 ID 할당 기능
// 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;
//
// // (★ 신규) 게임 완료 시간 및 힌트 사용량 계산
// const completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000);
// const hintsUsed = 5 - points; // 힌트 사용량 = 5 - 남은 포인트
//
// // --- 요소 참조 및 상호작용 비활성화 ---
// const viewport = document.getElementById('board-viewport');
// const puzzleGridContainer = document.querySelector('.puzzle-grid-container');
// const grayscaleImg = document.getElementById('grayscale-reveal');
// const originalImg = document.getElementById('original-reveal');
// puzzleGridContainer.style.pointerEvents = 'none';
// hintBtn.disabled = true;
//
// // --- 애니메이션 위치 및 크기 계산 ---
// const gridRect = puzzleGridContainer.getBoundingClientRect();
// const viewportRect = viewport.getBoundingClientRect();
// const top = gridRect.top - viewportRect.top;
// const left = gridRect.left - viewportRect.top; // (오타 수정) viewportRect.top -> viewportRect.left
//
// [grayscaleImg, originalImg].forEach(img => {
// img.style.top = `${top}px`;
// img.style.left = `${left}px`;
// img.style.width = `${gridRect.width}px`;
// img.style.height = `${gridRect.height}px`;
// img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage;
// });
//
// // --- 애니메이션 순차 실행 ---
// setTimeout(() => {
// grayscaleImg.style.opacity = '1';
// setTimeout(() => {
// originalImg.style.opacity = '1';
// setTimeout(() => {
// // (★ 수정) 모달 버튼 설정에 "랭킹 등록" 버튼 추가
// showResultModal({
// title: 'Success! 🎉',
// message: `퍼즐을 완성했습니다! (시간: ${completionTimeSeconds}초, 힌트 사용: ${hintsUsed}개)`,
// buttons: [
// {
// text: '랭킹 등록',
// class: 'primary',
// id: 'modal-submit-rank-btn', // (★ 신규) 랭킹 제출 버튼
// action: () => submitNonogramRank(completionTimeSeconds, hintsUsed)
// },
// { text: '다른 문제 풀기', action: () => window.location.href = '/puzzle/play' },
// { text: '홈으로', action: () => window.location.href = '/' }
// ]
// });
// }, 2000);
// }, 2000);
// }, 500);
// }
//
// // 힌트 버튼 클릭 이벤트 처리
// 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(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();
// updateMode();
//
// requestAnimationFrame(() => {
// fitBoardToScreen();
// window.addEventListener('resize', fitBoardToScreen);
// });
// });
//
//
// /**
// * ==============================================
// * upload.js (업로드 페이지 로직)
// * (★ 리팩토링: 통합 API 경로 사용)
// * ==============================================
// */
// let currentPuzzleData = null; // 업로드 성공 시 퍼즐 데이터 저장
//
// // (★ 수정 없음) 업로드 페이지용 성공 애니메이션 함수
// function showSuccessAnimation() {
// if (!currentPuzzleData) return;
//
// const puzzleContainer = document.getElementById('puzzle-container');
// const grayscaleImg = document.getElementById('grayscale-reveal');
// const originalImg = document.getElementById('original-reveal');
//
// grayscaleImg.src = currentPuzzleData.grayscaleImage;
// originalImg.src = currentPuzzleData.originalImage;
//
// puzzleContainer.style.transition = 'opacity 0.5s';
// puzzleContainer.style.opacity = '0';
// grayscaleImg.style.opacity = '1';
//
// setTimeout(() => {
// grayscaleImg.style.opacity = '0';
// originalImg.style.opacity = '1';
// }, 2000);
// }
//
// // (★ 수정 없음) 업로드 페이지용 퍼즐 미리보기 그리기 함수
// function drawPuzzle(puzzleData) {
// const container = document.getElementById('puzzle-container');
// container.innerHTML = '';
//
// const { solutionGrid, rowClues, colClues } = puzzleData;
// const numRows = solutionGrid.length;
// const numCols = solutionGrid[0].length;
//
// container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`;
// container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`;
//
// // 1. 코너
// const corner = document.createElement('div');
// corner.className = 'grid-cell';
// container.appendChild(corner);
//
// // 2. 열 힌트
// for (const clues of colClues) {
// const clueCell = document.createElement('div');
// clueCell.className = 'clue-cell';
// clueCell.innerHTML = clues.join('<br>');
// container.appendChild(clueCell);
// }
//
// // 3. 행 힌트 및 정답 그리드
// for (let i = 0; i < numRows; i++) {
// const rowClueCell = document.createElement('div');
// rowClueCell.className = 'clue-cell';
// rowClueCell.textContent = rowClues[i].join(' ');
// container.appendChild(rowClueCell);
//
// for (let j = 0; j < numCols; j++) {
// const cell = document.createElement('div');
// cell.className = 'solution-cell';
// if (solutionGrid[i][j] === 1) {
// cell.classList.add('filled');
// } else {
// cell.classList.add('empty');
// }
// container.appendChild(cell);
// }
// }
// }
//
//
// // upload.js의 DOMContentLoaded 리스너
// document.addEventListener('DOMContentLoaded', () => {
//
// const createBtn = document.getElementById('createBtn');
//
// // createBtn이 없는 nonogram.html(게임 페이지)에서는 이 리스너가 아무것도 실행하지 않음.
// if (!createBtn) {
// return;
// }
//
// // (업로드 페이지 전용 로직)
// createBtn.addEventListener('click', async () => {
// 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');
//
// if (uploader.files.length === 0) {
// statusDiv.textContent = '이미지 파일을 선택해주세요.';
// return;
// }
//
// const imageFile = uploader.files[0];
// const formData = new FormData();
// formData.append('imageFile', imageFile);
//
// statusDiv.textContent = '문제를 생성하는 중...';
// puzzleContainer.innerHTML = '';
//
// try {
// // (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/upload.bjx 호출
// const response = await fetch('/puzzle/upload.bjx', {
// method: 'POST',
// body: formData,
// });
//
// if (response.ok) {
// const puzzleData = await response.json();
// statusDiv.textContent = '문제 생성 성공!';
// 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}`;
// }
// } catch (error) {
// console.error('네트워크 오류:', error);
// statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
// }
//
// deleteBtn.addEventListener('click', async () => {
// if (!currentPuzzleData || !currentPuzzleData.id) {
// alert('삭제할 퍼즐이 선택되지 않았습니다.');
// return;
// }
// if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
// return;
// }
// try {
// // (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/{id}.bjx 호출
// const response = await fetch(`/puzzle/${currentPuzzleData.id}.bjx`, {
// method: 'DELETE',
// });
//
// if (response.ok) {
// statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.';
// puzzleContainer.innerHTML = '';
// // (버그 수정) success-animation-container 내부의 img src를 초기화해야 함
// document.getElementById('grayscale-reveal').src = "";
// document.getElementById('original-reveal').src = "";
//
// testSuccessBtn.style.display = 'none';
// deleteBtn.style.display = 'none';
// playBtn.style.display = 'none';
// currentPuzzleData = null;
// } else {
// statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`;
// }
// } catch (error) {
// console.error('삭제 중 네트워크 오류:', error);
// statusDiv.textContent = '삭제 중 오류가 발생했습니다.';
// }
// });
//
// playBtn.addEventListener('click', () => {
// if (currentPuzzleData && currentPuzzleData.id) {
// // (★ 수정 없음) 이 경로는 PuzzleController의 페이지 서빙 경로와 일치하므로 올바름.
// window.location.href = `/puzzle/play/${currentPuzzleData.id}`;
// }
// });
// });
// });

View File

@ -1,527 +0,0 @@
/**
* 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 modeSelector = document.getElementById('mode-selector');
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 currentMode = 'fill';
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);
/**
* Updates the currentMode based on the selected radio button
*/
function updateMode() {
currentMode = document.querySelector('input[name="play-mode"]:checked').value;
}
/**
* 폰트 높이와 자릿수 숫자 너비를 기준으로 최적의 크기를 계산합니다.
* @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);
}
}
/**
* ( REVISED) Event Listeners for Mouse and Touch
*/
function attachEventListeners(grid) {
// --- MOUSE EVENTS ---
grid.addEventListener('mousedown', (e) => handleDragStart(e));
grid.addEventListener('mouseover', (e) => handleDragMove(e));
grid.addEventListener('contextmenu', (e) => e.preventDefault());
// --- TOUCH EVENTS ---
grid.addEventListener('touchstart', (e) => handleDragStart(e), { passive: false });
grid.addEventListener('touchmove', (e) => handleDragMove(e), { passive: false });
}
// End drag for both mouse and touch
window.addEventListener('mouseup', () => handleDragEnd());
window.addEventListener('touchend', () => handleDragEnd());
// Listen for changes on the mode selector
modeSelector.addEventListener('change', updateMode);
/**
* ( NEW) Handles the start of a drag (mousedown or touchstart)
*/
function handleDragStart(e) {
if (isGameFinished || !e.target.classList.contains('grid-cell')) return;
isDragging = true;
e.preventDefault();
const cell = e.target;
const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
// --- 하이브리드 로직 ---
if (e.type === 'mousedown') { // 마우스 이벤트일 경우
if (e.button === 0) { // 좌클릭
dragAction = (currentState === 1) ? 'clear' : 'fill';
// UI를 실제 행동에 맞춰 동기화
document.querySelector('input[name="play-mode"][value="fill"]').checked = true;
} else if (e.button === 2) { // 우클릭
dragAction = (currentState === -1) ? 'clear' : 'mark';
// UI를 실제 행동에 맞춰 동기화
document.querySelector('input[name="play-mode"][value="mark"]').checked = true;
}
} else { // 터치 이벤트일 경우 ('touchstart')
// 현재 선택된 라디오 버튼 모드를 읽어옴
const currentMode = document.querySelector('input[name="play-mode"]:checked').value;
if (currentMode === 'fill') {
dragAction = (currentState === 1) ? 'clear' : 'fill';
} else { // 'mark' 모드
dragAction = (currentState === -1) ? 'clear' : 'mark';
}
}
// 드래그 시작점 기록 및 시각적 피드백 시작
startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
lastHoveredCell = startCell;
updateSelectionVisuals();
}
/**
* ( NEW) Handles movement during a drag (mouseover or touchmove)
*/
function handleDragMove(e) {
if (!isDragging) return;
e.preventDefault();
// For touch events, we need to find the element under the finger
const target = (e.touches)
? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)
: e.target;
if (target && target.classList.contains('grid-cell')) {
const row = parseInt(target.dataset.row);
const col = parseInt(target.dataset.col);
if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) {
lastHoveredCell = { row, col };
updateSelectionVisuals();
}
}
}
/**
* ( NEW) Handles the end of a drag (mouseup or touchend)
*/
function handleDragEnd() {
if (!isDragging) return;
currentSelection.forEach(cell => updateCellState(cell, dragAction));
clearSelectionVisuals();
if (dragAction === 'fill' || dragAction === 'clear') {
checkAndLockCompletedLines(affectedRows, affectedCols);
}
checkWinCondition();
// Reset state
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;
// --- 요소 참조 ---
const viewport = document.getElementById('board-viewport');
const puzzleGridContainer = document.querySelector('.puzzle-grid-container');
const grayscaleImg = document.getElementById('grayscale-reveal');
const originalImg = document.getElementById('original-reveal');
// --- 상호작용 비활성화 ---
puzzleGridContainer.style.pointerEvents = 'none';
hintBtn.disabled = true;
// --- (★ 핵심) 애니메이션 위치 및 크기 계산 ---
// 1. 렌더링된 퍼즐 격자와 뷰포트의 실제 위치/크기 정보를 가져옵니다.
const gridRect = puzzleGridContainer.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
// 2. 뷰포트를 기준으로 퍼즐 격자의 상대적인 위치(top, left)를 계산합니다.
const top = gridRect.top - viewportRect.top;
const left = gridRect.left - viewportRect.left;
// 3. 애니메이션 이미지들에 계산된 위치와 크기를 적용합니다.
[grayscaleImg, originalImg].forEach(img => {
img.style.top = `${top}px`;
img.style.left = `${left}px`;
img.style.width = `${gridRect.width}px`;
img.style.height = `${gridRect.height}px`;
// 이미지 소스 설정
img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage;
});
// --- 애니메이션 순차 실행 (기존과 동일) ---
setTimeout(() => {
grayscaleImg.style.opacity = '1'; // 그레이스케일 페이드 인
setTimeout(() => {
originalImg.style.opacity = '1'; // 컬러 이미지 페이드 인
setTimeout(() => {
// 최종 결과 모달 표시
showResultModal({
title: 'Success! 🎉',
message: '퍼즐을 완성했습니다!',
buttons: [
{ text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' },
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
]
});
}, 2000);
}, 2000);
}, 500);
}
// 힌트 버튼 클릭 이벤트 처리
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();
updateMode(); // Set initial mode
requestAnimationFrame(() => {
fitBoardToScreen();
window.addEventListener('resize', fitBoardToScreen);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,356 +1,363 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM 요소
const setupContainer = document.getElementById('setup-container');
const gameContainer = document.getElementById('game-container');
const startBtn = document.getElementById('start-btn');
const boardElement = document.getElementById('sudoku-board');
const timerElement = document.getElementById('timer');
const scoreElement = document.getElementById('score');
const hintBtn = document.getElementById('hint-btn');
const undoBtn = document.getElementById('undo-btn');
const completeBtn = document.getElementById('complete-btn');
const numberInputButtons = document.getElementById('number-input-buttons');
const modalOverlay = document.getElementById('modal-overlay');
const gameOverModal = document.getElementById('game-over-modal');
const retryBtn = document.getElementById('retry-btn');
const submitRankBtn = document.getElementById('submit-rank-btn');
const rankingList = document.getElementById('ranking-list');
const closeModalBtn = document.getElementById('close-modal-btn');
// 게임 상태 변수
let currentPuzzleId = null;
let solvedPuzzle = null;
let timerInterval = null;
let secondsElapsed = 0;
let selectedNumber = null;
let focusedCell = null;
let score = 5;
let history = [];
// --- 게임 초기화 및 시작 ---
startBtn.addEventListener('click', async () => {
const difficulty = document.getElementById('difficulty-select').value;
try {
const response = await fetch(`/sudoku/start?difficulty=${difficulty}`);
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
const gameData = await response.json();
currentPuzzleId = gameData.puzzleId;
solvedPuzzle = gameData.solution;
history = [];
score = 5;
updateScoreDisplay();
renderBoard(gameData.question);
startTimer();
updateButtonStates();
setupContainer.classList.add('hidden');
gameContainer.classList.remove('hidden');
numberInputButtons.classList.remove('hidden');
gameOverModal.classList.add('hidden');
} catch (error) {
alert('게임 로딩에 실패했습니다: ' + error.message);
console.error(error);
}
});
function renderBoard(puzzleString) {
boardElement.innerHTML = '';
for (let i = 0; i < 81; i++) {
const cell = document.createElement('div');
cell.classList.add('cell');
cell.dataset.index = i;
if (puzzleString[i] !== '0') {
cell.textContent = puzzleString[i];
} else {
cell.classList.add('editable');
}
boardElement.appendChild(cell);
}
}
function startTimer() {
secondsElapsed = 0;
timerElement.textContent = '00:00';
clearInterval(timerInterval);
timerInterval = setInterval(() => {
secondsElapsed++;
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
timerElement.textContent = `${minutes}:${seconds}`;
}, 1000);
}
function updateScoreDisplay() {
scoreElement.textContent = `SCORE: ${score}`;
if (score <= 0) {
clearInterval(timerInterval);
gameOverModal.classList.remove('hidden');
}
}
function updateButtonStates() {
const counts = {};
for (let i = 1; i <= 9; i++) counts[i] = 0;
boardElement.querySelectorAll('.cell').forEach(cell => {
const num = cell.textContent;
if (num && counts[num] !== undefined) counts[num]++;
});
for (let i = 1; i <= 9; i++) {
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
if (btn) {
if (counts[i] >= 9) {
btn.classList.add('completed');
if (selectedNumber == i) {
selectedNumber = null;
btn.classList.remove('selected');
}
} else {
btn.classList.remove('completed');
}
}
}
}
// --- 게임 플레이 이벤트 핸들링 ---
numberInputButtons.addEventListener('click', (event) => {
const target = event.target.closest('button');
if (!target) return;
if (target === undoBtn) {
undoAction();
return;
}
if (target.classList.contains('completed')) return;
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
if (target.classList.contains('num-btn')) {
const num = target.dataset.number;
selectedNumber = (selectedNumber === num) ? null : num;
if (selectedNumber) target.classList.add('selected');
}
highlightCells();
});
boardElement.addEventListener('click', (event) => {
const targetCell = event.target.closest('.cell.editable');
if (!targetCell) {
if (focusedCell) focusedCell = null;
highlightCells();
return;
}
focusedCell = targetCell;
if (selectedNumber) {
const previousValue = targetCell.textContent;
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
targetCell.textContent = newValue;
recordAction(targetCell, previousValue, newValue);
validateCell(targetCell);
updateButtonStates();
checkIfBoardIsFull();
}
highlightCells();
});
hintBtn.addEventListener('click', () => {
if (score <= 0) return;
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
if (emptyCells.length === 0) {
alert('모든 칸이 채워져 있습니다.');
return;
}
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
const cellIndex = parseInt(randomCell.dataset.index);
const correctAnswer = solvedPuzzle[cellIndex];
const previousValue = randomCell.textContent;
score--;
updateScoreDisplay();
recordAction(randomCell, previousValue, correctAnswer, true);
randomCell.textContent = correctAnswer;
randomCell.classList.remove('editable', 'incorrect');
updateButtonStates();
highlightCells();
checkIfBoardIsFull();
});
function undoAction() {
if (history.length === 0) return;
const lastAction = history.pop();
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
if (cell) {
cell.textContent = lastAction.previousValue;
if (lastAction.wasHint) {
cell.classList.add('editable');
}
validateCell(cell, false);
updateButtonStates();
highlightCells();
}
}
function recordAction(cell, previousValue, newValue, wasHint = false) {
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
}
function validateCell(cell, deductPoint = true) {
if (!cell.textContent) {
cell.classList.remove('incorrect');
return;
}
const cellIndex = parseInt(cell.dataset.index);
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
if (!isCorrect) {
cell.classList.add('incorrect');
if (deductPoint && score > 0) {
score--;
updateScoreDisplay();
}
} else {
cell.classList.remove('incorrect');
}
}
// --- 하이라이트 기능 ---
function highlightCells() {
document.querySelectorAll('.cell').forEach(cell => {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
});
if (focusedCell) {
focusedCell.classList.add('highlight-focused');
const focusedValue = focusedCell.textContent;
if (focusedValue) {
document.querySelectorAll('.cell').forEach(cell => {
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
});
}
}
if (selectedNumber) {
document.querySelectorAll('.cell').forEach(cell => {
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
});
}
}
// --- 게임 완료 및 모달 ---
async function checkSolution() {
let answerString = "";
boardElement.childNodes.forEach(cell => {
answerString += cell.textContent || '0';
});
if (answerString.includes('0')) {
alert('모든 칸을 채워주세요!');
return;
}
try {
const response = await fetch('/sudoku/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString })
});
const result = await response.json();
if (result.correct) {
clearInterval(timerInterval);
alert('🎉 정답입니다!');
showRankingModal();
} else {
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
}
} catch (error) {
console.error('정답 확인 중 오류 발생:', error);
alert('정답 확인 중 오류가 발생했습니다.');
}
}
function checkIfBoardIsFull() {
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
if (!emptyEditableCells) {
checkSolution();
}
}
completeBtn.addEventListener('click', checkSolution);
async function showRankingModal() {
modalOverlay.classList.remove('hidden');
document.getElementById('username-input').value = '';
submitRankBtn.disabled = false;
try {
const response = await fetch(`/sudoku/ranking/${currentPuzzleId}`);
const rankings = await response.json();
rankingList.innerHTML = '';
if (rankings.length === 0) {
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
} else {
rankings.forEach((rank, index) => {
const li = document.createElement('li');
const minutes = Math.floor(rank.completionTime / 60).toString().padStart(2, '0');
const seconds = (rank.completionTime % 60).toString().padStart(2, '0');
li.innerHTML = `<span>${index + 1}위: ${rank.userName}</span> <span>${minutes}:${seconds}</span>`;
rankingList.appendChild(li);
});
}
} catch (error) {
console.error('랭킹 조회 중 오류 발생:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
submitRankBtn.addEventListener('click', async () => {
const userName = document.getElementById('username-input').value.trim();
if (!userName) return alert('이름을 입력해주세요.');
try {
await fetch('/sudoku/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
puzzleId: currentPuzzleId,
userName: userName,
completionTime: secondsElapsed
})
});
alert('랭킹이 성공적으로 등록되었습니다!');
showRankingModal();
submitRankBtn.disabled = true;
} catch (error) {
console.error('랭킹 등록 중 오류 발생:', error);
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
}
});
function resetGameView() {
setupContainer.classList.remove('hidden');
gameContainer.classList.add('hidden');
numberInputButtons.classList.add('hidden');
clearInterval(timerInterval);
selectedNumber = null;
focusedCell = null;
document.querySelectorAll('.cell').forEach(cell => {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
});
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
}
closeModalBtn.addEventListener('click', () => {
modalOverlay.classList.add('hidden');
resetGameView();
});
retryBtn.addEventListener('click', () => {
gameOverModal.classList.add('hidden');
resetGameView();
});
});
// document.addEventListener('DOMContentLoaded', () => {
// // DOM 요소
// const setupContainer = document.getElementById('setup-container');
// const gameContainer = document.getElementById('game-container');
// const startBtn = document.getElementById('start-btn');
// const boardElement = document.getElementById('sudoku-board');
// const timerElement = document.getElementById('timer');
// const scoreElement = document.getElementById('score');
// const hintBtn = document.getElementById('hint-btn');
// const undoBtn = document.getElementById('undo-btn');
// const completeBtn = document.getElementById('complete-btn');
// const numberInputButtons = document.getElementById('number-input-buttons');
// const modalOverlay = document.getElementById('modal-overlay');
// const gameOverModal = document.getElementById('game-over-modal');
// const retryBtn = document.getElementById('retry-btn');
// const submitRankBtn = document.getElementById('submit-rank-btn');
// const rankingList = document.getElementById('ranking-list');
// const closeModalBtn = document.getElementById('close-modal-btn');
//
// // 게임 상태 변수
// let currentPuzzleId = null;
// let solvedPuzzle = null;
// let timerInterval = null;
// let secondsElapsed = 0;
// let selectedNumber = null;
// let focusedCell = null;
// let score = 5;
// let history = [];
//
// // (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
// startBtn.addEventListener('click', async () => {
// const difficulty = document.getElementById('difficulty-select').value;
// try {
// // (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
// const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
// if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
// const gameData = await response.json();
//
// currentContextId = gameData.puzzleId;
// solvedPuzzle = gameData.solution;
//
// history = [];
// score = 5;
// updateScoreDisplay();
//
// renderBoard(gameData.question);
// startTimer();
// updateButtonStates();
//
// setupContainer.classList.add('hidden');
// gameContainer.classList.remove('hidden');
// numberInputButtons.classList.remove('hidden');
// gameOverModal.classList.add('hidden');
// } catch (error) {
// alert('게임 로딩에 실패했습니다: ' + error.message);
// console.error(error);
// }
// });
//
// function renderBoard(puzzleString) {
// boardElement.innerHTML = '';
// for (let i = 0; i < 81; i++) {
// const cell = document.createElement('div');
// cell.classList.add('cell');
// cell.dataset.index = i;
//
// if (puzzleString[i] !== '0') {
// cell.textContent = puzzleString[i];
// } else {
// cell.classList.add('editable');
// }
// boardElement.appendChild(cell);
// }
// }
//
// function startTimer() {
// secondsElapsed = 0;
// timerElement.textContent = '00:00';
// clearInterval(timerInterval);
// timerInterval = setInterval(() => {
// secondsElapsed++;
// const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
// const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
// timerElement.textContent = `${minutes}:${seconds}`;
// }, 1000);
// }
//
// function updateScoreDisplay() {
// scoreElement.textContent = `SCORE: ${score}`;
// if (score <= 0) {
// clearInterval(timerInterval);
// gameOverModal.classList.remove('hidden');
// }
// }
//
// function updateButtonStates() {
// const counts = {};
// for (let i = 1; i <= 9; i++) counts[i] = 0;
// boardElement.querySelectorAll('.cell').forEach(cell => {
// const num = cell.textContent;
// if (num && counts[num] !== undefined) counts[num]++;
// });
// for (let i = 1; i <= 9; i++) {
// const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
// if (btn) {
// if (counts[i] >= 9) {
// btn.classList.add('completed');
// if (selectedNumber == i) {
// selectedNumber = null;
// btn.classList.remove('selected');
// }
// } else {
// btn.classList.remove('completed');
// }
// }
// }
// }
//
// // --- 게임 플레이 이벤트 핸들링 ---
// numberInputButtons.addEventListener('click', (event) => {
// const target = event.target.closest('button');
// if (!target) return;
//
// if (target === undoBtn) {
// undoAction();
// return;
// }
//
// if (target.classList.contains('completed')) return;
// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
//
// if (target.classList.contains('num-btn')) {
// const num = target.dataset.number;
// selectedNumber = (selectedNumber === num) ? null : num;
// if (selectedNumber) target.classList.add('selected');
// }
// highlightCells();
// });
//
// boardElement.addEventListener('click', (event) => {
// const targetCell = event.target.closest('.cell.editable');
// if (!targetCell) {
// if (focusedCell) focusedCell = null;
// highlightCells();
// return;
// }
// focusedCell = targetCell;
//
// if (selectedNumber) {
// const previousValue = targetCell.textContent;
// let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
// targetCell.textContent = newValue;
//
// recordAction(targetCell, previousValue, newValue);
// validateCell(targetCell);
// updateButtonStates();
// checkIfBoardIsFull();
// }
//
// highlightCells();
// });
//
// hintBtn.addEventListener('click', () => {
// if (score <= 0) return;
// const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
// if (emptyCells.length === 0) {
// alert('모든 칸이 채워져 있습니다.');
// return;
// }
//
// const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
// const cellIndex = parseInt(randomCell.dataset.index);
// const correctAnswer = solvedPuzzle[cellIndex];
// const previousValue = randomCell.textContent;
//
// score--;
// updateScoreDisplay();
// recordAction(randomCell, previousValue, correctAnswer, true);
//
// randomCell.textContent = correctAnswer;
// randomCell.classList.remove('editable', 'incorrect');
//
// updateButtonStates();
// highlightCells();
// checkIfBoardIsFull();
// });
//
// function undoAction() {
// if (history.length === 0) return;
// const lastAction = history.pop();
// const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
//
// if (cell) {
// cell.textContent = lastAction.previousValue;
// if (lastAction.wasHint) {
// cell.classList.add('editable');
// }
// validateCell(cell, false);
// updateButtonStates();
// highlightCells();
// }
// }
//
// function recordAction(cell, previousValue, newValue, wasHint = false) {
// history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
// }
//
// function validateCell(cell, deductPoint = true) {
// if (!cell.textContent) {
// cell.classList.remove('incorrect');
// return;
// }
// const cellIndex = parseInt(cell.dataset.index);
// const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
// if (!isCorrect) {
// cell.classList.add('incorrect');
// if (deductPoint && score > 0) {
// score--;
// updateScoreDisplay();
// }
// } else {
// cell.classList.remove('incorrect');
// }
// }
//
// // --- 하이라이트 기능 ---
// function highlightCells() {
// document.querySelectorAll('.cell').forEach(cell => {
// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
// });
// if (focusedCell) {
// focusedCell.classList.add('highlight-focused');
// const focusedValue = focusedCell.textContent;
// if (focusedValue) {
// document.querySelectorAll('.cell').forEach(cell => {
// if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
// });
// }
// }
// if (selectedNumber) {
// document.querySelectorAll('.cell').forEach(cell => {
// if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
// });
// }
// }
//
// // --- 게임 완료 및 모달 ---
// async function checkSolution() {
// let answerString = "";
// boardElement.childNodes.forEach(cell => {
// answerString += cell.textContent || '0';
// });
//
// if (answerString.includes('0')) {
// alert('모든 칸을 채워주세요!');
// return;
// }
//
// try {
// // (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
// // (★ 수정) contextId(puzzleId) 변수 사용
// const response = await fetch('/puzzle/sudoku/validate', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ puzzleId: currentContextId, answer: answerString })
// });
// const result = await response.json();
// if (result.correct) {
// clearInterval(timerInterval);
// alert('🎉 정답입니다!');
// showRankingModal(); // 랭킹 모달 표시
// } else {
// alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
// }
// } catch (error) {
// console.error('정답 확인 중 오류 발생:', error);
// alert('정답 확인 중 오류가 발생했습니다.');
// }
// }
//
// function checkIfBoardIsFull() {
// const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
// if (!emptyEditableCells) {
// checkSolution();
// }
// }
// completeBtn.addEventListener('click', checkSolution);
// /**
// * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
// */
// async function showRankingModal() {
// modalOverlay.classList.remove('hidden');
// document.getElementById('username-input').value = '';
// submitRankBtn.disabled = false;
// rankingList.innerHTML = '<li>로딩 중...</li>';
//
// try {
// // (★ 수정) user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID(ContextId) 전달)
// const rankings = await fetchRanks(currentGameType, currentContextId);
//
// rankingList.innerHTML = '';
// if (rankings.length === 0) {
// rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
// } else {
// rankings.forEach((rank, index) => {
// const li = document.createElement('li');
// // (★ 수정) 공통 모델 필드(primaryScore)를 시간(초)으로 변환
// const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
// const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
// li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
// rankingList.appendChild(li);
// });
// }
// } catch (error) {
// console.error('랭킹 조회 중 오류 발생:', error);
// rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
// }
// }
//
// /**
// * (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
// */
// submitRankBtn.addEventListener('click', async () => {
// const userName = document.getElementById('username-input').value.trim();
// if (!userName) return alert('이름을 입력해주세요.');
//
// try {
// // (★ 수정) user.js의 공통 submitRank 함수 호출
// // 스도쿠의 주 점수(primaryScore)는 완료 시간(secondsElapsed), 보조 점수는 없음.
// await submitRank(currentGameType, currentContextId, userName, secondsElapsed, null);
//
// alert('랭킹이 성공적으로 등록되었습니다!');
// showRankingModal(); // 랭킹 목록 새로고침
// submitRankBtn.disabled = true; // 중복 등록 방지
// } catch (error) {
// console.error('랭킹 등록 중 오류 발생:', error);
// alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
// }
// });
//
// function resetGameView() {
// setupContainer.classList.remove('hidden');
// gameContainer.classList.add('hidden');
// numberInputButtons.classList.add('hidden');
// clearInterval(timerInterval);
// selectedNumber = null;
// focusedCell = null;
// document.querySelectorAll('.cell').forEach(cell => {
// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
// });
// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
// }
//
// closeModalBtn.addEventListener('click', () => {
// modalOverlay.classList.add('hidden');
// resetGameView();
// });
//
// retryBtn.addEventListener('click', () => {
// gameOverModal.classList.add('hidden');
// resetGameView();
// });
// });₩

View File

@ -1,166 +0,0 @@
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
const { solutionGrid, rowClues, colClues } = puzzleData;
const numRows = solutionGrid.length;
const numCols = solutionGrid[0].length;
// Set up the CSS Grid layout
container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`;
container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`;
// 1. Create top-left empty corner
const corner = document.createElement('div');
corner.className = 'grid-cell';
container.appendChild(corner);
// 2. Create column clues (top row)
for (const clues of colClues) {
const clueCell = document.createElement('div');
clueCell.className = 'clue-cell';
clueCell.innerHTML = clues.join('<br>'); // Display clues vertically
container.appendChild(clueCell);
}
// 3. Create row clues and the solution grid
for (let i = 0; i < numRows; i++) {
// Create row clue cell for this row
const rowClueCell = document.createElement('div');
rowClueCell.className = 'clue-cell';
rowClueCell.textContent = rowClues[i].join(' ');
container.appendChild(rowClueCell);
// Create the solution cells for this row
for (let j = 0; j < numCols; j++) {
const cell = document.createElement('div');
cell.className = 'solution-cell';
if (solutionGrid[i][j] === 1) {
cell.classList.add('filled'); // Black square
} else {
cell.classList.add('empty'); // White square
}
container.appendChild(cell);
}
}
}
// Modify your existing click listener
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('createBtn').addEventListener('click', async () => {
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 = '이미지 파일을 선택해주세요.';
return;
}
const imageFile = uploader.files[0];
const formData = new FormData();
formData.append('imageFile', imageFile);
statusDiv.textContent = '문제를 생성하는 중...';
puzzleContainer.innerHTML = ''; // Clear old puzzle while loading
try {
const response = await fetch(getMainPath()+'/puzzle/upload.bjx', { // Make sure this URL is correct
method: 'POST',
body: formData,
});
if (response.ok) {
const puzzleData = await response.json();
statusDiv.textContent = '문제 생성 성공!';
// 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}`;
}
} catch (error) {
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

@ -1,77 +0,0 @@
function onclickJoin(type, keyword) {
let user_id = document.getElementById('user_id')
let user_pw = document.getElementById('user_pw')
let user_pw_check = document.getElementById('user_pw_check')
let user_name = document.getElementById('user_name')
let user_email = document.getElementById('user_email')
var fields = [user_id,user_pw, user_pw_check, user_name, user_email]
var hasValues = true
const spPattern = /[~!@#$%<>^&*]/; //특수문자
const korean = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/; //한글
const eng = /[a-zA-Z]/; //영어
const numbers = /[0-9]/; //숫자
const email = /[A-za-z0-9\-][A-Za-z0-9_.\-]+@[A-za-z0-9\-][A-Za-z0-9\-]+\.[A-za-z0-9\-][A-za-z0-9\-]+/;
fields.forEach(function (field , idx , all) {
if ((field.value.length > 7 ||
(field===user_name && user_name.value.length > 2) ||
(field===user_id && user_id.value.length > 6)) &&
hasValues) {
const text = field.value
switch (field) {
case user_id :
if (korean.test(text)) {
hasValues = false
alert("id를 확인 해보슈.");
}
break;
case user_pw :
if (
korean.test(text) ||
false === numbers.test(text) ||
false === eng.test(text) ||
false === spPattern.test(text)
) {
hasValues = false
alert("pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈.");
}
break
case user_email : if(false === email.test(field.value)) {
hasValues = false
alert("email를 확인 해보슈.");
}
break
}
} else if (hasValues) {
hasValues = false
switch (field) {
case user_id : alert("id를 확인 해보슈.");break
case user_pw : alert("pw를 확인 해보슈.");break
case user_pw_check : alert("pw를 확인 해보슈.");break
case user_name : alert("name를 확인 해보슈.");break
case user_email : alert("email를 확인 해보슈.");break
}
}
})
if (hasValues) {
let data = {
'user_id': user_id.value,
'user_pw': user_pw.value,
'user_email': user_email.value,
'user_name': user_name.value
}
if (user_pw.value === user_pw_check.value) {
if(confirm(JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) {
post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) {
alert(resultData)
})
} else {
}
} else {
alert("비번이 다름요")
}
}
}

View File

@ -13,7 +13,7 @@
<section class="wrapper style1">
<div class="container">
<div class="row justify-content-center">
<div class="col-8 col-12-narrower">
<div class="col-12">
<div id="content_inner">
<article>
<section th:each="post : ${postsPage.content}">

View File

@ -76,7 +76,7 @@
<button class="button" onclick="handleVote(this, 'like')">
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
</button>
<button class="button" onclick="handleVote(this, 'unlike')" style="margin-left: 1em;">
<button class="button" onclick="handleVote(this, 'unlike')">
👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
</button>
</div>

View File

@ -5,17 +5,14 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="content" id="content">
<section id="banner">
<section id="banner"
th:styleappend="${randomBannerImage != null} ? 'background-image: url(\'' + @{${randomBannerImage}} + '\');' : ''">
<header>
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
</header>
</section>
<!-- Highlights -->
<!-- Gigantic Heading -->
<section class="wrapper style2">
<div class="container">
<header class="major">
@ -31,24 +28,38 @@
</header>
</div>
</section>
<!-- Posts -->
<section class="wrapper style2">
<section class="wrapper style1">
<div class="container">
<div class="row" th:each="row : ${Posts}">
<section class="col-6 col-12-narrower" th:each="post : ${row}">
<!-- <span th:text="${cell}"></span>-->
<div class="box post" onclick="goToViewer(this)" th:id="${post.id}">
<a href="#" class="image left"><img th:src="${#strings.length(post.thumb) > 0} ? ${post.thumb} : 'images/pic01.jpg'" alt="" /></a>
<div class="inner">
<h3 th:text="${#strings.length(post.title) > 0} ? ${post.title} : ('untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')} + ']')"></h3>
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div>
<div class="row">
<div class="col-12">
<div id="content_inner">
<article>
<section th:each="post : ${Posts}">
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
<span class="image left">
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
</span>
<div class="inner">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;">
(읽음: <span th:text="${post.readCount}">0</span>)
</span>
</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div>
</div>
</section>
</article>
</div>
</section>
</div>
</div>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<div class="row gtr-200">
@ -82,7 +93,6 @@
</div>
</div>
</section>
<!-- CTA -->
<section id="cta2" class="wrapper style3">
<div class="container">
<header>
@ -91,4 +101,4 @@
</div>
</section>
</th:block>
</html>
</html>

View File

@ -5,16 +5,186 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/2048.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/2048.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<head>
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<style>
/* =================================
기본 및 전체 레이아웃 (수정됨)
================================= */
body {
/* (★ 삭제) font-family, text-align, background-color, color, margin, padding
-> 이 속성들은 모두 common_game_theme.css에서 관리합니다.
*/
box-sizing: border-box;
}
h1 {
font-size: 15vw; /* 2048 고유의 큰 폰트 크기는 유지 */
margin: 20px 0;
/* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 */
}
.score-container {
font-size: 24px;
margin-bottom: 20px;
}
/* =================================
게임 보드 (테마 적용)
================================= */
#game-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 2vw;
width: 95vw;
max-width: 500px; /* (★ 수정) 400px -> 500px (다른 게임과 통일) */
margin: 0 auto;
/* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 */
background-color: #b0bec5; /* #bbada0 (갈색) -> #b0bec5 (블루 그레이) */
padding: 2vw;
border-radius: 6px;
box-sizing: border-box;
aspect-ratio: 1 / 1;
touch-action: none;
/* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 */
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
}
@media (min-width: 481px) {
#game-board {
grid-gap: 10px;
padding: 10px;
}
}
/* =================================
타일 공통 스타일 (테마 적용)
================================= */
.tile {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
border-radius: 3px;
/* (★ 수정) 빈 타일 색상 변경 */
background-color: #eceff1; /* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) */
font-size: 5vw;
}
@media (min-width: 481px) {
.tile {
font-size: 30px;
}
}
/* =================================
타일 색상 (테마 적용)
================================= */
/* (★ 수정) 2, 4 타일은 베이지색 계열이라 테마와 충돌하므로 파란색 계열로 변경 */
.tile-2 { background-color: #e3f2fd; color: #333; } /* #eee4da (베이지) -> #e3f2fd (밝은 파랑) */
.tile-4 { background-color: #bbdefb; color: #333; } /* #ede0c8 (노란 베이지) -> #bbdefb (파랑) */
/* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) */
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
.tile-16 { background-color: #f59563; color: #f9f6f2; }
.tile-32 { background-color: #f67c5f; color: #f9f6f2; }
.tile-64 { background-color: #f65e3b; color: #f9f6f2; }
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
.tile-256 { background-color: #edcc61; color: #f9f6f2; }
.tile-512 { background-color: #edc850; color: #f9f6f2; }
.tile-1024 { background-color: #edc53f; color: #f9f6f2; }
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }
.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }
.tile-8192 { background-color: #ff3333; color: #f9f6f2; }
.tile-16384 { background-color: #0077cc; color: #f9f6f2; }
.tile-32768 { background-color: #9900cc; color: #f9f6f2; }
/* =================================
게임 오버 팝업 (테마 적용)
================================= */
.popup-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.popup {
/* (★ 수정) 배경색을 테마에 맞게 흰색으로 변경 */
background-color: #ffffff; /* #faf8ef (베이지) -> #ffffff (흰색) */
padding: 20px;
border-radius: 10px;
text-align: center;
width: 80vw;
max-width: 300px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2); /* 흰색 배경이므로 그림자 추가 */
}
.popup input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
}
.popup button {
padding: 10px 20px;
/* (★ 삭제) background-color, color, border -> common_game_theme의 파란색 버튼 스타일을 상속받음 */
border-radius: 5px;
cursor: pointer;
}
/* =================================
랭킹 리스트 (테마 적용)
================================= */
.ranking-container {
/*
(★ 참고) 이 컨테이너는 common_game_theme.css에서
.game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.
여기서는 내부 정렬만 담당합니다.
*/
width: 100%;
max-width: 500px; /* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) */
margin: 30px auto;
text-align: left;
}
.ranking-container h3 {
text-align: center;
}
#ranking-list {
list-style-type: none;
padding: 0;
}
#ranking-list li {
/* (★ 수정) 배경색 변경 */
background-color: #f0f4f8; /* #eee4da (베이지) -> #f0f4f8 (밝은 회색) */
margin-bottom: 5px;
padding: 10px;
border-radius: 5px;
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<th:block layout:fragment="content">
<h1>2048</h1>
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
@ -24,18 +194,261 @@
</div>
<div id="game-board"></div>
<div id="game-over-popup" class="popup-container" style="display:none;">
<div class="popup">
<h2>게임 오버!</h2>
<p>최종 점수: <span id="final-score">0</span></p>
<input type="text" id="player-name" placeholder="이름을 입력하세요">
<button id="save-score">점수 저장</button>
<div id="game-over-popup" class="popup-container" style="display:none;">
<div class="popup">
<h2>게임 오버!</h2>
<p>최종 점수: <span id="final-score">0</span></p>
<input type="text" id="player-name" placeholder="이름을 입력하세요">
<button id="save-score">점수 저장</button>
</div>
</div>
<div class="ranking-container">
<h3>🏆 랭킹</h3>
<ol id="ranking-list"></ol>
</div>
</div>
<div class="ranking-container">
<h3>🏆 랭킹</h3>
<ol id="ranking-list"></ol>
</div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', () => {
// ... (DOM 요소 가져오기 - 동일)
const gameBoard = document.getElementById('game-board');
const scoreDisplay = document.getElementById('score');
const gameOverPopup = document.getElementById('game-over-popup');
const finalScoreDisplay = document.getElementById('final-score');
const playerNameInput = document.getElementById('player-name');
const saveScoreButton = document.getElementById('save-score');
const rankingList = document.getElementById('ranking-list');
// (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
let gridSize = 4;
let board = [];
let score = 0;
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
// ----- 게임 핵심 로직 -----
function initializeBoard() {
gameBoard.innerHTML = ''; // 기존 타일 초기화
for (let i = 0; i < gridSize * gridSize; i++) {
const tile = document.createElement('div');
tile.className = 'tile';
gameBoard.appendChild(tile);
}
board = Array(gridSize * gridSize).fill(0);
addNumber();
addNumber();
updateBoard();
}
function updateBoard() {
const tiles = gameBoard.children;
for (let i = 0; i < board.length; i++) {
const value = board[i];
const tile = tiles[i];
tile.textContent = value === 0 ? '' : value;
tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
}
scoreDisplay.textContent = score;
}
function addNumber() {
const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
if (available.length > 0) {
const spot = available[Math.floor(Math.random() * available.length)];
board[spot] = Math.random() < 0.9 ? 2 : 4;
}
}
// ----- 타일 이동 및 병합 로직 -----
function moveRow(row) {
let arr = row.filter(val => val);
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] === arr[i + 1]) {
arr[i] *= 2;
score += arr[i];
arr[i + 1] = 0;
}
}
arr = arr.filter(val => val);
const missing = gridSize - arr.length;
const zeros = Array(missing).fill(0);
return arr.concat(zeros);
}
function moveLeft() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const rowStart = i * gridSize;
const row = board.slice(rowStart, rowStart + gridSize);
const newRow = moveRow(row);
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
board.splice(rowStart, gridSize, ...newRow);
}
return changed;
}
function moveRight() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const rowStart = i * gridSize;
const row = board.slice(rowStart, rowStart + gridSize).reverse();
const newRow = moveRow(row).reverse();
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
board.splice(rowStart, gridSize, ...newRow);
}
return changed;
}
function moveUp() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
const newCol = moveRow(col);
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
for (let j = 0; j < gridSize; j++) {
board[i + j * gridSize] = newCol[j];
}
}
return changed;
}
function moveDown() {
let changed = false;
for (let i = 0; i < gridSize; i++) {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
const newCol = moveRow(col).reverse();
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
for (let j = 0; j < gridSize; j++) {
board[i + j * gridSize] = newCol[j];
}
}
return changed;
}
// ----- 게임 상태 관리 -----
function isGameOver() {
if (!board.includes(0)) {
for (let i = 0; i < gridSize; i++) {
for (let j = 0; j < gridSize; j++) {
const current = board[i * gridSize + j];
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
return false;
}
}
}
return true;
}
return false;
}
function handleMove(moveFunction) {
if (moveFunction()) {
addNumber();
updateBoard();
if (isGameOver()) {
finalScoreDisplay.textContent = score;
gameOverPopup.style.display = 'flex';
}
}
}
// ----- 이벤트 리스너 -----
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp': handleMove(moveUp); break;
case 'ArrowDown': handleMove(moveDown); break;
case 'ArrowLeft': handleMove(moveLeft); break;
case 'ArrowRight': handleMove(moveRight); break;
}
});
gameBoard.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
});
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
gameBoard.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
handleSwipe();
});
function handleSwipe() {
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const swipeThreshold = 30;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (Math.abs(deltaX) > swipeThreshold) {
handleMove(deltaX > 0 ? moveRight : moveLeft);
}
} else {
if (Math.abs(deltaY) > swipeThreshold) {
handleMove(deltaY > 0 ? moveDown : moveUp);
}
}
}
// ----- 랭킹 API 연동 -----
saveScoreButton.addEventListener('click', async () => {
const playerName = playerNameInput.value.trim();
if (playerName === "") return alert("이름을 입력해주세요.");
try {
// (★ 수정) user.js의 공통 submitRank 함수 호출
// 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
await submitRank(currentGameType, currentContextId, playerName, score, null);
gameOverPopup.style.display = 'none';
playerNameInput.value = '';
score = 0;
updateRankingList(); // 랭킹 리스트 새로고침
initializeBoard(); // 새 게임 시작
} catch (error) {
console.error('Error submitting rank:', error);
alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
}
});
/**
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
*/
async function updateRankingList() {
rankingList.innerHTML = '<li>로딩 중...</li>';
try {
// (★ 수정) user.js의 공통 fetchRanks 함수 호출
const rankings = await fetchRanks(currentGameType, currentContextId);
rankingList.innerHTML = ''; // 리스트 비우기
if (!rankings || rankings.length === 0) {
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
return;
}
rankings.forEach((rank, index) => {
const li = document.createElement('li');
// (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
rankingList.appendChild(li);
});
} catch (error) {
console.error('Error fetching ranks:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
// ----- 게임 시작 -----
updateRankingList();
initializeBoard();
});
</script>
</th:block>
</html>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +0,0 @@
<!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="mode-selector">
<label>
<input type="radio" name="play-mode" value="fill" checked><span> Fill</span>
</label>
<label>
<input type="radio" name="play-mode" value="mark"><span> Mark (X)</span>
</label>
</div>
<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>

File diff suppressed because it is too large Load Diff

View File

@ -5,16 +5,253 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/sudoku.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<head layout:fragment="head" id="head">
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<style>
/* sudoku.css의 내용을 여기에 삽입 */
body {
/*
(★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f7f9;
display: flex;
justify-content: center;
min-height: 100vh;
*/
}
#sudoku-game-app {
width: 100%;
margin: 20px 0;
}
.container {
/*
(★ 삭제) 아래 속성들은 common_game_theme.css에서
'#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.
background: white;
padding: clamp(15px, 4vw, 30px);
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
*/
/* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 */
text-align: center;
}
h1 {
font-size: 1.8em;
color: #333;
margin-top: 0;
margin-bottom: 20px;
/* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.
만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.
현재는 공통 스타일이 적용됩니다. */
}
/* ======================================= */
/* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) */
/* ======================================= */
/* 게임 컨테이너 */
#game-container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 500px;
margin: 0 auto;
}
/* 게임 정보 (점수, 타이머) */
.game-info {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 0 10px;
box-sizing: border-box;
font-size: 1.5em;
font-weight: bold;
}
#score { color: #007bff; }
#timer { color: #333; }
/* 스도쿠 보드 */
#sudoku-board {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
width: 100%;
border: 3px solid #333;
aspect-ratio: 1 / 1;
}
.cell {
display: flex;
justify-content: center;
align-items: center;
font-size: clamp(1em, 4vw, 1.8em);
font-weight: bold;
color: #333;
border: 1px solid #ddd;
box-sizing: border-box;
cursor: pointer;
}
.cell:nth-child(3n) { border-right: 2px solid #333; }
.cell:nth-child(9n) { border-right-width: 1px; }
.cell:nth-child(n+19):nth-child(-n+27),
.cell:nth-child(n+46):nth-child(-n+54) {
border-bottom: 2px solid #333;
}
.cell:not(.editable) {
background-color: #f0f0f0;
color: #222;
cursor: default;
}
/* 하이라이트 & 오답 스타일 */
.cell.incorrect {
background-color: #ffdddd !important;
color: #d8000c !important;
}
.highlight-focused {
background-color: #dbeeff !important;
}
.highlight-same-number {
background-color: #e6e6e6 !important;
}
.highlight-selected-number {
background-color: #b3d7ff !important;
}
/* 숫자 입력 버튼 */
#number-input-buttons {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 15px;
gap: 1%;
}
#number-input-buttons .num-btn,
#number-input-buttons #undo-btn {
line-height: unset;
min-width: unset;
width: 9%;
aspect-ratio: 1/1;
font-size: clamp(1em, 4vw, 1.8em);
font-weight: bold;
border-radius: 8px;
background-color: #f0f0f0;
color: #333;
border: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.1s, opacity 0.2s;
}
#number-input-buttons .num-btn.selected {
background-color: #007bff;
color: white;
border-color: #007bff;
}
#number-input-buttons .num-btn.completed {
opacity: 0.4;
background-color: #e9ecef;
pointer-events: none;
}
#number-input-buttons #undo-btn {
background-color: #f8f9fa;
color: #dc3545;
}
/* 액션 버튼 (힌트, 정답확인) */
.action-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 15px;
width: 100%;
}
.action-buttons button {
flex-grow: 1;
max-width: 200px;
}
/* 모달 및 숨김 처리 */
.hidden {
display: none !important;
}
#modal-overlay, #game-over-modal {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
#modal-content {
background: white;
padding: 30px;
border-radius: 10px;
text-align: center;
width: 90%;
max-width: 400px;
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
#modal-content h2, #modal-content h3 {
color: #333;
margin-bottom: 15px;
}
#username-input {
width: calc(100% - 24px);
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 1em;
}
#ranking-list {
list-style-type: decimal;
list-style-position: inside;
padding: 0;
text-align: left;
margin-top: 20px;
}
#ranking-list li {
padding: 8px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
#ranking-list li:last-child {
border-bottom: none;
}
</style>
</head>
<body>
<th:block layout:fragment="content">
<div id="sudoku-game-app">
<div class="container">
@ -76,5 +313,373 @@
</div>
</div>
<script>
// sudoku.js의 내용을 여기에 삽입
document.addEventListener('DOMContentLoaded', () => {
// DOM 요소
const setupContainer = document.getElementById('setup-container');
const gameContainer = document.getElementById('game-container');
const startBtn = document.getElementById('start-btn');
const boardElement = document.getElementById('sudoku-board');
const timerElement = document.getElementById('timer');
const scoreElement = document.getElementById('score');
const hintBtn = document.getElementById('hint-btn');
const undoBtn = document.getElementById('undo-btn');
const completeBtn = document.getElementById('complete-btn');
const numberInputButtons = document.getElementById('number-input-buttons');
const modalOverlay = document.getElementById('modal-overlay');
const gameOverModal = document.getElementById('game-over-modal');
const retryBtn = document.getElementById('retry-btn');
const submitRankBtn = document.getElementById('submit-rank-btn');
const rankingList = document.getElementById('ranking-list');
const closeModalBtn = document.getElementById('close-modal-btn');
// 게임 상태 변수
let currentPuzzleId = null;
let solvedPuzzle = null;
let timerInterval = null;
let secondsElapsed = 0;
let selectedNumber = null;
let focusedCell = null;
let score = 5;
let history = [];
// (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
startBtn.addEventListener('click', async () => {
const difficulty = document.getElementById('difficulty-select').value;
try {
// (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
const gameData = await response.json();
currentPuzzleId = gameData.puzzleId;
solvedPuzzle = gameData.solution;
history = [];
score = 5;
updateScoreDisplay();
renderBoard(gameData.question);
startTimer();
updateButtonStates();
setupContainer.classList.add('hidden');
gameContainer.classList.remove('hidden');
numberInputButtons.classList.remove('hidden');
gameOverModal.classList.add('hidden');
} catch (error) {
alert('게임 로딩에 실패했습니다: ' + error.message);
console.error(error);
}
});
function renderBoard(puzzleString) {
boardElement.innerHTML = '';
for (let i = 0; i < 81; i++) {
const cell = document.createElement('div');
cell.classList.add('cell');
cell.dataset.index = i;
if (puzzleString[i] !== '0') {
cell.textContent = puzzleString[i];
} else {
cell.classList.add('editable');
}
boardElement.appendChild(cell);
}
}
function startTimer() {
secondsElapsed = 0;
timerElement.textContent = '00:00';
clearInterval(timerInterval);
timerInterval = setInterval(() => {
secondsElapsed++;
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
timerElement.textContent = `${minutes}:${seconds}`;
}, 1000);
}
function updateScoreDisplay() {
scoreElement.textContent = `SCORE: ${score}`;
if (score <= 0) {
clearInterval(timerInterval);
gameOverModal.classList.remove('hidden');
}
}
function updateButtonStates() {
const counts = {};
for (let i = 1; i <= 9; i++) counts[i] = 0;
boardElement.querySelectorAll('.cell').forEach(cell => {
const num = cell.textContent;
if (num && counts[num] !== undefined) counts[num]++;
});
for (let i = 1; i <= 9; i++) {
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
if (btn) {
if (counts[i] >= 9) {
btn.classList.add('completed');
if (selectedNumber == i) {
selectedNumber = null;
btn.classList.remove('selected');
}
} else {
btn.classList.remove('completed');
}
}
}
}
// --- 게임 플레이 이벤트 핸들링 ---
numberInputButtons.addEventListener('click', (event) => {
const target = event.target.closest('button');
if (!target) return;
if (target === undoBtn) {
undoAction();
return;
}
if (target.classList.contains('completed')) return;
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
if (target.classList.contains('num-btn')) {
const num = target.dataset.number;
selectedNumber = (selectedNumber === num) ? null : num;
if (selectedNumber) target.classList.add('selected');
}
highlightCells();
});
boardElement.addEventListener('click', (event) => {
const targetCell = event.target.closest('.cell.editable');
if (!targetCell) {
if (focusedCell) focusedCell = null;
highlightCells();
return;
}
focusedCell = targetCell;
if (selectedNumber) {
const previousValue = targetCell.textContent;
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
targetCell.textContent = newValue;
recordAction(targetCell, previousValue, newValue);
validateCell(targetCell);
updateButtonStates();
checkIfBoardIsFull();
}
highlightCells();
});
hintBtn.addEventListener('click', () => {
if (score <= 0) return;
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
if (emptyCells.length === 0) {
alert('모든 칸이 채워져 있습니다.');
return;
}
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
const cellIndex = parseInt(randomCell.dataset.index);
const correctAnswer = solvedPuzzle[cellIndex];
const previousValue = randomCell.textContent;
score--;
updateScoreDisplay();
recordAction(randomCell, previousValue, correctAnswer, true);
randomCell.textContent = correctAnswer;
randomCell.classList.remove('editable', 'incorrect');
updateButtonStates();
highlightCells();
checkIfBoardIsFull();
});
function undoAction() {
if (history.length === 0) return;
const lastAction = history.pop();
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
if (cell) {
cell.textContent = lastAction.previousValue;
if (lastAction.wasHint) {
cell.classList.add('editable');
}
validateCell(cell, false);
updateButtonStates();
highlightCells();
}
}
function recordAction(cell, previousValue, newValue, wasHint = false) {
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
}
function validateCell(cell, deductPoint = true) {
if (!cell.textContent) {
cell.classList.remove('incorrect');
return;
}
const cellIndex = parseInt(cell.dataset.index);
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
if (!isCorrect) {
cell.classList.add('incorrect');
if (deductPoint && score > 0) {
score--;
updateScoreDisplay();
}
} else {
cell.classList.remove('incorrect');
}
}
// --- 하이라이트 기능 ---
function highlightCells() {
document.querySelectorAll('.cell').forEach(cell => {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
});
if (focusedCell) {
focusedCell.classList.add('highlight-focused');
const focusedValue = focusedCell.textContent;
if (focusedValue) {
document.querySelectorAll('.cell').forEach(cell => {
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
});
}
}
if (selectedNumber) {
document.querySelectorAll('.cell').forEach(cell => {
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
});
}
}
// --- 게임 완료 및 모달 ---
async function checkSolution() {
let answerString = "";
boardElement.childNodes.forEach(cell => {
answerString += cell.textContent || '0';
});
if (answerString.includes('0')) {
alert('모든 칸을 채워주세요!');
return;
}
try {
// (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
// (★ 수정) currentPuzzleId 변수 사용
const response = await fetch('/puzzle/sudoku/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString })
});
const result = await response.json();
if (result.correct) {
clearInterval(timerInterval);
alert('🎉 정답입니다!');
showRankingModal(); // 랭킹 모달 표시
} else {
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
}
} catch (error) {
console.error('정답 확인 중 오류 발생:', error);
alert('정답 확인 중 오류가 발생했습니다.');
}
}
function checkIfBoardIsFull() {
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
if (!emptyEditableCells) {
checkSolution();
}
}
completeBtn.addEventListener('click', checkSolution);
/**
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
*/
async function showRankingModal() {
modalOverlay.classList.remove('hidden');
document.getElementById('username-input').value = '';
submitRankBtn.disabled = false;
rankingList.innerHTML = '<li>로딩 중...</li>';
try {
// user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID 전달)
// currentGameType 변수가 정의되어 있어야 합니다. 예: const currentGameType = 'sudoku';
const currentGameType = 'sudoku';
const rankings = await fetchRanks(currentGameType, currentPuzzleId);
rankingList.innerHTML = '';
if (rankings.length === 0) {
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
} else {
rankings.forEach((rank, index) => {
const li = document.createElement('li');
const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
rankingList.appendChild(li);
});
}
} catch (error) {
console.error('랭킹 조회 중 오류 발생:', error);
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
}
}
/**
* (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
*/
submitRankBtn.addEventListener('click', async () => {
const userName = document.getElementById('username-input').value.trim();
if (!userName) return alert('이름을 입력해주세요.');
try {
// user.js의 공통 submitRank 함수 호출
const currentGameType = 'sudoku';
await submitRank(currentGameType, currentPuzzleId, userName, secondsElapsed, null);
alert('랭킹이 성공적으로 등록되었습니다!');
showRankingModal(); // 랭킹 목록 새로고침
submitRankBtn.disabled = true; // 중복 등록 방지
} catch (error) {
console.error('랭킹 등록 중 오류 발생:', error);
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
}
});
function resetGameView() {
setupContainer.classList.remove('hidden');
gameContainer.classList.add('hidden');
numberInputButtons.classList.add('hidden');
clearInterval(timerInterval);
selectedNumber = null;
focusedCell = null;
document.querySelectorAll('.cell').forEach(cell => {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
});
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
}
closeModalBtn.addEventListener('click', () => {
modalOverlay.classList.add('hidden');
resetGameView();
});
retryBtn.addEventListener('click', () => {
gameOverModal.classList.add('hidden');
resetGameView();
});
});
</script>
</th:block>
</html>
</body>
</html>

View File

@ -6,8 +6,8 @@
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/upload.js}"></script>
<link th:href="@{/css/puzzle.css}" rel="stylesheet" />
<script type="text/javascript" th:src="@{/js/nonogram.js}"></script>
<link th:href="@{/css/nonogram.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>

View File

@ -6,12 +6,12 @@
<meta name="Referrer" content="origin"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1"/>
<script type="text/javascript" th:src="@{https://code.jquery.com/jquery-3.5.1.min.js}" crossorigin="anonymous"></script>
<title>BUM'sPace</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<script async th:src="@{https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716}" crossorigin="anonymous"></script>
<script type="text/javascript" th:src="@{/js/common.js}"></script>
<link th:href="@{/css/common.css}" rel="stylesheet" />
<link th:href="@{/css/main.css}" rel="stylesheet" />
<meta name="_csrf" th:content="${_csrf.token}"/>
@ -20,6 +20,7 @@
/*
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
* (이 블록은 <head>에 남아있어도 괜찮습니다. 전역 변수를 정의하는 역할입니다.)
*/
var serverData = {
// --- Key IDs ---

View File

@ -56,8 +56,10 @@
<th:block layout:fragment="head"></th:block>
<th:block th:replace="~{fragments/footer :: footer}"></th:block>
</div>
<!-- Scripts -->
<script th:src="@{/js/jquery.min.js}"></script>
<script th:src="@{/js/common.js}"></script>
<script th:src="@{/js/user}"></script>
<script th:src="@{/js/jquery.dropotron.min.js}"></script>
<script th:src="@{/js/browser.min.js}"></script>
<script th:src="@{/js/breakpoints.min.js}"></script>