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

View File

@ -8,6 +8,8 @@ import com.google.maps.GeocodingApi
import com.google.maps.model.LatLng import com.google.maps.model.LatLng
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse 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
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
@ -42,7 +44,9 @@ import java.net.URLDecoder
import java.security.Principal import java.security.Principal
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kr.lunaticbum.back.lun.model.ImageMeta // [신규 추가]
import kr.lunaticbum.back.lun.model.ImageMetaService // [신규 추가]
import javax.imageio.ImageIO // [신규 추가]
@RestController @RestController
@RequestMapping("/blog") @RequestMapping("/blog")
@ -50,6 +54,10 @@ class BlogController(private val commentService : CommentService) {
companion object { companion object {
val TEMPTOKEN = "TEMP_TOKEN_VIBUM" val TEMPTOKEN = "TEMP_TOKEN_VIBUM"
} }
@Autowired
private lateinit var imageMetaService: ImageMetaService
@Autowired @Autowired
lateinit var globalEvv : GlobalEnvironment lateinit var globalEvv : GlobalEnvironment
@ -355,7 +363,7 @@ class BlogController(private val commentService : CommentService) {
* @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8 설정 * @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8 설정
*/ */
@GetMapping("posts") @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") val vm = ResultMV("content/blog/posts")
try { try {
val postsList: List<Post> val postsList: List<Post>
@ -368,13 +376,15 @@ class BlogController(private val commentService : CommentService) {
if (isAdmin) { if (isAdmin) {
// [관리자]: 모든 버전의 글을 조회합니다. // [관리자]: 모든 버전의 글을 조회합니다.
logService.log("User is ADMIN. Loading all post versions.") logService.log("User is ADMIN. Loading all post versions.")
postsList = postManager.findAllVersionsPaginated(pageable) // 2. Use awaitSingleOrNull() instead of blocking assignment
totalPosts = postManager.countAllVersions().block() ?: 0L postsList = postManager.findAllVersionsPaginated(pageable).awaitSingleOrNull() ?: emptyList()
totalPosts = postManager.countAllVersions().awaitSingleOrNull() ?: 0L // 3. Use awaitSingleOrNull()
} else { } else {
// [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다. // [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다.
logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.") logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.")
postsList = postManager.findLatestUniquePaginated(pageable) // 4. Use awaitSingleOrNull() - THIS FIXES THE TYPE MISMATCH ERROR
totalPosts = postManager.countLatestUnique().block() ?: 0L postsList = postManager.findLatestUniquePaginated(pageable).awaitSingleOrNull() ?: emptyList()
totalPosts = postManager.countLatestUnique().awaitSingleOrNull() ?: 0L // 5. Use awaitSingleOrNull()
} }
// if (principal != null) { // if (principal != null) {
// // [인증 사용자]: 모든 버전의 글을 조회합니다. // // [인증 사용자]: 모든 버전의 글을 조회합니다.
@ -556,25 +566,49 @@ class BlogController(private val commentService : CommentService) {
out.write(bytes) out.write(bytes)
out.flush() 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) Thumbnails.of(originalImagePath)
.width(200) // 가로 크기를 설정 .width(200) // 가로 크기를 설정
.keepAspectRatio(true) .keepAspectRatio(true)
.toFile(thumbnailPath) .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) { } catch (e: IOException) {
e.printStackTrace() 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 com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactor.awaitSingle 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.PostManager
import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
@ -29,6 +33,9 @@ import java.net.URLDecoder
@RequestMapping() @RequestMapping()
class Home { class Home {
@Autowired
private lateinit var imageMetaService: ImageMetaService
@Autowired @Autowired
lateinit var logService: LogService lateinit var logService: LogService
@ -71,7 +78,30 @@ class Home {
suspend fun home() : ResultMV { suspend fun home() : ResultMV {
val vm = ResultMV("content/home") val vm = ResultMV("content/home")
try { 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 { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)
@ -100,7 +130,8 @@ class Home {
} }
it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
} }
}.chunked(2)) })
// === [END FIXED LOGIC] ===
}catch (ex: Exception){ex.printStackTrace()} }catch (ex: Exception){ex.printStackTrace()}
vm.modelMap.put("path","/blog/viewer/") vm.modelMap.put("path","/blog/viewer/")
return vm return vm

View File

@ -1,87 +1,171 @@
package kr.lunaticbum.back.lun.controllers package kr.lunaticbum.back.lun.controllers
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.PuzzleService import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
import kr.lunaticbum.back.lun.model.ResultMV
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.ui.Model import org.springframework.ui.Model
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.*
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.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
/**
* [통합 게임 API 허브 컨트롤러]
* 1. 모든 게임의 HTML 페이지 서빙
* 2. 모든 게임의 플레이 로직 API (게임 시작, 검증, 상태 업데이트 ) 제공
* (기존 SudokuController, SpiderController의 기능을 모두 통합)
*/
@RestController @RestController
@RequestMapping("/puzzle") @RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입 class PuzzleController(
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
private val puzzleService: PuzzleService
) {
// ======================================================
// 1. NONOGRAM API (기존 엔드포인트 유지)
// ======================================================
/**
* 노노그램: 이미지 업로드 퍼즐 생성
*/
@PostMapping("upload.bjx") @PostMapping("upload.bjx")
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> { suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
return try { return try {
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile) val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile)
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환 ResponseEntity.ok(savedPuzzle)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
// 실패 시 500 에러와 메시지 반환
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}") ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
} }
} }
/** /**
* 특정 ID의 퍼즐을 삭제하는 엔드포인트 * 노노그램: 퍼즐 ID로 삭제
* @param id URL 경로에서 추출한 퍼즐의 고유 ID
*/ */
@DeleteMapping("/{id}.bjx") @DeleteMapping("/{id}.bjx")
suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity<Void> { suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity<Void> {
return try { return try {
puzzleService.deletePuzzle(id) puzzleService.deletePuzzle(id)
// 성공적으로 삭제되면 204 No Content 응답을 보냅니다.
ResponseEntity.noContent().build() ResponseEntity.noContent().build()
} catch (e: Exception) { } catch (e: Exception) {
// 실패 시 500 에러 응답
ResponseEntity.internalServerError().build() 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}") @GetMapping("/play/{id}")
suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV { suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV {
val puzzle = puzzleService.findById(id).awaitSingleOrNull() val puzzle = puzzleService.findById(id).awaitSingleOrNull()
val vm = ResultMV("content/puzzle/play") val vm = ResultMV("content/puzzle/nonogram")
return if (puzzle != null) { return if (puzzle != null) {
vm.model.put("puzzle", puzzle) vm.model.put("puzzle", puzzle)
vm vm
} else { } else {
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
vm.viewName = "redirect:/" vm.viewName = "redirect:/"
vm vm
} }
} }
/** /**
* (추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. * 노노그램: 랜덤 퍼즐 플레이 페이지
*/ */
@GetMapping("/play") @GetMapping("/play")
suspend fun playRandomPuzzlePage(): ResultMV { suspend fun playRandomPuzzlePage(): ResultMV {
val puzzle = puzzleService.findRandomPuzzle() val puzzle = puzzleService.findRandomPuzzle()
val vm = ResultMV("content/puzzle/play") val vm = ResultMV("content/puzzle/nonogram")
return if (puzzle != null) { return if (puzzle != null) {
vm.model.put("puzzle", puzzle) vm.model.put("puzzle", puzzle)
vm vm
} else { } else {
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
vm.viewName = "redirect:/" vm.viewName = "redirect:/"
vm vm
} }
} }
/** /**
* (추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. * 2048: 게임 페이지 서빙
*/ */
@GetMapping("/2048") @GetMapping("/2048")
suspend fun play2048(): ResultMV { suspend fun play2048(): ResultMV {
@ -90,30 +174,27 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
} }
/** /**
* (추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. * 스도쿠: 게임 페이지 서빙
*/ */
@GetMapping("/sudoku") @GetMapping("/sudoku")
suspend fun sudoku(): ResultMV { suspend fun sudoku(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku") val vm = ResultMV("content/puzzle/sudoku")
return vm return vm
}
@GetMapping("/sudoku_gen.bs")
suspend fun sudoku_gen(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku_gen")
return vm
} }
/**
* 스파이더: 게임 페이지 서빙
*/
@GetMapping("/spider") @GetMapping("/spider")
suspend fun spider(): ResultMV { suspend fun spider(): ResultMV {
val vm = ResultMV("content/puzzle/spider") val vm = ResultMV("content/puzzle/spider")
return vm return vm
} }
/**
@GetMapping("/","/upload.bs") * 메인 페이지 (노노그램 업로드)
*/
@GetMapping("/", "/upload.bs")
suspend fun uploadPuzzle() : ResultMV { suspend fun uploadPuzzle() : ResultMV {
val vm = ResultMV("content/puzzle/upload") val vm = ResultMV("content/puzzle/upload")
return vm return vm

View File

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

View File

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

View File

@ -1,36 +1,26 @@
package kr.lunaticbum.back.lun.controllers //package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.GameRecord //import kr.lunaticbum.back.lun.model.GameRecord
import kr.lunaticbum.back.lun.model.SudokuPuzzle //import kr.lunaticbum.back.lun.model.SudokuPuzzle
import kr.lunaticbum.back.lun.model.SudokuService //import kr.lunaticbum.back.lun.model.SudokuService
import org.springframework.web.bind.annotation.* //import org.springframework.web.bind.annotation.*
//
@RestController //@RestController
@RequestMapping("/sudoku") //@RequestMapping("/sudoku")
class SudokuController(private val sudokuService: SudokuService) { //class SudokuController(private val sudokuService: SudokuService) {
//
@GetMapping("/start") // @GetMapping("/start")
suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto { // suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
return sudokuService.startGame(difficulty) // return sudokuService.startGame(difficulty)
} // }
//
@PostMapping("/complete") // @PostMapping("/generate")
suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) { // suspend fun generateSinglePuzzle(): SudokuPuzzle {
sudokuService.saveRecord(recordDto) // return sudokuService.generateAndSavePuzzle()
} // }
//
@GetMapping("/ranking/{puzzleId}") // @PostMapping("/validate")
suspend fun getRankings(@PathVariable puzzleId: Long): List<GameRecord> { // suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
return sudokuService.getRankings(puzzleId) // val isCorrect = sudokuService.validateSolution(validateDto)
} // return mapOf("correct" to isCorrect)
// }
@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.net.URLDecoder
import java.time.Duration 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 @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Document(collection = "Post") @Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
class Post { class Post {
@BsonId @BsonId
@BsonRepresentation(BsonType.OBJECT_ID) @BsonRepresentation(BsonType.OBJECT_ID)
@ -56,10 +61,14 @@ class Post {
var firstAddress = "" var firstAddress = ""
var modifyAddress = "" var modifyAddress = ""
// [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var modifyTime : Long = 0 var modifyTime : Long = 0
var modifyLat : Double = 0.0 var modifyLat : Double = 0.0
var modifyLon : Double = 0.0 var modifyLon : Double = 0.0
// [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var readCount : Long = 0 var readCount : Long = 0
var voteCount : Long = 0 var voteCount : Long = 0
var unlikeCount : Long = 0 var unlikeCount : Long = 0
@ -184,24 +193,25 @@ class PostManager(
/** /**
* [이름 변경] find20 -> findAllVersionsPaginated * [이름 변경] 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("pageSize >>> ${pageable.pageSize}")
println("pageNumber >>> ${pageable.pageNumber}") println("pageNumber >>> ${pageable.pageNumber}")
return postRepository.findAllByOrderByModifyTimeDesc(pageable) return postRepository.findAllByOrderByModifyTimeDesc(pageable)
.doOnNext { println(it) } // map 대신 doOnNext로 로그 출력 .doOnNext { println(it) } // map 대신 doOnNext로 로그 출력
.collectList() // Flux<Post> → Mono<List<Post>> .collectList() // Flux<Post> → Mono<List<Post>>
.block(Duration.ofSeconds(30)) // Mono<List<Post>> → List<Post> // .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK
?: listOf() // ?: 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) return postRepository.findLatestUniqueOriginPaginated(pageable)
.collectList() .collectList()
.block(Duration.ofSeconds(30)) ?: listOf()
} }
/** /**
@ -272,12 +282,13 @@ class PostManager(
/** /**
* [로직 수정] * [로직 수정]
* 화면은 이제 "익명 사용자용 최신 글" 0 페이지, 8 아이템을 명시적으로 요청합니다. * 화면은 이제 "익명 사용자용 최신 글" 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개 아이템을 요청합니다. // 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다.
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 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> { fun find20() : List<Post> {

View File

@ -3,8 +3,10 @@ package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
import kr.lunaticbum.back.lun.utils.SudokuGenerator
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Aggregation import org.springframework.data.mongodb.repository.Aggregation
@ -12,7 +14,7 @@ import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux import reactor.core.publisher.Flux // (★ 오류 수정: 누락된 Flux import 추가)
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.awt.Color import java.awt.Color
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
@ -20,93 +22,94 @@ import java.io.ByteArrayOutputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Base64 import java.util.Base64
import javax.imageio.ImageIO 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( data class NonogramPuzzle(
@Id @Id
val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언 val id: String? = null,
val solutionGrid: List<List<Int>>, val solutionGrid: List<List<Int>>,
val rowClues: List<List<Int>>, val rowClues: List<List<Int>>,
val colClues: List<List<Int>>, val colClues: List<List<Int>>,
// Add these two fields val grayscaleImage: String,
val grayscaleImage: String, // Base64 encoded grayscale image val originalImage: String,
val originalImage: String, // Base64 encoded original color image
val createdAt: LocalDateTime = LocalDateTime.now() val createdAt: LocalDateTime = LocalDateTime.now()
) )
@Repository @Repository
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> { interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
/**
* ( Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는
* 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다.
*/
@Aggregation(pipeline = [ @Aggregation(pipeline = [
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }", "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
"{ \$sample: { size: 1 } }" "{ \$sample: { size: 1 } }"
]) ])
fun findRandom(): Flux<NonogramPuzzle> fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
} }
/**
* ======================================================
* [통합 게임 서비스]
* 모든 게임(Nonogram, Sudoku, Spider) 로직을 처리하는 단일 서비스.
* ======================================================
*/
@Service @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 { companion object {
private const val MIN_PUZZLE_SIZE = 10 private const val MIN_PUZZLE_SIZE = 10
private const val MAX_PUZZLE_SIZE = 30 private const val MAX_PUZZLE_SIZE = 30
} }
/** // ======================================================
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다. // 1. NONOGRAM 서비스 로직 (기존 함수)
* @return 찾은 퍼즐 또는 DB가 비어있으면 null // ======================================================
*/
suspend fun findRandomPuzzle(): NonogramPuzzle? { suspend fun findRandomPuzzle(): NonogramPuzzle? {
return puzzleRepository.findRandom().awaitFirstOrNull() return puzzleRepository.findRandom().awaitFirstOrNull()
} }
fun findById(id: String) = puzzleRepository.findById(id) fun findById(id: String): Mono<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
fun deletePuzzle(id : String): Mono<Void> = puzzleRepository.deleteById(id) // Nonogram 용
/**
* ( 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다.
*/
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle { suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
val puzzleData = withContext(Dispatchers.IO) { val puzzleData = withContext(Dispatchers.IO) {
val originalImage = ImageIO.read(file.inputStream) val originalImage = ImageIO.read(file.inputStream)
val imageWithBackground = convertTransparentToWhite(originalImage) val imageWithBackground = convertTransparentToWhite(originalImage)
// (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정
val puzzleSize = determinePuzzleSize(imageWithBackground) val puzzleSize = determinePuzzleSize(imageWithBackground)
val resizedOriginal = resizeImage(imageWithBackground, 300)
// Create a resized color version for the final reveal
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
// 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성
val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply { val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply {
createGraphics().run { createGraphics().run {
drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null) drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null)
dispose() dispose()
} }
} }
val averageBrightness = calculateAverageBrightness(grayImage) val averageBrightness = calculateAverageBrightness(grayImage)
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness) val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
// 결정된 puzzleSize를 사용하여 solutionGrid 생성
val solutionGrid = List(puzzleSize) { y -> val solutionGrid = List(puzzleSize) { y ->
List(puzzleSize) { x -> List(puzzleSize) { x ->
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
} }
} }
val rowClues = solutionGrid.map { getCluesForLine(it) } val rowClues = solutionGrid.map { getCluesForLine(it) }
val colClues = transpose(solutionGrid).map { getCluesForLine(it) } val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
// Convert images to Base64 strings
val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300)) val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300))
val originalBase64 = imageToBase64(resizedOriginal) val originalBase64 = imageToBase64(resizedOriginal)
@ -118,39 +121,23 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
originalImage = originalBase64 originalImage = originalBase64
) )
} }
return puzzleRepository.save(puzzleData).awaitSingle() return puzzleRepository.save(puzzleData).awaitSingle()
} }
/** // --- (★ 오류 수정: 축약되었던 Nonogram 헬퍼 함수 본문 전체 복원) ---
* ( 새로 추가된 함수)
* 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다.
* @param image 분석할 원본 이미지
* @return 계산된 퍼즐 크기 (정수)
*/
private fun determinePuzzleSize(image: BufferedImage): Int { private fun determinePuzzleSize(image: BufferedImage): Int {
val width = image.width.toDouble() val width = image.width.toDouble()
val height = image.height.toDouble() val height = image.height.toDouble()
// 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다.
val maxDimension = maxOf(width, height) val maxDimension = maxOf(width, height)
val minDimension = minOf(width, height) val minDimension = minOf(width, height)
// 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상)
val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0 val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0
// 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다.
// - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다.
// - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다.
val baseSize = 15.0 val baseSize = 15.0
val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치 val factor = 5.0
val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor) val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor)
// 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다.
return calculatedSize.toInt().coerceIn(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 { private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply { return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().run { 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 { private fun imageToBase64(image: BufferedImage): String {
val os = ByteArrayOutputStream() val os = ByteArrayOutputStream()
ImageIO.write(image, "png", os) ImageIO.write(image, "png", os)
return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()) return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray())
} }
/**
* (추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
*/
private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage { private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
if (!sourceImage.colorModel.hasAlpha()) { if (!sourceImage.colorModel.hasAlpha()) {
return sourceImage return sourceImage
} }
return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply { return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().also { g2d -> createGraphics().also { g2d ->
g2d.color = Color.WHITE g2d.color = Color.WHITE
@ -185,9 +167,6 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
} }
} }
/**
* 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255)
*/
private fun calculateAverageBrightness(image: BufferedImage): Int { private fun calculateAverageBrightness(image: BufferedImage): Int {
var totalBrightness: Long = 0 var totalBrightness: Long = 0
val width = image.width val width = image.width
@ -200,21 +179,14 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
return (totalBrightness / (width * height)).toInt() return (totalBrightness / (width * height)).toInt()
} }
/**
* 평균 밝기에 따라 임계치를 조정합니다.
*/
private fun determineAdaptiveThreshold(averageBrightness: Int): Int { private fun determineAdaptiveThreshold(averageBrightness: Int): Int {
return when { return when {
// 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄
averageBrightness > 180 -> (averageBrightness * 0.9).toInt() averageBrightness > 180 -> (averageBrightness * 0.9).toInt()
// 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄
averageBrightness < 80 -> (averageBrightness * 1.1).toInt() averageBrightness < 80 -> (averageBrightness * 1.1).toInt()
// 보통 밝기의 이미지면 평균값을 그대로 사용
else -> averageBrightness else -> averageBrightness
} }
} }
// --- 헬퍼 함수들 ---
private fun getCluesForLine(line: List<Int>): List<Int> { private fun getCluesForLine(line: List<Int>): List<Int> {
val clues = mutableListOf<Int>() val clues = mutableListOf<Int>()
var count = 0 var count = 0
@ -233,4 +205,262 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
private fun transpose(grid: List<List<Int>>): List<List<Int>> { private fun transpose(grid: List<List<Int>>): List<List<Int>> {
return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } } 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
//
package kr.lunaticbum.back.lun.model //import org.springframework.data.annotation.Id
//import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Id //import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.data.mongodb.core.mapping.Document //import reactor.core.publisher.Mono
import org.springframework.data.mongodb.repository.ReactiveMongoRepository //// (★ 삭제) Service, Flux, Random 관련 import 제거
import org.springframework.stereotype.Service //
import reactor.core.publisher.Flux //
import reactor.core.publisher.Mono ///*
import kotlin.random.Random // * (★ 삭제됨) @Service class SpiderService (...)
// * -> 모든 로직이 PuzzleService로 통합됨.
@Document(collection = "spider_games") // */
data class SpiderGame( //
@Id ///* * (★ 삭제됨) data class SpiderRank (...)
val id: String? = null, // * -> 통합 GameRank 모델로 대체됨.
val tableau: List<List<SpiderCard>>, // */
val stock: List<SpiderCard>, //
val foundation: List<List<SpiderCard>>, ///*
val moves: Int, // * (★ 삭제됨) interface SpiderRankRepository (...)
val isCompleted: Boolean, // * -> 통합 GameRankRepository로 대체됨.
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>
}

View File

@ -1,136 +1,45 @@
package kr.lunaticbum.back.lun.model //package kr.lunaticbum.back.lun.model
//
import com.mongodb.DuplicateKeyException //// (★ 삭제) Service, Record 관련 import 모두 제거
import kotlinx.coroutines.flow.Flow //import org.springframework.data.annotation.Id
import kotlinx.coroutines.flow.toList //import org.springframework.data.mongodb.core.index.Indexed
import kr.lunaticbum.back.lun.utils.SudokuGenerator //import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.annotation.Id //import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.mongodb.core.index.Indexed //import org.springframework.stereotype.Repository
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") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
//data class SudokuPuzzle(
@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정 // @Id
data class SudokuPuzzle( // val id: String? = null,
@Id // val puzzleKey: Long? = null,
val id: String? = null, // MongoDB의 고유 _id 필드 // @Indexed(unique = true)
val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용) // val puzzle: String? // 81자리 완성된 퍼즐 데이터
@Indexed(unique = true) //)
val puzzle: String? // 81자리 완성된 퍼즐 데이터 //
) ///**
// * 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
// */
//@Repository
@Document(collection = "records") //interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
data class GameRecord( // override suspend fun count(): Long
@Id // suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
val id: String? = null, // suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조 //}
val userName: String, //
val completionTime: Long // 완료 시간 (초) //
) ///* * (★ 삭제됨) data class GameRecord (...)
// * -> 통합 GameRank 모델로 대체됨.
@Repository // */
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> { //
// 전체 퍼즐 개수를 반환하는 suspend 함수 ///*
override suspend fun count(): Long // * (★ 삭제됨) interface GameRecordRepository (...)
// puzzleKey로 퍼즐을 찾는 suspend 함수 // * -> 통합 GameRankRepository로 대체됨.
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? // */
// 👇 이 함수 선언을 추가해주세요. //
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? ///*
} // * (★ 삭제됨) @Service class SudokuService (...)
// * -> 모든 로직이 PuzzleService로 통합됨.
@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
}
}

View File

@ -1,156 +1,171 @@
/* ================================= /*!* =================================*/
기본 전체 레이아웃 /* 기본 및 전체 레이아웃 (수정됨)*/
================================= */ /* ================================= *!*/
body { /*body {*/
font-family: Arial, sans-serif; /* !* (★ 삭제) font-family, text-align, background-color, color, margin, padding*/
text-align: center; /* -> 이 속성들은 모두 common_game_theme.css에서 관리합니다.*/
background-color: #faf8ef; /* *!*/
color: #776e65; /* box-sizing: border-box;*/
margin: 0; /*}*/
padding: 10px;
box-sizing: border-box;
}
h1 { /*h1 {*/
font-size: 15vw; /* font-size: 15vw; !* 2048 고유의 큰 폰트 크기는 유지 *!*/
margin: 20px 0; /* margin: 20px 0;*/
} /* !* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 *!*/
/*}*/
.score-container { /*.score-container {*/
font-size: 24px; /* font-size: 24px;*/
margin-bottom: 20px; /* margin-bottom: 20px;*/
} /*}*/
/* ================================= /*!* =================================*/
게임 보드 (가장 중요한 부분) /* 게임 보드 (테마 적용)*/
================================= */ /* ================================= *!*/
#game-board { /*#game-board {*/
display: grid; /* display: grid;*/
grid-template-columns: repeat(4, 1fr); /* grid-template-columns: repeat(4, 1fr);*/
grid-gap: 2vw; /* grid-gap: 2vw;*/
width: 95vw; /* width: 95vw;*/
max-width: 400px; /* max-width: 500px; !* (★ 수정) 400px -> 500px (다른 게임과 통일) *!*/
margin: 0 auto; /* margin: 0 auto;*/
background-color: #bbada0;
padding: 2vw;
border-radius: 6px;
box-sizing: border-box;
aspect-ratio: 1 / 1; /* 정사각형 비율 유지 */
touch-action: none; /* 모바일에서 터치 시 화면 확대/이동 방지 */
}
/* ================================= /* !* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 *!*/
타일 공통 스타일 /* background-color: #b0bec5; !* #bbada0 (갈색) -> #b0bec5 (블루 그레이) *!*/
================================= */
.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;
}
/* ================================= /* padding: 2vw;*/
타일 색상 /* border-radius: 6px;*/
================================= */ /* box-sizing: border-box;*/
.tile-2 { background-color: #eee4da; color: #776e65; } /* aspect-ratio: 1 / 1;*/
.tile-4 { background-color: #ede0c8; color: #776e65; } /* touch-action: none;*/
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
.tile-16 { background-color: #f59563; color: #f9f6f2; } /* !* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 *!*/
.tile-32 { background-color: #f67c5f; color: #f9f6f2; } /* box-shadow: 0 4px 10px rgba(0,0,0,0.08);*/
.tile-64 { background-color: #f65e3b; color: #f9f6f2; } /*}*/
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
.tile-256 { background-color: #edcc61; color: #f9f6f2; } /*@media (min-width: 481px) {*/
.tile-512 { background-color: #edc850; color: #f9f6f2; } /* #game-board {*/
.tile-1024 { background-color: #edc53f; color: #f9f6f2; } /* grid-gap: 10px;*/
.tile-2048 { background-color: #edc22e; color: #f9f6f2; } /* padding: 10px;*/
.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; }
/* ================================= /*!* =================================*/
게임 오버 팝업 /* 타일 공통 스타일 (테마 적용)*/
================================= */ /* ================================= *!*/
.popup-container { /*.tile {*/
position: fixed; /* width: 100%;*/
top: 0; /* height: 100%;*/
left: 0; /* display: flex;*/
width: 100%; /* justify-content: center;*/
height: 100%; /* align-items: center;*/
background-color: rgba(0, 0, 0, 0.5); /* font-weight: bold;*/
display: flex; /* border-radius: 3px;*/
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;
}
/* ================================= /* !* (★ 수정) 빈 타일 색상 변경 *!*/
랭킹 리스트 /* background-color: #eceff1; !* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) *!*/
================================= */
.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;
}
/* ================================= /* font-size: 5vw;*/
반응형: PC & 태블릿 (화면이 481px 이상일 ) /*}*/
================================= */
@media (min-width: 481px) { /*@media (min-width: 481px) {*/
h1 { font-size: 80px; } /* .tile {*/
#game-board { /* font-size: 30px;*/
grid-gap: 10px; /* }*/
padding: 10px; /*}*/
}
.tile { font-size: 45px; } /*!* =================================*/
.tile-4096, .tile-8192 { font-size: 35px; } /* 타일 색상 (테마 적용)*/
.tile-16384, .tile-32768 { 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; cursor: pointer;
font-size: 0.85em; font-size: 0.85em;
font-weight: bold; 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 { /*!* (★ 수정) #game-container가 전체 화면(100vw/vh)을 차지하는 대신,*/
display: flex; /* common_game_theme의 body(#f4f7f9 배경) 위에 떠 있는*/
justify-content: center; /* 가로 중앙 정렬 */ /* '게임 테이블(카드)' 역할을 하도록 변경합니다. *!*/
align-items: flex-start; /* 세로 상단 정렬 */ /*#game-container {*/
background-color: #008000; /* !* (★ 남김) 내부 캔버스를 정렬하는 로직은 유지 *!*/
width: 100vw; /* display: flex;*/
height: 100vh; /* justify-content: center;*/
box-sizing: border-box; /* align-items: flex-start;*/
}
#gameCanvas { /* !* (★ 유지/수정) '펠트' 배경색은 유지하되, 공통 '카드' UI 요소를 추가 *!*/
background-color: #008000; /* background-color: #008000;*/
border: 2px solid #fff; /* border-radius: 8px; !* (★ 추가) 공통 테마 둥근 모서리 *!*/
width: 95%; /* box-shadow: 0 4px 10px rgba(0,0,0,0.08); !* (★ 추가) 공통 테마 그림자 *!*/
max-height: min(95vw, 95vh); /* padding: 15px; !* (★ 추가) 캔버스 주변 여백 *!*/
box-sizing: border-box; /* 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 { /*body {*/
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; /* !**/
margin: 0; /* (★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.*/
padding: 20px; /* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;*/
background-color: #f4f7f9; /* margin: 0;*/
display: flex; /* padding: 20px;*/
justify-content: center; /* background-color: #f4f7f9;*/
min-height: 100vh; /* display: flex;*/
} /* justify-content: center;*/
/* min-height: 100vh;*/
/* *!*/
/*}*/
#sudoku-game-app { /*#sudoku-game-app {*/
width: 100%; /* width: 100%;*/
margin: 20px 0; /* margin: 20px 0;*/
} /*}*/
.container { /*.container {*/
background: white; /* !**/
padding: clamp(15px, 4vw, 30px); /* (★ 삭제) 아래 속성들은 common_game_theme.css에서*/
border-radius: 8px; /* '#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.*/
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;
}
h1 { /* background: white;*/
font-size: 1.8em; /* padding: clamp(15px, 4vw, 30px);*/
color: #333; /* border-radius: 8px;*/
margin-top: 0; /* box-shadow: 0 4px 10px rgba(0,0,0,0.1);*/
margin-bottom: 20px; /* text-align: center;*/
} /* max-width: 500px;*/
/* width: 100%;*/
/* box-sizing: border-box;*/
/* margin: 0 auto;*/
/* *!*/
button { /* !* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 *!*/
padding: 10px 20px; /* text-align: center;*/
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;
}
/* 게임 컨테이너 */ /*h1 {*/
#game-container { /* font-size: 1.8em;*/
display: flex; /* color: #333;*/
flex-direction: column; /* margin-top: 0;*/
align-items: center; /* margin-bottom: 20px;*/
max-width: 500px; /* !* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.*/
margin: 0 auto; /* 만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.*/
} /* 현재는 공통 스타일이 적용됩니다. *!*/
/*}*/
/* 게임 정보 (점수, 타이머) */ /*!* (★ 삭제) 아래의 'button' 공통 스타일은 common_game_theme.css가 처리합니다. *!*/
.game-info { /*!**/
width: 100%; /*button {*/
display: flex; /* padding: 10px 20px;*/
justify-content: space-between; /* font-size: 1em;*/
align-items: center; /* cursor: pointer;*/
margin-bottom: 15px; /* background-color: #007bff;*/
padding: 0 10px; /* color: white;*/
box-sizing: border-box; /* border: none;*/
font-size: 1.5em; /* border-radius: 5px;*/
font-weight: bold; /* transition: background-color 0.2s;*/
} /*}*/
#score { color: #007bff; } /*button:hover:not(:disabled) {*/
#timer { color: #333; } /* 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; } /*#game-container {*/
.cell:nth-child(n+19):nth-child(-n+27), /* display: flex;*/
.cell:nth-child(n+46):nth-child(-n+54) { /* flex-direction: column;*/
border-bottom: 2px solid #333; /* align-items: center;*/
} /* max-width: 500px;*/
/* margin: 0 auto;*/
/*}*/
.cell:not(.editable) { /*!* 게임 정보 (점수, 타이머) *!*/
background-color: #f0f0f0; /*.game-info {*/
color: #222; /* width: 100%;*/
cursor: default; /* 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 { /*#sudoku-board {*/
background-color: #ffdddd !important; /* display: grid;*/
color: #d8000c !important; /* grid-template-columns: repeat(9, 1fr);*/
} /* grid-template-rows: repeat(9, 1fr);*/
.highlight-focused { /* width: 100%;*/
background-color: #dbeeff !important; /* border: 3px solid #333;*/
} /* aspect-ratio: 1 / 1;*/
.highlight-same-number { /*}*/
background-color: #e6e6e6 !important;
}
.highlight-selected-number {
background-color: #b3d7ff !important;
}
/* 숫자 입력 버튼 */ /*.cell {*/
#number-input-buttons { /* display: flex;*/
display: flex; /* justify-content: center;*/
justify-content: space-between; /* align-items: center;*/
width: 100%; /* font-size: clamp(1em, 4vw, 1.8em);*/
margin-top: 15px; /* font-weight: bold;*/
gap: 1%; /* color: #333;*/
} /* border: 1px solid #ddd;*/
/* box-sizing: border-box;*/
/* cursor: pointer;*/
/*}*/
#number-input-buttons .num-btn, /*.cell:nth-child(3n) { border-right: 2px solid #333; }*/
#number-input-buttons #undo-btn { /*.cell:nth-child(9n) { border-right-width: 1px; }*/
line-height: unset; /*.cell:nth-child(n+19):nth-child(-n+27),*/
min-width: unset; /*.cell:nth-child(n+46):nth-child(-n+54) {*/
width: 9%; /* border-bottom: 2px solid #333;*/
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 { /*.cell:not(.editable) {*/
background-color: #007bff; /* background-color: #f0f0f0;*/
color: white; /* color: #222;*/
border-color: #007bff; /* cursor: default;*/
} /*}*/
#number-input-buttons .num-btn.completed { /*!* 하이라이트 & 오답 스타일 *!*/
opacity: 0.4; /*.cell.incorrect {*/
background-color: #e9ecef; /* background-color: #ffdddd !important;*/
pointer-events: none; /* 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; /*#number-input-buttons {*/
color: #dc3545; /* display: flex;*/
} /* justify-content: space-between;*/
/* width: 100%;*/
/* margin-top: 15px;*/
/* gap: 1%;*/
/*}*/
/* 액션 버튼 (힌트, 정답확인) */ /*#number-input-buttons .num-btn,*/
.action-buttons { /*#number-input-buttons #undo-btn {*/
display: flex; /* line-height: unset;*/
justify-content: center; /* min-width: unset;*/
gap: 10px; /* width: 9%;*/
margin-top: 15px; /* aspect-ratio: 1/1;*/
width: 100%; /* font-size: clamp(1em, 4vw, 1.8em);*/
} /* font-weight: bold;*/
.action-buttons button { /* border-radius: 8px;*/
flex-grow: 1; /* background-color: #f0f0f0;*/
max-width: 200px; /* 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 {*/
.hidden { /* background-color: #007bff;*/
display: none !important; /* color: white;*/
} /* border-color: #007bff;*/
#modal-overlay, #game-over-modal { /*}*/
position: fixed;
top: 0; left: 0; /*#number-input-buttons .num-btn.completed {*/
width: 100%; height: 100%; /* opacity: 0.4;*/
background-color: rgba(0,0,0,0.7); /* background-color: #e9ecef;*/
display: flex; /* pointer-events: none;*/
justify-content: center; /*}*/
align-items: center;
z-index: 1000; /*#number-input-buttons #undo-btn {*/
} /* background-color: #f8f9fa;*/
#modal-content { /* color: #dc3545;*/
background: white; /*}*/
padding: 30px;
border-radius: 10px; /*!* 액션 버튼 (힌트, 정답확인) *!*/
text-align: center; /*.action-buttons {*/
width: 90%; /* display: flex;*/
max-width: 400px; /* justify-content: center;*/
box-shadow: 0 8px 20px rgba(0,0,0,0.2); /* gap: 10px;*/
} /* margin-top: 15px;*/
#modal-content h2, #modal-content h3 { /* width: 100%;*/
color: #333; /*}*/
margin-bottom: 15px; /*.action-buttons button {*/
} /* flex-grow: 1;*/
#username-input { /* max-width: 200px;*/
width: calc(100% - 24px); /*}*/
padding: 10px;
margin-bottom: 15px; /*!* 모달 및 숨김 처리 *!*/
border: 1px solid #ccc; /*.hidden {*/
border-radius: 5px; /* display: none !important;*/
font-size: 1em; /*}*/
} /*#modal-overlay, #game-over-modal {*/
#ranking-list { /* position: fixed;*/
list-style-type: decimal; /* top: 0; left: 0;*/
list-style-position: inside; /* width: 100%; height: 100%;*/
padding: 0; /* background-color: rgba(0,0,0,0.7);*/
text-align: left; /* display: flex;*/
margin-top: 20px; /* justify-content: center;*/
} /* align-items: center;*/
#ranking-list li { /* z-index: 1000;*/
padding: 8px 0; /*}*/
border-bottom: 1px solid #eee; /*#modal-content {*/
display: flex; /* background: white;*/
justify-content: space-between; /* padding: 30px;*/
align-items: center; /* border-radius: 10px;*/
} /* text-align: center;*/
#ranking-list li:last-child { /* width: 90%;*/
border-bottom: none; /* 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', () => { // document.addEventListener('DOMContentLoaded', () => {
// DOM 요소 가져오기 // // ... (DOM 요소 가져오기 - 동일)
const gameBoard = document.getElementById('game-board'); // const gameBoard = document.getElementById('game-board');
const scoreDisplay = document.getElementById('score'); // const scoreDisplay = document.getElementById('score');
const gameOverPopup = document.getElementById('game-over-popup'); // const gameOverPopup = document.getElementById('game-over-popup');
const finalScoreDisplay = document.getElementById('final-score'); // const finalScoreDisplay = document.getElementById('final-score');
const playerNameInput = document.getElementById('player-name'); // const playerNameInput = document.getElementById('player-name');
const saveScoreButton = document.getElementById('save-score'); // const saveScoreButton = document.getElementById('save-score');
const rankingList = document.getElementById('ranking-list'); // const rankingList = document.getElementById('ranking-list');
//
// 게임 설정 및 변수 // // (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
const currentGameId = '2048'; // const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
const gridSize = 4; // const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
let board = []; //
let score = 0; // let gridSize = 4;
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0; // 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++) { // function initializeBoard() {
const tile = document.createElement('div'); // gameBoard.innerHTML = ''; // 기존 타일 초기화
tile.className = 'tile'; // for (let i = 0; i < gridSize * gridSize; i++) {
gameBoard.appendChild(tile); // const tile = document.createElement('div');
} // tile.className = 'tile';
board = Array(gridSize * gridSize).fill(0); // gameBoard.appendChild(tile);
addNumber(); // }
addNumber(); // board = Array(gridSize * gridSize).fill(0);
updateBoard(); // addNumber();
} // addNumber();
// updateBoard();
function updateBoard() { // }
const tiles = gameBoard.children; //
for (let i = 0; i < board.length; i++) { // function updateBoard() {
const value = board[i]; // const tiles = gameBoard.children;
const tile = tiles[i]; // for (let i = 0; i < board.length; i++) {
tile.textContent = value === 0 ? '' : value; // const value = board[i];
tile.className = 'tile' + (value > 0 ? ' tile-' + value : ''); // const tile = tiles[i];
} // tile.textContent = value === 0 ? '' : value;
scoreDisplay.textContent = score; // 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) { // function addNumber() {
const spot = available[Math.floor(Math.random() * available.length)]; // const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
board[spot] = Math.random() < 0.9 ? 2 : 4; // 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++) { // function moveRow(row) {
if (arr[i] === arr[i + 1]) { // let arr = row.filter(val => val);
arr[i] *= 2; // for (let i = 0; i < arr.length - 1; i++) {
score += arr[i]; // if (arr[i] === arr[i + 1]) {
arr[i + 1] = 0; // 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); // arr = arr.filter(val => val);
return arr.concat(zeros); // 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++) { // function moveLeft() {
const rowStart = i * gridSize; // let changed = false;
const row = board.slice(rowStart, rowStart + gridSize); // for (let i = 0; i < gridSize; i++) {
const newRow = moveRow(row); // const rowStart = i * gridSize;
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true; // const row = board.slice(rowStart, rowStart + gridSize);
board.splice(rowStart, gridSize, ...newRow); // const newRow = moveRow(row);
} // if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
return changed; // board.splice(rowStart, gridSize, ...newRow);
} // }
// return changed;
function moveRight() { // }
let changed = false; //
for (let i = 0; i < gridSize; i++) { // function moveRight() {
const rowStart = i * gridSize; // let changed = false;
const row = board.slice(rowStart, rowStart + gridSize).reverse(); // for (let i = 0; i < gridSize; i++) {
const newRow = moveRow(row).reverse(); // const rowStart = i * gridSize;
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true; // const row = board.slice(rowStart, rowStart + gridSize).reverse();
board.splice(rowStart, gridSize, ...newRow); // const newRow = moveRow(row).reverse();
} // if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
return changed; // board.splice(rowStart, gridSize, ...newRow);
} // }
// return changed;
function moveUp() { // }
let changed = false; //
for (let i = 0; i < gridSize; i++) { // function moveUp() {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]; // let changed = false;
const newCol = moveRow(col); // for (let i = 0; i < gridSize; i++) {
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true; // const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
for (let j = 0; j < gridSize; j++) { // const newCol = moveRow(col);
board[i + j * gridSize] = newCol[j]; // if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
} // for (let j = 0; j < gridSize; j++) {
} // board[i + j * gridSize] = newCol[j];
return changed; // }
} // }
// return changed;
function moveDown() { // }
let changed = false; //
for (let i = 0; i < gridSize; i++) { // function moveDown() {
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse(); // let changed = false;
const newCol = moveRow(col).reverse(); // for (let i = 0; i < gridSize; i++) {
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true; // const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
for (let j = 0; j < gridSize; j++) { // const newCol = moveRow(col).reverse();
board[i + j * gridSize] = newCol[j]; // 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; // }
} // }
// return changed;
// ----- 게임 상태 관리 ----- // }
function isGameOver() { //
if (!board.includes(0)) { // // ----- 게임 상태 관리 -----
for (let i = 0; i < gridSize; i++) { // function isGameOver() {
for (let j = 0; j < gridSize; j++) { // if (!board.includes(0)) {
const current = board[i * gridSize + j]; // for (let i = 0; i < gridSize; i++) {
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) || // for (let j = 0; j < gridSize; j++) {
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) { // const current = board[i * gridSize + j];
return false; // 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; // return true;
} // }
// return false;
function handleMove(moveFunction) { // }
if (moveFunction()) { //
addNumber(); // function handleMove(moveFunction) {
updateBoard(); // if (moveFunction()) {
if (isGameOver()) { // addNumber();
finalScoreDisplay.textContent = score; // updateBoard();
gameOverPopup.style.display = 'flex'; // if (isGameOver()) {
} // finalScoreDisplay.textContent = score;
} // gameOverPopup.style.display = 'flex';
} // }
// }
// ----- 이벤트 리스너 ----- // }
document.addEventListener('keydown', (e) => { //
switch (e.key) { // // ----- 이벤트 리스너 -----
case 'ArrowUp': handleMove(moveUp); break; // document.addEventListener('keydown', (e) => {
case 'ArrowDown': handleMove(moveDown); break; // switch (e.key) {
case 'ArrowLeft': handleMove(moveLeft); break; // case 'ArrowUp': handleMove(moveUp); break;
case 'ArrowRight': handleMove(moveRight); 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('touchstart', (e) => {
}); // touchStartX = e.changedTouches[0].screenX;
// touchStartY = e.changedTouches[0].screenY;
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); // });
//
gameBoard.addEventListener('touchend', (e) => { // gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
touchEndX = e.changedTouches[0].screenX; //
touchEndY = e.changedTouches[0].screenY; // gameBoard.addEventListener('touchend', (e) => {
handleSwipe(); // touchEndX = e.changedTouches[0].screenX;
}); // touchEndY = e.changedTouches[0].screenY;
// handleSwipe();
function handleSwipe() { // });
const deltaX = touchEndX - touchStartX; //
const deltaY = touchEndY - touchStartY; // function handleSwipe() {
const swipeThreshold = 30; // const deltaX = touchEndX - touchStartX;
// const deltaY = touchEndY - touchStartY;
if (Math.abs(deltaX) > Math.abs(deltaY)) { // const swipeThreshold = 30;
if (Math.abs(deltaX) > swipeThreshold) { //
handleMove(deltaX > 0 ? moveRight : moveLeft); // if (Math.abs(deltaX) > Math.abs(deltaY)) {
} // if (Math.abs(deltaX) > swipeThreshold) {
} else { // handleMove(deltaX > 0 ? moveRight : moveLeft);
if (Math.abs(deltaY) > swipeThreshold) { // }
handleMove(deltaY > 0 ? moveDown : moveUp); // } else {
} // if (Math.abs(deltaY) > swipeThreshold) {
} // handleMove(deltaY > 0 ? moveDown : moveUp);
} // }
// }
// ----- 랭킹 API 연동 ----- // }
saveScoreButton.addEventListener('click', async () => { //
const playerName = playerNameInput.value.trim(); // // ----- 랭킹 API 연동 -----
if (playerName === "") return alert("이름을 입력해주세요."); // saveScoreButton.addEventListener('click', async () => {
// const playerName = playerNameInput.value.trim();
const newScore = { gameId: currentGameId, name: playerName, score: score }; // if (playerName === "") return alert("이름을 입력해주세요.");
try { //
const response = await fetch(getMainPath() + '/rank/ranks', { // try {
method: 'POST', // // (★ 수정) user.js의 공통 submitRank 함수 호출
headers: {'Content-Type': 'application/json'}, // // 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
body: JSON.stringify(newScore), // await submitRank(currentGameType, currentContextId, playerName, score, null);
}); //
if (!response.ok) throw new Error('점수 저장 실패'); // gameOverPopup.style.display = 'none';
// playerNameInput.value = '';
gameOverPopup.style.display = 'none'; // score = 0;
playerNameInput.value = ''; // updateRankingList(); // 랭킹 리스트 새로고침
score = 0; // 점수 초기화 // initializeBoard(); // 새 게임 시작
updateRankingList(); //
initializeBoard(); // } catch (error) {
} catch (error) { // console.error('Error submitting rank:', error);
console.error('Error:', error); // alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
alert('서버와 통신 중 오류가 발생했습니다.'); // }
} // });
}); //
// /**
async function updateRankingList() { // * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
rankingList.innerHTML = ''; // */
try { // async function updateRankingList() {
const response = await fetch(getMainPath() +`/rank/ranks/${currentGameId}`); // rankingList.innerHTML = '<li>로딩 중...</li>';
if (!response.ok) throw new Error('랭킹 로딩 실패'); // try {
const rankings = await response.json(); // // (★ 수정) user.js의 공통 fetchRanks 함수 호출
if (rankings.length === 0) { // const rankings = await fetchRanks(currentGameType, currentContextId);
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>'; //
return; // rankingList.innerHTML = ''; // 리스트 비우기
} // if (!rankings || rankings.length === 0) {
rankings.forEach((rank, index) => { // rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
const li = document.createElement('li'); // return;
li.innerHTML = `<span>${index + 1}. ${rank.name}</span><strong>${rank.score}점</strong>`; // }
rankingList.appendChild(li); //
}); // rankings.forEach((rank, index) => {
} catch (error) { // const li = document.createElement('li');
console.error('Error:', error); // // (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>'; // li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
} // rankingList.appendChild(li);
} // });
// } catch (error) {
// ----- 게임 시작 ----- // console.error('Error fetching ranks:', error);
updateRankingList(); // rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
initializeBoard(); // }
}); // }
//
// // ----- 게임 시작 -----
// 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', () => { // document.addEventListener('DOMContentLoaded', () => {
// DOM 요소 // // DOM 요소
const setupContainer = document.getElementById('setup-container'); // const setupContainer = document.getElementById('setup-container');
const gameContainer = document.getElementById('game-container'); // const gameContainer = document.getElementById('game-container');
const startBtn = document.getElementById('start-btn'); // const startBtn = document.getElementById('start-btn');
const boardElement = document.getElementById('sudoku-board'); // const boardElement = document.getElementById('sudoku-board');
const timerElement = document.getElementById('timer'); // const timerElement = document.getElementById('timer');
const scoreElement = document.getElementById('score'); // const scoreElement = document.getElementById('score');
const hintBtn = document.getElementById('hint-btn'); // const hintBtn = document.getElementById('hint-btn');
const undoBtn = document.getElementById('undo-btn'); // const undoBtn = document.getElementById('undo-btn');
const completeBtn = document.getElementById('complete-btn'); // const completeBtn = document.getElementById('complete-btn');
const numberInputButtons = document.getElementById('number-input-buttons'); // const numberInputButtons = document.getElementById('number-input-buttons');
const modalOverlay = document.getElementById('modal-overlay'); // const modalOverlay = document.getElementById('modal-overlay');
const gameOverModal = document.getElementById('game-over-modal'); // const gameOverModal = document.getElementById('game-over-modal');
const retryBtn = document.getElementById('retry-btn'); // const retryBtn = document.getElementById('retry-btn');
const submitRankBtn = document.getElementById('submit-rank-btn'); // const submitRankBtn = document.getElementById('submit-rank-btn');
const rankingList = document.getElementById('ranking-list'); // const rankingList = document.getElementById('ranking-list');
const closeModalBtn = document.getElementById('close-modal-btn'); // const closeModalBtn = document.getElementById('close-modal-btn');
//
// 게임 상태 변수 // // 게임 상태 변수
let currentPuzzleId = null; // let currentPuzzleId = null;
let solvedPuzzle = null; // let solvedPuzzle = null;
let timerInterval = null; // let timerInterval = null;
let secondsElapsed = 0; // let secondsElapsed = 0;
let selectedNumber = null; // let selectedNumber = null;
let focusedCell = null; // let focusedCell = null;
let score = 5; // let score = 5;
let history = []; // let history = [];
//
// --- 게임 초기화 및 시작 --- // // (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
startBtn.addEventListener('click', async () => { // startBtn.addEventListener('click', async () => {
const difficulty = document.getElementById('difficulty-select').value; // const difficulty = document.getElementById('difficulty-select').value;
try { // try {
const response = await fetch(`/sudoku/start?difficulty=${difficulty}`); // // (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.'); // const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
const gameData = await response.json(); // if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
// const gameData = await response.json();
currentPuzzleId = gameData.puzzleId; //
solvedPuzzle = gameData.solution; // currentContextId = gameData.puzzleId;
// solvedPuzzle = gameData.solution;
history = []; //
score = 5; // history = [];
updateScoreDisplay(); // score = 5;
// updateScoreDisplay();
renderBoard(gameData.question); //
startTimer(); // renderBoard(gameData.question);
updateButtonStates(); // startTimer();
// updateButtonStates();
setupContainer.classList.add('hidden'); //
gameContainer.classList.remove('hidden'); // setupContainer.classList.add('hidden');
numberInputButtons.classList.remove('hidden'); // gameContainer.classList.remove('hidden');
gameOverModal.classList.add('hidden'); // numberInputButtons.classList.remove('hidden');
} catch (error) { // gameOverModal.classList.add('hidden');
alert('게임 로딩에 실패했습니다: ' + error.message); // } catch (error) {
console.error(error); // alert('게임 로딩에 실패했습니다: ' + error.message);
} // console.error(error);
}); // }
// });
function renderBoard(puzzleString) { //
boardElement.innerHTML = ''; // function renderBoard(puzzleString) {
for (let i = 0; i < 81; i++) { // boardElement.innerHTML = '';
const cell = document.createElement('div'); // for (let i = 0; i < 81; i++) {
cell.classList.add('cell'); // const cell = document.createElement('div');
cell.dataset.index = i; // cell.classList.add('cell');
// cell.dataset.index = i;
if (puzzleString[i] !== '0') { //
cell.textContent = puzzleString[i]; // if (puzzleString[i] !== '0') {
} else { // cell.textContent = puzzleString[i];
cell.classList.add('editable'); // } else {
} // cell.classList.add('editable');
boardElement.appendChild(cell); // }
} // boardElement.appendChild(cell);
} // }
// }
function startTimer() { //
secondsElapsed = 0; // function startTimer() {
timerElement.textContent = '00:00'; // secondsElapsed = 0;
clearInterval(timerInterval); // timerElement.textContent = '00:00';
timerInterval = setInterval(() => { // clearInterval(timerInterval);
secondsElapsed++; // timerInterval = setInterval(() => {
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0'); // secondsElapsed++;
const seconds = (secondsElapsed % 60).toString().padStart(2, '0'); // const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
timerElement.textContent = `${minutes}:${seconds}`; // const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
}, 1000); // timerElement.textContent = `${minutes}:${seconds}`;
} // }, 1000);
// }
function updateScoreDisplay() { //
scoreElement.textContent = `SCORE: ${score}`; // function updateScoreDisplay() {
if (score <= 0) { // scoreElement.textContent = `SCORE: ${score}`;
clearInterval(timerInterval); // if (score <= 0) {
gameOverModal.classList.remove('hidden'); // clearInterval(timerInterval);
} // gameOverModal.classList.remove('hidden');
} // }
// }
function updateButtonStates() { //
const counts = {}; // function updateButtonStates() {
for (let i = 1; i <= 9; i++) counts[i] = 0; // const counts = {};
boardElement.querySelectorAll('.cell').forEach(cell => { // for (let i = 1; i <= 9; i++) counts[i] = 0;
const num = cell.textContent; // boardElement.querySelectorAll('.cell').forEach(cell => {
if (num && counts[num] !== undefined) counts[num]++; // 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}"]`); // for (let i = 1; i <= 9; i++) {
if (btn) { // const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
if (counts[i] >= 9) { // if (btn) {
btn.classList.add('completed'); // if (counts[i] >= 9) {
if (selectedNumber == i) { // btn.classList.add('completed');
selectedNumber = null; // if (selectedNumber == i) {
btn.classList.remove('selected'); // selectedNumber = null;
} // btn.classList.remove('selected');
} else { // }
btn.classList.remove('completed'); // } else {
} // btn.classList.remove('completed');
} // }
} // }
} // }
// }
// --- 게임 플레이 이벤트 핸들링 --- //
numberInputButtons.addEventListener('click', (event) => { // // --- 게임 플레이 이벤트 핸들링 ---
const target = event.target.closest('button'); // numberInputButtons.addEventListener('click', (event) => {
if (!target) return; // const target = event.target.closest('button');
// if (!target) return;
if (target === undoBtn) { //
undoAction(); // if (target === undoBtn) {
return; // undoAction();
} // return;
// }
if (target.classList.contains('completed')) return; //
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected')); // 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; // if (target.classList.contains('num-btn')) {
selectedNumber = (selectedNumber === num) ? null : num; // const num = target.dataset.number;
if (selectedNumber) target.classList.add('selected'); // selectedNumber = (selectedNumber === num) ? null : num;
} // if (selectedNumber) target.classList.add('selected');
highlightCells(); // }
}); // highlightCells();
// });
boardElement.addEventListener('click', (event) => { //
const targetCell = event.target.closest('.cell.editable'); // boardElement.addEventListener('click', (event) => {
if (!targetCell) { // const targetCell = event.target.closest('.cell.editable');
if (focusedCell) focusedCell = null; // if (!targetCell) {
highlightCells(); // if (focusedCell) focusedCell = null;
return; // highlightCells();
} // return;
focusedCell = targetCell; // }
// focusedCell = targetCell;
if (selectedNumber) { //
const previousValue = targetCell.textContent; // if (selectedNumber) {
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber; // const previousValue = targetCell.textContent;
targetCell.textContent = newValue; // let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
// targetCell.textContent = newValue;
recordAction(targetCell, previousValue, newValue); //
validateCell(targetCell); // recordAction(targetCell, previousValue, newValue);
updateButtonStates(); // validateCell(targetCell);
checkIfBoardIsFull(); // updateButtonStates();
} // checkIfBoardIsFull();
// }
highlightCells(); //
}); // highlightCells();
// });
hintBtn.addEventListener('click', () => { //
if (score <= 0) return; // hintBtn.addEventListener('click', () => {
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent); // if (score <= 0) return;
if (emptyCells.length === 0) { // const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
alert('모든 칸이 채워져 있습니다.'); // if (emptyCells.length === 0) {
return; // alert('모든 칸이 채워져 있습니다.');
} // return;
// }
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; //
const cellIndex = parseInt(randomCell.dataset.index); // const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
const correctAnswer = solvedPuzzle[cellIndex]; // const cellIndex = parseInt(randomCell.dataset.index);
const previousValue = randomCell.textContent; // const correctAnswer = solvedPuzzle[cellIndex];
// const previousValue = randomCell.textContent;
score--; //
updateScoreDisplay(); // score--;
recordAction(randomCell, previousValue, correctAnswer, true); // updateScoreDisplay();
// recordAction(randomCell, previousValue, correctAnswer, true);
randomCell.textContent = correctAnswer; //
randomCell.classList.remove('editable', 'incorrect'); // randomCell.textContent = correctAnswer;
// randomCell.classList.remove('editable', 'incorrect');
updateButtonStates(); //
highlightCells(); // updateButtonStates();
checkIfBoardIsFull(); // highlightCells();
}); // checkIfBoardIsFull();
// });
function undoAction() { //
if (history.length === 0) return; // function undoAction() {
const lastAction = history.pop(); // if (history.length === 0) return;
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`); // const lastAction = history.pop();
// const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
if (cell) { //
cell.textContent = lastAction.previousValue; // if (cell) {
if (lastAction.wasHint) { // cell.textContent = lastAction.previousValue;
cell.classList.add('editable'); // if (lastAction.wasHint) {
} // cell.classList.add('editable');
validateCell(cell, false); // }
updateButtonStates(); // validateCell(cell, false);
highlightCells(); // updateButtonStates();
} // highlightCells();
} // }
// }
function recordAction(cell, previousValue, newValue, wasHint = false) { //
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint }); // function recordAction(cell, previousValue, newValue, wasHint = false) {
} // history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
// }
function validateCell(cell, deductPoint = true) { //
if (!cell.textContent) { // function validateCell(cell, deductPoint = true) {
cell.classList.remove('incorrect'); // if (!cell.textContent) {
return; // cell.classList.remove('incorrect');
} // return;
const cellIndex = parseInt(cell.dataset.index); // }
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]); // const cellIndex = parseInt(cell.dataset.index);
if (!isCorrect) { // const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
cell.classList.add('incorrect'); // if (!isCorrect) {
if (deductPoint && score > 0) { // cell.classList.add('incorrect');
score--; // if (deductPoint && score > 0) {
updateScoreDisplay(); // score--;
} // updateScoreDisplay();
} else { // }
cell.classList.remove('incorrect'); // } else {
} // cell.classList.remove('incorrect');
} // }
// }
// --- 하이라이트 기능 --- //
function highlightCells() { // // --- 하이라이트 기능 ---
document.querySelectorAll('.cell').forEach(cell => { // function highlightCells() {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); // document.querySelectorAll('.cell').forEach(cell => {
}); // cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
if (focusedCell) { // });
focusedCell.classList.add('highlight-focused'); // if (focusedCell) {
const focusedValue = focusedCell.textContent; // focusedCell.classList.add('highlight-focused');
if (focusedValue) { // const focusedValue = focusedCell.textContent;
document.querySelectorAll('.cell').forEach(cell => { // if (focusedValue) {
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number'); // document.querySelectorAll('.cell').forEach(cell => {
}); // if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
} // });
} // }
if (selectedNumber) { // }
document.querySelectorAll('.cell').forEach(cell => { // if (selectedNumber) {
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number'); // document.querySelectorAll('.cell').forEach(cell => {
}); // if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
} // });
} // }
// }
// --- 게임 완료 및 모달 --- //
async function checkSolution() { // // --- 게임 완료 및 모달 ---
let answerString = ""; // async function checkSolution() {
boardElement.childNodes.forEach(cell => { // let answerString = "";
answerString += cell.textContent || '0'; // boardElement.childNodes.forEach(cell => {
}); // answerString += cell.textContent || '0';
// });
if (answerString.includes('0')) { //
alert('모든 칸을 채워주세요!'); // if (answerString.includes('0')) {
return; // alert('모든 칸을 채워주세요!');
} // return;
// }
try { //
const response = await fetch('/sudoku/validate', { // try {
method: 'POST', // // (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
headers: { 'Content-Type': 'application/json' }, // // (★ 수정) contextId(puzzleId) 변수 사용
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString }) // const response = await fetch('/puzzle/sudoku/validate', {
}); // method: 'POST',
const result = await response.json(); // headers: { 'Content-Type': 'application/json' },
if (result.correct) { // body: JSON.stringify({ puzzleId: currentContextId, answer: answerString })
clearInterval(timerInterval); // });
alert('🎉 정답입니다!'); // const result = await response.json();
showRankingModal(); // if (result.correct) {
} else { // clearInterval(timerInterval);
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.'); // alert('🎉 정답입니다!');
} // showRankingModal(); // 랭킹 모달 표시
} catch (error) { // } else {
console.error('정답 확인 중 오류 발생:', error); // alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
alert('정답 확인 중 오류가 발생했습니다.'); // }
} // } catch (error) {
} // console.error('정답 확인 중 오류 발생:', error);
// alert('정답 확인 중 오류가 발생했습니다.');
function checkIfBoardIsFull() { // }
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty'); // }
if (!emptyEditableCells) { //
checkSolution(); // function checkIfBoardIsFull() {
} // const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
} // if (!emptyEditableCells) {
// checkSolution();
completeBtn.addEventListener('click', checkSolution); // }
// }
async function showRankingModal() { // completeBtn.addEventListener('click', checkSolution);
modalOverlay.classList.remove('hidden'); // /**
document.getElementById('username-input').value = ''; // * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
submitRankBtn.disabled = false; // */
try { // async function showRankingModal() {
const response = await fetch(`/sudoku/ranking/${currentPuzzleId}`); // modalOverlay.classList.remove('hidden');
const rankings = await response.json(); // document.getElementById('username-input').value = '';
rankingList.innerHTML = ''; // submitRankBtn.disabled = false;
if (rankings.length === 0) { // rankingList.innerHTML = '<li>로딩 중...</li>';
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>'; //
} else { // try {
rankings.forEach((rank, index) => { // // (★ 수정) user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID(ContextId) 전달)
const li = document.createElement('li'); // const rankings = await fetchRanks(currentGameType, currentContextId);
const minutes = Math.floor(rank.completionTime / 60).toString().padStart(2, '0'); //
const seconds = (rank.completionTime % 60).toString().padStart(2, '0'); // rankingList.innerHTML = '';
li.innerHTML = `<span>${index + 1}위: ${rank.userName}</span> <span>${minutes}:${seconds}</span>`; // if (rankings.length === 0) {
rankingList.appendChild(li); // rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
}); // } else {
} // rankings.forEach((rank, index) => {
} catch (error) { // const li = document.createElement('li');
console.error('랭킹 조회 중 오류 발생:', error); // // (★ 수정) 공통 모델 필드(primaryScore)를 시간(초)으로 변환
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</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);
submitRankBtn.addEventListener('click', async () => { // });
const userName = document.getElementById('username-input').value.trim(); // }
if (!userName) return alert('이름을 입력해주세요.'); // } catch (error) {
try { // console.error('랭킹 조회 중 오류 발생:', error);
await fetch('/sudoku/complete', { // rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
method: 'POST', // }
headers: { 'Content-Type': 'application/json' }, // }
body: JSON.stringify({ //
puzzleId: currentPuzzleId, // /**
userName: userName, // * (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
completionTime: secondsElapsed // */
}) // submitRankBtn.addEventListener('click', async () => {
}); // const userName = document.getElementById('username-input').value.trim();
alert('랭킹이 성공적으로 등록되었습니다!'); // if (!userName) return alert('이름을 입력해주세요.');
showRankingModal(); //
submitRankBtn.disabled = true; // try {
} catch (error) { // // (★ 수정) user.js의 공통 submitRank 함수 호출
console.error('랭킹 등록 중 오류 발생:', error); // // 스도쿠의 주 점수(primaryScore)는 완료 시간(secondsElapsed), 보조 점수는 없음.
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.'); // await submitRank(currentGameType, currentContextId, userName, secondsElapsed, null);
} //
}); // alert('랭킹이 성공적으로 등록되었습니다!');
// showRankingModal(); // 랭킹 목록 새로고침
function resetGameView() { // submitRankBtn.disabled = true; // 중복 등록 방지
setupContainer.classList.remove('hidden'); // } catch (error) {
gameContainer.classList.add('hidden'); // console.error('랭킹 등록 중 오류 발생:', error);
numberInputButtons.classList.add('hidden'); // alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
clearInterval(timerInterval); // }
selectedNumber = null; // });
focusedCell = null; //
document.querySelectorAll('.cell').forEach(cell => { // function resetGameView() {
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); // setupContainer.classList.remove('hidden');
}); // gameContainer.classList.add('hidden');
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed')); // numberInputButtons.classList.add('hidden');
} // clearInterval(timerInterval);
// selectedNumber = null;
closeModalBtn.addEventListener('click', () => { // focusedCell = null;
modalOverlay.classList.add('hidden'); // document.querySelectorAll('.cell').forEach(cell => {
resetGameView(); // cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
}); // });
// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
retryBtn.addEventListener('click', () => { // }
gameOverModal.classList.add('hidden'); //
resetGameView(); // 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"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-8 col-12-narrower"> <div class="col-12">
<div id="content_inner"> <div id="content_inner">
<article> <article>
<section th:each="post : ${postsPage.content}"> <section th:each="post : ${postsPage.content}">

View File

@ -76,7 +76,7 @@
<button class="button" onclick="handleVote(this, 'like')"> <button class="button" onclick="handleVote(this, 'like')">
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>) 👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
</button> </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>) 👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
</button> </button>
</div> </div>

View File

@ -5,17 +5,14 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"> layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section id="banner"> <section id="banner"
th:styleappend="${randomBannerImage != null} ? 'background-image: url(\'' + @{${randomBannerImage}} + '\');' : ''">
<header> <header>
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2> <h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a> <a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
</header> </header>
</section> </section>
<!-- Highlights -->
<!-- Gigantic Heading -->
<section class="wrapper style2"> <section class="wrapper style2">
<div class="container"> <div class="container">
<header class="major"> <header class="major">
@ -31,24 +28,38 @@
</header> </header>
</div> </div>
</section> </section>
<!-- Posts -->
<section class="wrapper style2"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row" th:each="row : ${Posts}"> <div class="row">
<section class="col-6 col-12-narrower" th:each="post : ${row}"> <div class="col-12">
<!-- <span th:text="${cell}"></span>--> <div id="content_inner">
<div class="box post" onclick="goToViewer(this)" th:id="${post.id}"> <article>
<a href="#" class="image left"><img th:src="${#strings.length(post.thumb) > 0} ? ${post.thumb} : 'images/pic01.jpg'" alt="" /></a> <section th:each="post : ${Posts}">
<div class="inner"> <div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
<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> <span class="image left">
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p> <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
</div> </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> </div>
</section> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row gtr-200"> <div class="row gtr-200">
@ -82,7 +93,6 @@
</div> </div>
</div> </div>
</section> </section>
<!-- CTA -->
<section id="cta2" class="wrapper style3"> <section id="cta2" class="wrapper style3">
<div class="container"> <div class="container">
<header> <header>
@ -91,4 +101,4 @@
</div> </div>
</section> </section>
</th:block> </th:block>
</html> </html>

View File

@ -5,16 +5,186 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}" layout:decorate="~{layout/default_layout}"
> >
<th:block layout:fragment="head" id="head"> <head>
<script type="text/javascript" th:src="@{/js/2048.js}"></script> <link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/2048.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script> <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"> <th:block layout:fragment="content">
<h1>2048</h1> <h1>2048</h1>
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p> <p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
@ -24,18 +194,261 @@
</div> </div>
<div id="game-board"></div> <div id="game-board"></div>
<div id="game-over-popup" class="popup-container" style="display:none;"> <div id="game-over-popup" class="popup-container" style="display:none;">
<div class="popup"> <div class="popup">
<h2>게임 오버!</h2> <h2>게임 오버!</h2>
<p>최종 점수: <span id="final-score">0</span></p> <p>최종 점수: <span id="final-score">0</span></p>
<input type="text" id="player-name" placeholder="이름을 입력하세요"> <input type="text" id="player-name" placeholder="이름을 입력하세요">
<button id="save-score">점수 저장</button> <button id="save-score">점수 저장</button>
</div>
</div>
<div class="ranking-container">
<h3>🏆 랭킹</h3>
<ol id="ranking-list"></ol>
</div> </div>
</div> </div>
<div class="ranking-container"> <script type="text/javascript">
<h3>🏆 랭킹</h3> document.addEventListener('DOMContentLoaded', () => {
<ol id="ranking-list"></ol> // ... (DOM 요소 가져오기 - 동일)
</div> 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> </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" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}" layout:decorate="~{layout/default_layout}"
> >
<th:block layout:fragment="head" id="head"> <head layout:fragment="head" id="head">
<script type="text/javascript" th:src="@{/js/sudoku.js}"></script> <link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script> <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"> <th:block layout:fragment="content">
<div id="sudoku-game-app"> <div id="sudoku-game-app">
<div class="container"> <div class="container">
@ -76,5 +313,373 @@
</div> </div>
</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> </th:block>
</html> </body>
</html>

View File

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

View File

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

View File

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