diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index 70dc54c..f7bb44c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -29,6 +29,9 @@ import org.springframework.security.web.authentication.rememberme.JdbcTokenRepos import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository import org.springframework.web.ErrorResponse +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource import javax.sql.DataSource @@ -64,60 +67,86 @@ class SecurityConfig( } } + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration() + configuration.allowedOrigins = listOf("*") // 모든 도메인 허용 + configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") // 모든 HTTP 메서드 허용 + configuration.allowedHeaders = listOf("*") // 모든 헤더 허용 + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) // 모든 경로에 이 설정 적용 + return source + } + + @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { - http.csrf { csrf -> - // [수정] - // CSRF 보호 예외 목록에서 like/unlike 엔드포인트를 제거합니다. - // 이 엔드포인트들은 POST 요청이며 인증이 필요하므로, CSRF 보호를 받는 것이 올바릅니다. - // (common.js에서 X-CSRF-TOKEN 헤더를 정상적으로 보내고 있습니다.) - csrf.ignoringRequestMatchers( - "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", - "/blog/post/imageUpload.bjx", "/blog/post.bjx", - // "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨 - "/puzzle/**","/puzzle/play/**","/bums/save/**", - "/rank/**","/spider/**", - "/sudoku/**", - ) - }.authorizeHttpRequests { auth -> - auth - // [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다. - .requestMatchers(HttpMethod.GET, "/blog/comments/{commentId}/replies.bjx").permitAll() - .requestMatchers(HttpMethod.GET, "/blog/posts/{postId}/comments.bjx").permitAll() - .requestMatchers(HttpMethod.POST, "/blog/posts/{postId}/comments.bjx").permitAll() - .requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll() - .requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll() - .requestMatchers(HttpMethod.POST, "/bums/save/loc.api").permitAll() - // permitAll() 목록 - .requestMatchers( + // ★★★ 1. CORS 설정을 적용하도록 .cors {} 를 추가합니다. ★★★ + http.cors { } + .csrf { csrf -> + // [수정] 사용자의 요구사항대로 공개 POST API 목록을 CSRF 예외에 다시 추가합니다. + csrf.ignoringRequestMatchers( + "/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx", + "/blog/post/imageUpload.bjx", "/blog/post.bjx", + "/puzzle/**", // ★ 게임 관련 API (전체) + "/puzzle/play/**", // ★ + "/bums/save/**", // ★ 위치 저장 API + "/rank/**", // ★ 랭킹 API + "/sudoku/**" // ★ + ) + }.authorizeHttpRequests { auth -> + auth + // 1. 정적 리소스 = permitAll (변경 없음) + .requestMatchers( + "/webfonts/**", "/css/**", "/js/**", "/images/**", + "/webjars/**", "/assets/**" + ).permitAll() - "/", - "/home.bs", - "/bums/where.bs" , - "/tlg/repotToMe.bjx", - "/user/login.bs", "/user/signup.bs","/user/login.bjx", - "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", - // "/blog/post/images/**", // WebSecurityCustomizer에서 ignoring 처리되었으므로 여기서 제외해도 됩니다. - "/spider/new**", - "/rank/**","/sudoku/**","/spider/**", - "/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider", - "/webfonts/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() + // 2. 공개 GET API 및 페이지 = permitAll + .requestMatchers(HttpMethod.GET, + "/", "/home.bs", "/bums/where.bs", + "/user/login.bs", "/user/signup.bs", + "/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx", + "/blog/comments/{commentId}/replies.bjx", + "/blog/posts/{postId}/comments.bjx", + "/puzzle/play", "/puzzle/2048", "/puzzle/sudoku", "/puzzle/spider", + "/puzzle/**", // ★ 게임 GET 요청 전체 허용 + "/rank/**", // ★ 랭킹 GET 요청 전체 허용 + "/sudoku/**" // ★ + ).permitAll() - // 나머지 모든 요청은 인증이 필요합니다. - .anyRequest().authenticated() - }.formLogin { form -> - form.loginPage("/user/login.bs") - .defaultSuccessUrl("/", true) - .permitAll() - }.rememberMe { rememberMe -> - rememberMe.rememberMeServices(rememberMeServices()) - .key("remember-BsTs*!12@") // 보통 안전한 키 지정 - .tokenRepository(tokenRepository) - .tokenValiditySeconds(60 * 60 * 24 * 7) // 7일간 유효 - .userDetailsService(userManager) // 사용자 정보 서비스 지정 - }.logout { logout -> - logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() - } + // 3. 공개 POST API = permitAll (요구사항 반영) + .requestMatchers(HttpMethod.POST, + "/user/login.bjx", + "/user/joinUser.bjx", + "/tlg/repotToMe.bjx", + "/bums/save/loc.api", // ★ 위치 저장 POST 공개 + "/puzzle/spider/**", // ★ 스파이더 POST API(deal, undo 등) 전체 공개 + "/rank/**", // ★ 랭킹 제출 POST API 전체 공개 + "/sudoku/**" // ★ 스도쿠 POST API 전체 공개 + ).permitAll() + + // [중요] 블로그 '좋아요', '댓글 작성' 등은 위 permitAll 목록에 없으므로 + // 여전히 '4. 나머지 요청'으로 분류되어 인증 + CSRF 보호를 받습니다. + + // 4. 나머지 모든 요청 = authenticated (변경 없음) + .anyRequest().authenticated() + + }.formLogin { form -> + // (이하 formLogin, rememberMe, logout 설정은 동일) + form.loginPage("/user/login.bs") + .defaultSuccessUrl("/", true) + .permitAll() + }.rememberMe { rememberMe -> + rememberMe.rememberMeServices(rememberMeServices()) + .key("remember-BsTs*!12@") + .tokenRepository(tokenRepository) + .tokenValiditySeconds(60 * 60 * 24 * 7) + .userDetailsService(userManager) + }.logout { logout -> + logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll() + } return http.build() } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 6120884..f25dead 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -8,6 +8,8 @@ import com.google.maps.GeocodingApi import com.google.maps.model.LatLng import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import kotlinx.coroutines.reactive.awaitSingleOrNull +import kotlinx.coroutines.reactor.awaitSingleOrNull import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11 @@ -42,7 +44,9 @@ import java.net.URLDecoder import java.security.Principal import java.text.SimpleDateFormat import java.util.* - +import kr.lunaticbum.back.lun.model.ImageMeta // [신규 추가] +import kr.lunaticbum.back.lun.model.ImageMetaService // [신규 추가] +import javax.imageio.ImageIO // [신규 추가] @RestController @RequestMapping("/blog") @@ -50,6 +54,10 @@ class BlogController(private val commentService : CommentService) { companion object { val TEMPTOKEN = "TEMP_TOKEN_VIBUM" } + + @Autowired + private lateinit var imageMetaService: ImageMetaService + @Autowired lateinit var globalEvv : GlobalEnvironment @@ -355,7 +363,7 @@ class BlogController(private val commentService : CommentService) { * @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8로 설정 */ @GetMapping("posts") - fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가 + suspend fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가 val vm = ResultMV("content/blog/posts") try { val postsList: List @@ -368,13 +376,15 @@ class BlogController(private val commentService : CommentService) { if (isAdmin) { // [관리자]: 모든 버전의 글을 조회합니다. logService.log("User is ADMIN. Loading all post versions.") - postsList = postManager.findAllVersionsPaginated(pageable) - totalPosts = postManager.countAllVersions().block() ?: 0L + // 2. Use awaitSingleOrNull() instead of blocking assignment + postsList = postManager.findAllVersionsPaginated(pageable).awaitSingleOrNull() ?: emptyList() + totalPosts = postManager.countAllVersions().awaitSingleOrNull() ?: 0L // 3. Use awaitSingleOrNull() } else { // [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다. logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.") - postsList = postManager.findLatestUniquePaginated(pageable) - totalPosts = postManager.countLatestUnique().block() ?: 0L + // 4. Use awaitSingleOrNull() - THIS FIXES THE TYPE MISMATCH ERROR + postsList = postManager.findLatestUniquePaginated(pageable).awaitSingleOrNull() ?: emptyList() + totalPosts = postManager.countLatestUnique().awaitSingleOrNull() ?: 0L // 5. Use awaitSingleOrNull() } // if (principal != null) { // // [인증 사용자]: 모든 버전의 글을 조회합니다. @@ -556,25 +566,49 @@ class BlogController(private val commentService : CommentService) { out.write(bytes) out.flush() - // 썸네일 생성 및 저장 + // --- [신규 로직 시작] --- + try { + // 1. 저장된 원본 파일에서 이미지 크기(dimensions) 추출 + val savedFile = File(originalImagePath) + val bufferedImage = ImageIO.read(savedFile) + val imgWidth = bufferedImage.width + val imgHeight = bufferedImage.height + + // 2. ImageMeta 객체 생성 + val metadata = ImageMeta( + fileName = "$uuid.$extension", + originalFileName = upload.originalFilename, + fileType = upload.contentType, + fileSize = upload.size, + width = imgWidth, + height = imgHeight, + uploadTime = System.currentTimeMillis(), + path = "/blog/post/images/$uuid.$extension" // 이미지를 불러오는 GetMapping 경로 기준 + ) + + // 3. 메타데이터를 DB에 저장 (Reactive 호출을 동기식으로 대기) + imageMetaService.save(metadata).block() // .block()은 실제 프로덕션에서는 권장되지 않으나, 현재 컨트롤러 구조(동기식 반환)에 맞춤 + + } catch (metaError: Exception) { + // 메타데이터 저장에 실패해도 원본 이미지 업로드는 성공한 것으로 처리 (로그만 남김) + logService.log("Failed to save image metadata: ${metaError.message}") + metaError.printStackTrace() + } + // --- [신규 로직 끝] --- + + // 썸네일 생성 및 저장 (기존 로직) Thumbnails.of(originalImagePath) .width(200) // 가로 크기를 설정 .keepAspectRatio(true) .toFile(thumbnailPath) - logService.log("Original image saved: ${File(originalImagePath).exists()}") - logService.log("Thumbnail saved: ${File(thumbnailPath).exists()}") + // --- [신규 추가 로직 시작] --- + // 업로드가 완료되었으므로, 백그라운드 동기화 작업을 호출합니다. + // (혹시 모를 다른 누락 파일을 찾기 위함. 이미 실행 중이면 무시됨) + imageMetaService.launchSyncTask() + // --- [신규 추가 로직 끝] --- - // 메타데이터 읽기 (원본 이미지에서) - val metadata: Metadata? = ImageMetadataReader.readMetadata(File(originalImagePath)) - metadata?.let { - it.directories?.forEach { directory -> - logService.log(directory.name) - logService.log(directory.tags.map { tag -> - logService.log("tag.tagName >>> ${tag.tagName} || tag.description ${tag.description}") - }.joinToString(" \n")) - } - } + // ... (기존 메타데이터 읽기 로그) ... } catch (e: IOException) { e.printStackTrace() diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt new file mode 100644 index 0000000..0e2d844 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/GameRankController.kt @@ -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> { + 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 { + // contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리 + val effectiveContextId = if (contextId == "null") null else contextId + return gameRankService.getRanks(gameType, effectiveContextId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt index ef77c04..f977c69 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt @@ -6,6 +6,10 @@ import com.google.gson.JsonObject import com.google.gson.JsonParser import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kr.lunaticbum.back.lun.model.ImageMeta +import kr.lunaticbum.back.lun.model.ImageMetaService +import kr.lunaticbum.back.lun.model.Post import kr.lunaticbum.back.lun.model.PostManager import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.utils.LogService @@ -29,6 +33,9 @@ import java.net.URLDecoder @RequestMapping() class Home { + @Autowired + private lateinit var imageMetaService: ImageMetaService + @Autowired lateinit var logService: LogService @@ -71,7 +78,30 @@ class Home { suspend fun home() : ResultMV { val vm = ResultMV("content/home") try { - vm.modelMap.put("Posts", postManager.find8().apply { + try { + // 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다. + // DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다. + val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() // + + // 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다. + if (randomImage != null) { + vm.modelMap.put("randomBannerImage", randomImage.path) + } + // 3. else (null인 경우): 아무것도 하지 않습니다. + // 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다. + + } catch (e: Exception) { + // 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다. + logService.log("CRITICAL Error during random banner image query: ${e.message}") + } + + // === [FIXED LOGIC] === + // 1. Asynchronously await the Mono result without blocking the thread. + // Use awaitSingleOrNull() just like the random image query. + val postsList: List = postManager.find8().awaitSingleOrNull() ?: emptyList() + + // 2. Apply the processing logic to the resulting list. + vm.modelMap.put("Posts", postsList.apply { this.forEach { it.title = URLDecoder.decode(it.title) it.content = URLDecoder.decode(it.content) @@ -100,7 +130,8 @@ class Home { } it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" } - }.chunked(2)) + }) + // === [END FIXED LOGIC] === }catch (ex: Exception){ex.printStackTrace()} vm.modelMap.put("path","/blog/viewer/") return vm diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index 951760a..d45aa94 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -1,87 +1,171 @@ package kr.lunaticbum.back.lun.controllers import kotlinx.coroutines.reactor.awaitSingleOrNull -import kr.lunaticbum.back.lun.model.PuzzleService -import kr.lunaticbum.back.lun.model.ResultMV +import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import import org.springframework.http.ResponseEntity import org.springframework.ui.Model -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile +/** + * [통합 게임 API 허브 컨트롤러] + * 1. 모든 게임의 HTML 페이지 서빙 + * 2. 모든 게임의 플레이 로직 API (게임 시작, 검증, 상태 업데이트 등) 제공 + * (기존 SudokuController, SpiderController의 기능을 모두 통합) + */ @RestController -@RequestMapping("/puzzle") -class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입 +@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치 +class PuzzleController( + // 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음 + private val puzzleService: PuzzleService +) { + // ====================================================== + // 1. NONOGRAM API (기존 엔드포인트 유지) + // ====================================================== + + /** + * 노노그램: 이미지 업로드 및 퍼즐 생성 + */ @PostMapping("upload.bjx") suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity { return try { val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile) - ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환 + ResponseEntity.ok(savedPuzzle) } catch (e: Exception) { e.printStackTrace() - // 실패 시 500 에러와 메시지 반환 ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}") } } + /** - * 특정 ID의 퍼즐을 삭제하는 엔드포인트 - * @param id URL 경로에서 추출한 퍼즐의 고유 ID + * 노노그램: 퍼즐 ID로 삭제 */ @DeleteMapping("/{id}.bjx") suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity { return try { puzzleService.deletePuzzle(id) - // 성공적으로 삭제되면 204 No Content 응답을 보냅니다. ResponseEntity.noContent().build() } catch (e: Exception) { - // 실패 시 500 에러 응답 ResponseEntity.internalServerError().build() } } + // ====================================================== + // 2. SUDOKU API (★ SudokuController에서 마이그레이션됨) + // ====================================================== /** - * ID가 지정된 경우 특정 퍼즐을 로드합니다. + * 스도쿠: 새 게임 시작 (난이도별 문제 반환) + */ + @GetMapping("/sudoku/start") + suspend fun sudokuStartGame(@RequestParam(defaultValue = "easy") difficulty: String): PuzzleService.SudokuGameDto { + return puzzleService.sudoku_startGame(difficulty) + } + + /** + * 스도쿠: 사용자가 제출한 답안 검증 + */ + @PostMapping("/sudoku/validate") + suspend fun sudokuValidate(@RequestBody validateDto: PuzzleService.SudokuValidateDto): Map { + 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 { + 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): 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): SpiderGame { + val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.") + return puzzleService.spider_undoGame(gameId) + } + + // ====================================================== + // 4. 페이지 서빙 엔드포인트 (기존 로직 유지) + // ====================================================== + + /** + * 노노그램: 특정 ID의 퍼즐 플레이 페이지 */ @GetMapping("/play/{id}") suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV { val puzzle = puzzleService.findById(id).awaitSingleOrNull() - val vm = ResultMV("content/puzzle/play") + val vm = ResultMV("content/puzzle/nonogram") return if (puzzle != null) { vm.model.put("puzzle", puzzle) vm } else { - // DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트 vm.viewName = "redirect:/" vm } } /** - * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + * 노노그램: 랜덤 퍼즐 플레이 페이지 */ @GetMapping("/play") suspend fun playRandomPuzzlePage(): ResultMV { val puzzle = puzzleService.findRandomPuzzle() - val vm = ResultMV("content/puzzle/play") + val vm = ResultMV("content/puzzle/nonogram") return if (puzzle != null) { vm.model.put("puzzle", puzzle) vm } else { - // DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트 vm.viewName = "redirect:/" vm } } /** - * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + * 2048: 게임 페이지 서빙 */ @GetMapping("/2048") suspend fun play2048(): ResultMV { @@ -90,30 +174,27 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 } /** - * (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다. + * 스도쿠: 게임 페이지 서빙 */ @GetMapping("/sudoku") suspend fun sudoku(): ResultMV { val vm = ResultMV("content/puzzle/sudoku") return vm - - } - - @GetMapping("/sudoku_gen.bs") - suspend fun sudoku_gen(): ResultMV { - val vm = ResultMV("content/puzzle/sudoku_gen") - return vm - } + /** + * 스파이더: 게임 페이지 서빙 + */ @GetMapping("/spider") suspend fun spider(): ResultMV { val vm = ResultMV("content/puzzle/spider") return vm } - - @GetMapping("/","/upload.bs") + /** + * 메인 페이지 (노노그램 업로드) + */ + @GetMapping("/", "/upload.bs") suspend fun uploadPuzzle() : ResultMV { val vm = ResultMV("content/puzzle/upload") return vm diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt index 81ee354..6df11df 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/RankController.kt @@ -1,39 +1,39 @@ -package kr.lunaticbum.back.lun.controllers - -import kr.lunaticbum.back.lun.model.Rank -import kr.lunaticbum.back.lun.model.RankRepository -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - - -@RestController -@RequestMapping("/rank") -class RankController(val rankRepository: RankRepository) { -// private val rankRepository: RankRepository +//package kr.lunaticbum.back.lun.controllers // -// init { -// this.rankRepository = rankRepository +//import kr.lunaticbum.back.lun.model.Rank +//import kr.lunaticbum.back.lun.model.RankRepository +//import org.springframework.web.bind.annotation.* +//import reactor.core.publisher.Flux +//import reactor.core.publisher.Mono +// +// +//@RestController +//@RequestMapping("/rank") +//class RankController(val rankRepository: RankRepository) { +//// private val rankRepository: RankRepository +//// +//// init { +//// this.rankRepository = rankRepository +//// } +// +// /** +// * 새로운 랭킹을 저장합니다. +// * 요청 Body에 gameId가 포함되어야 합니다. +// * @param rank 저장할 랭크 정보 (gameId, name, score) +// * @return Mono +// */ +// @PostMapping("/ranks") +// fun saveRank(@RequestBody rank: Rank): Mono { // 👈 요청 Body는 Rank 모델을 그대로 사용 +// return rankRepository.save(rank) // } - - /** - * 새로운 랭킹을 저장합니다. - * 요청 Body에 gameId가 포함되어야 합니다. - * @param rank 저장할 랭크 정보 (gameId, name, score) - * @return Mono - */ - @PostMapping("/ranks") - fun saveRank(@RequestBody rank: Rank): Mono { // 👈 요청 Body는 Rank 모델을 그대로 사용 - return rankRepository.save(rank) - } - - /** - * 특정 게임의 상위 10개 랭킹 리스트를 조회합니다. - * @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다. - * @return Flux - */ - @GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가 - fun getRankingsByGameId(@PathVariable gameId: String): Flux { - return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId) - } -} \ No newline at end of file +// +// /** +// * 특정 게임의 상위 10개 랭킹 리스트를 조회합니다. +// * @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다. +// * @return Flux +// */ +// @GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가 +// fun getRankingsByGameId(@PathVariable gameId: String): Flux { +// return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt index 5c2c6cf..97d3c2e 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt @@ -1,64 +1,64 @@ -package kr.lunaticbum.back.lun.controllers - -import kr.lunaticbum.back.lun.model.SpiderGame -import kr.lunaticbum.back.lun.model.SpiderRank -import kr.lunaticbum.back.lun.model.SpiderService -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - -@RestController -@RequestMapping("/spider") -class SpiderController(private val spiderService: SpiderService,) { - - @GetMapping("/new") - fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono { - return spiderService.newGame(numSuits, numCards) - } - - @GetMapping("/{id}") - fun getGame(@PathVariable id: String): Mono { - return spiderService.getGame(id) - } - - @PostMapping("/update") - fun updateGame(@RequestBody game: SpiderGame): Mono { - return spiderService.updateGame(game) - } - - // 랭킹 등록 엔드포인트 - @PostMapping("/register") - fun registerRank(@RequestBody rank: SpiderRank): Mono> { - return spiderService.registerRank(rank) - .map { savedRank -> ResponseEntity.ok(savedRank) } - .onErrorResume(IllegalArgumentException::class.java) { e -> - Mono.just(ResponseEntity.badRequest().body(null)) - } - } - - // 게임 ID별 랭킹 조회 엔드포인트 - @GetMapping("/list/{gameId}") - fun getRanks(@PathVariable gameId: String): Flux { - return spiderService.getRanksByGameId(gameId) - } - - @PostMapping("/deal") - fun dealCards(@RequestBody request: Map): Mono { - val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required.")) - return spiderService.dealCardsFromStock(gameId) - } - - // 실행 취소 엔드포인트 추가 - @PostMapping("/undo") - fun undo(@RequestBody request: Map): Mono { - val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required.")) - return spiderService.undoGame(gameId) - } -} \ No newline at end of file +//package kr.lunaticbum.back.lun.controllers +// +//import kr.lunaticbum.back.lun.model.SpiderGame +//import kr.lunaticbum.back.lun.model.SpiderRank +//import kr.lunaticbum.back.lun.model.SpiderService +//import org.springframework.http.ResponseEntity +//import org.springframework.web.bind.annotation.GetMapping +//import org.springframework.web.bind.annotation.PathVariable +//import org.springframework.web.bind.annotation.PostMapping +//import org.springframework.web.bind.annotation.RequestBody +//import org.springframework.web.bind.annotation.RequestMapping +//import org.springframework.web.bind.annotation.RequestParam +//import org.springframework.web.bind.annotation.RestController +//import reactor.core.publisher.Flux +//import reactor.core.publisher.Mono +// +//@RestController +//@RequestMapping("/spider") +//class SpiderController(private val spiderService: SpiderService,) { +// +// @GetMapping("/new") +// fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono { +// return spiderService.newGame(numSuits, numCards) +// } +// +// @GetMapping("/{id}") +// fun getGame(@PathVariable id: String): Mono { +// return spiderService.getGame(id) +// } +// +// @PostMapping("/update") +// fun updateGame(@RequestBody game: SpiderGame): Mono { +// return spiderService.updateGame(game) +// } +// +// // 랭킹 등록 엔드포인트 +// @PostMapping("/register") +// fun registerRank(@RequestBody rank: SpiderRank): Mono> { +// return spiderService.registerRank(rank) +// .map { savedRank -> ResponseEntity.ok(savedRank) } +// .onErrorResume(IllegalArgumentException::class.java) { e -> +// Mono.just(ResponseEntity.badRequest().body(null)) +// } +// } +// +// // 게임 ID별 랭킹 조회 엔드포인트 +// @GetMapping("/list/{gameId}") +// fun getRanks(@PathVariable gameId: String): Flux { +// return spiderService.getRanksByGameId(gameId) +// } +// +// @PostMapping("/deal") +// fun dealCards(@RequestBody request: Map): Mono { +// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required.")) +// return spiderService.dealCardsFromStock(gameId) +// } +// +// // 실행 취소 엔드포인트 추가 +// @PostMapping("/undo") +// fun undo(@RequestBody request: Map): Mono { +// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required.")) +// return spiderService.undoGame(gameId) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt index dfef078..e7474c0 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SudokuController.kt @@ -1,36 +1,26 @@ -package kr.lunaticbum.back.lun.controllers -import kr.lunaticbum.back.lun.model.GameRecord -import kr.lunaticbum.back.lun.model.SudokuPuzzle -import kr.lunaticbum.back.lun.model.SudokuService -import org.springframework.web.bind.annotation.* - -@RestController -@RequestMapping("/sudoku") -class SudokuController(private val sudokuService: SudokuService) { - - @GetMapping("/start") - suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto { - return sudokuService.startGame(difficulty) - } - - @PostMapping("/complete") - suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) { - sudokuService.saveRecord(recordDto) - } - - @GetMapping("/ranking/{puzzleId}") - suspend fun getRankings(@PathVariable puzzleId: Long): List { - return sudokuService.getRankings(puzzleId) - } - - @PostMapping("/generate") - suspend fun generateSinglePuzzle(): SudokuPuzzle { - return sudokuService.generateAndSavePuzzle() - } - - @PostMapping("/validate") - suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map { - val isCorrect = sudokuService.validateSolution(validateDto) - return mapOf("correct" to isCorrect) - } -} \ No newline at end of file +//package kr.lunaticbum.back.lun.controllers +//import kr.lunaticbum.back.lun.model.GameRecord +//import kr.lunaticbum.back.lun.model.SudokuPuzzle +//import kr.lunaticbum.back.lun.model.SudokuService +//import org.springframework.web.bind.annotation.* +// +//@RestController +//@RequestMapping("/sudoku") +//class SudokuController(private val sudokuService: SudokuService) { +// +// @GetMapping("/start") +// suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto { +// return sudokuService.startGame(difficulty) +// } +// +// @PostMapping("/generate") +// suspend fun generateSinglePuzzle(): SudokuPuzzle { +// return sudokuService.generateAndSavePuzzle() +// } +// +// @PostMapping("/validate") +// suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map { +// val isCorrect = sudokuService.validateSolution(validateDto) +// return mapOf("correct" to isCorrect) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt new file mode 100644 index 0000000..aad7814 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt @@ -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 { + + fun save(gameRank: GameRank): Mono + // 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048) + fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc( + gameType: GameType, + contextId: String? + ): Flux + + // 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수) + fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc( + gameType: GameType, + contextId: String? + ): Flux +} + + +@Service +class GameRankService(private val rankRepository: GameRankRepository) { + /** + * 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. + */ + fun getRanks(gameType: GameType, contextId: String?): Flux { + 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 { + val gameRank = GameRank( + gameType = rankDto.gameType, + contextId = rankDto.contextId, + playerName = rankDto.playerName, + primaryScore = rankDto.primaryScore, + secondaryScore = rankDto.secondaryScore + ) + return rankRepository.save(gameRank) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt new file mode 100644 index 0000000..381d12d --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt @@ -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 { + + /** + * MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다. + */ + @Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ]) + fun findRandomImage(): Mono + + // [신규 추가] 파일 이름으로 문서를 찾는 기능 + fun findByFileName(fileName: String): Mono +} + +/** + * 이미지 메타데이터 로직을 처리할 서비스 + */ +@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 { + return repository.save(imageMeta) + } + + /** + * 공개 메소드: 랜덤 이미지 가져오기 (Home 컨트롤러에서 사용) + */ + fun getRandomImage(): Mono { + 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 블록이 실행되어 잠금이 해제됩니다. + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index d1e1588..e4e9116 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -28,10 +28,15 @@ import reactor.core.publisher.Mono import java.net.URLDecoder import java.time.Duration +import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가] +import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가] +import org.springframework.data.mongodb.core.index.Indexed // [신규 추가] + @Data @NoArgsConstructor @AllArgsConstructor @Document(collection = "Post") +@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}") class Post { @BsonId @BsonRepresentation(BsonType.OBJECT_ID) @@ -56,10 +61,14 @@ class Post { var firstAddress = "" var modifyAddress = "" + // [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가 + @Indexed(direction = IndexDirection.DESCENDING) var modifyTime : Long = 0 var modifyLat : Double = 0.0 var modifyLon : Double = 0.0 + // [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가 + @Indexed(direction = IndexDirection.DESCENDING) var readCount : Long = 0 var voteCount : Long = 0 var unlikeCount : Long = 0 @@ -184,24 +193,25 @@ class PostManager( /** * [이름 변경] find20 -> findAllVersionsPaginated * 인증된 사용자를 위한 메서드 (모든 버전 조회) + * [FIX]: Change return type to Mono> and remove the blocking call. */ - fun findAllVersionsPaginated(pageable :Pageable) : List { + fun findAllVersionsPaginated(pageable :Pageable) : Mono> { // <-- 1. Change return type println("pageSize >>> ${pageable.pageSize}") println("pageNumber >>> ${pageable.pageNumber}") return postRepository.findAllByOrderByModifyTimeDesc(pageable) .doOnNext { println(it) } // map 대신 doOnNext로 로그 출력 .collectList() // Flux → Mono> - .block(Duration.ofSeconds(30)) // Mono> → List - ?: listOf() + // .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK + // ?: listOf() } /** * 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회) + * [FIX]: This function should already be correct from the previous step. */ - fun findLatestUniquePaginated(pageable: Pageable) : List { + fun findLatestUniquePaginated(pageable: Pageable) : Mono> { // <-- Should already return Mono return postRepository.findLatestUniqueOriginPaginated(pageable) .collectList() - .block(Duration.ofSeconds(30)) ?: listOf() } /** @@ -272,12 +282,13 @@ class PostManager( /** * [로직 수정] * 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다. + * [FIX]: Return Mono> to match the updated callee. */ - fun find8() : List { + fun find8() : Mono> { // <-- 3. Change return type to Mono> // 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다. val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8 // 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다. - return this.findLatestUniquePaginated(pageRequest) + return this.findLatestUniquePaginated(pageRequest) // <-- 4. This now correctly returns the Mono } fun find20() : List { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index 681e9fd..d2c1152 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -3,8 +3,10 @@ package kr.lunaticbum.back.lun.model import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withContext import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite +import kr.lunaticbum.back.lun.utils.SudokuGenerator import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.repository.Aggregation @@ -12,7 +14,7 @@ import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.stereotype.Repository import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile -import reactor.core.publisher.Flux +import reactor.core.publisher.Flux // (★ 오류 수정: 누락된 Flux import 추가) import reactor.core.publisher.Mono import java.awt.Color import java.awt.image.BufferedImage @@ -20,93 +22,94 @@ import java.io.ByteArrayOutputStream import java.time.LocalDateTime import java.util.Base64 import javax.imageio.ImageIO +import kotlin.random.Random +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.data.mongodb.core.index.Indexed -@Document("puzzles") // "puzzles" 컬렉션에 매핑 +/** + * ====================================================== + * 1. NONOGRAM 모델 및 리포지토리 + * ====================================================== + */ + +@Document("puzzles") // "puzzles" 컬렉션 (노노그램용) data class NonogramPuzzle( @Id - val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언 + val id: String? = null, val solutionGrid: List>, val rowClues: List>, val colClues: List>, - // Add these two fields - val grayscaleImage: String, // Base64 encoded grayscale image - val originalImage: String, // Base64 encoded original color image - + val grayscaleImage: String, + val originalImage: String, val createdAt: LocalDateTime = LocalDateTime.now() - ) @Repository interface NonogramPuzzleRepository : ReactiveMongoRepository { - // ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공 - /** - * (★ Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는 - * 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다. - */ @Aggregation(pipeline = [ "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }", "{ \$sample: { size: 1 } }" ]) - fun findRandom(): Flux + fun findRandom(): Flux // (★ Flux를 인식하기 위해 import 필요) } +/** + * ====================================================== + * [통합 게임 서비스] + * 모든 게임(Nonogram, Sudoku, Spider)의 로직을 처리하는 단일 서비스. + * ====================================================== + */ @Service -class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입 +class PuzzleService( + // 1. Nonogram 의존성 + private val puzzleRepository: NonogramPuzzleRepository, + + // 2. Sudoku 의존성 + private val sudokuPuzzleRepository: SudokuPuzzleRepository, + + // 3. Spider 의존성 + private val spiderGameRepository: SpiderGameRepository +) { - // 퍼즐 크기의 최소/최대값을 상수로 정의하여 관리 용이성을 높입니다. companion object { private const val MIN_PUZZLE_SIZE = 10 private const val MAX_PUZZLE_SIZE = 30 } - /** - * 랜덤으로 퍼즐 하나를 찾아서 반환합니다. - * @return 찾은 퍼즐 또는 DB가 비어있으면 null - */ + // ====================================================== + // 1. NONOGRAM 서비스 로직 (기존 함수) + // ====================================================== + suspend fun findRandomPuzzle(): NonogramPuzzle? { return puzzleRepository.findRandom().awaitFirstOrNull() } - fun findById(id: String) = puzzleRepository.findById(id) - fun deletePuzzle(id : String) = puzzleRepository.deleteById(id) + fun findById(id: String): Mono = puzzleRepository.findById(id) // Nonogram 용 + + fun deletePuzzle(id : String): Mono = puzzleRepository.deleteById(id) // Nonogram 용 - /** - * (★ 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다. - */ suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle { val puzzleData = withContext(Dispatchers.IO) { val originalImage = ImageIO.read(file.inputStream) val imageWithBackground = convertTransparentToWhite(originalImage) - - // (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정 val puzzleSize = determinePuzzleSize(imageWithBackground) - - // Create a resized color version for the final reveal - val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display - - // 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성 + val resizedOriginal = resizeImage(imageWithBackground, 300) val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply { createGraphics().run { drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null) dispose() } } - val averageBrightness = calculateAverageBrightness(grayImage) val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness) - - // 결정된 puzzleSize를 사용하여 solutionGrid 생성 val solutionGrid = List(puzzleSize) { y -> List(puzzleSize) { x -> if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0 } } - val rowClues = solutionGrid.map { getCluesForLine(it) } val colClues = transpose(solutionGrid).map { getCluesForLine(it) } - - // Convert images to Base64 strings val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300)) val originalBase64 = imageToBase64(resizedOriginal) @@ -118,39 +121,23 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // originalImage = originalBase64 ) } - return puzzleRepository.save(puzzleData).awaitSingle() } - /** - * (★ 새로 추가된 함수) - * 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다. - * @param image 분석할 원본 이미지 - * @return 계산된 퍼즐 크기 (정수) - */ + // --- (★ 오류 수정: 축약되었던 Nonogram 헬퍼 함수 본문 전체 복원) --- + private fun determinePuzzleSize(image: BufferedImage): Int { val width = image.width.toDouble() val height = image.height.toDouble() - - // 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다. val maxDimension = maxOf(width, height) val minDimension = minOf(width, height) - - // 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상) val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0 - - // 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다. - // - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다. - // - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다. val baseSize = 15.0 - val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치 + val factor = 5.0 val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor) - - // 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다. return calculatedSize.toInt().coerceIn(MIN_PUZZLE_SIZE, MAX_PUZZLE_SIZE) } - // Helper function to resize images private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage { return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply { createGraphics().run { @@ -160,21 +147,16 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // } } - // Helper function to convert a BufferedImage to a Base64 String private fun imageToBase64(image: BufferedImage): String { val os = ByteArrayOutputStream() ImageIO.write(image, "png", os) return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()) } - /** - * (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다. - */ private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage { if (!sourceImage.colorModel.hasAlpha()) { return sourceImage } - return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply { createGraphics().also { g2d -> g2d.color = Color.WHITE @@ -185,9 +167,6 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // } } - /** - * 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255) - */ private fun calculateAverageBrightness(image: BufferedImage): Int { var totalBrightness: Long = 0 val width = image.width @@ -200,21 +179,14 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // return (totalBrightness / (width * height)).toInt() } - /** - * 평균 밝기에 따라 임계치를 조정합니다. - */ private fun determineAdaptiveThreshold(averageBrightness: Int): Int { return when { - // 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄 averageBrightness > 180 -> (averageBrightness * 0.9).toInt() - // 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄 averageBrightness < 80 -> (averageBrightness * 1.1).toInt() - // 보통 밝기의 이미지면 평균값을 그대로 사용 else -> averageBrightness } } - // --- 헬퍼 함수들 --- private fun getCluesForLine(line: List): List { val clues = mutableListOf() var count = 0 @@ -233,4 +205,262 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // private fun transpose(grid: List>): List> { 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 { + 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() + 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, numCards: String): Pair>, List> { + 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() } + 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>, + val stock: List, + val foundation: List>, + val moves: Int, + val isCompleted: Boolean, + val undoCount: Int = 0, // 실행 취소 횟수 + val undoHistory: List = emptyList(), // 게임 상태 히스토리 + val timestamp: Long = System.currentTimeMillis() +) + +data class SpiderCard( + val suit: String, + val rank: Int, + var isFaceUp: Boolean, +) + +// 게임 상태 히스토리 모델 +data class SpiderGameHistory( + val tableau: List>, + val stock: List, + val foundation: List>, + val moves: Int +) + +/** + * 스파이더 게임 상태 리포지토리 (PuzzleService에서 사용됨) + * (참고: 이 리포지토리는 Reactive 타입으로 유지하고, + * 서비스단에서 Coroutine으로 브리징하여 사용합니다.) + */ +interface SpiderGameRepository : ReactiveMongoRepository { + override fun findById(id: String): Mono +} + + +/** + * 스도쿠 퍼즐 원본 데이터를 저장하는 모델 + */ +@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 { + override suspend fun count(): Long + suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? + suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt index 1f02ddc..dc3e86b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt @@ -1,219 +1,22 @@ -// kr.lunaticbum.back.lun.model.Spider.kt - -package kr.lunaticbum.back.lun.model - -import org.springframework.data.annotation.Id -import org.springframework.data.mongodb.core.mapping.Document -import org.springframework.data.mongodb.repository.ReactiveMongoRepository -import org.springframework.stereotype.Service -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import kotlin.random.Random - -@Document(collection = "spider_games") -data class SpiderGame( - @Id - val id: String? = null, - val tableau: List>, - val stock: List, - val foundation: List>, - val moves: Int, - val isCompleted: Boolean, - val undoCount: Int = 0, // 실행 취소 횟수: 최대 5회 제한 - val undoHistory: List = emptyList(), // 게임 상태 히스토리 저장 - val timestamp: Long = System.currentTimeMillis() -) - -data class SpiderCard( - val suit: String, - val rank: Int, - var isFaceUp: Boolean, -) - -// 게임 상태 히스토리를 저장할 데이터 클래스 -data class SpiderGameHistory( - val tableau: List>, - val stock: List, - val foundation: List>, - val moves: Int -) - -interface SpiderGameRepository : ReactiveMongoRepository { - override fun findById(id: String): Mono -} - -@Service -class SpiderService( - private val spiderGameRepository: SpiderGameRepository, - private val spiderRankRepository: SpiderRankRepository -) { - // 덱 생성, 카드 분배 등 기존 로직은 그대로 유지 - private fun createDeck(numSuits: Int): List { - 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() - 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, numCards: String): Pair>, List> { - 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() } - - // 각 스택에 초기 카드를 분배합니다. - 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 { - 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 { - return spiderGameRepository.findById(id) - } - - // updateGame 메서드: 게임 상태를 업데이트하기 전에 히스토리를 저장합니다. - fun updateGame(game: SpiderGame): Mono { - 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 { - 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 { - 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 { - return spiderRankRepository.save(rank) - } - - fun getRanksByGameId(gameId: String): Flux { - 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 { - fun findByGameIdOrderByMovesAscCompletionTimeAsc(gameId: String): Flux -} \ No newline at end of file +//package kr.lunaticbum.back.lun.model +// +//import org.springframework.data.annotation.Id +//import org.springframework.data.mongodb.core.mapping.Document +//import org.springframework.data.mongodb.repository.ReactiveMongoRepository +//import reactor.core.publisher.Mono +//// (★ 삭제) Service, Flux, Random 관련 import 제거 +// +// +///* +// * (★ 삭제됨) @Service class SpiderService (...) +// * -> 모든 로직이 PuzzleService로 통합됨. +// */ +// +///* * (★ 삭제됨) data class SpiderRank (...) +// * -> 통합 GameRank 모델로 대체됨. +// */ +// +///* +// * (★ 삭제됨) interface SpiderRankRepository (...) +// * -> 통합 GameRankRepository로 대체됨. +// */ \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt index f271137..6813d89 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/SudokuPuzzle.kt @@ -1,136 +1,45 @@ -package kr.lunaticbum.back.lun.model - -import com.mongodb.DuplicateKeyException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.toList -import kr.lunaticbum.back.lun.utils.SudokuGenerator -import org.springframework.data.annotation.Id -import org.springframework.data.mongodb.core.index.Indexed -import org.springframework.data.mongodb.core.mapping.Document -import org.springframework.data.repository.kotlin.CoroutineCrudRepository -import org.springframework.stereotype.Repository -import org.springframework.stereotype.Service -import kotlin.random.Random - -@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정 -data class SudokuPuzzle( - @Id - val id: String? = null, // MongoDB의 고유 _id 필드 - val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용) - @Indexed(unique = true) - val puzzle: String? // 81자리 완성된 퍼즐 데이터 -) - - - -@Document(collection = "records") -data class GameRecord( - @Id - val id: String? = null, - val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조 - val userName: String, - val completionTime: Long // 완료 시간 (초) -) - -@Repository -interface SudokuPuzzleRepository : CoroutineCrudRepository { - // 전체 퍼즐 개수를 반환하는 suspend 함수 - override suspend fun count(): Long - // puzzleKey로 퍼즐을 찾는 suspend 함수 - suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? - // 👇 이 함수 선언을 추가해주세요. - suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? -} - -@Repository -interface GameRecordRepository : CoroutineCrudRepository { - // 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림) - fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow -} - -@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 { - // Flow를 최종적으로 List로 변환하여 반환 - return recordRepository.findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId).toList() - } - - private fun createQuestion(puzzle: String, holes: Int): String { - val chars = puzzle.toMutableList() - var remainingHoles = holes - while (remainingHoles > 0) { - val randomIndex = Random.nextInt(chars.size) - if (chars[randomIndex] != '0') { - chars[randomIndex] = '0' - remainingHoles-- - } - } - return chars.joinToString("") - } - - suspend fun generateAndSavePuzzle(): SudokuPuzzle { - var attempts = 0 - val maxAttempts = 10 // 중복 시 최대 10번 재시도 - - while (attempts < maxAttempts) { - try { - val puzzleString = SudokuGenerator().generate() - println("puzzleString >>> ${puzzleString}") - // DB에 저장하기 전에 가장 큰 puzzleKey를 찾아 +1 - val lastPuzzle = puzzleRepository.findTopByOrderByPuzzleKeyDesc() - val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L - - val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString) - return puzzleRepository.save(newPuzzle) - } catch (e: DuplicateKeyException) { - attempts++ - println("중복 퍼즐 생성됨, 재시도... ($attempts/$maxAttempts)") - } - } - throw IllegalStateException("새로운 고유 퍼즐 생성에 실패했습니다.") - } - - data class ValidateDto(val puzzleId: Long, val answer: String) - - suspend fun validateSolution(validateDto: ValidateDto): Boolean { - val originalPuzzle = puzzleRepository.findByPuzzleKey(validateDto.puzzleId) - ?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.") - - return originalPuzzle.puzzle == validateDto.answer - } -} +//package kr.lunaticbum.back.lun.model +// +//// (★ 삭제) Service, Record 관련 import 모두 제거 +//import org.springframework.data.annotation.Id +//import org.springframework.data.mongodb.core.index.Indexed +//import org.springframework.data.mongodb.core.mapping.Document +//import org.springframework.data.repository.kotlin.CoroutineCrudRepository +//import org.springframework.stereotype.Repository +// +///** +// * 스도쿠 퍼즐 원본 데이터를 저장하는 모델 +// */ +//@Document(collection = "puzzles") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름) +//data class SudokuPuzzle( +// @Id +// val id: String? = null, +// val puzzleKey: Long? = null, +// @Indexed(unique = true) +// val puzzle: String? // 81자리 완성된 퍼즐 데이터 +//) +// +///** +// * 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨) +// */ +//@Repository +//interface SudokuPuzzleRepository : CoroutineCrudRepository { +// override suspend fun count(): Long +// suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle? +// suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle? +//} +// +// +///* * (★ 삭제됨) data class GameRecord (...) +// * -> 통합 GameRank 모델로 대체됨. +// */ +// +///* +// * (★ 삭제됨) interface GameRecordRepository (...) +// * -> 통합 GameRankRepository로 대체됨. +// */ +// +///* +// * (★ 삭제됨) @Service class SudokuService (...) +// * -> 모든 로직이 PuzzleService로 통합됨. +// */ \ No newline at end of file diff --git a/src/main/resources/static/css/2048.css b/src/main/resources/static/css/2048.css index d4a0632..933d03f 100644 --- a/src/main/resources/static/css/2048.css +++ b/src/main/resources/static/css/2048.css @@ -1,156 +1,171 @@ -/* ================================= - 기본 및 전체 레이아웃 - ================================= */ -body { - font-family: Arial, sans-serif; - text-align: center; - background-color: #faf8ef; - color: #776e65; - margin: 0; - padding: 10px; - box-sizing: border-box; -} +/*!* =================================*/ +/* 기본 및 전체 레이아웃 (수정됨)*/ +/* ================================= *!*/ +/*body {*/ +/* !* (★ 삭제) font-family, text-align, background-color, color, margin, padding*/ +/* -> 이 속성들은 모두 common_game_theme.css에서 관리합니다.*/ +/* *!*/ +/* box-sizing: border-box;*/ +/*}*/ -h1 { - font-size: 15vw; - margin: 20px 0; -} +/*h1 {*/ +/* font-size: 15vw; !* 2048 고유의 큰 폰트 크기는 유지 *!*/ +/* margin: 20px 0;*/ +/* !* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 *!*/ +/*}*/ -.score-container { - font-size: 24px; - margin-bottom: 20px; -} +/*.score-container {*/ +/* font-size: 24px;*/ +/* margin-bottom: 20px;*/ +/*}*/ -/* ================================= - 게임 보드 (가장 중요한 부분) - ================================= */ -#game-board { - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-gap: 2vw; - width: 95vw; - max-width: 400px; - margin: 0 auto; - background-color: #bbada0; - padding: 2vw; - border-radius: 6px; - box-sizing: border-box; - aspect-ratio: 1 / 1; /* 정사각형 비율 유지 */ - touch-action: none; /* 모바일에서 터치 시 화면 확대/이동 방지 */ -} +/*!* =================================*/ +/* 게임 보드 (테마 적용)*/ +/* ================================= *!*/ +/*#game-board {*/ +/* display: grid;*/ +/* grid-template-columns: repeat(4, 1fr);*/ +/* grid-gap: 2vw;*/ +/* width: 95vw;*/ +/* max-width: 500px; !* (★ 수정) 400px -> 500px (다른 게임과 통일) *!*/ +/* margin: 0 auto;*/ -/* ================================= - 타일 공통 스타일 - ================================= */ -.tile { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - font-weight: bold; - border-radius: 3px; - background-color: #cdc1b4; - font-size: 7vw; -} +/* !* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 *!*/ +/* background-color: #b0bec5; !* #bbada0 (갈색) -> #b0bec5 (블루 그레이) *!*/ -/* ================================= - 타일 색상 - ================================= */ -.tile-2 { background-color: #eee4da; color: #776e65; } -.tile-4 { background-color: #ede0c8; color: #776e65; } -.tile-8 { background-color: #f2b179; color: #f9f6f2; } -.tile-16 { background-color: #f59563; color: #f9f6f2; } -.tile-32 { background-color: #f67c5f; color: #f9f6f2; } -.tile-64 { background-color: #f65e3b; color: #f9f6f2; } -.tile-128 { background-color: #edcf72; color: #f9f6f2; } -.tile-256 { background-color: #edcc61; color: #f9f6f2; } -.tile-512 { background-color: #edc850; color: #f9f6f2; } -.tile-1024 { background-color: #edc53f; color: #f9f6f2; } -.tile-2048 { background-color: #edc22e; color: #f9f6f2; } -.tile-4096 { background-color: #3c3a32; color: #f9f6f2; } -.tile-8192 { background-color: #ff3333; color: #f9f6f2; } -.tile-16384 { background-color: #0077cc; color: #f9f6f2; } -.tile-32768 { background-color: #9900cc; color: #f9f6f2; } -.tile-4096, .tile-8192 { font-size: 6vw; } -.tile-16384, .tile-32768 { font-size: 5vw; } +/* padding: 2vw;*/ +/* border-radius: 6px;*/ +/* box-sizing: border-box;*/ +/* aspect-ratio: 1 / 1;*/ +/* touch-action: none;*/ + +/* !* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 *!*/ +/* box-shadow: 0 4px 10px rgba(0,0,0,0.08);*/ +/*}*/ + +/*@media (min-width: 481px) {*/ +/* #game-board {*/ +/* grid-gap: 10px;*/ +/* padding: 10px;*/ +/* }*/ +/*}*/ -/* ================================= - 게임 오버 팝업 - ================================= */ -.popup-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 100; -} -.popup { - background-color: #faf8ef; - padding: 20px; - border-radius: 10px; - text-align: center; - width: 80vw; - max-width: 300px; -} -.popup input { - width: 100%; - padding: 10px; - margin: 10px 0; - border: 1px solid #ccc; - border-radius: 5px; - box-sizing: border-box; -} -.popup button { - padding: 10px 20px; - background-color: #8f7a66; - color: white; - border: none; - border-radius: 5px; - cursor: pointer; -} +/*!* =================================*/ +/* 타일 공통 스타일 (테마 적용)*/ +/* ================================= *!*/ +/*.tile {*/ +/* width: 100%;*/ +/* height: 100%;*/ +/* display: flex;*/ +/* justify-content: center;*/ +/* align-items: center;*/ +/* font-weight: bold;*/ +/* border-radius: 3px;*/ -/* ================================= - 랭킹 리스트 - ================================= */ -.ranking-container { - width: 100%; - max-width: 400px; - margin: 30px auto; - text-align: left; -} -.ranking-container h3 { - text-align: center; -} -#ranking-list { - list-style-type: none; - padding: 0; -} -#ranking-list li { - background-color: #eee4da; - margin-bottom: 5px; - padding: 10px; - border-radius: 5px; - display: flex; - justify-content: space-between; -} +/* !* (★ 수정) 빈 타일 색상 변경 *!*/ +/* background-color: #eceff1; !* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) *!*/ -/* ================================= - 반응형: PC & 태블릿 (화면이 481px 이상일 때) - ================================= */ -@media (min-width: 481px) { - h1 { font-size: 80px; } - #game-board { - grid-gap: 10px; - padding: 10px; - } - .tile { font-size: 45px; } - .tile-4096, .tile-8192 { font-size: 35px; } - .tile-16384, .tile-32768 { font-size: 30px; } -} \ No newline at end of file +/* 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;*/ +/*}*/ \ No newline at end of file diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css index 798470c..87edb42 100644 --- a/src/main/resources/static/css/common.css +++ b/src/main/resources/static/css/common.css @@ -535,4 +535,17 @@ a.btn_layerClose:hover { cursor: pointer; font-size: 0.85em; font-weight: bold; +} +@media screen and (max-width: 480px) { + .vote-controls { + display: flex; /* Flexbox 컨테이너로 변경 */ + gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */ + } + + .vote-controls .button { + width: auto; /* main.css의 width: 100% 덮어쓰기 */ + display: inline-block; /* main.css의 display: block 덮어쓰기 */ + flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */ + margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */ + } } \ No newline at end of file diff --git a/src/main/resources/static/css/common_game_theme.css b/src/main/resources/static/css/common_game_theme.css new file mode 100644 index 0000000..05b081f --- /dev/null +++ b/src/main/resources/static/css/common_game_theme.css @@ -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; +} + +/* (★ 통일) 모든 ` - : ''; // 대댓글에는 "답글" 버튼 표시 안 함 + : ''; return `
@@ -1120,15 +1239,14 @@ function createCommentHTML(comment, isReply = false) {

${safeContent}

`; } + /** - * [신규 추가] "답글 달기" 버튼 클릭 시 호출되는 헬퍼 함수 - * @param {string} commentId - 부모가 될 댓글의 ID - * @param {string} writerName - 부모 댓글 작성자명 + * "답글 달기" 버튼 클릭 시 호출 (바닐라 JS) + * 전역 변수에 부모 ID를 설정하고 UI(상태바)를 업데이트합니다. */ function setReplyTarget(commentId, writerName) { currentReplyParentId = commentId; // 전역 변수(상태) 설정 - // UI 업데이트 const statusBar = document.getElementById('reply-status-bar'); const statusText = document.getElementById('reply-status-text'); const commentInput = document.getElementById('comment-input'); @@ -1141,7 +1259,7 @@ function setReplyTarget(commentId, writerName) { } /** - * [신규 추가] 답글 달기 "취소" 시 호출되는 헬퍼 함수 + * 답글 달기 "취소" 시 호출 (바닐라 JS) */ function cancelReply() { currentReplyParentId = null; // 상태 초기화 @@ -1151,4 +1269,65 @@ function cancelReply() { } } -/* ============================================= */ + + + +/** + * ============================================== + * user.js (공통 API 및 유틸리티 모듈) + * (모든 게임 페이지에서 공통으로 로드됨) + * ============================================== + */ + +/** + * [신규] 통합 랭킹 API (POST /api/ranks/submit)를 호출하는 공통 함수 + * 모든 게임(2048, 스도쿠, 스파이더, 노노그램)이 이 함수를 사용합니다. + * + * @param {string} gameType - (필수) GameType Enum (예: 'GAME_2048', 'SUDOKU', 'SPIDER', 'NONOGRAM') + * @param {string | null} contextId - (선택) 게임의 세부 ID (예: 스도쿠/노노그램 퍼즐 ID) + * @param {string} playerName - (필수) 사용자 이름 + * @param {number} primaryScore - (필수) 주 점수 (게임별 의미 다름: 2048=점수, 스도쿠=시간) + * @param {number | null} secondaryScore - (선택) 보조 점수 (예: 스파이더=시간, 노노그램=남은포인트) + * @returns {Promise} 저장된 랭킹 데이터 (JSON) + */ +async function submitRank(gameType, contextId, playerName, primaryScore, secondaryScore = null) { + const rankDto = { + gameType: gameType, + contextId: contextId, + playerName: playerName, + primaryScore: primaryScore, + secondaryScore: secondaryScore + }; + + console.log("Submitting Rank:", rankDto); + + const response = await fetch('/api/ranks/submit', { // ★ 통합 API 엔드포인트 + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rankDto), + }); + + if (!response.ok) { + throw new Error('랭킹 등록에 실패했습니다.'); + } + return response.json(); +} + +/** + * [신규] 통합 랭킹 API (GET /api/ranks/list)를 호출하는 공통 함수 + * + * @param {string} gameType - (필수) GameType Enum + * @param {string | null} contextId - (선택) 조회할 세부 ID + * @returns {Promise} 랭킹 배열 (JSON) + */ +async function fetchRanks(gameType, contextId = null) { + // contextId가 null이거나 undefined일 경우 "null" 문자열로 전송되는 것을 방지 + const contextParam = (contextId !== null && contextId !== undefined) ? `&contextId=${contextId}` : ''; + + const response = await fetch(`/api/ranks/list?gameType=${gameType}${contextParam}`); // ★ 통합 API 엔드포인트 + + if (!response.ok) { + throw new Error('랭킹 로드에 실패했습니다.'); + } + return response.json(); +} diff --git a/src/main/resources/static/js/nonogram.js b/src/main/resources/static/js/nonogram.js new file mode 100644 index 0000000..fe126f1 --- /dev/null +++ b/src/main/resources/static/js/nonogram.js @@ -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 = '

오류: 퍼즐 데이터를 불러올 수 없습니다.

'; +// } +// 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('
'); +// 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('
'); +// 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}`; +// } +// }); +// }); +// }); \ No newline at end of file diff --git a/src/main/resources/static/js/play.js b/src/main/resources/static/js/play.js deleted file mode 100644 index 80a896c..0000000 --- a/src/main/resources/static/js/play.js +++ /dev/null @@ -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 = '

오류: 퍼즐 데이터를 불러올 수 없습니다.

'; - 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('
'); - 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); - }); -}); \ No newline at end of file diff --git a/src/main/resources/static/js/spider.js b/src/main/resources/static/js/spider.js index d7c5e5c..ae613f7 100644 --- a/src/main/resources/static/js/spider.js +++ b/src/main/resources/static/js/spider.js @@ -1,1009 +1,1083 @@ -// src/main/resources/static/js/spider.js - -document.addEventListener('DOMContentLoaded', () => { - - // ======================================= - // 1. 상수 및 변수 선언 - // ======================================= - console.log("DOM 콘텐츠가 로드되었습니다. 초기 설정 시작."); - - // HTML 요소 - const canvas = document.getElementById('gameCanvas'); - const ctx = canvas.getContext('2d'); - - // ** UI 버튼 및 영역의 논리적 위치 정의 (캔버스 내 좌표) ** - const UI_ELEMENTS = {}; - - // ** 비율 상수 정의 ** - const CARD_WIDTH_RATIO = 1 / 11.5; - const CARD_HEIGHT_RATIO = 1.4; - const CARD_GAP_X_RATIO = 0.15; - const CARD_OVERLAP_Y_RATIO = 0.3; - const CARD_RANK_LEFT_PADDING = 0.1; - const CARD_RANK_TOP_PADDING = 0.1; - const CARD_SYMBOL_TOP_PADDING = 0.25; - const CARD_SYMBOL_BOTTOM_PADDING = 0.15; - const FOUNDATION_CARD_SPACING = 0.2; // 카드 겹침 비율 (20%만 보이게) - const FOUNDATION_WIDTH_RATIO = 0.45; // 파운데이션 영역 최대 너비 - - // 게임 상태 및 데이터 - let gameId = null; - let currentGame = null; - let isGameCompleted = false; // 게임 완료 상태 변수 - const API_BASE_URL = '/spider'; - - // 동적으로 계산될 레이아웃 변수 - let cardWidth = 0; - let cardHeight = 0; - let cardGapX = 0; - let cardOverlapY = 0; - let totalTableauWidth = 0; - let tableauStartX = 0; - - // 드래그 및 애니메이션 관련 변수 - let isAnimating = false; - let isDragging = false; - let dragStartX = 0; - let dragStartY = 0; - const DRAG_THRESHOLD = 5; - let draggedCards = []; - let dragOffsetX = 0; - let dragOffsetY = 0; - let animatedCard = null; - let animationProgress = 0; - let completedStackCards = []; - let isAnimatingCompletion = false; - - // 하단 정렬을 위한 Y 좌표 기준 - const BOTTOM_ROW_Y_RATIO = 0.9; - let dpr = 1; - const MAX_UNDO_COUNT = 5; - - // 카드 뒷면 이미지 로드 - const cardBackImage = new Image(); - cardBackImage.src = '../assets/css/images/card-back.png'; - let assetsLoaded = false; - cardBackImage.onload = () => { - assetsLoaded = true; - resizeCanvas(); - draw(); - console.log("카드 뒷면 이미지가 로드되었습니다."); - }; - - // UI 옵션 데이터 - const suitOptions = [{ value: 1, text: '1개' }, { value: 2, text: '2개' }, { value: 4, text: '4개' }]; - const cardDistributionOptions = { - '1': [{ value: '4,3', text: '쉬움' }, { value: '5,4', text: '보통' }, { value: '6,5', text: '어려움' }], - '2': [{ value: '5,4', text: '쉬움' }, { value: '6,5', text: '보통' }, { value: '7,6', text: '어려움' }], - '4': [{ value: '6,5', text: '쉬움' }, { value: '7,6', text: '보통' }, { value: '8,7', text: '어려움' }] - }; - let selectedSuit = 1; - let selectedCardCount = '4,3'; - - // ======================================= - // 2. 렌더링 (그리기) 관련 함수 - // ======================================= - console.log("렌더링 관련 함수 정의 시작."); - - // 캔버스 크기 조정 및 레이아웃 변수 계산 - function resizeCanvas() { - console.log("resizeCanvas 함수 호출."); - const size = Math.min(window.innerWidth, window.innerHeight) * 0.95; - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - dpr = window.devicePixelRatio || 1; - canvas.width = size * dpr; - canvas.height = size * dpr; - ctx.scale(dpr, dpr); - const logicalWidth = size; - const logicalHeight = size; - cardWidth = logicalWidth * CARD_WIDTH_RATIO; - cardHeight = cardWidth * CARD_HEIGHT_RATIO; - cardGapX = cardWidth * CARD_GAP_X_RATIO; - cardOverlapY = cardHeight * CARD_OVERLAP_Y_RATIO; - totalTableauWidth = cardWidth * 10 + cardGapX * 9; - tableauStartX = (logicalWidth - totalTableauWidth) / 2; - - // ** UI 요소 위치 재계산 (캔버스 내에서) ** - const buttonWidth = logicalWidth * 0.2; - const buttonHeight = logicalHeight * 0.05; - const buttonGap = 10; - const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2; - const startY = logicalHeight * 0.05; - - // 게임 시작 전 난이도 설정 UI - UI_ELEMENTS.difficultyUI = { - x: logicalWidth / 2 - (buttonWidth * 1.5 + buttonGap), - y: logicalHeight * 0.45, - width: (buttonWidth * 3 + buttonGap * 2), - height: buttonHeight - }; - UI_ELEMENTS.suitSelect = { x: startX, y: startY, width: buttonWidth, height: buttonHeight }; - UI_ELEMENTS.cardCountSelect = { x: startX + buttonWidth + buttonGap, y: startY, width: buttonWidth, height: buttonHeight }; - UI_ELEMENTS.startButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY, width: buttonWidth, height: buttonHeight }; - - // 하단 UI 요소들의 기준 Y 좌표 - const bottomY = logicalHeight * BOTTOM_ROW_Y_RATIO; - const itemSpacing = 20; - - // 파운데이션 영역 - const foundationX = logicalWidth * 0.05; - const foundationAreaWidth = logicalWidth * FOUNDATION_WIDTH_RATIO; // 화면 너비의 45%로 제한 - UI_ELEMENTS.foundationArea = { - x: foundationX, - y: bottomY, - width: foundationAreaWidth, - height: cardHeight - }; - - const undoButtonWidth = cardWidth * 0.8; - const undoButtonHeight = cardHeight * 0.5; - const undoCountDisplayWidth = cardWidth * 0.5; - - // 실행 취소/게임 포기 버튼 위치 - const undoButtonX = logicalWidth * 0.5 - (undoButtonWidth + undoCountDisplayWidth + itemSpacing) / 2; - UI_ELEMENTS.undoButton = { - x: undoButtonX, - y: bottomY + (cardHeight - undoButtonHeight) / 2, - width: undoButtonWidth, - height: undoButtonHeight - }; - - // 취소 횟수 표시 위치 - UI_ELEMENTS.undoCountDisplay = { - x: undoButtonX + undoButtonWidth + itemSpacing, - y: bottomY + (cardHeight - undoButtonHeight) / 2, - width: undoCountDisplayWidth, - height: undoButtonHeight - }; - - // 스톡 영역 - const stockX = logicalWidth * 0.95 - cardWidth; - UI_ELEMENTS.stockArea = { - x: stockX, - y: bottomY, - width: cardWidth, - height: cardHeight - }; - - // 게임 완료 버튼 위치 - UI_ELEMENTS.restartButton = { - x: logicalWidth / 2 - buttonWidth / 2, - y: logicalHeight / 2 + 50, - width: buttonWidth, - height: buttonHeight - }; - - console.log(`캔버스 논리적 크기: ${size}x${size}, 캔버스 물리적 해상도: ${canvas.width}x${canvas.height}`); - } - - window.addEventListener('resize', resizeCanvas); - - // 메인 그리기 루프 - function draw() { - if (!assetsLoaded) { - requestAnimationFrame(draw); - return; - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - if (currentGame) { - drawGame(currentGame); - } - - drawUI(); - - requestAnimationFrame(draw); - } - - // UI 요소를 캔버스에 직접 그리는 함수 - function drawUI() { - if (!currentGame) { - // 게임 시작 전 난이도 선택 UI - const suitSelect = UI_ELEMENTS.suitSelect; - ctx.fillStyle = '#f0f0f0'; - ctx.fillRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); - ctx.strokeStyle = '#333'; - ctx.strokeRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); - ctx.fillStyle = '#000'; - ctx.font = '16px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2); - - const cardCountSelect = UI_ELEMENTS.cardCountSelect; - ctx.fillStyle = '#f0f0f0'; - ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); - ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); - ctx.fillStyle = '#000'; - ctx.fillText(`카드: ${cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount).text}`, - cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2); - - const startButton = UI_ELEMENTS.startButton; - ctx.fillStyle = '#4CAF50'; - ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height); - ctx.strokeStyle = '#333'; - ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height); - ctx.fillStyle = '#fff'; - ctx.fillText('새 게임 시작', startButton.x + startButton.width / 2, startButton.y + startButton.height / 2); - } else { - // 게임 중 하단 UI - const undoButton = UI_ELEMENTS.undoButton; - const undoCountDisplay = UI_ELEMENTS.undoCountDisplay; - - const isUndoPossible = currentGame.undoHistory.length > 0; - const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible; - const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT; - - if (isUndoEnabled) { - const buttonText = '실행 취소'; - const buttonColor = '#ff9800'; - ctx.fillStyle = buttonColor; - ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.strokeStyle = '#333'; - ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.fillStyle = '#fff'; - ctx.font = '14px Arial'; - ctx.fillText(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); - - // 남은 취소 횟수 표시 - const remainingUndos = MAX_UNDO_COUNT - currentGame.undoCount; - ctx.fillStyle = '#fff'; - ctx.font = '20px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(`${remainingUndos}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2); - } else if (isSurrender) { - const buttonText = '게임 포기'; - const buttonColor = '#f44336'; - ctx.fillStyle = buttonColor; - ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.strokeStyle = '#333'; - ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); - ctx.fillStyle = '#fff'; - ctx.font = '14px Arial'; - ctx.fillText(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); - } - } - } - - // 전체 게임 화면을 그리는 메인 함수 - function drawGame(game) { - drawBackground(); - drawTableau(game.tableau); - drawStockAndFoundation(game.stock, game.foundation); - drawDraggedCards(draggedCards); - drawCompletionAnimation(); - - // 게임 완료 시 메시지 표시 - if (isGameCompleted) { - drawCompletionMessage(); - } - } - - // 게임 완료 메시지 그리기 - function drawCompletionMessage() { - const logicalWidth = canvas.width / dpr; - const logicalHeight = canvas.height / dpr; - - // 반투명한 배경 - ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(0, 0, logicalWidth, logicalHeight); - - // 메시지 텍스트 - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 36px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('게임 완료! 축하합니다!', logicalWidth / 2, logicalHeight / 2); - - // '새 게임 시작' 버튼 그리기 - const restartButton = UI_ELEMENTS.restartButton; - ctx.fillStyle = '#4CAF50'; - ctx.fillRect(restartButton.x, restartButton.y, restartButton.width, restartButton.height); - ctx.strokeStyle = '#fff'; - ctx.strokeRect(restartButton.x, restartButton.y, restartButton.width, restartButton.height); - ctx.fillStyle = '#fff'; - ctx.font = '20px Arial'; - ctx.fillText('다시 시작', restartButton.x + restartButton.width / 2, restartButton.y + restartButton.height / 2); - } - - // 게임 배경 그리기 - function drawBackground() { - ctx.fillStyle = '#008000'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - - // 테이블 스택 그리기 - function drawTableau(tableau) { - const startY = cardHeight * 0.5 - const draggingCards = isDragging ? new Set(draggedCards) : null; - tableau.forEach((stack, stackIndex) => { - stack.forEach((card, cardIndex) => { - if (draggingCards && draggingCards.has(card)) { - return; - } - const x = tableauStartX + stackIndex * (cardWidth + cardGapX); - const y = startY + cardIndex * cardOverlapY; - card.touchHeight = (cardIndex === stack.length - 1) ? cardHeight : cardOverlapY; - drawSingleCard(card, x, y); - }); - }); - } - - // 드래그 중인 카드 묶음 그리기 - function drawDraggedCards(cards) { - if (!isDragging || !Array.isArray(cards) || cards.length === 0) { - return; - } - cards.forEach((card, index) => { - const x = cards[0].x; - const y = cards[0].y + index * cardOverlapY; - drawSingleCard(card, x, y); - }); - } - - // 완성된 스택 애니메이션 그리기 - function drawCompletionAnimation() { - if (isAnimatingCompletion) { - const now = Date.now(); - completedStackCards = completedStackCards.filter(card => { - if (now < card.animEndTime) { - const progress = (now - (card.animEndTime - 500)) / 500; - const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress; - const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress; - drawSingleCard(card, currentX, currentY); - return true; - } else { - return false; - } - }); - if (completedStackCards.length === 0) { - isAnimatingCompletion = false; - } - } - } - - // 단일 카드 그리기 (좌표와 크기를 카드 객체에 저장) - function drawSingleCard(card, x, y) { - card.x = x; - card.y = y; - card.width = cardWidth; - card.height = cardHeight; - if (card.isFaceUp) { - ctx.fillStyle = '#ffffff'; - ctx.fillRect(x, y, cardWidth, cardHeight); - ctx.strokeStyle = '#333333'; - ctx.strokeRect(x, y, cardWidth, cardHeight); - const isRed = (card.suit === 'heart' || card.suit === 'diamond'); - ctx.fillStyle = isRed ? '#ff0000' : '#000000'; - ctx.font = `${cardWidth * 0.25}px Arial`; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING); - drawSuitSymbols(card, x, y); - } else { - ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); - } - } - - function drawSuitSymbols(card, x, y) { - const symbol = getSuitSymbol(card.suit); - let symbolSize; - if (card.rank >= 2 && card.rank <= 5) { - symbolSize = cardWidth * 0.2; - } else { - symbolSize = cardWidth * 0.15; - } - ctx.font = `${symbolSize}px Arial`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = (card.suit === 'heart' || card.suit === 'diamond') ? '#ff0000' : '#000000'; - const symbolAreaY = y + cardHeight * CARD_SYMBOL_TOP_PADDING; - const symbolAreaHeight = cardHeight * (1 - CARD_SYMBOL_TOP_PADDING - CARD_SYMBOL_BOTTOM_PADDING); - const symbolAreaMiddleY = symbolAreaY + symbolAreaHeight / 2; - const symbolAreaLeftX = x + cardWidth * 0.25; - const symbolAreaRightX = x + cardWidth * 0.75; - const symbolGapY = symbolAreaHeight / 3; - const positions = { - top: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, - bottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, - center: { x: x + cardWidth / 2, y: symbolAreaMiddleY }, - leftTop: { x: symbolAreaLeftX, y: symbolAreaY + symbolGapY * 0.5 }, - rightTop: { x: symbolAreaRightX, y: symbolAreaY + symbolGapY * 0.5 }, - leftCenter: { x: symbolAreaLeftX, y: symbolAreaMiddleY }, - rightCenter: { x: symbolAreaRightX, y: symbolAreaMiddleY }, - leftBottom: { x: symbolAreaLeftX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, - rightBottom: { x: symbolAreaRightX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, - middleTop: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, - middleBottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 } - }; - - switch (card.rank) { - case 1: case 11: case 12: case 13: - ctx.font = `${cardWidth * 0.6}px Arial`; - ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2); - break; - case 2: - ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); - ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); - break; - case 3: - ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); - ctx.fillText(symbol, positions.center.x, positions.center.y); - ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); - break; - case 4: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - break; - case 5: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.center.x, positions.center.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - break; - case 6: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); - ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - break; - case 7: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); - ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - ctx.fillText(symbol, positions.center.x, positions.center.y); - break; - case 8: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); - ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); - ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); - break; - case 9: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); - ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - ctx.fillText(symbol, positions.center.x, positions.center.y); - ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); - ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); - break; - case 10: - ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); - ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); - ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); - ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); - ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); - ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); - ctx.fillText(symbol, positions.top.x, positions.top.y); - ctx.fillText(symbol, positions.bottom.x, positions.bottom.y); - ctx.fillText(symbol, positions.middleTop.x, (positions.top.y + symbolSize + positions.center.y) / 2 ); - ctx.fillText(symbol, positions.middleBottom.x, ((positions.bottom.y + positions.center.y) - symbolSize) / 2); - break; - } - } - - // 스톡 및 파운데이션 그리기 - function drawStockAndFoundation(stock, foundation) { - const logicalCanvasWidth = canvas.width / (window.devicePixelRatio || 1); - const logicalCanvasHeight = canvas.height / (window.devicePixelRatio || 1); - const stockArea = UI_ELEMENTS.stockArea; - const foundationArea = UI_ELEMENTS.foundationArea; - const bottomY = logicalCanvasHeight * BOTTOM_ROW_Y_RATIO; - - // 파운데이션 영역을 투명한 색으로 표현 - ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; - ctx.fillRect(foundationArea.x, bottomY, foundationArea.width, foundationArea.height); - - // 파운데이션 - foundation.forEach((stack, index) => { - const foundationX = foundationArea.x + index * (cardWidth * FOUNDATION_CARD_SPACING); // 겹쳐지게 그리기 - if (stack.length > 0) { - // 완성된 카드 스택을 그립니다. - const topCard = stack[stack.length - 1]; - drawSingleCard(topCard, foundationX, bottomY); - } - }); - - // 스톡 - if (stock.length > 0) { - ctx.drawImage(cardBackImage, stockArea.x, bottomY, cardWidth, cardHeight); - - // 스톡 위에 남은 카드 수를 표시합니다. - const remainingDeals = Math.floor(stock.length / 10); - ctx.fillStyle = '#fff'; - ctx.font = '20px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(`${remainingDeals}`, stockArea.x + stockArea.width / 2, stockArea.y + stockArea.height / 2); - } - } - - // ======================================= - // 3. 이벤트 핸들러 및 유틸리티 함수 - // ======================================= - console.log("이벤트 핸들러 등록 시작."); - - canvas.addEventListener('mousedown', handlePointerDown); - canvas.addEventListener('mousemove', handlePointerMove); - canvas.addEventListener('mouseup', handlePointerUp); - canvas.addEventListener('touchstart', handlePointerDown); - canvas.addEventListener('touchmove', handlePointerMove); - canvas.addEventListener('touchend', handlePointerUp); - canvas.addEventListener('dblclick', handleDoubleClick); - - function getCanvasCoordinates(event) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const clientX = event.touches ? event.touches[0].clientX : event.clientX; - const clientY = event.touches ? event.touches[0].clientY : event.clientY; - return { - x: (clientX - rect.left) * scaleX / dpr, - y: (clientY - rect.top) * scaleY / dpr - }; - } - - // 클릭된 위치의 카드 또는 UI 요소 찾기 - function findElementAt(x, y) { - if (isGameCompleted) { - const restartButton = UI_ELEMENTS.restartButton; - if (x >= restartButton.x && x <= restartButton.x + restartButton.width && y >= restartButton.y && y <= restartButton.y + restartButton.height) { - return { type: 'ui', name: 'restartButton' }; - } - } - - // 게임 진행 중일 때만 스톡과 실행 취소 버튼을 감지합니다. - if (currentGame) { - // 스톡 클릭 감지 - const stockArea = UI_ELEMENTS.stockArea; - if (x >= stockArea.x && x <= stockArea.x + stockArea.width && y >= stockArea.y && y <= stockArea.y + stockArea.height) { - return { type: 'stock' }; - } - - // 실행 취소 버튼 클릭 감지 - const undoButton = UI_ELEMENTS.undoButton; - if (x >= undoButton.x && x <= undoButton.x + undoButton.width && y >= undoButton.y && y <= undoButton.y + undoButton.height) { - return { type: 'ui', name: 'undoButton' }; - } - } - - // 게임 상태와 관계 없이 항상 난이도 선택 UI를 감지합니다. - if (!currentGame) { - // 난이도 선택 UI 클릭 감지 - const suitSelect = UI_ELEMENTS.suitSelect; - if (x >= suitSelect.x && x <= suitSelect.x + suitSelect.width && y >= suitSelect.y && y <= suitSelect.y + suitSelect.height) { - return { type: 'ui', name: 'suitSelect' }; - } - const cardCountSelect = UI_ELEMENTS.cardCountSelect; - if (x >= cardCountSelect.x && x <= cardCountSelect.x + cardCountSelect.width && y >= cardCountSelect.y && y <= cardCountSelect.y + cardCountSelect.height) { - return { type: 'ui', name: 'cardCountSelect' }; - } - const startButton = UI_ELEMENTS.startButton; - if (x >= startButton.x && x <= startButton.x + startButton.width && y >= startButton.y && y <= startButton.y + startButton.height) { - return { type: 'ui', name: 'startButton' }; - } - } - - // 카드 클릭 감지 - if (currentGame) { - for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { - const stackCards = currentGame.tableau[stackIndex]; - for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { - const card = stackCards[cardIndex]; - if (!card.isFaceUp) continue; - if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { - return { type: 'card', card, stackIndex, cardIndex }; - } - } - } - } - - return null; - } - - // 이동 가능한 카드 묶음 검사 (기존 로직) - function getCardStackForMove(card, stackIndex, cardIndex) { - const stack = currentGame.tableau[stackIndex]; - if (cardIndex === -1 || !card.isFaceUp) { - return null; - } - const movableStack = []; - for (let i = cardIndex; i < stack.length; i++) { - if (stack[i].isFaceUp) { - movableStack.push(stack[i]); - } else { - break; - } - } - if (movableStack.length === 0) { - return null; - } - for (let i = 0; i < movableStack.length - 1; i++) { - if (movableStack[i].rank !== movableStack[i + 1].rank + 1 || movableStack[i].suit !== movableStack[i + 1].suit) { - return null; - } - } - return movableStack; - } - - // ======================================= - // 4. 게임 로직 및 상호작용 - // ======================================= - let touchStart = {}; - - function handlePointerDown(event) { - if (isAnimating || isAnimatingCompletion) return; - const coords = getCanvasCoordinates(event); - touchStart = { x: coords.x, y: coords.y, time: Date.now() }; - - const element = findElementAt(coords.x, coords.y); - if (!element) return; - - if (element.type === 'ui') { - switch (element.name) { - case 'startButton': - startNewGame(); - break; - case 'undoButton': - if (currentGame.undoCount < MAX_UNDO_COUNT) { - handleUndo(); - } else { - currentGame = null; - draw(); - } - break; - case 'suitSelect': - selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1; - selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value; - draw(); - break; - case 'cardCountSelect': - const currentOptions = cardDistributionOptions[selectedSuit.toString()]; - const currentIndex = currentOptions.findIndex(opt => opt.value === selectedCardCount); - const nextIndex = (currentIndex + 1) % currentOptions.length; - selectedCardCount = currentOptions[nextIndex].value; - draw(); - break; - case 'restartButton': - currentGame = null; - isGameCompleted = false; - draw(); - break; - } - } else if (element.type === 'card') { - const { card, stackIndex, cardIndex } = element; - const movableStack = getCardStackForMove(card, stackIndex, cardIndex); - if (movableStack && movableStack.length > 0) { - draggedCards = movableStack; - draggedCards.sourceStackIndex = stackIndex; - const cardPos = getCardPosition(card, stackIndex); - dragOffsetX = coords.x - cardPos.x; - dragOffsetY = coords.y - cardPos.y; - } - } else if (element.type === 'stock') { - handleStockClick(); - } - } - - function handlePointerMove(event) { - if (!draggedCards || draggedCards.length === 0) return; - event.preventDefault(); - const coords = getCanvasCoordinates(event); - - if (!isDragging) { - const dx = coords.x - touchStart.x; - const dy = coords.y - touchStart.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance > DRAG_THRESHOLD) { - isDragging = true; - } - } - - if (isDragging) { - draggedCards[0].x = coords.x - dragOffsetX; - draggedCards[0].y = coords.y - dragOffsetY; - } - - draw(); - } - - function handlePointerUp(event) { - if (!isDragging || draggedCards.length === 0) { - returnToOriginalPosition(); - return; - } - const coords = getCanvasCoordinates(event); - const dropTargetStackId = findStackAt(coords.x, coords.y); - const sourceStackIndex = draggedCards.sourceStackIndex; - if (dropTargetStackId) { - const destinationStackIndex = (parseInt(dropTargetStackId.split('-')[1]) || 1) - 1; - const isValid = isValidMove(draggedCards, destinationStackIndex); - if (isValid) { - moveCardLocally(draggedCards, sourceStackIndex, destinationStackIndex); - checkCompletedStacks(); - updateGameOnServer(currentGame); - } else { - returnToOriginalPosition(); - } - } else { - returnToOriginalPosition(); - } - isDragging = false; - draggedCards = []; - draw(); - } - - function returnToOriginalPosition() { - isDragging = false; - draggedCards = []; - } - - function handleStockClick() { - if (!currentGame || isAnimating || currentGame.stock.length === 0) return; - dealFromStock(); - } - - function handleDoubleClick(event) { - const coords = getCanvasCoordinates(event); - const clickedCardData = findCardAt(coords.x, coords.y); - if (clickedCardData) { - const { card, stackIndex, cardIndex } = clickedCardData; - const movableStack = getCardStackForMove(card, stackIndex, cardIndex); - if (movableStack) { - const destinationStackId = getBestMoveForStack(movableStack); - if (destinationStackId) { - const destinationStackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1; - moveCardLocally(movableStack, stackIndex, destinationStackIndex); - checkCompletedStacks(); - updateGameOnServer(currentGame); - } - } - } - } - - function checkCompletedStacks() { - let completedCount = 0; - for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) { - const stack = currentGame.tableau[stackIndex]; - if (stack.length < 13) continue; - const last13Cards = stack.slice(stack.length - 13); - let isCompleted = true; - for (let i = 0; i < 12; i++) { - if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { - isCompleted = false; - break; - } - } - if (isCompleted) { - completedCount++; - isAnimatingCompletion = true; - const cardsToRemove = stack.splice(stack.length - 13, 13); - cardsToRemove.forEach(card => { - const cardPos = getCardPosition(card, stackIndex); - card.animStartX = cardPos.x; - card.animStartY = cardPos.y; - card.animEndTime = Date.now() + 500; - card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING)); - card.animTargetY = UI_ELEMENTS.foundationArea.y; - completedStackCards.push(card); - }); - if (stack.length > 0) { - stack[stack.length - 1].isFaceUp = true; - } - // 완성된 스택을 foundation에 추가 - currentGame.foundation.push(cardsToRemove); - } - } - // 모든 카드가 foundation으로 이동했는지 확인 - const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0); - if (totalFoundationCards === 104) { - isGameCompleted = true; - } - } - - function isValidMove(cardsToMove, destinationStackIndex) { - if (cardsToMove.length === 0) return false; - const firstCardToMove = cardsToMove[0]; - const destStackCards = currentGame.tableau[destinationStackIndex]; - if (destStackCards.length === 0) { - return true; - } - const destTopCard = destStackCards[destStackCards.length - 1]; - if (firstCardToMove.rank === destTopCard.rank - 1) { - return true; - } - return false; - } - - function moveCardLocally(cardsToMove, sourceStackIndex, destinationStackIndex) { - const sourceStack = currentGame.tableau[sourceStackIndex]; - const newSourceStack = sourceStack.slice(0, sourceStack.length - cardsToMove.length); - const destinationStack = currentGame.tableau[destinationStackIndex]; - const newDestinationStack = [...destinationStack, ...cardsToMove]; - const newTableau = [...currentGame.tableau]; - newTableau[sourceStackIndex] = newSourceStack; - newTableau[destinationStackIndex] = newDestinationStack; - if (newSourceStack.length > 0 && !newSourceStack[newSourceStack.length - 1].isFaceUp) { - newSourceStack[newSourceStack.length - 1].isFaceUp = true; - } - currentGame.tableau = newTableau; - currentGame.moves++; - } - - // 실행 취소 핸들러 - async function handleUndo() { - if (!currentGame || isAnimating || currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) { - console.log("실행 취소 불가: 횟수 초과, 히스토리 없음 또는 애니메이션 진행 중"); - return; - } - try { - const response = await fetch(`${API_BASE_URL}/undo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameId: currentGame.id }) - }); - const newGame = await response.json(); - currentGame = newGame; - draw(); - } catch (error) { - console.error("실행 취소 중 오류 발생:", error); - } - } - - // ======================================= - // 5. 서버 통신 함수 - // ======================================= - - async function dealFromStock() { - if (!currentGame || isAnimating || currentGame.stock.length === 0) return; - isAnimating = true; - try { - const response = await fetch(`${API_BASE_URL}/deal`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameId: currentGame.id }) - }); - const newGame = await response.json(); - currentGame = newGame; - draw(); - } catch (error) { - console.error("카드 분배 중 오류 발생:", error); - } finally { - isAnimating = false; - } - } - - async function updateGameOnServer(updatedGame) { - try { - const response = await fetch(`${API_BASE_URL}/update`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedGame) - }); - const newGame = await response.json(); - currentGame = newGame; - isDragging = false; - draggedCards = []; - draw(); - } catch (error) { - console.error("게임 상태 업데이트 중 오류 발생:", error); - } - } - - async function startNewGame() { - if (!assetsLoaded) return; - const numSuits = selectedSuit; - const numCards = selectedCardCount; - try { - const response = await fetch(`${API_BASE_URL}/new?numSuits=${numSuits}&numCards=${numCards}`); - currentGame = await response.json(); - gameId = currentGame.id; - isDragging = false; - draggedCards = []; - isGameCompleted = false; // 새 게임 시작 시 완료 상태 초기화 - draw(); - } catch (error) { - console.error("새 게임 시작 중 오류 발생:", error); - } - } - - // ======================================= - // 6. 기타 유틸리티 함수 - // ======================================= - function findStackAt(x, y) { - const startY = cardHeight * 0.5; - for (let i = 0; i < 10; i++) { - const stackX = tableauStartX + i * (cardWidth + cardGapX); - const stackCards = currentGame.tableau[i]; - if (stackCards.length === 0) { - if (x >= stackX && x <= stackX + cardWidth && y >= startY) { - return `tableau-${i + 1}`; - } - } - const lastCardIndex = stackCards.length - 1; - const lastCardY = startY + lastCardIndex * cardOverlapY; - if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) { - return `tableau-${i + 1}`; - } - } - return null; - } - - function findCardAt(x, y) { - for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { - const stackCards = currentGame.tableau[stackIndex]; - for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { - const card = stackCards[cardIndex]; - if (!card.isFaceUp) continue; - if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { - return { card, stackIndex, cardIndex }; - } - } - } - return null; - } - - function getCardPosition(card, stackIndex) { - const startY = cardHeight * 0.5; - const stackCards = currentGame.tableau[stackIndex]; - const cardIndexInStack = stackCards.findIndex(c => c.suit === card.suit && c.rank === card.rank); - const x = tableauStartX + stackIndex * (cardWidth + cardGapX); - const y = startY + cardIndexInStack * cardOverlapY; - return { x, y }; - } - - function getRankText(rank) { - if (rank === 1) return 'A'; - if (rank === 11) return 'J'; - if (rank === 12) return 'Q'; - if (rank === 13) return 'K'; - return String(rank); - } - - function getSuitSymbol(suit) { - if (suit === 'spade') return '♠️'; - if (suit === 'heart') return '♥️'; - if (suit === 'club') return '♣️'; - if (suit === 'diamond') return '♦️'; - } - - function getBestMoveForStack(cardsToMove) { - if (cardsToMove.length === 0) return null; - const firstCardToMove = cardsToMove[0]; - for (let i = 0; i < 10; i++) { - const destStackId = `tableau-${i + 1}`; - const destStackCards = currentGame.tableau[i]; - if (destStackCards.length === 0) { - return destStackId; - } else { - const destTopCard = destStackCards[destStackCards.length - 1]; - if (firstCardToMove.rank === destTopCard.rank - 1) { - return destStackId; - } - } - } - return null; - } - - // 초기화 - resizeCanvas(); - draw(); -}); \ No newline at end of file +// /** +// * ============================================== +// * spider.js (Canvas 렌더링 게임) +// * (★ 리팩토링: CSS 변수 연동, 타이머 및 랭킹 API 기능 추가, API 경로 통합) +// * ============================================== +// */ +// +// document.addEventListener('DOMContentLoaded', () => { +// +// // ======================================= +// // 1. 상수 및 변수 선언 +// // ======================================= +// console.log("DOM 콘텐츠가 로드되었습니다. 초기 설정 시작."); +// +// // HTML 요소 +// const canvas = document.getElementById('gameCanvas'); +// const ctx = canvas.getContext('2d'); +// +// // ** UI 버튼 및 영역의 논리적 위치 정의 (캔버스 내 좌표) ** +// const UI_ELEMENTS = {}; +// +// // ** 비율 상수 정의 ** +// const CARD_WIDTH_RATIO = 1 / 11.5; +// const CARD_HEIGHT_RATIO = 1.4; +// const CARD_GAP_X_RATIO = 0.15; +// const CARD_OVERLAP_Y_RATIO = 0.3; +// const CARD_RANK_LEFT_PADDING = 0.1; +// const CARD_RANK_TOP_PADDING = 0.1; +// const CARD_SYMBOL_TOP_PADDING = 0.25; +// const CARD_SYMBOL_BOTTOM_PADDING = 0.15; +// const FOUNDATION_CARD_SPACING = 0.2; // 카드 겹침 비율 (20%만 보이게) +// const FOUNDATION_WIDTH_RATIO = 0.45; // 파운데이션 영역 최대 너비 +// +// // 게임 상태 및 데이터 +// let gameId = null; +// let currentGame = null; +// let isGameCompleted = false; +// +// // (★ 삭제) API_BASE_URL 삭제. 모든 경로는 root-relative('/puzzle/...')로 변경 +// // const API_BASE_URL = '/spider'; (삭제) +// +// // (★ 신규) 게임 타이머 및 랭킹 관련 변수 +// let gameStartTime = 0; // 게임 시작 시간 (ms) +// let completionTimeSeconds = 0; // 게임 완료 시간 (초) +// const currentGameType = 'SPIDER'; // 통합 랭킹용 GameType +// let currentContextId = ''; // 예: "1_SUITS_4,3" (난이도 저장용) +// +// +// // 동적으로 계산될 레이아웃 변수 +// let cardWidth = 0; +// let cardHeight = 0; +// let cardGapX = 0; +// let cardOverlapY = 0; +// let totalTableauWidth = 0; +// let tableauStartX = 0; +// +// // 드래그 및 애니메이션 관련 변수 +// let isAnimating = false; +// let isDragging = false; +// let dragStartX = 0; +// let dragStartY = 0; +// const DRAG_THRESHOLD = 5; +// let draggedCards = []; +// let dragOffsetX = 0; +// let dragOffsetY = 0; +// let animatedCard = null; +// let animationProgress = 0; +// let completedStackCards = []; +// let isAnimatingCompletion = false; +// +// // 하단 정렬을 위한 Y 좌표 기준 +// const BOTTOM_ROW_Y_RATIO = 0.9; +// let dpr = 1; +// const MAX_UNDO_COUNT = 5; +// +// // 카드 뒷면 이미지 로드 +// const cardBackImage = new Image(); +// cardBackImage.src = '../css/images/card-back.png'; +// let assetsLoaded = false; +// cardBackImage.onload = () => { +// assetsLoaded = true; +// resizeCanvas(); +// draw(); +// console.log("카드 뒷면 이미지가 로드되었습니다."); +// }; +// +// // UI 옵션 데이터 +// const suitOptions = [{ value: 1, text: '1개' }, { value: 2, text: '2개' }, { value: 4, text: '4개' }]; +// const cardDistributionOptions = { +// '1': [{ value: '4,3', text: '쉬움' }, { value: '5,4', text: '보통' }, { value: '6,5', text: '어려움' }], +// '2': [{ value: '5,4', text: '쉬움' }, { value: '6,5', text: '보통' }, { value: '7,6', text: '어려움' }], +// '4': [{ value: '6,5', text: '쉬움' }, { value: '7,6', text: '보통' }, { value: '8,7', text: '어려움' }] +// }; +// let selectedSuit = 1; +// let selectedCardCount = '4,3'; +// +// // ======================================= +// // 2. 렌더링 (그리기) 관련 함수 (★ CSS 변수 적용) +// // ======================================= +// console.log("렌더링 관련 함수 정의 시작."); +// +// /** +// * (★ 신규) CSS :root에서 정의된 변수 값을 읽어오는 헬퍼 함수 +// * @param {string} varName - 읽어올 CSS 변수 이름 (예: '--color-primary') +// * @returns {string} 변수의 값 (trim된 문자열) +// */ +// function getCssVar(varName) { +// // getComputedStyle을 통해 현재 적용된 CSS 변수 값을 읽어옴 +// return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); +// } +// +// // 캔버스 크기 조정 및 레이아웃 변수 계산 +// function resizeCanvas() { +// // ... (모든 캔버스 및 UI 좌표 계산 로직 - 수정 없음) ... +// // 이 로직은 캔버스 내부의 "좌표"만 계산하며, "색상"은 draw 함수에서 결정합니다. +// console.log("resizeCanvas 함수 호출."); +// const size = Math.min(window.innerWidth, window.innerHeight) * 0.95; +// canvas.style.width = `${size}px`; +// canvas.style.height = `${size}px`; +// dpr = window.devicePixelRatio || 1; +// canvas.width = size * dpr; +// canvas.height = size * dpr; +// ctx.scale(dpr, dpr); +// const logicalWidth = size; +// const logicalHeight = size; +// cardWidth = logicalWidth * CARD_WIDTH_RATIO; +// cardHeight = cardWidth * CARD_HEIGHT_RATIO; +// cardGapX = cardWidth * CARD_GAP_X_RATIO; +// cardOverlapY = cardHeight * CARD_OVERLAP_Y_RATIO; +// totalTableauWidth = cardWidth * 10 + cardGapX * 9; +// tableauStartX = (logicalWidth - totalTableauWidth) / 2; +// const buttonWidth = logicalWidth * 0.2; +// const buttonHeight = logicalHeight * 0.05; +// const buttonGap = 10; +// const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2; +// const startY = logicalHeight * 0.05; +// UI_ELEMENTS.difficultyUI = { x: logicalWidth / 2 - (buttonWidth * 1.5 + buttonGap), y: logicalHeight * 0.45, width: (buttonWidth * 3 + buttonGap * 2), height: buttonHeight }; +// UI_ELEMENTS.suitSelect = { x: startX, y: startY, width: buttonWidth, height: buttonHeight }; +// UI_ELEMENTS.cardCountSelect = { x: startX + buttonWidth + buttonGap, y: startY, width: buttonWidth, height: buttonHeight }; +// UI_ELEMENTS.startButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY, width: buttonWidth, height: buttonHeight }; +// const bottomY = logicalHeight * BOTTOM_ROW_Y_RATIO; +// const itemSpacing = 20; +// const foundationX = logicalWidth * 0.05; +// const foundationAreaWidth = logicalWidth * FOUNDATION_WIDTH_RATIO; +// UI_ELEMENTS.foundationArea = { x: foundationX, y: bottomY, width: foundationAreaWidth, height: cardHeight }; +// const undoButtonWidth = cardWidth * 0.8; +// const undoButtonHeight = cardHeight * 0.5; +// const undoCountDisplayWidth = cardWidth * 0.5; +// const undoButtonX = logicalWidth * 0.5 - (undoButtonWidth + undoCountDisplayWidth + itemSpacing) / 2; +// UI_ELEMENTS.undoButton = { x: undoButtonX, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoButtonWidth, height: undoButtonHeight }; +// UI_ELEMENTS.undoCountDisplay = { x: undoButtonX + undoButtonWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoCountDisplayWidth, height: undoButtonHeight }; +// const stockX = logicalWidth * 0.95 - cardWidth; +// UI_ELEMENTS.stockArea = { x: stockX, y: bottomY, width: cardWidth, height: cardHeight }; +// UI_ELEMENTS.restartButton = { x: logicalWidth / 2 - buttonWidth / 2, y: logicalHeight / 2 + 50, width: buttonWidth, height: buttonHeight }; +// } +// +// window.addEventListener('resize', resizeCanvas); +// +// // 메인 그리기 루프 +// function draw() { +// if (!assetsLoaded) { +// requestAnimationFrame(draw); +// return; +// } +// +// ctx.clearRect(0, 0, canvas.width, canvas.height); +// +// if (currentGame) { +// drawGame(currentGame); +// } +// drawUI(); +// +// requestAnimationFrame(draw); +// } +// /** +// * (★ 수정) UI 요소를 캔버스에 직접 그리는 함수 +// * 하드코딩된 색상 대신 CSS 변수 값을 읽어와서 사용합니다. +// */ +// /** +// * (★ 수정) UI 요소를 캔버스에 직접 그리는 함수 +// * 하드코딩된 색상 대신 CSS 변수 값을 읽어와서 사용합니다. +// */ +// function drawUI() { +// if (!currentGame) { +// // --- 게임 시작 전 난이도 선택 UI --- +// // (CSS 변수와 무관한 기본 UI 로직) +// const suitSelect = UI_ELEMENTS.suitSelect; +// ctx.fillStyle = '#f0f0f0'; +// ctx.fillRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); +// ctx.strokeStyle = '#333'; +// ctx.strokeRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height); +// ctx.fillStyle = '#000'; +// ctx.font = '16px Arial'; +// ctx.textAlign = 'center'; +// ctx.textBaseline = 'middle'; +// ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2); +// +// const cardCountSelect = UI_ELEMENTS.cardCountSelect; +// ctx.fillStyle = '#f0f0f0'; +// ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); +// ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height); +// ctx.fillStyle = '#000'; +// ctx.fillText(`카드: ${cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount).text}`, +// cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2); +// +// // (★ 수정) 시작 버튼 색상을 CSS 변수('--color-success')에서 읽어옴 +// const startButton = UI_ELEMENTS.startButton; +// ctx.fillStyle = getCssVar('--color-success') || '#4CAF50'; // CSS 변수 읽기 (실패 시 기본값) +// ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height); +// ctx.strokeStyle = '#333'; +// ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height); +// ctx.fillStyle = '#fff'; +// ctx.fillText('새 게임 시작', startButton.x + startButton.width / 2, startButton.y + startButton.height / 2); +// +// } else { +// // --- 게임 중 하단 UI (Undo 버튼 등) --- +// const undoButton = UI_ELEMENTS.undoButton; +// const undoCountDisplay = UI_ELEMENTS.undoCountDisplay; +// const isUndoPossible = currentGame.undoHistory.length > 0; +// const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible; +// const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT; +// +// if (isUndoEnabled) { +// // (★ 수정) '실행 취소' 버튼 색상을 CSS 변수('--color-warning')에서 읽어옴 +// const buttonColor = getCssVar('--color-warning') || '#ff9800'; +// const buttonText = '실행 취소'; // ★★★ 버그 수정: 변수 선언 추가 ★★★ +// ctx.fillStyle = buttonColor; +// ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); +// ctx.strokeStyle = '#333'; +// ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); +// ctx.fillStyle = '#fff'; +// ctx.font = '14px Arial'; +// ctx.fillText(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); +// +// // 남은 취소 횟수 표시 +// const remainingUndos = MAX_UNDO_COUNT - currentGame.undoCount; +// ctx.fillText(`${remainingUndos}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2); +// +// } else if (isSurrender) { +// // (★ 수정) '게임 포기' 버튼 색상을 CSS 변수('--color-danger')에서 읽어옴 +// const buttonColor = getCssVar('--color-danger') || '#f44336'; +// const buttonText = '게임 포기'; // ★★★ 버그 수정: 변수 선언 추가 ★★★ +// ctx.fillStyle = buttonColor; +// ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); +// ctx.strokeStyle = '#333'; +// ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height); +// ctx.fillStyle = '#fff'; +// ctx.font = '14px Arial'; +// ctx.fillText(buttonText, undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2); +// } +// } +// } +// +// // 전체 게임 화면을 그리는 메인 함수 +// function drawGame(game) { +// drawBackground(); +// drawTableau(game.tableau); +// drawStockAndFoundation(game.stock, game.foundation); +// drawDraggedCards(draggedCards); +// drawCompletionAnimation(); +// +// // 게임 완료 시 메시지 표시 +// if (isGameCompleted) { +// drawCompletionMessage(); +// } +// } +// +// /** +// * (★ 수정) 게임 완료 메시지 그리기 (제출 버튼 로직 추가) +// */ +// function drawCompletionMessage() { +// const logicalWidth = canvas.width / dpr; +// const logicalHeight = canvas.height / dpr; +// +// ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; +// ctx.fillRect(0, 0, logicalWidth, logicalHeight); +// +// ctx.fillStyle = '#ffffff'; +// ctx.font = 'bold 36px Arial'; +// ctx.textAlign = 'center'; +// ctx.textBaseline = 'middle'; +// ctx.fillText('게임 완료! 축하합니다!', logicalWidth / 2, logicalHeight / 2); +// +// // (★ 수정) '다시 시작' 버튼 -> '랭킹 등록' 버튼으로 변경 +// // UI_ELEMENTS.restartButton 좌표를 그대로 사용 +// const submitButton = UI_ELEMENTS.restartButton; +// +// // (★ 수정) 성공 버튼 색상 CSS 변수 사용 +// ctx.fillStyle = getCssVar('--color-success') || '#4CAF50'; +// ctx.fillRect(submitButton.x, submitButton.y, submitButton.width, submitButton.height); +// ctx.strokeStyle = '#fff'; +// ctx.strokeRect(submitButton.x, submitButton.y, submitButton.width, submitButton.height); +// ctx.fillStyle = '#fff'; +// ctx.font = '20px Arial'; +// ctx.fillText('랭킹 등록', submitButton.x + submitButton.width / 2, submitButton.y + submitButton.height / 2); +// } +// +// /** +// * (★ 수정) 게임 배경 그리기 (CSS 변수 사용) +// */ +// function drawBackground() { +// // (★ 수정) 하드코딩된 '#008000' 대신 CSS 변수('--color-felt-green') 사용 +// ctx.fillStyle = getCssVar('--color-felt-green') || '#008000'; +// ctx.fillRect(0, 0, canvas.width, canvas.height); +// } +// +// // --- (모든 카드 그리기 로직 (drawTableau, drawDraggedCards 등) - 수정 없음) --- +// // (이 로직들은 색상이 카드 데이터(suit)에 따라 동적으로 결정되므로 CSS 변수와 무관함) +// function drawTableau(tableau) { +// const startY = cardHeight * 0.5 +// const draggingCards = isDragging ? new Set(draggedCards) : null; +// tableau.forEach((stack, stackIndex) => { +// stack.forEach((card, cardIndex) => { +// if (draggingCards && draggingCards.has(card)) { +// return; +// } +// const x = tableauStartX + stackIndex * (cardWidth + cardGapX); +// const y = startY + cardIndex * cardOverlapY; +// card.touchHeight = (cardIndex === stack.length - 1) ? cardHeight : cardOverlapY; +// drawSingleCard(card, x, y); +// }); +// }); +// } +// function drawDraggedCards(cards) { +// if (!isDragging || !Array.isArray(cards) || cards.length === 0) { +// return; +// } +// cards.forEach((card, index) => { +// const x = cards[0].x; +// const y = cards[0].y + index * cardOverlapY; +// drawSingleCard(card, x, y); +// }); +// } +// function drawCompletionAnimation() { +// if (isAnimatingCompletion) { +// const now = Date.now(); +// completedStackCards = completedStackCards.filter(card => { +// if (now < card.animEndTime) { +// const progress = (now - (card.animEndTime - 500)) / 500; +// const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress; +// const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress; +// drawSingleCard(card, currentX, currentY); +// return true; +// } else { +// return false; +// } +// }); +// if (completedStackCards.length === 0) { +// isAnimatingCompletion = false; +// } +// } +// } +// function drawSingleCard(card, x, y) {card.x = x; +// card.y = y; +// card.width = cardWidth; +// card.height = cardHeight; +// if (card.isFaceUp) { +// ctx.fillStyle = '#ffffff'; +// ctx.fillRect(x, y, cardWidth, cardHeight); +// ctx.strokeStyle = '#333333'; +// ctx.strokeRect(x, y, cardWidth, cardHeight); +// const isRed = (card.suit === 'heart' || card.suit === 'diamond'); +// ctx.fillStyle = isRed ? '#ff0000' : '#000000'; +// ctx.font = `${cardWidth * 0.25}px Arial`; +// ctx.textAlign = 'left'; +// ctx.textBaseline = 'top'; +// ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING); +// drawSuitSymbols(card, x, y); +// } else { +// ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); +// }} +// function drawSuitSymbols(card, x, y) { +// const symbol = getSuitSymbol(card.suit); +// let symbolSize; +// if (card.rank >= 2 && card.rank <= 5) { +// symbolSize = cardWidth * 0.2; +// } else { +// symbolSize = cardWidth * 0.15; +// } +// ctx.font = `${symbolSize}px Arial`; +// ctx.textAlign = 'center'; +// ctx.textBaseline = 'middle'; +// ctx.fillStyle = (card.suit === 'heart' || card.suit === 'diamond') ? '#ff0000' : '#000000'; +// const symbolAreaY = y + cardHeight * CARD_SYMBOL_TOP_PADDING; +// const symbolAreaHeight = cardHeight * (1 - CARD_SYMBOL_TOP_PADDING - CARD_SYMBOL_BOTTOM_PADDING); +// const symbolAreaMiddleY = symbolAreaY + symbolAreaHeight / 2; +// const symbolAreaLeftX = x + cardWidth * 0.25; +// const symbolAreaRightX = x + cardWidth * 0.75; +// const symbolGapY = symbolAreaHeight / 3; +// const positions = { +// top: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, +// bottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, +// center: { x: x + cardWidth / 2, y: symbolAreaMiddleY }, +// leftTop: { x: symbolAreaLeftX, y: symbolAreaY + symbolGapY * 0.5 }, +// rightTop: { x: symbolAreaRightX, y: symbolAreaY + symbolGapY * 0.5 }, +// leftCenter: { x: symbolAreaLeftX, y: symbolAreaMiddleY }, +// rightCenter: { x: symbolAreaRightX, y: symbolAreaMiddleY }, +// leftBottom: { x: symbolAreaLeftX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, +// rightBottom: { x: symbolAreaRightX, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 }, +// middleTop: { x: x + cardWidth / 2, y: symbolAreaY + symbolGapY * 0.5 }, +// middleBottom: { x: x + cardWidth / 2, y: symbolAreaY + symbolAreaHeight - symbolGapY * 0.5 } +// }; +// +// switch (card.rank) { +// case 1: case 11: case 12: case 13: +// ctx.font = `${cardWidth * 0.6}px Arial`; +// ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2); +// break; +// case 2: +// ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); +// ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); +// break; +// case 3: +// ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); +// ctx.fillText(symbol, positions.center.x, positions.center.y); +// ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); +// break; +// case 4: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// break; +// case 5: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.center.x, positions.center.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// break; +// case 6: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); +// ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// break; +// case 7: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); +// ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// ctx.fillText(symbol, positions.center.x, positions.center.y); +// break; +// case 8: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); +// ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); +// ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); +// break; +// case 9: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); +// ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// ctx.fillText(symbol, positions.center.x, positions.center.y); +// ctx.fillText(symbol, positions.middleTop.x, positions.middleTop.y); +// ctx.fillText(symbol, positions.middleBottom.x, positions.middleBottom.y); +// break; +// case 10: +// ctx.fillText(symbol, positions.leftTop.x, positions.leftTop.y); +// ctx.fillText(symbol, positions.rightTop.x, positions.rightTop.y); +// ctx.fillText(symbol, positions.leftCenter.x, positions.leftCenter.y); +// ctx.fillText(symbol, positions.rightCenter.x, positions.rightCenter.y); +// ctx.fillText(symbol, positions.leftBottom.x, positions.leftBottom.y); +// ctx.fillText(symbol, positions.rightBottom.x, positions.rightBottom.y); +// ctx.fillText(symbol, positions.top.x, positions.top.y); +// ctx.fillText(symbol, positions.bottom.x, positions.bottom.y); +// ctx.fillText(symbol, positions.middleTop.x, (positions.top.y + symbolSize + positions.center.y) / 2 ); +// ctx.fillText(symbol, positions.middleBottom.x, ((positions.bottom.y + positions.center.y) - symbolSize) / 2); +// break; +// } +// } +// +// +// /** +// * (★ 수정) 스톡 및 파운데이션 그리기 (CSS 변수 사용) +// */ +// function drawStockAndFoundation(stock, foundation) { +// const logicalCanvasWidth = canvas.width / (window.devicePixelRatio || 1); +// const logicalCanvasHeight = canvas.height / (window.devicePixelRatio || 1); +// const stockArea = UI_ELEMENTS.stockArea; +// const foundationArea = UI_ELEMENTS.foundationArea; +// +// // (★ 신규) 캔버스 테두리 색상 변수('--color-felt-border') 사용 +// ctx.strokeStyle = getCssVar('--color-felt-border') || '#004d00'; +// +// // 파운데이션 영역 (테두리 추가) +// ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; +// ctx.fillRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); +// ctx.strokeRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); +// +// foundation.forEach((stack, index) => { +// const foundationX = foundationArea.x + index * (cardWidth * FOUNDATION_CARD_SPACING); // 겹쳐지게 그리기 +// if (stack.length > 0) { +// // 완성된 카드 스택을 그립니다. +// const topCard = stack[stack.length - 1]; +// drawSingleCard(topCard, foundationX, bottomY); +// } +// }); +// +// // 스톡 +// if (stock.length > 0) { +// ctx.drawImage(cardBackImage, stockArea.x, stockArea.y, cardWidth, cardHeight); +// ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); // 테두리 추가 +// // 스톡 위에 남은 카드 수를 표시합니다. +// const remainingDeals = Math.floor(stock.length / 10); +// ctx.fillStyle = '#fff'; +// ctx.font = '20px Arial'; +// ctx.textAlign = 'center'; +// ctx.textBaseline = 'middle'; +// ctx.fillText(`${remainingDeals}`, stockArea.x + stockArea.width / 2, stockArea.y + stockArea.height / 2); +// } else { +// ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight); // 빈 스톡 테두리 +// } +// } +// +// // ======================================= +// // 3. 이벤트 핸들러 및 유틸리티 함수 +// // ======================================= +// console.log("이벤트 핸들러 등록 시작."); +// +// canvas.addEventListener('mousedown', handlePointerDown); +// canvas.addEventListener('mousemove', handlePointerMove); +// canvas.addEventListener('mouseup', handlePointerUp); +// canvas.addEventListener('touchstart', handlePointerDown); +// canvas.addEventListener('touchmove', handlePointerMove); +// canvas.addEventListener('touchend', handlePointerUp); +// canvas.addEventListener('dblclick', handleDoubleClick); +// +// function getCanvasCoordinates(event) { +// const rect = canvas.getBoundingClientRect(); +// const scaleX = canvas.width / rect.width; +// const scaleY = canvas.height / rect.height; +// const clientX = event.touches ? event.touches[0].clientX : event.clientX; +// const clientY = event.touches ? event.touches[0].clientY : event.clientY; +// return { +// x: (clientX - rect.left) * scaleX / dpr, +// y: (clientY - rect.top) * scaleY / dpr +// }; +// } +// +// /** +// * (★ 수정) 클릭된 위치 찾기 (게임 완료 시 'submitButton' 처리) +// */ +// function findElementAt(x, y) { +// if (isGameCompleted) { +// // 완료 화면의 '랭킹 등록' 버튼 (UI_ELEMENTS.restartButton 좌표 사용) +// const restartButton = UI_ELEMENTS.restartButton; +// if (x >= restartButton.x && x <= restartButton.x + restartButton.width && y >= restartButton.y && y <= restartButton.y + restartButton.height) { +// // (★ 수정) 'restartButton' -> 'submitButton'으로 로직명 변경 +// return { type: 'ui', name: 'submitButton' }; +// } +// } +// +// // 게임 진행 중일 때만 스톡과 실행 취소 버튼을 감지 +// if (currentGame) { +// // 스톡 클릭 감지 +// const stockArea = UI_ELEMENTS.stockArea; +// if (x >= stockArea.x && x <= stockArea.x + stockArea.width && y >= stockArea.y && y <= stockArea.y + stockArea.height) { +// return { type: 'stock' }; +// } +// +// // 실행 취소 버튼 클릭 감지 +// const undoButton = UI_ELEMENTS.undoButton; +// if (x >= undoButton.x && x <= undoButton.x + undoButton.width && y >= undoButton.y && y <= undoButton.y + undoButton.height) { +// return { type: 'ui', name: 'undoButton' }; +// } +// } +// +// // 게임 시작 전 난이도 선택 UI 감지 +// if (!currentGame) { +// // 난이도 선택 UI 클릭 감지 +// const suitSelect = UI_ELEMENTS.suitSelect; +// if (x >= suitSelect.x && x <= suitSelect.x + suitSelect.width && y >= suitSelect.y && y <= suitSelect.y + suitSelect.height) { +// return { type: 'ui', name: 'suitSelect' }; +// } +// const cardCountSelect = UI_ELEMENTS.cardCountSelect; +// if (x >= cardCountSelect.x && x <= cardCountSelect.x + cardCountSelect.width && y >= cardCountSelect.y && y <= cardCountSelect.y + cardCountSelect.height) { +// return { type: 'ui', name: 'cardCountSelect' }; +// } +// const startButton = UI_ELEMENTS.startButton; +// if (x >= startButton.x && x <= startButton.x + startButton.width && y >= startButton.y && y <= startButton.y + startButton.height) { +// return { type: 'ui', name: 'startButton' }; +// } +// } +// +// // 카드 클릭 감지 +// if (currentGame) { +// for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { +// const stackCards = currentGame.tableau[stackIndex]; +// for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { +// const card = stackCards[cardIndex]; +// if (!card.isFaceUp) continue; +// if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { +// return { type: 'card', card, stackIndex, cardIndex }; +// } +// } +// } +// } +// +// return null; +// } +// +// // 이동 가능한 카드 묶음 검사 (기존 로직) +// function getCardStackForMove(card, stackIndex, cardIndex) { +// const stack = currentGame.tableau[stackIndex]; +// if (cardIndex === -1 || !card.isFaceUp) { +// return null; +// } +// const movableStack = []; +// for (let i = cardIndex; i < stack.length; i++) { +// if (stack[i].isFaceUp) { +// movableStack.push(stack[i]); +// } else { +// break; +// } +// } +// if (movableStack.length === 0) { +// return null; +// } +// for (let i = 0; i < movableStack.length - 1; i++) { +// if (movableStack[i].rank !== movableStack[i + 1].rank + 1 || movableStack[i].suit !== movableStack[i + 1].suit) { +// return null; +// } +// } +// return movableStack; +// } +// +// // ======================================= +// // 4. 게임 로직 및 상호작용 +// // ======================================= +// let touchStart = {}; +// +// /** +// * (★ 수정) handlePointerDown (랭킹 등록 로직 추가) +// */ +// function handlePointerDown(event) { +// if (isAnimating || isAnimatingCompletion) return; +// const coords = getCanvasCoordinates(event); +// touchStart = { x: coords.x, y: coords.y, time: Date.now() }; +// +// const element = findElementAt(coords.x, coords.y); +// if (!element) return; +// +// if (element.type === 'ui') { +// switch (element.name) { +// case 'startButton': +// startNewGame(); +// break; +// case 'undoButton': +// if (currentGame.undoCount < MAX_UNDO_COUNT) { +// handleUndo(); // API 호출 +// } else { +// currentGame = null; // 포기하고 메뉴로 +// draw(); +// } +// break; +// case 'suitSelect': +// selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1; +// selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value; +// draw(); +// break; +// case 'cardCountSelect': +// const currentOptions = cardDistributionOptions[selectedSuit.toString()]; +// const currentIndex = currentOptions.findIndex(opt => opt.value === selectedCardCount); +// const nextIndex = (currentIndex + 1) % currentOptions.length; +// selectedCardCount = currentOptions[nextIndex].value; +// draw(); +// break; +// case 'submitButton': // (★ 신규) 'restartButton' 대신 'submitButton' 클릭 처리 +// handleRankSubmit(); // 랭킹 제출 함수 호출 +// break; +// } +// } else if (element.type === 'card') { +// const { card, stackIndex, cardIndex } = element; +// const movableStack = getCardStackForMove(card, stackIndex, cardIndex); +// if (movableStack && movableStack.length > 0) { +// draggedCards = movableStack; +// draggedCards.sourceStackIndex = stackIndex; +// const cardPos = getCardPosition(card, stackIndex); +// dragOffsetX = coords.x - cardPos.x; +// dragOffsetY = coords.y - cardPos.y; +// } +// } else if (element.type === 'stock') { +// handleStockClick(); // API 호출 +// } +// } +// +// /** +// * (★ 신규) 랭킹 등록 처리 함수 +// * user.js의 공통 submitRank 함수를 호출합니다. +// */ +// async function handleRankSubmit() { +// const playerName = prompt("랭킹에 등록할 이름을 입력하세요:", "Player"); +// if (!playerName || playerName.trim() === "") return; +// +// try { +// // (★ 신규) user.js의 공통 submitRank 함수 호출 +// // 주 점수(primaryScore) = 이동 횟수 (낮을수록 좋음) +// // 보조 점수(secondaryScore) = 완료 시간 (낮을수록 좋음) +// await submitRank( +// currentGameType, // 'SPIDER' +// currentContextId, // 예: "1_SUITS_4,3" +// playerName.trim(), // Player Name +// currentGame.moves, // Primary Score (Moves) +// completionTimeSeconds // Secondary Score (Time) +// ); +// +// alert("랭킹이 등록되었습니다!"); +// // 랭킹 등록 후 새 게임 시작 (메뉴 화면으로 돌아감) +// currentGame = null; +// isGameCompleted = false; +// draw(); +// +// } catch (error) { +// console.error("Rank submission failed:", error); +// alert("랭킹 등록에 실패했습니다: " + error.message); +// } +// } +// +// +// // --- (PointerMove, PointerUp, Stock/Double Click 핸들러 - 수정 없음) --- +// function handlePointerMove(event) { +// if (!draggedCards || draggedCards.length === 0) return; +// event.preventDefault(); +// const coords = getCanvasCoordinates(event); +// +// if (!isDragging) { +// const dx = coords.x - touchStart.x; +// const dy = coords.y - touchStart.y; +// const distance = Math.sqrt(dx * dx + dy * dy); +// if (distance > DRAG_THRESHOLD) { +// isDragging = true; +// } +// } +// +// if (isDragging) { +// draggedCards[0].x = coords.x - dragOffsetX; +// draggedCards[0].y = coords.y - dragOffsetY; +// } +// +// draw(); +// } +// function handlePointerUp(event) { +// if (!isDragging || draggedCards.length === 0) { +// returnToOriginalPosition(); +// return; +// } +// const coords = getCanvasCoordinates(event); +// const dropTargetStackId = findStackAt(coords.x, coords.y); +// const sourceStackIndex = draggedCards.sourceStackIndex; +// if (dropTargetStackId) { +// const destinationStackIndex = (parseInt(dropTargetStackId.split('-')[1]) || 1) - 1; +// const isValid = isValidMove(draggedCards, destinationStackIndex); +// if (isValid) { +// moveCardLocally(draggedCards, sourceStackIndex, destinationStackIndex); +// checkCompletedStacks(); +// updateGameOnServer(currentGame); +// } else { +// returnToOriginalPosition(); +// } +// } else { +// returnToOriginalPosition(); +// } +// isDragging = false; +// draggedCards = []; +// draw(); +// } +// function returnToOriginalPosition() { +// isDragging = false; +// draggedCards = []; +// } +// function handleStockClick() { +// if (!currentGame || isAnimating || currentGame.stock.length === 0) return; +// dealFromStock(); // API 호출 +// } +// function handleDoubleClick(event) { +// // (★ 수정) 게임이 시작되지 않았거나, 이미 완료되었다면 아무것도 하지 않음 +// if (!currentGame || isGameCompleted) { +// return; +// } +// +// const coords = getCanvasCoordinates(event); +// const clickedCardData = findCardAt(coords.x, coords.y); // 이제 이 코드는 currentGame이 있을 때만 실행됨 +// if (clickedCardData) { +// const { card, stackIndex, cardIndex } = clickedCardData; +// const movableStack = getCardStackForMove(card, stackIndex, cardIndex); +// if (movableStack) { +// const destinationStackId = getBestMoveForStack(movableStack); +// if (destinationStackId) { +// const destinationStackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1; +// moveCardLocally(movableStack, stackIndex, destinationStackIndex); +// checkCompletedStacks(); +// updateGameOnServer(currentGame); +// } +// } +// } +// } +// +// +// /** +// * (★ 수정) checkCompletedStacks (게임 완료 시 타이머 중지) +// */ +// function checkCompletedStacks() { +// let completedCount = 0; +// for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) { +// const stack = currentGame.tableau[stackIndex]; +// if (stack.length < 13) continue; +// const last13Cards = stack.slice(stack.length - 13); +// let isCompleted = true; +// for (let i = 0; i < 12; i++) { +// if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { +// isCompleted = false; +// break; +// } +// } +// if (isCompleted) { +// completedCount++; +// isAnimatingCompletion = true; +// const cardsToRemove = stack.splice(stack.length - 13, 13); +// cardsToRemove.forEach(card => { +// const cardPos = getCardPosition(card, stackIndex); +// card.animStartX = cardPos.x; +// card.animStartY = cardPos.y; +// card.animEndTime = Date.now() + 500; +// card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING)); +// card.animTargetY = UI_ELEMENTS.foundationArea.y; +// completedStackCards.push(card); +// }); +// if (stack.length > 0) { +// stack[stack.length - 1].isFaceUp = true; +// } +// // 완성된 스택을 foundation에 추가 +// currentGame.foundation.push(cardsToRemove); +// } +// } +// +// // 모든 카드가 foundation으로 이동했는지 확인 +// const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0); +// +// // (★ 수정) 게임이 아직 완료되지 않았을 때만 체크 +// if (totalFoundationCards === 104 && !isGameCompleted) { +// isGameCompleted = true; +// +// // (★ 신규) 게임 완료 시점의 시간(초) 기록 +// completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000); +// console.log(`Game Won! Moves: ${currentGame.moves}, Time: ${completionTimeSeconds}s`); +// } +// } +// +// // --- (isValidMove, moveCardLocally - 수정 없음) --- +// function isValidMove(cardsToMove, destinationStackIndex) { +// if (cardsToMove.length === 0) return false; +// const firstCardToMove = cardsToMove[0]; +// const destStackCards = currentGame.tableau[destinationStackIndex]; +// if (destStackCards.length === 0) { +// return true; +// } +// const destTopCard = destStackCards[destStackCards.length - 1]; +// if (firstCardToMove.rank === destTopCard.rank - 1) { +// return true; +// } +// return false; +// } +// function moveCardLocally(cardsToMove, sourceStackIndex, destinationStackIndex) { +// const sourceStack = currentGame.tableau[sourceStackIndex]; +// const newSourceStack = sourceStack.slice(0, sourceStack.length - cardsToMove.length); +// const destinationStack = currentGame.tableau[destinationStackIndex]; +// const newDestinationStack = [...destinationStack, ...cardsToMove]; +// const newTableau = [...currentGame.tableau]; +// newTableau[sourceStackIndex] = newSourceStack; +// newTableau[destinationStackIndex] = newDestinationStack; +// if (newSourceStack.length > 0 && !newSourceStack[newSourceStack.length - 1].isFaceUp) { +// newSourceStack[newSourceStack.length - 1].isFaceUp = true; +// } +// currentGame.tableau = newTableau; +// currentGame.moves++; +// } +// +// +// /** +// * (★ 수정) handleUndo (통합 API 경로로 변경) +// */ +// async function handleUndo() { +// if (!currentGame || isAnimating || currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) { +// console.log("실행 취소 불가"); +// return; +// } +// try { +// // (★ 수정) API 경로 변경 -> /puzzle/spider/undo +// const response = await fetch(`/puzzle/spider/undo`, { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ gameId: currentGame.id }) +// }); +// if (!response.ok) throw new Error('Undo failed on server'); +// const newGame = await response.json(); +// currentGame = newGame; +// draw(); +// } catch (error) { +// console.error("실행 취소 중 오류 발생:", error); +// } +// } +// +// // ======================================= +// // 5. 서버 통신 함수 (★ API 경로 전체 수정) +// // ======================================= +// +// /** +// * (★ 수정) API 경로 변경 -> /puzzle/spider/deal +// */ +// async function dealFromStock() { +// if (!currentGame || isAnimating || currentGame.stock.length === 0) return; +// isAnimating = true; +// try { +// const response = await fetch(`/puzzle/spider/deal`, { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ gameId: currentGame.id }) +// }); +// if (!response.ok) throw new Error('Deal failed on server'); +// const newGame = await response.json(); +// currentGame = newGame; +// draw(); +// } catch (error) { +// console.error("카드 분배 중 오류 발생:", error); +// } finally { +// isAnimating = false; +// } +// } +// +// /** +// * (★ 수정) API 경로 변경 -> /puzzle/spider/update +// */ +// async function updateGameOnServer(updatedGame) { +// try { +// const response = await fetch(`/puzzle/spider/update`, { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify(updatedGame) +// }); +// if (!response.ok) throw new Error('Update failed on server'); +// const newGame = await response.json(); +// currentGame = newGame; +// isDragging = false; +// draggedCards = []; +// draw(); +// } catch (error) { +// console.error("게임 상태 업데이트 중 오류 발생:", error); +// } +// } +// +// /** +// * (★ 수정) API 경로 변경 및 타이머/ContextId 설정 +// */ +// async function startNewGame() { +// if (!assetsLoaded) return; +// const numSuits = selectedSuit; +// const numCards = selectedCardCount; +// +// // (★ 신규) 랭킹 등록용 Context ID 설정 (예: "1_SUITS_4,3") +// currentContextId = `${numSuits}_SUITS_${numCards.replace(',', '-')}`; +// +// try { +// // (★ 수정) API 경로 변경 -> /puzzle/spider/new +// const response = await fetch(`/puzzle/spider/new?numSuits=${numSuits}&numCards=${numCards}`); +// if (!response.ok) throw new Error('Failed to start new game'); +// currentGame = await response.json(); +// gameId = currentGame.id; +// isDragging = false; +// draggedCards = []; +// isGameCompleted = false; +// +// // (★ 신규) 새 게임 시작 시 타이머 시작 +// gameStartTime = Date.now(); +// completionTimeSeconds = 0; +// +// draw(); +// } catch (error) { +// console.error("새 게임 시작 중 오류 발생:", error); +// } +// } +// +// // ======================================= +// // 6. 기타 유틸리티 함수 +// // ======================================= +// // (★ 수정 없음) findStackAt, findCardAt, getCardPosition, getRankText, getSuitSymbol, getBestMoveForStack +// // --- (모든 유틸리티 함수 동일하게 유지) --- +// function findStackAt(x, y) { +// const startY = cardHeight * 0.5; +// for (let i = 0; i < 10; i++) { +// const stackX = tableauStartX + i * (cardWidth + cardGapX); +// const stackCards = currentGame.tableau[i]; +// if (stackCards.length === 0) { +// if (x >= stackX && x <= stackX + cardWidth && y >= startY) { +// return `tableau-${i + 1}`; +// } +// } +// const lastCardIndex = stackCards.length - 1; +// const lastCardY = startY + lastCardIndex * cardOverlapY; +// if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) { +// return `tableau-${i + 1}`; +// } +// } +// return null; +// } +// +// function findCardAt(x, y) { +// // (★ 수정) 안전을 위해 가드 절 추가. currentGame이 없으면 null 반환 +// if (!currentGame) { +// return null; +// } +// +// for (let stackIndex = 9; stackIndex >= 0; stackIndex--) { +// const stackCards = currentGame.tableau[stackIndex]; // 이제 이 코드는 currentGame이 있을 때만 실행됨 +// for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) { +// const card = stackCards[cardIndex]; +// if (!card.isFaceUp) continue; +// if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) { +// return { card, stackIndex, cardIndex }; +// } +// } +// } +// return null; +// } +// +// function getCardPosition(card, stackIndex) { +// const startY = cardHeight * 0.5; +// const stackCards = currentGame.tableau[stackIndex]; +// const cardIndexInStack = stackCards.findIndex(c => c.suit === card.suit && c.rank === card.rank); +// const x = tableauStartX + stackIndex * (cardWidth + cardGapX); +// const y = startY + cardIndexInStack * cardOverlapY; +// return { x, y }; +// } +// +// function getRankText(rank) { +// if (rank === 1) return 'A'; +// if (rank === 11) return 'J'; +// if (rank === 12) return 'Q'; +// if (rank === 13) return 'K'; +// return String(rank); +// } +// +// function getSuitSymbol(suit) { +// if (suit === 'spade') return '♠️'; +// if (suit === 'heart') return '♥️'; +// if (suit === 'club') return '♣️'; +// if (suit === 'diamond') return '♦️'; +// } +// +// function getBestMoveForStack(cardsToMove) { +// if (cardsToMove.length === 0) return null; +// const firstCardToMove = cardsToMove[0]; +// for (let i = 0; i < 10; i++) { +// const destStackId = `tableau-${i + 1}`; +// const destStackCards = currentGame.tableau[i]; +// if (destStackCards.length === 0) { +// return destStackId; +// } else { +// const destTopCard = destStackCards[destStackCards.length - 1]; +// if (firstCardToMove.rank === destTopCard.rank - 1) { +// return destStackId; +// } +// } +// } +// return null; +// } +// +// +// // --- 초기화 --- +// resizeCanvas(); +// draw(); +// }); \ No newline at end of file diff --git a/src/main/resources/static/js/sudoku.js b/src/main/resources/static/js/sudoku.js index 532666b..699b172 100644 --- a/src/main/resources/static/js/sudoku.js +++ b/src/main/resources/static/js/sudoku.js @@ -1,356 +1,363 @@ -document.addEventListener('DOMContentLoaded', () => { - // DOM 요소 - const setupContainer = document.getElementById('setup-container'); - const gameContainer = document.getElementById('game-container'); - const startBtn = document.getElementById('start-btn'); - const boardElement = document.getElementById('sudoku-board'); - const timerElement = document.getElementById('timer'); - const scoreElement = document.getElementById('score'); - const hintBtn = document.getElementById('hint-btn'); - const undoBtn = document.getElementById('undo-btn'); - const completeBtn = document.getElementById('complete-btn'); - const numberInputButtons = document.getElementById('number-input-buttons'); - const modalOverlay = document.getElementById('modal-overlay'); - const gameOverModal = document.getElementById('game-over-modal'); - const retryBtn = document.getElementById('retry-btn'); - const submitRankBtn = document.getElementById('submit-rank-btn'); - const rankingList = document.getElementById('ranking-list'); - const closeModalBtn = document.getElementById('close-modal-btn'); - - // 게임 상태 변수 - let currentPuzzleId = null; - let solvedPuzzle = null; - let timerInterval = null; - let secondsElapsed = 0; - let selectedNumber = null; - let focusedCell = null; - let score = 5; - let history = []; - - // --- 게임 초기화 및 시작 --- - startBtn.addEventListener('click', async () => { - const difficulty = document.getElementById('difficulty-select').value; - try { - const response = await fetch(`/sudoku/start?difficulty=${difficulty}`); - if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.'); - const gameData = await response.json(); - - currentPuzzleId = gameData.puzzleId; - solvedPuzzle = gameData.solution; - - history = []; - score = 5; - updateScoreDisplay(); - - renderBoard(gameData.question); - startTimer(); - updateButtonStates(); - - setupContainer.classList.add('hidden'); - gameContainer.classList.remove('hidden'); - numberInputButtons.classList.remove('hidden'); - gameOverModal.classList.add('hidden'); - } catch (error) { - alert('게임 로딩에 실패했습니다: ' + error.message); - console.error(error); - } - }); - - function renderBoard(puzzleString) { - boardElement.innerHTML = ''; - for (let i = 0; i < 81; i++) { - const cell = document.createElement('div'); - cell.classList.add('cell'); - cell.dataset.index = i; - - if (puzzleString[i] !== '0') { - cell.textContent = puzzleString[i]; - } else { - cell.classList.add('editable'); - } - boardElement.appendChild(cell); - } - } - - function startTimer() { - secondsElapsed = 0; - timerElement.textContent = '00:00'; - clearInterval(timerInterval); - timerInterval = setInterval(() => { - secondsElapsed++; - const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0'); - const seconds = (secondsElapsed % 60).toString().padStart(2, '0'); - timerElement.textContent = `${minutes}:${seconds}`; - }, 1000); - } - - function updateScoreDisplay() { - scoreElement.textContent = `SCORE: ${score}`; - if (score <= 0) { - clearInterval(timerInterval); - gameOverModal.classList.remove('hidden'); - } - } - - function updateButtonStates() { - const counts = {}; - for (let i = 1; i <= 9; i++) counts[i] = 0; - boardElement.querySelectorAll('.cell').forEach(cell => { - const num = cell.textContent; - if (num && counts[num] !== undefined) counts[num]++; - }); - for (let i = 1; i <= 9; i++) { - const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`); - if (btn) { - if (counts[i] >= 9) { - btn.classList.add('completed'); - if (selectedNumber == i) { - selectedNumber = null; - btn.classList.remove('selected'); - } - } else { - btn.classList.remove('completed'); - } - } - } - } - - // --- 게임 플레이 이벤트 핸들링 --- - numberInputButtons.addEventListener('click', (event) => { - const target = event.target.closest('button'); - if (!target) return; - - if (target === undoBtn) { - undoAction(); - return; - } - - if (target.classList.contains('completed')) return; - document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected')); - - if (target.classList.contains('num-btn')) { - const num = target.dataset.number; - selectedNumber = (selectedNumber === num) ? null : num; - if (selectedNumber) target.classList.add('selected'); - } - highlightCells(); - }); - - boardElement.addEventListener('click', (event) => { - const targetCell = event.target.closest('.cell.editable'); - if (!targetCell) { - if (focusedCell) focusedCell = null; - highlightCells(); - return; - } - focusedCell = targetCell; - - if (selectedNumber) { - const previousValue = targetCell.textContent; - let newValue = (previousValue === selectedNumber) ? '' : selectedNumber; - targetCell.textContent = newValue; - - recordAction(targetCell, previousValue, newValue); - validateCell(targetCell); - updateButtonStates(); - checkIfBoardIsFull(); - } - - highlightCells(); - }); - - hintBtn.addEventListener('click', () => { - if (score <= 0) return; - const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent); - if (emptyCells.length === 0) { - alert('모든 칸이 채워져 있습니다.'); - return; - } - - const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; - const cellIndex = parseInt(randomCell.dataset.index); - const correctAnswer = solvedPuzzle[cellIndex]; - const previousValue = randomCell.textContent; - - score--; - updateScoreDisplay(); - recordAction(randomCell, previousValue, correctAnswer, true); - - randomCell.textContent = correctAnswer; - randomCell.classList.remove('editable', 'incorrect'); - - updateButtonStates(); - highlightCells(); - checkIfBoardIsFull(); - }); - - function undoAction() { - if (history.length === 0) return; - const lastAction = history.pop(); - const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`); - - if (cell) { - cell.textContent = lastAction.previousValue; - if (lastAction.wasHint) { - cell.classList.add('editable'); - } - validateCell(cell, false); - updateButtonStates(); - highlightCells(); - } - } - - function recordAction(cell, previousValue, newValue, wasHint = false) { - history.push({ index: cell.dataset.index, previousValue, newValue, wasHint }); - } - - function validateCell(cell, deductPoint = true) { - if (!cell.textContent) { - cell.classList.remove('incorrect'); - return; - } - const cellIndex = parseInt(cell.dataset.index); - const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]); - if (!isCorrect) { - cell.classList.add('incorrect'); - if (deductPoint && score > 0) { - score--; - updateScoreDisplay(); - } - } else { - cell.classList.remove('incorrect'); - } - } - - // --- 하이라이트 기능 --- - function highlightCells() { - document.querySelectorAll('.cell').forEach(cell => { - cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); - }); - if (focusedCell) { - focusedCell.classList.add('highlight-focused'); - const focusedValue = focusedCell.textContent; - if (focusedValue) { - document.querySelectorAll('.cell').forEach(cell => { - if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number'); - }); - } - } - if (selectedNumber) { - document.querySelectorAll('.cell').forEach(cell => { - if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number'); - }); - } - } - - // --- 게임 완료 및 모달 --- - async function checkSolution() { - let answerString = ""; - boardElement.childNodes.forEach(cell => { - answerString += cell.textContent || '0'; - }); - - if (answerString.includes('0')) { - alert('모든 칸을 채워주세요!'); - return; - } - - try { - const response = await fetch('/sudoku/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString }) - }); - const result = await response.json(); - if (result.correct) { - clearInterval(timerInterval); - alert('🎉 정답입니다!'); - showRankingModal(); - } else { - alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.'); - } - } catch (error) { - console.error('정답 확인 중 오류 발생:', error); - alert('정답 확인 중 오류가 발생했습니다.'); - } - } - - function checkIfBoardIsFull() { - const emptyEditableCells = boardElement.querySelector('.cell.editable:empty'); - if (!emptyEditableCells) { - checkSolution(); - } - } - - completeBtn.addEventListener('click', checkSolution); - - async function showRankingModal() { - modalOverlay.classList.remove('hidden'); - document.getElementById('username-input').value = ''; - submitRankBtn.disabled = false; - try { - const response = await fetch(`/sudoku/ranking/${currentPuzzleId}`); - const rankings = await response.json(); - rankingList.innerHTML = ''; - if (rankings.length === 0) { - rankingList.innerHTML = '
  • 아직 등록된 랭킹이 없습니다.
  • '; - } else { - rankings.forEach((rank, index) => { - const li = document.createElement('li'); - const minutes = Math.floor(rank.completionTime / 60).toString().padStart(2, '0'); - const seconds = (rank.completionTime % 60).toString().padStart(2, '0'); - li.innerHTML = `${index + 1}위: ${rank.userName} ${minutes}:${seconds}`; - rankingList.appendChild(li); - }); - } - } catch (error) { - console.error('랭킹 조회 중 오류 발생:', error); - rankingList.innerHTML = '
  • 랭킹을 불러올 수 없습니다.
  • '; - } - } - - submitRankBtn.addEventListener('click', async () => { - const userName = document.getElementById('username-input').value.trim(); - if (!userName) return alert('이름을 입력해주세요.'); - try { - await fetch('/sudoku/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - puzzleId: currentPuzzleId, - userName: userName, - completionTime: secondsElapsed - }) - }); - alert('랭킹이 성공적으로 등록되었습니다!'); - showRankingModal(); - submitRankBtn.disabled = true; - } catch (error) { - console.error('랭킹 등록 중 오류 발생:', error); - alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.'); - } - }); - - function resetGameView() { - setupContainer.classList.remove('hidden'); - gameContainer.classList.add('hidden'); - numberInputButtons.classList.add('hidden'); - clearInterval(timerInterval); - selectedNumber = null; - focusedCell = null; - document.querySelectorAll('.cell').forEach(cell => { - cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); - }); - document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed')); - } - - closeModalBtn.addEventListener('click', () => { - modalOverlay.classList.add('hidden'); - resetGameView(); - }); - - retryBtn.addEventListener('click', () => { - gameOverModal.classList.add('hidden'); - resetGameView(); - }); -}); \ No newline at end of file +// document.addEventListener('DOMContentLoaded', () => { +// // DOM 요소 +// const setupContainer = document.getElementById('setup-container'); +// const gameContainer = document.getElementById('game-container'); +// const startBtn = document.getElementById('start-btn'); +// const boardElement = document.getElementById('sudoku-board'); +// const timerElement = document.getElementById('timer'); +// const scoreElement = document.getElementById('score'); +// const hintBtn = document.getElementById('hint-btn'); +// const undoBtn = document.getElementById('undo-btn'); +// const completeBtn = document.getElementById('complete-btn'); +// const numberInputButtons = document.getElementById('number-input-buttons'); +// const modalOverlay = document.getElementById('modal-overlay'); +// const gameOverModal = document.getElementById('game-over-modal'); +// const retryBtn = document.getElementById('retry-btn'); +// const submitRankBtn = document.getElementById('submit-rank-btn'); +// const rankingList = document.getElementById('ranking-list'); +// const closeModalBtn = document.getElementById('close-modal-btn'); +// +// // 게임 상태 변수 +// let currentPuzzleId = null; +// let solvedPuzzle = null; +// let timerInterval = null; +// let secondsElapsed = 0; +// let selectedNumber = null; +// let focusedCell = null; +// let score = 5; +// let history = []; +// +// // (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경 +// startBtn.addEventListener('click', async () => { +// const difficulty = document.getElementById('difficulty-select').value; +// try { +// // (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start +// const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`); +// if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.'); +// const gameData = await response.json(); +// +// currentContextId = gameData.puzzleId; +// solvedPuzzle = gameData.solution; +// +// history = []; +// score = 5; +// updateScoreDisplay(); +// +// renderBoard(gameData.question); +// startTimer(); +// updateButtonStates(); +// +// setupContainer.classList.add('hidden'); +// gameContainer.classList.remove('hidden'); +// numberInputButtons.classList.remove('hidden'); +// gameOverModal.classList.add('hidden'); +// } catch (error) { +// alert('게임 로딩에 실패했습니다: ' + error.message); +// console.error(error); +// } +// }); +// +// function renderBoard(puzzleString) { +// boardElement.innerHTML = ''; +// for (let i = 0; i < 81; i++) { +// const cell = document.createElement('div'); +// cell.classList.add('cell'); +// cell.dataset.index = i; +// +// if (puzzleString[i] !== '0') { +// cell.textContent = puzzleString[i]; +// } else { +// cell.classList.add('editable'); +// } +// boardElement.appendChild(cell); +// } +// } +// +// function startTimer() { +// secondsElapsed = 0; +// timerElement.textContent = '00:00'; +// clearInterval(timerInterval); +// timerInterval = setInterval(() => { +// secondsElapsed++; +// const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0'); +// const seconds = (secondsElapsed % 60).toString().padStart(2, '0'); +// timerElement.textContent = `${minutes}:${seconds}`; +// }, 1000); +// } +// +// function updateScoreDisplay() { +// scoreElement.textContent = `SCORE: ${score}`; +// if (score <= 0) { +// clearInterval(timerInterval); +// gameOverModal.classList.remove('hidden'); +// } +// } +// +// function updateButtonStates() { +// const counts = {}; +// for (let i = 1; i <= 9; i++) counts[i] = 0; +// boardElement.querySelectorAll('.cell').forEach(cell => { +// const num = cell.textContent; +// if (num && counts[num] !== undefined) counts[num]++; +// }); +// for (let i = 1; i <= 9; i++) { +// const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`); +// if (btn) { +// if (counts[i] >= 9) { +// btn.classList.add('completed'); +// if (selectedNumber == i) { +// selectedNumber = null; +// btn.classList.remove('selected'); +// } +// } else { +// btn.classList.remove('completed'); +// } +// } +// } +// } +// +// // --- 게임 플레이 이벤트 핸들링 --- +// numberInputButtons.addEventListener('click', (event) => { +// const target = event.target.closest('button'); +// if (!target) return; +// +// if (target === undoBtn) { +// undoAction(); +// return; +// } +// +// if (target.classList.contains('completed')) return; +// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected')); +// +// if (target.classList.contains('num-btn')) { +// const num = target.dataset.number; +// selectedNumber = (selectedNumber === num) ? null : num; +// if (selectedNumber) target.classList.add('selected'); +// } +// highlightCells(); +// }); +// +// boardElement.addEventListener('click', (event) => { +// const targetCell = event.target.closest('.cell.editable'); +// if (!targetCell) { +// if (focusedCell) focusedCell = null; +// highlightCells(); +// return; +// } +// focusedCell = targetCell; +// +// if (selectedNumber) { +// const previousValue = targetCell.textContent; +// let newValue = (previousValue === selectedNumber) ? '' : selectedNumber; +// targetCell.textContent = newValue; +// +// recordAction(targetCell, previousValue, newValue); +// validateCell(targetCell); +// updateButtonStates(); +// checkIfBoardIsFull(); +// } +// +// highlightCells(); +// }); +// +// hintBtn.addEventListener('click', () => { +// if (score <= 0) return; +// const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent); +// if (emptyCells.length === 0) { +// alert('모든 칸이 채워져 있습니다.'); +// return; +// } +// +// const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; +// const cellIndex = parseInt(randomCell.dataset.index); +// const correctAnswer = solvedPuzzle[cellIndex]; +// const previousValue = randomCell.textContent; +// +// score--; +// updateScoreDisplay(); +// recordAction(randomCell, previousValue, correctAnswer, true); +// +// randomCell.textContent = correctAnswer; +// randomCell.classList.remove('editable', 'incorrect'); +// +// updateButtonStates(); +// highlightCells(); +// checkIfBoardIsFull(); +// }); +// +// function undoAction() { +// if (history.length === 0) return; +// const lastAction = history.pop(); +// const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`); +// +// if (cell) { +// cell.textContent = lastAction.previousValue; +// if (lastAction.wasHint) { +// cell.classList.add('editable'); +// } +// validateCell(cell, false); +// updateButtonStates(); +// highlightCells(); +// } +// } +// +// function recordAction(cell, previousValue, newValue, wasHint = false) { +// history.push({ index: cell.dataset.index, previousValue, newValue, wasHint }); +// } +// +// function validateCell(cell, deductPoint = true) { +// if (!cell.textContent) { +// cell.classList.remove('incorrect'); +// return; +// } +// const cellIndex = parseInt(cell.dataset.index); +// const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]); +// if (!isCorrect) { +// cell.classList.add('incorrect'); +// if (deductPoint && score > 0) { +// score--; +// updateScoreDisplay(); +// } +// } else { +// cell.classList.remove('incorrect'); +// } +// } +// +// // --- 하이라이트 기능 --- +// function highlightCells() { +// document.querySelectorAll('.cell').forEach(cell => { +// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); +// }); +// if (focusedCell) { +// focusedCell.classList.add('highlight-focused'); +// const focusedValue = focusedCell.textContent; +// if (focusedValue) { +// document.querySelectorAll('.cell').forEach(cell => { +// if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number'); +// }); +// } +// } +// if (selectedNumber) { +// document.querySelectorAll('.cell').forEach(cell => { +// if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number'); +// }); +// } +// } +// +// // --- 게임 완료 및 모달 --- +// async function checkSolution() { +// let answerString = ""; +// boardElement.childNodes.forEach(cell => { +// answerString += cell.textContent || '0'; +// }); +// +// if (answerString.includes('0')) { +// alert('모든 칸을 채워주세요!'); +// return; +// } +// +// try { +// // (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate +// // (★ 수정) contextId(puzzleId) 변수 사용 +// const response = await fetch('/puzzle/sudoku/validate', { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ puzzleId: currentContextId, answer: answerString }) +// }); +// const result = await response.json(); +// if (result.correct) { +// clearInterval(timerInterval); +// alert('🎉 정답입니다!'); +// showRankingModal(); // 랭킹 모달 표시 +// } else { +// alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.'); +// } +// } catch (error) { +// console.error('정답 확인 중 오류 발생:', error); +// alert('정답 확인 중 오류가 발생했습니다.'); +// } +// } +// +// function checkIfBoardIsFull() { +// const emptyEditableCells = boardElement.querySelector('.cell.editable:empty'); +// if (!emptyEditableCells) { +// checkSolution(); +// } +// } +// completeBtn.addEventListener('click', checkSolution); +// /** +// * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정 +// */ +// async function showRankingModal() { +// modalOverlay.classList.remove('hidden'); +// document.getElementById('username-input').value = ''; +// submitRankBtn.disabled = false; +// rankingList.innerHTML = '
  • 로딩 중...
  • '; +// +// try { +// // (★ 수정) user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID(ContextId) 전달) +// const rankings = await fetchRanks(currentGameType, currentContextId); +// +// rankingList.innerHTML = ''; +// if (rankings.length === 0) { +// rankingList.innerHTML = '
  • 아직 등록된 랭킹이 없습니다.
  • '; +// } else { +// rankings.forEach((rank, index) => { +// const li = document.createElement('li'); +// // (★ 수정) 공통 모델 필드(primaryScore)를 시간(초)으로 변환 +// const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0'); +// const seconds = (rank.primaryScore % 60).toString().padStart(2, '0'); +// li.innerHTML = `${index + 1}위: ${rank.playerName} ${minutes}:${seconds}`; +// rankingList.appendChild(li); +// }); +// } +// } catch (error) { +// console.error('랭킹 조회 중 오류 발생:', error); +// rankingList.innerHTML = '
  • 랭킹을 불러올 수 없습니다.
  • '; +// } +// } +// +// /** +// * (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정 +// */ +// submitRankBtn.addEventListener('click', async () => { +// const userName = document.getElementById('username-input').value.trim(); +// if (!userName) return alert('이름을 입력해주세요.'); +// +// try { +// // (★ 수정) user.js의 공통 submitRank 함수 호출 +// // 스도쿠의 주 점수(primaryScore)는 완료 시간(secondsElapsed), 보조 점수는 없음. +// await submitRank(currentGameType, currentContextId, userName, secondsElapsed, null); +// +// alert('랭킹이 성공적으로 등록되었습니다!'); +// showRankingModal(); // 랭킹 목록 새로고침 +// submitRankBtn.disabled = true; // 중복 등록 방지 +// } catch (error) { +// console.error('랭킹 등록 중 오류 발생:', error); +// alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.'); +// } +// }); +// +// function resetGameView() { +// setupContainer.classList.remove('hidden'); +// gameContainer.classList.add('hidden'); +// numberInputButtons.classList.add('hidden'); +// clearInterval(timerInterval); +// selectedNumber = null; +// focusedCell = null; +// document.querySelectorAll('.cell').forEach(cell => { +// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number'); +// }); +// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed')); +// } +// +// closeModalBtn.addEventListener('click', () => { +// modalOverlay.classList.add('hidden'); +// resetGameView(); +// }); +// +// retryBtn.addEventListener('click', () => { +// gameOverModal.classList.add('hidden'); +// resetGameView(); +// }); +// });₩ \ No newline at end of file diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js deleted file mode 100644 index 183b645..0000000 --- a/src/main/resources/static/js/upload.js +++ /dev/null @@ -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('
    '); // 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}`; - } - }); - }); - - -}); \ No newline at end of file diff --git a/src/main/resources/static/js/user.js b/src/main/resources/static/js/user.js index ef4e3c8..e69de29 100644 --- a/src/main/resources/static/js/user.js +++ b/src/main/resources/static/js/user.js @@ -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("비번이 다름요") - } - } -} - - diff --git a/src/main/resources/templates/content/blog/posts.html b/src/main/resources/templates/content/blog/posts.html index afce43c..45c7d69 100644 --- a/src/main/resources/templates/content/blog/posts.html +++ b/src/main/resources/templates/content/blog/posts.html @@ -13,7 +13,7 @@
    -
    +
    diff --git a/src/main/resources/templates/content/blog/viewer.html b/src/main/resources/templates/content/blog/viewer.html index 300cfcb..83f43a5 100644 --- a/src/main/resources/templates/content/blog/viewer.html +++ b/src/main/resources/templates/content/blog/viewer.html @@ -76,7 +76,7 @@ -
    diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index bad9789..b477edf 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -5,17 +5,14 @@ xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/default_layout}"> -
    -
    @@ -82,7 +93,6 @@
    -
    @@ -91,4 +101,4 @@
    - + \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/2048.html b/src/main/resources/templates/content/puzzle/2048.html index 57cbf35..2a16135 100644 --- a/src/main/resources/templates/content/puzzle/2048.html +++ b/src/main/resources/templates/content/puzzle/2048.html @@ -5,16 +5,186 @@ xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/default_layout}" > - - - - - - + + - + + + + +

    2048

    화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!

    @@ -24,18 +194,261 @@
    - - + + +