...
This commit is contained in:
parent
d62a4a3c15
commit
19c5d5473f
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import com.google.maps.GeocodingApi
|
||||
import com.google.maps.model.LatLng
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import kotlinx.coroutines.reactive.awaitSingleOrNull
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
|
||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
|
||||
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
|
||||
@ -42,7 +44,9 @@ import java.net.URLDecoder
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
import kr.lunaticbum.back.lun.model.ImageMeta // [신규 추가]
|
||||
import kr.lunaticbum.back.lun.model.ImageMetaService // [신규 추가]
|
||||
import javax.imageio.ImageIO // [신규 추가]
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/blog")
|
||||
@ -50,6 +54,10 @@ class BlogController(private val commentService : CommentService) {
|
||||
companion object {
|
||||
val TEMPTOKEN = "TEMP_TOKEN_VIBUM"
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private lateinit var imageMetaService: ImageMetaService
|
||||
|
||||
@Autowired
|
||||
lateinit var globalEvv : GlobalEnvironment
|
||||
|
||||
@ -355,7 +363,7 @@ class BlogController(private val commentService : CommentService) {
|
||||
* @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8로 설정
|
||||
*/
|
||||
@GetMapping("posts")
|
||||
fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
|
||||
suspend fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
|
||||
val vm = ResultMV("content/blog/posts")
|
||||
try {
|
||||
val postsList: List<Post>
|
||||
@ -368,13 +376,15 @@ class BlogController(private val commentService : CommentService) {
|
||||
if (isAdmin) {
|
||||
// [관리자]: 모든 버전의 글을 조회합니다.
|
||||
logService.log("User is ADMIN. Loading all post versions.")
|
||||
postsList = postManager.findAllVersionsPaginated(pageable)
|
||||
totalPosts = postManager.countAllVersions().block() ?: 0L
|
||||
// 2. Use awaitSingleOrNull() instead of blocking assignment
|
||||
postsList = postManager.findAllVersionsPaginated(pageable).awaitSingleOrNull() ?: emptyList()
|
||||
totalPosts = postManager.countAllVersions().awaitSingleOrNull() ?: 0L // 3. Use awaitSingleOrNull()
|
||||
} else {
|
||||
// [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다.
|
||||
logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.")
|
||||
postsList = postManager.findLatestUniquePaginated(pageable)
|
||||
totalPosts = postManager.countLatestUnique().block() ?: 0L
|
||||
// 4. Use awaitSingleOrNull() - THIS FIXES THE TYPE MISMATCH ERROR
|
||||
postsList = postManager.findLatestUniquePaginated(pageable).awaitSingleOrNull() ?: emptyList()
|
||||
totalPosts = postManager.countLatestUnique().awaitSingleOrNull() ?: 0L // 5. Use awaitSingleOrNull()
|
||||
}
|
||||
// if (principal != null) {
|
||||
// // [인증 사용자]: 모든 버전의 글을 조회합니다.
|
||||
@ -556,25 +566,49 @@ class BlogController(private val commentService : CommentService) {
|
||||
out.write(bytes)
|
||||
out.flush()
|
||||
|
||||
// 썸네일 생성 및 저장
|
||||
// --- [신규 로직 시작] ---
|
||||
try {
|
||||
// 1. 저장된 원본 파일에서 이미지 크기(dimensions) 추출
|
||||
val savedFile = File(originalImagePath)
|
||||
val bufferedImage = ImageIO.read(savedFile)
|
||||
val imgWidth = bufferedImage.width
|
||||
val imgHeight = bufferedImage.height
|
||||
|
||||
// 2. ImageMeta 객체 생성
|
||||
val metadata = ImageMeta(
|
||||
fileName = "$uuid.$extension",
|
||||
originalFileName = upload.originalFilename,
|
||||
fileType = upload.contentType,
|
||||
fileSize = upload.size,
|
||||
width = imgWidth,
|
||||
height = imgHeight,
|
||||
uploadTime = System.currentTimeMillis(),
|
||||
path = "/blog/post/images/$uuid.$extension" // 이미지를 불러오는 GetMapping 경로 기준
|
||||
)
|
||||
|
||||
// 3. 메타데이터를 DB에 저장 (Reactive 호출을 동기식으로 대기)
|
||||
imageMetaService.save(metadata).block() // .block()은 실제 프로덕션에서는 권장되지 않으나, 현재 컨트롤러 구조(동기식 반환)에 맞춤
|
||||
|
||||
} catch (metaError: Exception) {
|
||||
// 메타데이터 저장에 실패해도 원본 이미지 업로드는 성공한 것으로 처리 (로그만 남김)
|
||||
logService.log("Failed to save image metadata: ${metaError.message}")
|
||||
metaError.printStackTrace()
|
||||
}
|
||||
// --- [신규 로직 끝] ---
|
||||
|
||||
// 썸네일 생성 및 저장 (기존 로직)
|
||||
Thumbnails.of(originalImagePath)
|
||||
.width(200) // 가로 크기를 설정
|
||||
.keepAspectRatio(true)
|
||||
.toFile(thumbnailPath)
|
||||
|
||||
logService.log("Original image saved: ${File(originalImagePath).exists()}")
|
||||
logService.log("Thumbnail saved: ${File(thumbnailPath).exists()}")
|
||||
// --- [신규 추가 로직 시작] ---
|
||||
// 업로드가 완료되었으므로, 백그라운드 동기화 작업을 호출합니다.
|
||||
// (혹시 모를 다른 누락 파일을 찾기 위함. 이미 실행 중이면 무시됨)
|
||||
imageMetaService.launchSyncTask()
|
||||
// --- [신규 추가 로직 끝] ---
|
||||
|
||||
// 메타데이터 읽기 (원본 이미지에서)
|
||||
val metadata: Metadata? = ImageMetadataReader.readMetadata(File(originalImagePath))
|
||||
metadata?.let {
|
||||
it.directories?.forEach { directory ->
|
||||
logService.log(directory.name)
|
||||
logService.log(directory.tags.map { tag ->
|
||||
logService.log("tag.tagName >>> ${tag.tagName} || tag.description ${tag.description}")
|
||||
}.joinToString(" \n"))
|
||||
}
|
||||
}
|
||||
// ... (기존 메타데이터 읽기 로그) ...
|
||||
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import kr.lunaticbum.back.lun.model.GameRank
|
||||
import kr.lunaticbum.back.lun.model.GameRankService
|
||||
import kr.lunaticbum.back.lun.model.GameType
|
||||
import kr.lunaticbum.back.lun.model.UnifiedRankDto
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
|
||||
class GameRankController(private val gameRankService: GameRankService) {
|
||||
|
||||
/**
|
||||
* 모든 게임을 위한 통합 랭킹 등록 엔드포인트
|
||||
*/
|
||||
@PostMapping("/submit")
|
||||
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<GameRank>> {
|
||||
return gameRankService.submitRank(rankDto)
|
||||
.map { savedRank -> ResponseEntity.ok(savedRank) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
|
||||
* 예: /api/ranks/list?gameType=SUDOKU&contextId=123
|
||||
* 예: /api/ranks/list?gameType=GAME_2048
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getUnifiedRanks(
|
||||
@RequestParam gameType: GameType,
|
||||
@RequestParam contextId: String? = null
|
||||
): Flux<GameRank> {
|
||||
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
|
||||
val effectiveContextId = if (contextId == "null") null else contextId
|
||||
return gameRankService.getRanks(gameType, effectiveContextId)
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,10 @@ import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.model.ImageMeta
|
||||
import kr.lunaticbum.back.lun.model.ImageMetaService
|
||||
import kr.lunaticbum.back.lun.model.Post
|
||||
import kr.lunaticbum.back.lun.model.PostManager
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
@ -29,6 +33,9 @@ import java.net.URLDecoder
|
||||
@RequestMapping()
|
||||
class Home {
|
||||
|
||||
@Autowired
|
||||
private lateinit var imageMetaService: ImageMetaService
|
||||
|
||||
@Autowired
|
||||
lateinit var logService: LogService
|
||||
|
||||
@ -71,7 +78,30 @@ class Home {
|
||||
suspend fun home() : ResultMV {
|
||||
val vm = ResultMV("content/home")
|
||||
try {
|
||||
vm.modelMap.put("Posts", postManager.find8().apply {
|
||||
try {
|
||||
// 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다.
|
||||
// DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다.
|
||||
val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() //
|
||||
|
||||
// 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다.
|
||||
if (randomImage != null) {
|
||||
vm.modelMap.put("randomBannerImage", randomImage.path)
|
||||
}
|
||||
// 3. else (null인 경우): 아무것도 하지 않습니다.
|
||||
// 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다.
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다.
|
||||
logService.log("CRITICAL Error during random banner image query: ${e.message}")
|
||||
}
|
||||
|
||||
// === [FIXED LOGIC] ===
|
||||
// 1. Asynchronously await the Mono result without blocking the thread.
|
||||
// Use awaitSingleOrNull() just like the random image query.
|
||||
val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
|
||||
|
||||
// 2. Apply the processing logic to the resulting list.
|
||||
vm.modelMap.put("Posts", postsList.apply {
|
||||
this.forEach {
|
||||
it.title = URLDecoder.decode(it.title)
|
||||
it.content = URLDecoder.decode(it.content)
|
||||
@ -100,7 +130,8 @@ class Home {
|
||||
}
|
||||
it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
|
||||
}
|
||||
}.chunked(2))
|
||||
})
|
||||
// === [END FIXED LOGIC] ===
|
||||
}catch (ex: Exception){ex.printStackTrace()}
|
||||
vm.modelMap.put("path","/blog/viewer/")
|
||||
return vm
|
||||
|
||||
@ -1,87 +1,171 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.model.PuzzleService
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
/**
|
||||
* [통합 게임 API 허브 컨트롤러]
|
||||
* 1. 모든 게임의 HTML 페이지 서빙
|
||||
* 2. 모든 게임의 플레이 로직 API (게임 시작, 검증, 상태 업데이트 등) 제공
|
||||
* (기존 SudokuController, SpiderController의 기능을 모두 통합)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/puzzle")
|
||||
class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입
|
||||
@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
|
||||
class PuzzleController(
|
||||
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
|
||||
private val puzzleService: PuzzleService
|
||||
) {
|
||||
|
||||
// ======================================================
|
||||
// 1. NONOGRAM API (기존 엔드포인트 유지)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* 노노그램: 이미지 업로드 및 퍼즐 생성
|
||||
*/
|
||||
@PostMapping("upload.bjx")
|
||||
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
|
||||
return try {
|
||||
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile)
|
||||
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환
|
||||
ResponseEntity.ok(savedPuzzle)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// 실패 시 500 에러와 메시지 반환
|
||||
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 ID의 퍼즐을 삭제하는 엔드포인트
|
||||
* @param id URL 경로에서 추출한 퍼즐의 고유 ID
|
||||
* 노노그램: 퍼즐 ID로 삭제
|
||||
*/
|
||||
@DeleteMapping("/{id}.bjx")
|
||||
suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity<Void> {
|
||||
return try {
|
||||
puzzleService.deletePuzzle(id)
|
||||
// 성공적으로 삭제되면 204 No Content 응답을 보냅니다.
|
||||
ResponseEntity.noContent().build()
|
||||
} catch (e: Exception) {
|
||||
// 실패 시 500 에러 응답
|
||||
ResponseEntity.internalServerError().build()
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 2. SUDOKU API (★ SudokuController에서 마이그레이션됨)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* ID가 지정된 경우 특정 퍼즐을 로드합니다.
|
||||
* 스도쿠: 새 게임 시작 (난이도별 문제 반환)
|
||||
*/
|
||||
@GetMapping("/sudoku/start")
|
||||
suspend fun sudokuStartGame(@RequestParam(defaultValue = "easy") difficulty: String): PuzzleService.SudokuGameDto {
|
||||
return puzzleService.sudoku_startGame(difficulty)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스도쿠: 사용자가 제출한 답안 검증
|
||||
*/
|
||||
@PostMapping("/sudoku/validate")
|
||||
suspend fun sudokuValidate(@RequestBody validateDto: PuzzleService.SudokuValidateDto): Map<String, Boolean> {
|
||||
val isCorrect = puzzleService.sudoku_validateSolution(validateDto)
|
||||
return mapOf("correct" to isCorrect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스도쿠: (관리용) 새 퍼즐 문제 생성 및 DB 저장
|
||||
*/
|
||||
@GetMapping("/sudoku/generate")
|
||||
suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle {
|
||||
return puzzleService.sudoku_generateAndSavePuzzle()
|
||||
}
|
||||
|
||||
|
||||
// ======================================================
|
||||
// 3. SPIDER API (★ SpiderController에서 마이그레이션 및 Coroutine 변환됨)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* 스파이더: 새 게임 시작 (무늬 수, 카드 장 수 기반)
|
||||
*/
|
||||
@GetMapping("/spider/new")
|
||||
suspend fun spiderNewGame(@RequestParam numSuits: Int, @RequestParam numCards: String): SpiderGame {
|
||||
return puzzleService.spider_newGame(numSuits, numCards)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스파이더: ID로 기존 게임 불러오기
|
||||
*/
|
||||
@GetMapping("/spider/{id}")
|
||||
suspend fun spiderGetGame(@PathVariable id: String): ResponseEntity<SpiderGame> {
|
||||
val game = puzzleService.spider_getGame(id)
|
||||
return if (game != null) ResponseEntity.ok(game) else ResponseEntity.notFound().build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 스파이더: 게임 상태 업데이트 (카드 이동 시)
|
||||
*/
|
||||
@PostMapping("/spider/update")
|
||||
suspend fun spiderUpdateGame(@RequestBody game: SpiderGame): SpiderGame {
|
||||
return puzzleService.spider_updateGame(game)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스파이더: 스톡에서 새 카드 분배
|
||||
*/
|
||||
@PostMapping("/spider/deal")
|
||||
suspend fun spiderDealCards(@RequestBody request: Map<String, String>): SpiderGame {
|
||||
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
|
||||
return puzzleService.spider_dealCardsFromStock(gameId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 스파이더: 실행 취소 (Undo)
|
||||
*/
|
||||
@PostMapping("/spider/undo")
|
||||
suspend fun spiderUndo(@RequestBody request: Map<String, String>): SpiderGame {
|
||||
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
|
||||
return puzzleService.spider_undoGame(gameId)
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 4. 페이지 서빙 엔드포인트 (기존 로직 유지)
|
||||
// ======================================================
|
||||
|
||||
/**
|
||||
* 노노그램: 특정 ID의 퍼즐 플레이 페이지
|
||||
*/
|
||||
@GetMapping("/play/{id}")
|
||||
suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV {
|
||||
val puzzle = puzzleService.findById(id).awaitSingleOrNull()
|
||||
val vm = ResultMV("content/puzzle/play")
|
||||
val vm = ResultMV("content/puzzle/nonogram")
|
||||
return if (puzzle != null) {
|
||||
vm.model.put("puzzle", puzzle)
|
||||
vm
|
||||
} else {
|
||||
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
|
||||
vm.viewName = "redirect:/"
|
||||
vm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
|
||||
* 노노그램: 랜덤 퍼즐 플레이 페이지
|
||||
*/
|
||||
@GetMapping("/play")
|
||||
suspend fun playRandomPuzzlePage(): ResultMV {
|
||||
val puzzle = puzzleService.findRandomPuzzle()
|
||||
val vm = ResultMV("content/puzzle/play")
|
||||
val vm = ResultMV("content/puzzle/nonogram")
|
||||
return if (puzzle != null) {
|
||||
vm.model.put("puzzle", puzzle)
|
||||
vm
|
||||
} else {
|
||||
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
|
||||
vm.viewName = "redirect:/"
|
||||
vm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
|
||||
* 2048: 게임 페이지 서빙
|
||||
*/
|
||||
@GetMapping("/2048")
|
||||
suspend fun play2048(): ResultMV {
|
||||
@ -90,30 +174,27 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
|
||||
}
|
||||
|
||||
/**
|
||||
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
|
||||
* 스도쿠: 게임 페이지 서빙
|
||||
*/
|
||||
@GetMapping("/sudoku")
|
||||
suspend fun sudoku(): ResultMV {
|
||||
val vm = ResultMV("content/puzzle/sudoku")
|
||||
return vm
|
||||
|
||||
}
|
||||
|
||||
@GetMapping("/sudoku_gen.bs")
|
||||
suspend fun sudoku_gen(): ResultMV {
|
||||
val vm = ResultMV("content/puzzle/sudoku_gen")
|
||||
return vm
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 스파이더: 게임 페이지 서빙
|
||||
*/
|
||||
@GetMapping("/spider")
|
||||
suspend fun spider(): ResultMV {
|
||||
val vm = ResultMV("content/puzzle/spider")
|
||||
return vm
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/","/upload.bs")
|
||||
/**
|
||||
* 메인 페이지 (노노그램 업로드)
|
||||
*/
|
||||
@GetMapping("/", "/upload.bs")
|
||||
suspend fun uploadPuzzle() : ResultMV {
|
||||
val vm = ResultMV("content/puzzle/upload")
|
||||
return vm
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import kr.lunaticbum.back.lun.model.Rank
|
||||
import kr.lunaticbum.back.lun.model.RankRepository
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/rank")
|
||||
class RankController(val rankRepository: RankRepository) {
|
||||
// private val rankRepository: RankRepository
|
||||
//package kr.lunaticbum.back.lun.controllers
|
||||
//
|
||||
// init {
|
||||
// this.rankRepository = rankRepository
|
||||
//import kr.lunaticbum.back.lun.model.Rank
|
||||
//import kr.lunaticbum.back.lun.model.RankRepository
|
||||
//import org.springframework.web.bind.annotation.*
|
||||
//import reactor.core.publisher.Flux
|
||||
//import reactor.core.publisher.Mono
|
||||
//
|
||||
//
|
||||
//@RestController
|
||||
//@RequestMapping("/rank")
|
||||
//class RankController(val rankRepository: RankRepository) {
|
||||
//// private val rankRepository: RankRepository
|
||||
////
|
||||
//// init {
|
||||
//// this.rankRepository = rankRepository
|
||||
//// }
|
||||
//
|
||||
// /**
|
||||
// * 새로운 랭킹을 저장합니다.
|
||||
// * 요청 Body에 gameId가 포함되어야 합니다.
|
||||
// * @param rank 저장할 랭크 정보 (gameId, name, score)
|
||||
// * @return Mono<Rank>
|
||||
// </Rank> */
|
||||
// @PostMapping("/ranks")
|
||||
// fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
|
||||
// return rankRepository.save(rank)
|
||||
// }
|
||||
|
||||
/**
|
||||
* 새로운 랭킹을 저장합니다.
|
||||
* 요청 Body에 gameId가 포함되어야 합니다.
|
||||
* @param rank 저장할 랭크 정보 (gameId, name, score)
|
||||
* @return Mono<Rank>
|
||||
</Rank> */
|
||||
@PostMapping("/ranks")
|
||||
fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
|
||||
return rankRepository.save(rank)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 게임의 상위 10개 랭킹 리스트를 조회합니다.
|
||||
* @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다.
|
||||
* @return Flux<Rank>
|
||||
</Rank> */
|
||||
@GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
|
||||
fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
|
||||
return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
|
||||
}
|
||||
}
|
||||
//
|
||||
// /**
|
||||
// * 특정 게임의 상위 10개 랭킹 리스트를 조회합니다.
|
||||
// * @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다.
|
||||
// * @return Flux<Rank>
|
||||
// </Rank> */
|
||||
// @GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
|
||||
// fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
|
||||
// return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
|
||||
// }
|
||||
//}
|
||||
@ -1,64 +1,64 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import kr.lunaticbum.back.lun.model.SpiderGame
|
||||
import kr.lunaticbum.back.lun.model.SpiderRank
|
||||
import kr.lunaticbum.back.lun.model.SpiderService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/spider")
|
||||
class SpiderController(private val spiderService: SpiderService,) {
|
||||
|
||||
@GetMapping("/new")
|
||||
fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono<SpiderGame> {
|
||||
return spiderService.newGame(numSuits, numCards)
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getGame(@PathVariable id: String): Mono<SpiderGame> {
|
||||
return spiderService.getGame(id)
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
fun updateGame(@RequestBody game: SpiderGame): Mono<SpiderGame> {
|
||||
return spiderService.updateGame(game)
|
||||
}
|
||||
|
||||
// 랭킹 등록 엔드포인트
|
||||
@PostMapping("/register")
|
||||
fun registerRank(@RequestBody rank: SpiderRank): Mono<ResponseEntity<SpiderRank>> {
|
||||
return spiderService.registerRank(rank)
|
||||
.map { savedRank -> ResponseEntity.ok(savedRank) }
|
||||
.onErrorResume(IllegalArgumentException::class.java) { e ->
|
||||
Mono.just(ResponseEntity.badRequest().body(null))
|
||||
}
|
||||
}
|
||||
|
||||
// 게임 ID별 랭킹 조회 엔드포인트
|
||||
@GetMapping("/list/{gameId}")
|
||||
fun getRanks(@PathVariable gameId: String): Flux<SpiderRank> {
|
||||
return spiderService.getRanksByGameId(gameId)
|
||||
}
|
||||
|
||||
@PostMapping("/deal")
|
||||
fun dealCards(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
|
||||
val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
|
||||
return spiderService.dealCardsFromStock(gameId)
|
||||
}
|
||||
|
||||
// 실행 취소 엔드포인트 추가
|
||||
@PostMapping("/undo")
|
||||
fun undo(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
|
||||
val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
|
||||
return spiderService.undoGame(gameId)
|
||||
}
|
||||
}
|
||||
//package kr.lunaticbum.back.lun.controllers
|
||||
//
|
||||
//import kr.lunaticbum.back.lun.model.SpiderGame
|
||||
//import kr.lunaticbum.back.lun.model.SpiderRank
|
||||
//import kr.lunaticbum.back.lun.model.SpiderService
|
||||
//import org.springframework.http.ResponseEntity
|
||||
//import org.springframework.web.bind.annotation.GetMapping
|
||||
//import org.springframework.web.bind.annotation.PathVariable
|
||||
//import org.springframework.web.bind.annotation.PostMapping
|
||||
//import org.springframework.web.bind.annotation.RequestBody
|
||||
//import org.springframework.web.bind.annotation.RequestMapping
|
||||
//import org.springframework.web.bind.annotation.RequestParam
|
||||
//import org.springframework.web.bind.annotation.RestController
|
||||
//import reactor.core.publisher.Flux
|
||||
//import reactor.core.publisher.Mono
|
||||
//
|
||||
//@RestController
|
||||
//@RequestMapping("/spider")
|
||||
//class SpiderController(private val spiderService: SpiderService,) {
|
||||
//
|
||||
// @GetMapping("/new")
|
||||
// fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono<SpiderGame> {
|
||||
// return spiderService.newGame(numSuits, numCards)
|
||||
// }
|
||||
//
|
||||
// @GetMapping("/{id}")
|
||||
// fun getGame(@PathVariable id: String): Mono<SpiderGame> {
|
||||
// return spiderService.getGame(id)
|
||||
// }
|
||||
//
|
||||
// @PostMapping("/update")
|
||||
// fun updateGame(@RequestBody game: SpiderGame): Mono<SpiderGame> {
|
||||
// return spiderService.updateGame(game)
|
||||
// }
|
||||
//
|
||||
// // 랭킹 등록 엔드포인트
|
||||
// @PostMapping("/register")
|
||||
// fun registerRank(@RequestBody rank: SpiderRank): Mono<ResponseEntity<SpiderRank>> {
|
||||
// return spiderService.registerRank(rank)
|
||||
// .map { savedRank -> ResponseEntity.ok(savedRank) }
|
||||
// .onErrorResume(IllegalArgumentException::class.java) { e ->
|
||||
// Mono.just(ResponseEntity.badRequest().body(null))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 게임 ID별 랭킹 조회 엔드포인트
|
||||
// @GetMapping("/list/{gameId}")
|
||||
// fun getRanks(@PathVariable gameId: String): Flux<SpiderRank> {
|
||||
// return spiderService.getRanksByGameId(gameId)
|
||||
// }
|
||||
//
|
||||
// @PostMapping("/deal")
|
||||
// fun dealCards(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
|
||||
// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
|
||||
// return spiderService.dealCardsFromStock(gameId)
|
||||
// }
|
||||
//
|
||||
// // 실행 취소 엔드포인트 추가
|
||||
// @PostMapping("/undo")
|
||||
// fun undo(@RequestBody request: Map<String, String>): Mono<SpiderGame> {
|
||||
// val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required."))
|
||||
// return spiderService.undoGame(gameId)
|
||||
// }
|
||||
//}
|
||||
@ -1,36 +1,26 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
import kr.lunaticbum.back.lun.model.GameRecord
|
||||
import kr.lunaticbum.back.lun.model.SudokuPuzzle
|
||||
import kr.lunaticbum.back.lun.model.SudokuService
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/sudoku")
|
||||
class SudokuController(private val sudokuService: SudokuService) {
|
||||
|
||||
@GetMapping("/start")
|
||||
suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
|
||||
return sudokuService.startGame(difficulty)
|
||||
}
|
||||
|
||||
@PostMapping("/complete")
|
||||
suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) {
|
||||
sudokuService.saveRecord(recordDto)
|
||||
}
|
||||
|
||||
@GetMapping("/ranking/{puzzleId}")
|
||||
suspend fun getRankings(@PathVariable puzzleId: Long): List<GameRecord> {
|
||||
return sudokuService.getRankings(puzzleId)
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
suspend fun generateSinglePuzzle(): SudokuPuzzle {
|
||||
return sudokuService.generateAndSavePuzzle()
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
|
||||
val isCorrect = sudokuService.validateSolution(validateDto)
|
||||
return mapOf("correct" to isCorrect)
|
||||
}
|
||||
}
|
||||
//package kr.lunaticbum.back.lun.controllers
|
||||
//import kr.lunaticbum.back.lun.model.GameRecord
|
||||
//import kr.lunaticbum.back.lun.model.SudokuPuzzle
|
||||
//import kr.lunaticbum.back.lun.model.SudokuService
|
||||
//import org.springframework.web.bind.annotation.*
|
||||
//
|
||||
//@RestController
|
||||
//@RequestMapping("/sudoku")
|
||||
//class SudokuController(private val sudokuService: SudokuService) {
|
||||
//
|
||||
// @GetMapping("/start")
|
||||
// suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
|
||||
// return sudokuService.startGame(difficulty)
|
||||
// }
|
||||
//
|
||||
// @PostMapping("/generate")
|
||||
// suspend fun generateSinglePuzzle(): SudokuPuzzle {
|
||||
// return sudokuService.generateAndSavePuzzle()
|
||||
// }
|
||||
//
|
||||
// @PostMapping("/validate")
|
||||
// suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
|
||||
// val isCorrect = sudokuService.validateSolution(validateDto)
|
||||
// return mapOf("correct" to isCorrect)
|
||||
// }
|
||||
//}
|
||||
106
src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt
Normal file
106
src/main/kotlin/kr/lunaticbum/back/lun/model/GameRank.kt
Normal file
@ -0,0 +1,106 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import java.time.Instant
|
||||
import org.springframework.data.repository.reactive.ReactiveSortingRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/**
|
||||
* 모든 게임의 랭킹을 저장하는 통합 모델
|
||||
*/
|
||||
@Document(collection = "game_ranks")
|
||||
data class GameRank(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val gameType: GameType, // 게임 종류 (2048, SUDOKU, SPIDER 등)
|
||||
val contextId: String?, // 게임의 세부 ID (예: 스도쿠 퍼즐 Key, 스파이더 난이도, 노노그램 퍼즐 ID)
|
||||
val playerName: String, // 표준화된 플레이어 이름 필드
|
||||
|
||||
/** * 기본 점수 필드 (정렬 1순위).
|
||||
* - 2048: 점수 (높을수록 좋음)
|
||||
* - Sudoku: 완료 시간(초) (낮을수록 좋음)
|
||||
* - Spider: 이동 횟수 (낮을수록 좋음)
|
||||
*/
|
||||
val primaryScore: Long,
|
||||
|
||||
/** * 보조 점수 필드 (정렬 2순위. 예: 스파이더의 완료 시간).
|
||||
*/
|
||||
val secondaryScore: Long? = null,
|
||||
|
||||
val timestamp: Instant = Instant.now()
|
||||
)
|
||||
|
||||
/**
|
||||
* 지원하는 게임 타입을 정의하는 Enum
|
||||
*/
|
||||
enum class GameType {
|
||||
GAME_2048,
|
||||
SUDOKU,
|
||||
SPIDER,
|
||||
NONOGRAM
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO
|
||||
*/
|
||||
data class UnifiedRankDto(
|
||||
val gameType: GameType,
|
||||
val contextId: String?,
|
||||
val playerName: String,
|
||||
val primaryScore: Long,
|
||||
val secondaryScore: Long? = null
|
||||
)
|
||||
|
||||
@Repository
|
||||
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
|
||||
|
||||
fun save(gameRank: GameRank): Mono<GameRank>
|
||||
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
|
||||
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
|
||||
gameType: GameType,
|
||||
contextId: String?
|
||||
): Flux<GameRank>
|
||||
|
||||
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
|
||||
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
|
||||
gameType: GameType,
|
||||
contextId: String?
|
||||
): Flux<GameRank>
|
||||
}
|
||||
|
||||
|
||||
@Service
|
||||
class GameRankService(private val rankRepository: GameRankRepository) {
|
||||
/**
|
||||
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
|
||||
*/
|
||||
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
|
||||
return when (gameType) {
|
||||
// 점수가 높아야 하는 게임 (2048)
|
||||
GameType.GAME_2048 ->
|
||||
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
|
||||
|
||||
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
|
||||
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
|
||||
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 DTO를 받아 랭킹을 저장합니다.
|
||||
*/
|
||||
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
|
||||
val gameRank = GameRank(
|
||||
gameType = rankDto.gameType,
|
||||
contextId = rankDto.contextId,
|
||||
playerName = rankDto.playerName,
|
||||
primaryScore = rankDto.primaryScore,
|
||||
secondaryScore = rankDto.secondaryScore
|
||||
)
|
||||
return rankRepository.save(gameRank)
|
||||
}
|
||||
}
|
||||
180
src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt
Normal file
180
src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt
Normal file
@ -0,0 +1,180 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import org.bson.BsonType
|
||||
import org.bson.codecs.pojo.annotations.BsonId
|
||||
import org.bson.codecs.pojo.annotations.BsonRepresentation
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import org.springframework.data.mongodb.repository.Aggregation
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Mono
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
|
||||
@Document(collection = "ImageMeta") // 이미지 메타데이터를 저장할 컬렉션
|
||||
data class ImageMeta(
|
||||
@BsonId
|
||||
@BsonRepresentation(BsonType.OBJECT_ID)
|
||||
var id: String? = null,
|
||||
|
||||
var fileName: String, // 저장된 파일명 (예: uuid.jpg)
|
||||
var originalFileName: String?, // 원본 파일명
|
||||
var fileType: String?, // MIME 타입 (예: image/jpeg)
|
||||
var fileSize: Long, // 파일 크기 (bytes)
|
||||
var width: Int, // 이미지 가로 픽셀
|
||||
var height: Int, // 이미지 세로 픽셀
|
||||
var uploadTime: Long, // 등록일시 (Timestamp)
|
||||
var path: String // 이미지 접근 가능 URL 경로
|
||||
)
|
||||
|
||||
/**
|
||||
* ImageMeta 컬렉션을 위한 Spring Data Repository
|
||||
*/
|
||||
interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
|
||||
|
||||
/**
|
||||
* MongoDB Aggregation의 $sample 파이프라인을 사용해 랜덤으로 1개의 문서를 가져옵니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
|
||||
fun findRandomImage(): Mono<ImageMeta>
|
||||
|
||||
// [신규 추가] 파일 이름으로 문서를 찾는 기능
|
||||
fun findByFileName(fileName: String): Mono<ImageMeta>
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 메타데이터 로직을 처리할 서비스
|
||||
*/
|
||||
@Service
|
||||
class ImageMetaService(
|
||||
private val repository: ImageMetaRepository,
|
||||
private val logService: LogService, // LogService 주입
|
||||
@Value("\${image.upload.path}") private val uploadPath: String // application.properties의 업로드 경로 주입
|
||||
) {
|
||||
|
||||
// [신규 추가] 백그라운드 작업용 Coroutine Scope 정의
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// [신규 추가] 동기화 작업이 중복 실행되는 것을 방지하기 위한 Atomic Lock
|
||||
private val isSyncRunning = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* 공개 메소드: 메타데이터 저장 (BlogController에서 사용)
|
||||
*/
|
||||
fun save(imageMeta: ImageMeta): Mono<ImageMeta> {
|
||||
return repository.save(imageMeta)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 메소드: 랜덤 이미지 가져오기 (Home 컨트롤러에서 사용)
|
||||
*/
|
||||
fun getRandomImage(): Mono<ImageMeta> {
|
||||
return repository.findRandomImage()
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
logService.log("Application ready. Launching initial image DB sync task...")
|
||||
launchSyncTask()
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] 동기화 작업을 비동기(Coroutine)로 실행하는 런처 (잠금 처리)
|
||||
* BlogController에서도 이 함수를 호출할 수 있습니다.
|
||||
*/
|
||||
fun launchSyncTask() {
|
||||
// AtomicBoolean을 사용해 현재 동기화 작업이 실행 중이 아닐 때만 true로 설정하고 새 작업을 시작
|
||||
if (isSyncRunning.compareAndSet(false, true)) {
|
||||
logService.log("Starting background image sync...")
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
// 실제 동기화 로직 실행
|
||||
runFileSystemSync()
|
||||
} catch (e: Exception) {
|
||||
logService.log("Unhandled error in sync launcher: ${e.message}")
|
||||
} finally {
|
||||
// 작업이 성공하든, 실패(중단)하든 항상 잠금을 해제하여 다음 작업을 허용
|
||||
isSyncRunning.set(false)
|
||||
logService.log("Background image sync finished. Lock released.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logService.log("Skipping sync launch: Task is already running.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직
|
||||
*/
|
||||
private suspend fun runFileSystemSync() {
|
||||
// 1. 실제 업로드 폴더에서 원본 파일 목록(썸네일 제외)을 가져옵니다.
|
||||
val physicalFiles = File(uploadPath).listFiles { _, name ->
|
||||
!name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif"))
|
||||
} ?: emptyArray()
|
||||
|
||||
// 2. DB에서 모든 이미지 메타데이터 개수를 가져옵니다.
|
||||
val dbCount = repository.count().awaitSingle()
|
||||
|
||||
// 3. (요청사항) 개수가 동일하면 작업을 수행할 필요가 없습니다.
|
||||
if (physicalFiles.size.toLong() == dbCount) {
|
||||
logService.log("Image sync check: Counts match (${dbCount} files). No sync needed.")
|
||||
return
|
||||
}
|
||||
|
||||
logService.log("Image sync: File count (${physicalFiles.size}) vs DB count ($dbCount). Syncing...")
|
||||
|
||||
// 4. DB에 존재하는 모든 파일 이름을 Set으로 변환하여 빠른 조회를 준비합니다.
|
||||
val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet()
|
||||
|
||||
// 5. (요청사항) 에러 발생 시 즉시 중단되도록 전체 루프를 try-catch로 감쌉니다.
|
||||
try {
|
||||
// 6. 실제 파일 목록을 순회합니다.
|
||||
for (file in physicalFiles) {
|
||||
// 7. 파일 이름이 DB 파일 이름 Set에 없는 경우(누락된 경우)에만 처리합니다.
|
||||
if (file.name !in dbFilenames) {
|
||||
logService.log("Sync: Found missing file: ${file.name}. Adding to DB...")
|
||||
|
||||
// 8. 메타데이터 추출 (이 과정에서 파일이 손상되었으면 ImageIO.read가 Exception 발생)
|
||||
val bufferedImage = ImageIO.read(file)
|
||||
val width = bufferedImage.width
|
||||
val height = bufferedImage.height
|
||||
|
||||
val metadata = ImageMeta(
|
||||
fileName = file.name,
|
||||
originalFileName = "Scanned from disk", // 원본 파일명은 알 수 없으므로 대체 텍스트 사용
|
||||
fileType = Files.probeContentType(file.toPath()), // 파일 시스템 기반 MIME 타입 추측
|
||||
fileSize = file.length(),
|
||||
width = width,
|
||||
height = height,
|
||||
uploadTime = file.lastModified(), // 등록일시 대신 파일 최종 수정일시 사용
|
||||
path = "/blog/post/images/${file.name}" // 저장된 경로
|
||||
)
|
||||
|
||||
// 9. DB에 저장 (DB 연결 오류 시 Exception 발생)
|
||||
repository.save(metadata).awaitSingle()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// [요청사항] IO 오류(손상된 파일) 또는 DB 오류 발생 시, 즉시 작업을 중단하고 로그를 남깁니다.
|
||||
logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task immediately. Remaining files will not be processed.")
|
||||
e.printStackTrace()
|
||||
// 함수가 여기서 종료되며, launchSyncTask의 finally 블록이 실행되어 잠금이 해제됩니다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,10 +28,15 @@ import reactor.core.publisher.Mono
|
||||
import java.net.URLDecoder
|
||||
import java.time.Duration
|
||||
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
|
||||
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
|
||||
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "Post")
|
||||
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
|
||||
class Post {
|
||||
@BsonId
|
||||
@BsonRepresentation(BsonType.OBJECT_ID)
|
||||
@ -56,10 +61,14 @@ class Post {
|
||||
var firstAddress = ""
|
||||
var modifyAddress = ""
|
||||
|
||||
// [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가
|
||||
@Indexed(direction = IndexDirection.DESCENDING)
|
||||
var modifyTime : Long = 0
|
||||
var modifyLat : Double = 0.0
|
||||
var modifyLon : Double = 0.0
|
||||
|
||||
// [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가
|
||||
@Indexed(direction = IndexDirection.DESCENDING)
|
||||
var readCount : Long = 0
|
||||
var voteCount : Long = 0
|
||||
var unlikeCount : Long = 0
|
||||
@ -184,24 +193,25 @@ class PostManager(
|
||||
/**
|
||||
* [이름 변경] find20 -> findAllVersionsPaginated
|
||||
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
|
||||
* [FIX]: Change return type to Mono<List<Post>> and remove the blocking call.
|
||||
*/
|
||||
fun findAllVersionsPaginated(pageable :Pageable) : List<Post> {
|
||||
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> { // <-- 1. Change return type
|
||||
println("pageSize >>> ${pageable.pageSize}")
|
||||
println("pageNumber >>> ${pageable.pageNumber}")
|
||||
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
|
||||
.doOnNext { println(it) } // map 대신 doOnNext로 로그 출력
|
||||
.collectList() // Flux<Post> → Mono<List<Post>>
|
||||
.block(Duration.ofSeconds(30)) // Mono<List<Post>> → List<Post>
|
||||
?: listOf()
|
||||
// .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK
|
||||
// ?: listOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회)
|
||||
* [FIX]: This function should already be correct from the previous step.
|
||||
*/
|
||||
fun findLatestUniquePaginated(pageable: Pageable) : List<Post> {
|
||||
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> { // <-- Should already return Mono
|
||||
return postRepository.findLatestUniqueOriginPaginated(pageable)
|
||||
.collectList()
|
||||
.block(Duration.ofSeconds(30)) ?: listOf()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -272,12 +282,13 @@ class PostManager(
|
||||
/**
|
||||
* [로직 수정]
|
||||
* 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다.
|
||||
* [FIX]: Return Mono<List<Post>> to match the updated callee.
|
||||
*/
|
||||
fun find8() : List<Post> {
|
||||
fun find8() : Mono<List<Post>> { // <-- 3. Change return type to Mono<List<Post>>
|
||||
// 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다.
|
||||
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
|
||||
// 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다.
|
||||
return this.findLatestUniquePaginated(pageRequest)
|
||||
return this.findLatestUniquePaginated(pageRequest) // <-- 4. This now correctly returns the Mono
|
||||
}
|
||||
|
||||
fun find20() : List<Post> {
|
||||
|
||||
@ -3,8 +3,10 @@ package kr.lunaticbum.back.lun.model
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
||||
import kr.lunaticbum.back.lun.utils.SudokuGenerator
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import org.springframework.data.mongodb.repository.Aggregation
|
||||
@ -12,7 +14,7 @@ import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Flux // (★ 오류 수정: 누락된 Flux import 추가)
|
||||
import reactor.core.publisher.Mono
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
@ -20,93 +22,94 @@ import java.io.ByteArrayOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Base64
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.random.Random
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import org.springframework.data.mongodb.core.index.Indexed
|
||||
|
||||
|
||||
@Document("puzzles") // "puzzles" 컬렉션에 매핑
|
||||
/**
|
||||
* ======================================================
|
||||
* 1. NONOGRAM 모델 및 리포지토리
|
||||
* ======================================================
|
||||
*/
|
||||
|
||||
@Document("puzzles") // "puzzles" 컬렉션 (노노그램용)
|
||||
data class NonogramPuzzle(
|
||||
@Id
|
||||
val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언
|
||||
val id: String? = null,
|
||||
val solutionGrid: List<List<Int>>,
|
||||
val rowClues: List<List<Int>>,
|
||||
val colClues: List<List<Int>>,
|
||||
// Add these two fields
|
||||
val grayscaleImage: String, // Base64 encoded grayscale image
|
||||
val originalImage: String, // Base64 encoded original color image
|
||||
|
||||
val grayscaleImage: String,
|
||||
val originalImage: String,
|
||||
val createdAt: LocalDateTime = LocalDateTime.now()
|
||||
|
||||
)
|
||||
|
||||
@Repository
|
||||
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
||||
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
|
||||
/**
|
||||
* (★ Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는
|
||||
* 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
|
||||
"{ \$sample: { size: 1 } }"
|
||||
])
|
||||
fun findRandom(): Flux<NonogramPuzzle>
|
||||
fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
|
||||
}
|
||||
|
||||
/**
|
||||
* ======================================================
|
||||
* [통합 게임 서비스]
|
||||
* 모든 게임(Nonogram, Sudoku, Spider)의 로직을 처리하는 단일 서비스.
|
||||
* ======================================================
|
||||
*/
|
||||
@Service
|
||||
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
|
||||
class PuzzleService(
|
||||
// 1. Nonogram 의존성
|
||||
private val puzzleRepository: NonogramPuzzleRepository,
|
||||
|
||||
// 2. Sudoku 의존성
|
||||
private val sudokuPuzzleRepository: SudokuPuzzleRepository,
|
||||
|
||||
// 3. Spider 의존성
|
||||
private val spiderGameRepository: SpiderGameRepository
|
||||
) {
|
||||
|
||||
// 퍼즐 크기의 최소/최대값을 상수로 정의하여 관리 용이성을 높입니다.
|
||||
companion object {
|
||||
private const val MIN_PUZZLE_SIZE = 10
|
||||
private const val MAX_PUZZLE_SIZE = 30
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다.
|
||||
* @return 찾은 퍼즐 또는 DB가 비어있으면 null
|
||||
*/
|
||||
// ======================================================
|
||||
// 1. NONOGRAM 서비스 로직 (기존 함수)
|
||||
// ======================================================
|
||||
|
||||
suspend fun findRandomPuzzle(): NonogramPuzzle? {
|
||||
return puzzleRepository.findRandom().awaitFirstOrNull()
|
||||
}
|
||||
|
||||
fun findById(id: String) = puzzleRepository.findById(id)
|
||||
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
|
||||
fun findById(id: String): Mono<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
|
||||
|
||||
fun deletePuzzle(id : String): Mono<Void> = puzzleRepository.deleteById(id) // Nonogram 용
|
||||
|
||||
/**
|
||||
* (★ 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다.
|
||||
*/
|
||||
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
|
||||
val puzzleData = withContext(Dispatchers.IO) {
|
||||
val originalImage = ImageIO.read(file.inputStream)
|
||||
val imageWithBackground = convertTransparentToWhite(originalImage)
|
||||
|
||||
// (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정
|
||||
val puzzleSize = determinePuzzleSize(imageWithBackground)
|
||||
|
||||
// Create a resized color version for the final reveal
|
||||
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
|
||||
|
||||
// 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성
|
||||
val resizedOriginal = resizeImage(imageWithBackground, 300)
|
||||
val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply {
|
||||
createGraphics().run {
|
||||
drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null)
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
|
||||
val averageBrightness = calculateAverageBrightness(grayImage)
|
||||
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
|
||||
|
||||
// 결정된 puzzleSize를 사용하여 solutionGrid 생성
|
||||
val solutionGrid = List(puzzleSize) { y ->
|
||||
List(puzzleSize) { x ->
|
||||
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
|
||||
}
|
||||
}
|
||||
|
||||
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
||||
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
|
||||
|
||||
// Convert images to Base64 strings
|
||||
val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300))
|
||||
val originalBase64 = imageToBase64(resizedOriginal)
|
||||
|
||||
@ -118,39 +121,23 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
||||
originalImage = originalBase64
|
||||
)
|
||||
}
|
||||
|
||||
return puzzleRepository.save(puzzleData).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ 새로 추가된 함수)
|
||||
* 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다.
|
||||
* @param image 분석할 원본 이미지
|
||||
* @return 계산된 퍼즐 크기 (정수)
|
||||
*/
|
||||
// --- (★ 오류 수정: 축약되었던 Nonogram 헬퍼 함수 본문 전체 복원) ---
|
||||
|
||||
private fun determinePuzzleSize(image: BufferedImage): Int {
|
||||
val width = image.width.toDouble()
|
||||
val height = image.height.toDouble()
|
||||
|
||||
// 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다.
|
||||
val maxDimension = maxOf(width, height)
|
||||
val minDimension = minOf(width, height)
|
||||
|
||||
// 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상)
|
||||
val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0
|
||||
|
||||
// 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다.
|
||||
// - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다.
|
||||
// - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다.
|
||||
val baseSize = 15.0
|
||||
val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치
|
||||
val factor = 5.0
|
||||
val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor)
|
||||
|
||||
// 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다.
|
||||
return calculatedSize.toInt().coerceIn(MIN_PUZZLE_SIZE, MAX_PUZZLE_SIZE)
|
||||
}
|
||||
|
||||
// Helper function to resize images
|
||||
private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
|
||||
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {
|
||||
createGraphics().run {
|
||||
@ -160,21 +147,16 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert a BufferedImage to a Base64 String
|
||||
private fun imageToBase64(image: BufferedImage): String {
|
||||
val os = ByteArrayOutputStream()
|
||||
ImageIO.write(image, "png", os)
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
|
||||
*/
|
||||
private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
|
||||
if (!sourceImage.colorModel.hasAlpha()) {
|
||||
return sourceImage
|
||||
}
|
||||
|
||||
return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply {
|
||||
createGraphics().also { g2d ->
|
||||
g2d.color = Color.WHITE
|
||||
@ -185,9 +167,6 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255)
|
||||
*/
|
||||
private fun calculateAverageBrightness(image: BufferedImage): Int {
|
||||
var totalBrightness: Long = 0
|
||||
val width = image.width
|
||||
@ -200,21 +179,14 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
||||
return (totalBrightness / (width * height)).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 밝기에 따라 임계치를 조정합니다.
|
||||
*/
|
||||
private fun determineAdaptiveThreshold(averageBrightness: Int): Int {
|
||||
return when {
|
||||
// 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄
|
||||
averageBrightness > 180 -> (averageBrightness * 0.9).toInt()
|
||||
// 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄
|
||||
averageBrightness < 80 -> (averageBrightness * 1.1).toInt()
|
||||
// 보통 밝기의 이미지면 평균값을 그대로 사용
|
||||
else -> averageBrightness
|
||||
}
|
||||
}
|
||||
|
||||
// --- 헬퍼 함수들 ---
|
||||
private fun getCluesForLine(line: List<Int>): List<Int> {
|
||||
val clues = mutableListOf<Int>()
|
||||
var count = 0
|
||||
@ -233,4 +205,262 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
|
||||
private fun transpose(grid: List<List<Int>>): List<List<Int>> {
|
||||
return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } }
|
||||
}
|
||||
// --- (헬퍼 함수 복원 끝) ---
|
||||
|
||||
|
||||
// ======================================================
|
||||
// 2. SUDOKU 서비스 로직 (통합됨)
|
||||
// ======================================================
|
||||
|
||||
data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String)
|
||||
data class SudokuValidateDto(val puzzleId: Long, val answer: String)
|
||||
|
||||
suspend fun sudoku_startGame(difficulty: String): SudokuGameDto {
|
||||
val puzzleCount = sudokuPuzzleRepository.count()
|
||||
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
|
||||
|
||||
val randomKey = Random.nextLong(1, puzzleCount - 1)
|
||||
val solvedPuzzle = sudokuPuzzleRepository.findByPuzzleKey(randomKey)
|
||||
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
|
||||
|
||||
val holes = when (difficulty.lowercase()) {
|
||||
"medium" -> 45
|
||||
"hard" -> 55
|
||||
else -> 35 // easy
|
||||
}
|
||||
|
||||
val question = sudoku_createQuestion(solvedPuzzle.puzzle!!, holes)
|
||||
return SudokuGameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
|
||||
}
|
||||
|
||||
suspend fun sudoku_validateSolution(validateDto: SudokuValidateDto): Boolean {
|
||||
val originalPuzzle = sudokuPuzzleRepository.findByPuzzleKey(validateDto.puzzleId)
|
||||
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
|
||||
return originalPuzzle.puzzle == validateDto.answer
|
||||
}
|
||||
|
||||
suspend fun sudoku_generateAndSavePuzzle(): SudokuPuzzle {
|
||||
val puzzleString = SudokuGenerator().generate()
|
||||
val lastPuzzle = sudokuPuzzleRepository.findTopByOrderByPuzzleKeyDesc()
|
||||
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
|
||||
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
|
||||
return sudokuPuzzleRepository.save(newPuzzle)
|
||||
}
|
||||
|
||||
private fun sudoku_createQuestion(puzzle: String, holes: Int): String {
|
||||
val chars = puzzle.toMutableList()
|
||||
var remainingHoles = holes
|
||||
while (remainingHoles > 0) {
|
||||
val randomIndex = Random.nextInt(chars.size)
|
||||
if (chars[randomIndex] != '0') {
|
||||
chars[randomIndex] = '0'
|
||||
remainingHoles--
|
||||
}
|
||||
}
|
||||
return chars.joinToString("")
|
||||
}
|
||||
|
||||
|
||||
// ======================================================
|
||||
// 3. SPIDER 서비스 로직 (통합 및 Coroutine 변환됨)
|
||||
// ======================================================
|
||||
|
||||
suspend fun spider_newGame(numSuits: Int, numCards: String): SpiderGame {
|
||||
val allCards = spider_createDeck(numSuits)
|
||||
val shuffledCards = allCards.shuffled(Random)
|
||||
val (tableau, stock) = spider_dealCards(shuffledCards, numCards)
|
||||
|
||||
val initialGame = SpiderGame(
|
||||
id = null,
|
||||
tableau = tableau,
|
||||
stock = stock,
|
||||
foundation = emptyList(),
|
||||
moves = 0,
|
||||
isCompleted = false,
|
||||
undoCount = 0,
|
||||
undoHistory = emptyList()
|
||||
)
|
||||
return spiderGameRepository.save(initialGame).awaitSingle()
|
||||
}
|
||||
|
||||
suspend fun spider_getGame(id: String): SpiderGame? {
|
||||
return spiderGameRepository.findById(id).awaitSingleOrNull()
|
||||
}
|
||||
|
||||
suspend fun spider_updateGame(game: SpiderGame): SpiderGame {
|
||||
val historyToSave = SpiderGameHistory(
|
||||
tableau = game.tableau,
|
||||
stock = game.stock,
|
||||
foundation = game.foundation,
|
||||
moves = game.moves
|
||||
)
|
||||
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
|
||||
val updatedGame = game.copy(undoHistory = updatedHistory)
|
||||
return spiderGameRepository.save(updatedGame).awaitSingle()
|
||||
}
|
||||
|
||||
suspend fun spider_dealCardsFromStock(gameId: String): SpiderGame {
|
||||
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
|
||||
?: throw IllegalArgumentException("Game not found: $gameId")
|
||||
|
||||
val stockCards = game.stock.toMutableList()
|
||||
if (stockCards.size < 10) {
|
||||
throw IllegalArgumentException("No more cards in stock.")
|
||||
}
|
||||
|
||||
val historyToSave = SpiderGameHistory(
|
||||
tableau = game.tableau,
|
||||
stock = game.stock,
|
||||
foundation = game.foundation,
|
||||
moves = game.moves
|
||||
)
|
||||
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
|
||||
|
||||
val updatedTableau = game.tableau.toMutableList()
|
||||
val remainingStock = stockCards.drop(10)
|
||||
|
||||
updatedTableau.forEachIndexed { index, stack ->
|
||||
val cardToDeal = stockCards[index]
|
||||
cardToDeal.isFaceUp = true
|
||||
(stack as MutableList).add(cardToDeal)
|
||||
}
|
||||
|
||||
val updatedGame = game.copy(
|
||||
tableau = updatedTableau,
|
||||
stock = remainingStock,
|
||||
moves = game.moves + 1,
|
||||
undoHistory = updatedHistory
|
||||
)
|
||||
return spiderGameRepository.save(updatedGame).awaitSingle()
|
||||
}
|
||||
|
||||
suspend fun spider_undoGame(gameId: String): SpiderGame {
|
||||
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
|
||||
?: throw IllegalArgumentException("Game not found: $gameId")
|
||||
|
||||
if (game.undoHistory.isEmpty() || game.undoCount >= 5) {
|
||||
throw IllegalArgumentException("Cannot undo. No more history or undo limit reached.")
|
||||
}
|
||||
|
||||
val lastHistory = game.undoHistory.last()
|
||||
val remainingHistory = game.undoHistory.dropLast(1)
|
||||
|
||||
val updatedGame = game.copy(
|
||||
tableau = lastHistory.tableau,
|
||||
stock = lastHistory.stock,
|
||||
foundation = lastHistory.foundation,
|
||||
moves = lastHistory.moves,
|
||||
undoCount = game.undoCount + 1,
|
||||
undoHistory = remainingHistory
|
||||
)
|
||||
return spiderGameRepository.save(updatedGame).awaitSingle()
|
||||
}
|
||||
|
||||
// --- (스파이더 헬퍼 함수들) ---
|
||||
private fun spider_createDeck(numSuits: Int): List<SpiderCard> {
|
||||
val allSuits = listOf("spade", "heart", "club", "diamond")
|
||||
val suits = allSuits.take(numSuits)
|
||||
val setsPerSuit = when (numSuits) {
|
||||
1 -> 8
|
||||
2 -> 4
|
||||
4 -> 2
|
||||
else -> throw IllegalArgumentException("Invalid number of suits: $numSuits")
|
||||
}
|
||||
val deck = mutableListOf<SpiderCard>()
|
||||
repeat(setsPerSuit) {
|
||||
for (suit in suits) {
|
||||
for (rank in 1..13) {
|
||||
deck.add(SpiderCard(suit, rank, isFaceUp = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
private fun spider_dealCards(shuffledCards: List<SpiderCard>, numCards: String): Pair<List<List<SpiderCard>>, List<SpiderCard>> {
|
||||
val initialCards = numCards.split(",").map { it.trim().toInt() }
|
||||
val cardsPerStack = List(10) { index ->
|
||||
if (index < 4) initialCards[0] else initialCards[1]
|
||||
}
|
||||
val cardsToDeal = shuffledCards.toMutableList()
|
||||
val tableau = MutableList(10) { mutableListOf<SpiderCard>() }
|
||||
cardsPerStack.forEachIndexed { stackIndex, count ->
|
||||
repeat(count) {
|
||||
if (cardsToDeal.isNotEmpty()) {
|
||||
val card = cardsToDeal.removeFirst()
|
||||
tableau[stackIndex].add(card)
|
||||
}
|
||||
}
|
||||
}
|
||||
tableau.forEach { stack ->
|
||||
if (stack.isNotEmpty()) {
|
||||
stack.last().isFaceUp = true
|
||||
}
|
||||
}
|
||||
return Pair(tableau, cardsToDeal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 스파이더 게임 상태 저장 모델
|
||||
*/
|
||||
@Document(collection = "spider_games")
|
||||
data class SpiderGame(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val tableau: List<List<SpiderCard>>,
|
||||
val stock: List<SpiderCard>,
|
||||
val foundation: List<List<SpiderCard>>,
|
||||
val moves: Int,
|
||||
val isCompleted: Boolean,
|
||||
val undoCount: Int = 0, // 실행 취소 횟수
|
||||
val undoHistory: List<SpiderGameHistory> = emptyList(), // 게임 상태 히스토리
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class SpiderCard(
|
||||
val suit: String,
|
||||
val rank: Int,
|
||||
var isFaceUp: Boolean,
|
||||
)
|
||||
|
||||
// 게임 상태 히스토리 모델
|
||||
data class SpiderGameHistory(
|
||||
val tableau: List<List<SpiderCard>>,
|
||||
val stock: List<SpiderCard>,
|
||||
val foundation: List<List<SpiderCard>>,
|
||||
val moves: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 스파이더 게임 상태 리포지토리 (PuzzleService에서 사용됨)
|
||||
* (참고: 이 리포지토리는 Reactive 타입으로 유지하고,
|
||||
* 서비스단에서 Coroutine으로 브리징하여 사용합니다.)
|
||||
*/
|
||||
interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
|
||||
override fun findById(id: String): Mono<SpiderGame>
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 스도쿠 퍼즐 원본 데이터를 저장하는 모델
|
||||
*/
|
||||
@Document(collection = "puzzles") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
|
||||
data class SudokuPuzzle(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val puzzleKey: Long? = null,
|
||||
@Indexed(unique = true)
|
||||
val puzzle: String? // 81자리 완성된 퍼즐 데이터
|
||||
)
|
||||
|
||||
/**
|
||||
* 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
|
||||
*/
|
||||
@Repository
|
||||
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
|
||||
override suspend fun count(): Long
|
||||
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
|
||||
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
|
||||
}
|
||||
|
||||
@ -1,219 +1,22 @@
|
||||
// kr.lunaticbum.back.lun.model.Spider.kt
|
||||
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import kotlin.random.Random
|
||||
|
||||
@Document(collection = "spider_games")
|
||||
data class SpiderGame(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val tableau: List<List<SpiderCard>>,
|
||||
val stock: List<SpiderCard>,
|
||||
val foundation: List<List<SpiderCard>>,
|
||||
val moves: Int,
|
||||
val isCompleted: Boolean,
|
||||
val undoCount: Int = 0, // 실행 취소 횟수: 최대 5회 제한
|
||||
val undoHistory: List<SpiderGameHistory> = emptyList(), // 게임 상태 히스토리 저장
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class SpiderCard(
|
||||
val suit: String,
|
||||
val rank: Int,
|
||||
var isFaceUp: Boolean,
|
||||
)
|
||||
|
||||
// 게임 상태 히스토리를 저장할 데이터 클래스
|
||||
data class SpiderGameHistory(
|
||||
val tableau: List<List<SpiderCard>>,
|
||||
val stock: List<SpiderCard>,
|
||||
val foundation: List<List<SpiderCard>>,
|
||||
val moves: Int
|
||||
)
|
||||
|
||||
interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
|
||||
override fun findById(id: String): Mono<SpiderGame>
|
||||
}
|
||||
|
||||
@Service
|
||||
class SpiderService(
|
||||
private val spiderGameRepository: SpiderGameRepository,
|
||||
private val spiderRankRepository: SpiderRankRepository
|
||||
) {
|
||||
// 덱 생성, 카드 분배 등 기존 로직은 그대로 유지
|
||||
private fun createDeck(numSuits: Int): List<SpiderCard> {
|
||||
val allSuits = listOf("spade", "heart", "club", "diamond")
|
||||
val suits = allSuits.take(numSuits)
|
||||
val setsPerSuit = when (numSuits) {
|
||||
1 -> 8 // 13 ranks * 1 suit * 8 sets = 104 cards
|
||||
2 -> 4 // 13 ranks * 2 suits * 4 sets = 104 cards
|
||||
4 -> 2 // 13 ranks * 4 suits * 2 sets = 104 cards
|
||||
else -> throw IllegalArgumentException("Invalid number of suits: $numSuits")
|
||||
}
|
||||
val deck = mutableListOf<SpiderCard>()
|
||||
repeat(setsPerSuit) {
|
||||
for (suit in suits) {
|
||||
for (rank in 1..13) {
|
||||
deck.add(SpiderCard(suit, rank, isFaceUp = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
private fun dealCards(shuffledCards: List<SpiderCard>, numCards: String): Pair<List<List<SpiderCard>>, List<SpiderCard>> {
|
||||
val initialCards = numCards.split(",").map { it.trim().toInt() }
|
||||
val cardsPerStack = List(10) { index ->
|
||||
if (index < 4) initialCards[0] else initialCards[1]
|
||||
}
|
||||
|
||||
val cardsToDeal = shuffledCards.toMutableList()
|
||||
val tableau = MutableList(10) { mutableListOf<SpiderCard>() }
|
||||
|
||||
// 각 스택에 초기 카드를 분배합니다.
|
||||
cardsPerStack.forEachIndexed { stackIndex, count ->
|
||||
repeat(count) {
|
||||
if (cardsToDeal.isNotEmpty()) {
|
||||
val card = cardsToDeal.removeFirst()
|
||||
tableau[stackIndex].add(card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 각 스택의 맨 위 카드를 앞면으로 설정합니다.
|
||||
tableau.forEach { stack ->
|
||||
if (stack.isNotEmpty()) {
|
||||
stack.last().isFaceUp = true
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(tableau, cardsToDeal)
|
||||
}
|
||||
|
||||
fun newGame(numSuits: Int, numCards: String): Mono<SpiderGame> {
|
||||
val allCards = createDeck(numSuits)
|
||||
val shuffledCards = allCards.shuffled(Random)
|
||||
val (tableau, stock) = dealCards(shuffledCards, numCards)
|
||||
|
||||
val initialGame = SpiderGame(
|
||||
id = null,
|
||||
tableau = tableau,
|
||||
stock = stock,
|
||||
foundation = emptyList(),
|
||||
moves = 0,
|
||||
isCompleted = false,
|
||||
undoCount = 0,
|
||||
undoHistory = emptyList()
|
||||
)
|
||||
return spiderGameRepository.save(initialGame)
|
||||
}
|
||||
|
||||
fun getGame(id: String): Mono<SpiderGame> {
|
||||
return spiderGameRepository.findById(id)
|
||||
}
|
||||
|
||||
// updateGame 메서드: 게임 상태를 업데이트하기 전에 히스토리를 저장합니다.
|
||||
fun updateGame(game: SpiderGame): Mono<SpiderGame> {
|
||||
val historyToSave = SpiderGameHistory(
|
||||
tableau = game.tableau,
|
||||
stock = game.stock,
|
||||
foundation = game.foundation,
|
||||
moves = game.moves
|
||||
)
|
||||
// 기존 히스토리에 현재 상태를 추가하고, 최대 5개까지만 유지합니다.
|
||||
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
|
||||
val updatedGame = game.copy(undoHistory = updatedHistory)
|
||||
return spiderGameRepository.save(updatedGame)
|
||||
}
|
||||
|
||||
// dealCardsFromStock 메서드: 카드 분배 전에 히스토리를 저장합니다.
|
||||
fun dealCardsFromStock(gameId: String): Mono<SpiderGame> {
|
||||
return spiderGameRepository.findById(gameId)
|
||||
.flatMap { game ->
|
||||
val stockCards = game.stock.toMutableList()
|
||||
if (stockCards.size >= 10) {
|
||||
// 현재 게임 상태를 히스토리에 저장
|
||||
val historyToSave = SpiderGameHistory(
|
||||
tableau = game.tableau,
|
||||
stock = game.stock,
|
||||
foundation = game.foundation,
|
||||
moves = game.moves
|
||||
)
|
||||
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
|
||||
|
||||
val updatedTableau = game.tableau.toMutableList()
|
||||
val remainingStock = stockCards.drop(10)
|
||||
|
||||
updatedTableau.forEachIndexed { index, stack ->
|
||||
val cardToDeal = stockCards[index]
|
||||
cardToDeal.isFaceUp = true // 카드를 추가할 때 앞면으로 뒤집기
|
||||
(stack as MutableList).add(cardToDeal)
|
||||
}
|
||||
|
||||
val updatedGame = game.copy(
|
||||
tableau = updatedTableau,
|
||||
stock = remainingStock,
|
||||
moves = game.moves + 1,
|
||||
undoHistory = updatedHistory // 히스토리 업데이트
|
||||
)
|
||||
spiderGameRepository.save(updatedGame)
|
||||
} else {
|
||||
Mono.error(IllegalArgumentException("No more cards in stock."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// undoGame 메서드: 히스토리에서 마지막 상태를 불러와 게임을 되돌립니다.
|
||||
fun undoGame(gameId: String): Mono<SpiderGame> {
|
||||
return spiderGameRepository.findById(gameId)
|
||||
.flatMap { game ->
|
||||
if (game.undoHistory.isNotEmpty() && game.undoCount < 5) {
|
||||
val lastHistory = game.undoHistory.last()
|
||||
val remainingHistory = game.undoHistory.dropLast(1)
|
||||
|
||||
val updatedGame = game.copy(
|
||||
tableau = lastHistory.tableau,
|
||||
stock = lastHistory.stock,
|
||||
foundation = lastHistory.foundation,
|
||||
moves = lastHistory.moves,
|
||||
undoCount = game.undoCount + 1, // 실행 취소 횟수 증가
|
||||
undoHistory = remainingHistory // 마지막 히스토리는 제거
|
||||
)
|
||||
spiderGameRepository.save(updatedGame)
|
||||
} else {
|
||||
Mono.error(IllegalArgumentException("Cannot undo. No more history or undo limit reached."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 랭킹 관련 기존 메서드들은 그대로 유지
|
||||
fun registerRank(rank: SpiderRank): Mono<SpiderRank> {
|
||||
return spiderRankRepository.save(rank)
|
||||
}
|
||||
|
||||
fun getRanksByGameId(gameId: String): Flux<SpiderRank> {
|
||||
return spiderRankRepository.findByGameIdOrderByMovesAscCompletionTimeAsc(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
@Document(collection = "spider_ranks")
|
||||
data class SpiderRank(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val gameId: String,
|
||||
val playerName: String,
|
||||
val moves: Int,
|
||||
val completionTime: Long,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
interface SpiderRankRepository : ReactiveMongoRepository<SpiderRank, String> {
|
||||
fun findByGameIdOrderByMovesAscCompletionTimeAsc(gameId: String): Flux<SpiderRank>
|
||||
}
|
||||
//package kr.lunaticbum.back.lun.model
|
||||
//
|
||||
//import org.springframework.data.annotation.Id
|
||||
//import org.springframework.data.mongodb.core.mapping.Document
|
||||
//import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
//import reactor.core.publisher.Mono
|
||||
//// (★ 삭제) Service, Flux, Random 관련 import 제거
|
||||
//
|
||||
//
|
||||
///*
|
||||
// * (★ 삭제됨) @Service class SpiderService (...)
|
||||
// * -> 모든 로직이 PuzzleService로 통합됨.
|
||||
// */
|
||||
//
|
||||
///* * (★ 삭제됨) data class SpiderRank (...)
|
||||
// * -> 통합 GameRank 모델로 대체됨.
|
||||
// */
|
||||
//
|
||||
///*
|
||||
// * (★ 삭제됨) interface SpiderRankRepository (...)
|
||||
// * -> 통합 GameRankRepository로 대체됨.
|
||||
// */
|
||||
@ -1,136 +1,45 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import com.mongodb.DuplicateKeyException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kr.lunaticbum.back.lun.utils.SudokuGenerator
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.index.Indexed
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
import kotlin.random.Random
|
||||
|
||||
@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정
|
||||
data class SudokuPuzzle(
|
||||
@Id
|
||||
val id: String? = null, // MongoDB의 고유 _id 필드
|
||||
val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용)
|
||||
@Indexed(unique = true)
|
||||
val puzzle: String? // 81자리 완성된 퍼즐 데이터
|
||||
)
|
||||
|
||||
|
||||
|
||||
@Document(collection = "records")
|
||||
data class GameRecord(
|
||||
@Id
|
||||
val id: String? = null,
|
||||
val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조
|
||||
val userName: String,
|
||||
val completionTime: Long // 완료 시간 (초)
|
||||
)
|
||||
|
||||
@Repository
|
||||
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
|
||||
// 전체 퍼즐 개수를 반환하는 suspend 함수
|
||||
override suspend fun count(): Long
|
||||
// puzzleKey로 퍼즐을 찾는 suspend 함수
|
||||
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
|
||||
// 👇 이 함수 선언을 추가해주세요.
|
||||
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface GameRecordRepository : CoroutineCrudRepository<GameRecord, String> {
|
||||
// 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림)
|
||||
fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow<GameRecord>
|
||||
}
|
||||
|
||||
@Service
|
||||
class SudokuService(
|
||||
private val puzzleRepository: SudokuPuzzleRepository,
|
||||
private val recordRepository: GameRecordRepository
|
||||
) {
|
||||
// DTO 정의 (파일 하단 또는 별도 파일)
|
||||
data class GameDto(val puzzleId: Long, val question: String, val solution: String)
|
||||
|
||||
data class RecordDto(val puzzleId: Long, val userName: String, val completionTime: Long)
|
||||
|
||||
suspend fun startGame(difficulty: String): GameDto {
|
||||
val puzzleCount = puzzleRepository.count()
|
||||
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
|
||||
|
||||
val randomKey = Random.nextLong(1, puzzleCount - 1)
|
||||
val solvedPuzzle = puzzleRepository.findByPuzzleKey(randomKey)
|
||||
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
|
||||
|
||||
val holes = when (difficulty.lowercase()) {
|
||||
"medium" -> 45
|
||||
"hard" -> 55
|
||||
else -> 35 // easy
|
||||
}
|
||||
|
||||
val question = createQuestion(solvedPuzzle.puzzle!!, holes)
|
||||
return GameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
|
||||
}
|
||||
|
||||
suspend fun saveRecord(recordDto: RecordDto) {
|
||||
val record = GameRecord(
|
||||
puzzleId = recordDto.puzzleId,
|
||||
userName = recordDto.userName,
|
||||
completionTime = recordDto.completionTime
|
||||
)
|
||||
recordRepository.save(record)
|
||||
}
|
||||
|
||||
suspend fun getRankings(puzzleId: Long): List<GameRecord> {
|
||||
// Flow를 최종적으로 List로 변환하여 반환
|
||||
return recordRepository.findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId).toList()
|
||||
}
|
||||
|
||||
private fun createQuestion(puzzle: String, holes: Int): String {
|
||||
val chars = puzzle.toMutableList()
|
||||
var remainingHoles = holes
|
||||
while (remainingHoles > 0) {
|
||||
val randomIndex = Random.nextInt(chars.size)
|
||||
if (chars[randomIndex] != '0') {
|
||||
chars[randomIndex] = '0'
|
||||
remainingHoles--
|
||||
}
|
||||
}
|
||||
return chars.joinToString("")
|
||||
}
|
||||
|
||||
suspend fun generateAndSavePuzzle(): SudokuPuzzle {
|
||||
var attempts = 0
|
||||
val maxAttempts = 10 // 중복 시 최대 10번 재시도
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
val puzzleString = SudokuGenerator().generate()
|
||||
println("puzzleString >>> ${puzzleString}")
|
||||
// DB에 저장하기 전에 가장 큰 puzzleKey를 찾아 +1
|
||||
val lastPuzzle = puzzleRepository.findTopByOrderByPuzzleKeyDesc()
|
||||
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
|
||||
|
||||
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
|
||||
return puzzleRepository.save(newPuzzle)
|
||||
} catch (e: DuplicateKeyException) {
|
||||
attempts++
|
||||
println("중복 퍼즐 생성됨, 재시도... ($attempts/$maxAttempts)")
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("새로운 고유 퍼즐 생성에 실패했습니다.")
|
||||
}
|
||||
|
||||
data class ValidateDto(val puzzleId: Long, val answer: String)
|
||||
|
||||
suspend fun validateSolution(validateDto: ValidateDto): Boolean {
|
||||
val originalPuzzle = puzzleRepository.findByPuzzleKey(validateDto.puzzleId)
|
||||
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
|
||||
|
||||
return originalPuzzle.puzzle == validateDto.answer
|
||||
}
|
||||
}
|
||||
//package kr.lunaticbum.back.lun.model
|
||||
//
|
||||
//// (★ 삭제) Service, Record 관련 import 모두 제거
|
||||
//import org.springframework.data.annotation.Id
|
||||
//import org.springframework.data.mongodb.core.index.Indexed
|
||||
//import org.springframework.data.mongodb.core.mapping.Document
|
||||
//import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
//import org.springframework.stereotype.Repository
|
||||
//
|
||||
///**
|
||||
// * 스도쿠 퍼즐 원본 데이터를 저장하는 모델
|
||||
// */
|
||||
//@Document(collection = "puzzles") // (참고: 노노그램과 같은 컬렉션을 쓰지만 구조가 다름)
|
||||
//data class SudokuPuzzle(
|
||||
// @Id
|
||||
// val id: String? = null,
|
||||
// val puzzleKey: Long? = null,
|
||||
// @Indexed(unique = true)
|
||||
// val puzzle: String? // 81자리 완성된 퍼즐 데이터
|
||||
//)
|
||||
//
|
||||
///**
|
||||
// * 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
|
||||
// */
|
||||
//@Repository
|
||||
//interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
|
||||
// override suspend fun count(): Long
|
||||
// suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
|
||||
// suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
|
||||
//}
|
||||
//
|
||||
//
|
||||
///* * (★ 삭제됨) data class GameRecord (...)
|
||||
// * -> 통합 GameRank 모델로 대체됨.
|
||||
// */
|
||||
//
|
||||
///*
|
||||
// * (★ 삭제됨) interface GameRecordRepository (...)
|
||||
// * -> 통합 GameRankRepository로 대체됨.
|
||||
// */
|
||||
//
|
||||
///*
|
||||
// * (★ 삭제됨) @Service class SudokuService (...)
|
||||
// * -> 모든 로직이 PuzzleService로 통합됨.
|
||||
// */
|
||||
@ -1,156 +1,171 @@
|
||||
/* =================================
|
||||
기본 및 전체 레이아웃
|
||||
================================= */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
background-color: #faf8ef;
|
||||
color: #776e65;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/*!* =================================*/
|
||||
/* 기본 및 전체 레이아웃 (수정됨)*/
|
||||
/* ================================= *!*/
|
||||
/*body {*/
|
||||
/* !* (★ 삭제) font-family, text-align, background-color, color, margin, padding*/
|
||||
/* -> 이 속성들은 모두 common_game_theme.css에서 관리합니다.*/
|
||||
/* *!*/
|
||||
/* box-sizing: border-box;*/
|
||||
/*}*/
|
||||
|
||||
h1 {
|
||||
font-size: 15vw;
|
||||
margin: 20px 0;
|
||||
}
|
||||
/*h1 {*/
|
||||
/* font-size: 15vw; !* 2048 고유의 큰 폰트 크기는 유지 *!*/
|
||||
/* margin: 20px 0;*/
|
||||
/* !* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 *!*/
|
||||
/*}*/
|
||||
|
||||
.score-container {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
/*.score-container {*/
|
||||
/* font-size: 24px;*/
|
||||
/* margin-bottom: 20px;*/
|
||||
/*}*/
|
||||
|
||||
/* =================================
|
||||
게임 보드 (가장 중요한 부분)
|
||||
================================= */
|
||||
#game-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 2vw;
|
||||
width: 95vw;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background-color: #bbada0;
|
||||
padding: 2vw;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 1 / 1; /* 정사각형 비율 유지 */
|
||||
touch-action: none; /* 모바일에서 터치 시 화면 확대/이동 방지 */
|
||||
}
|
||||
/*!* =================================*/
|
||||
/* 게임 보드 (테마 적용)*/
|
||||
/* ================================= *!*/
|
||||
/*#game-board {*/
|
||||
/* display: grid;*/
|
||||
/* grid-template-columns: repeat(4, 1fr);*/
|
||||
/* grid-gap: 2vw;*/
|
||||
/* width: 95vw;*/
|
||||
/* max-width: 500px; !* (★ 수정) 400px -> 500px (다른 게임과 통일) *!*/
|
||||
/* margin: 0 auto;*/
|
||||
|
||||
/* =================================
|
||||
타일 공통 스타일
|
||||
================================= */
|
||||
.tile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
background-color: #cdc1b4;
|
||||
font-size: 7vw;
|
||||
}
|
||||
/* !* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 *!*/
|
||||
/* background-color: #b0bec5; !* #bbada0 (갈색) -> #b0bec5 (블루 그레이) *!*/
|
||||
|
||||
/* =================================
|
||||
타일 색상
|
||||
================================= */
|
||||
.tile-2 { background-color: #eee4da; color: #776e65; }
|
||||
.tile-4 { background-color: #ede0c8; color: #776e65; }
|
||||
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
|
||||
.tile-16 { background-color: #f59563; color: #f9f6f2; }
|
||||
.tile-32 { background-color: #f67c5f; color: #f9f6f2; }
|
||||
.tile-64 { background-color: #f65e3b; color: #f9f6f2; }
|
||||
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
|
||||
.tile-256 { background-color: #edcc61; color: #f9f6f2; }
|
||||
.tile-512 { background-color: #edc850; color: #f9f6f2; }
|
||||
.tile-1024 { background-color: #edc53f; color: #f9f6f2; }
|
||||
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }
|
||||
.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }
|
||||
.tile-8192 { background-color: #ff3333; color: #f9f6f2; }
|
||||
.tile-16384 { background-color: #0077cc; color: #f9f6f2; }
|
||||
.tile-32768 { background-color: #9900cc; color: #f9f6f2; }
|
||||
.tile-4096, .tile-8192 { font-size: 6vw; }
|
||||
.tile-16384, .tile-32768 { font-size: 5vw; }
|
||||
/* padding: 2vw;*/
|
||||
/* border-radius: 6px;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* aspect-ratio: 1 / 1;*/
|
||||
/* touch-action: none;*/
|
||||
|
||||
/* !* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 *!*/
|
||||
/* box-shadow: 0 4px 10px rgba(0,0,0,0.08);*/
|
||||
/*}*/
|
||||
|
||||
/*@media (min-width: 481px) {*/
|
||||
/* #game-board {*/
|
||||
/* grid-gap: 10px;*/
|
||||
/* padding: 10px;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
|
||||
/* =================================
|
||||
게임 오버 팝업
|
||||
================================= */
|
||||
.popup-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.popup {
|
||||
background-color: #faf8ef;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 80vw;
|
||||
max-width: 300px;
|
||||
}
|
||||
.popup input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.popup button {
|
||||
padding: 10px 20px;
|
||||
background-color: #8f7a66;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/*!* =================================*/
|
||||
/* 타일 공통 스타일 (테마 적용)*/
|
||||
/* ================================= *!*/
|
||||
/*.tile {*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* font-weight: bold;*/
|
||||
/* border-radius: 3px;*/
|
||||
|
||||
/* =================================
|
||||
랭킹 리스트
|
||||
================================= */
|
||||
.ranking-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 30px auto;
|
||||
text-align: left;
|
||||
}
|
||||
.ranking-container h3 {
|
||||
text-align: center;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
#ranking-list li {
|
||||
background-color: #eee4da;
|
||||
margin-bottom: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* !* (★ 수정) 빈 타일 색상 변경 *!*/
|
||||
/* background-color: #eceff1; !* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) *!*/
|
||||
|
||||
/* =================================
|
||||
반응형: PC & 태블릿 (화면이 481px 이상일 때)
|
||||
================================= */
|
||||
@media (min-width: 481px) {
|
||||
h1 { font-size: 80px; }
|
||||
#game-board {
|
||||
grid-gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.tile { font-size: 45px; }
|
||||
.tile-4096, .tile-8192 { font-size: 35px; }
|
||||
.tile-16384, .tile-32768 { font-size: 30px; }
|
||||
}
|
||||
/* font-size: 5vw;*/
|
||||
/*}*/
|
||||
|
||||
/*@media (min-width: 481px) {*/
|
||||
/* .tile {*/
|
||||
/* font-size: 30px;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
/*!* =================================*/
|
||||
/* 타일 색상 (테마 적용)*/
|
||||
/* ================================= *!*/
|
||||
|
||||
/*!* (★ 수정) 2, 4 타일은 베이지색 계열이라 테마와 충돌하므로 파란색 계열로 변경 *!*/
|
||||
/*.tile-2 { background-color: #e3f2fd; color: #333; } !* #eee4da (베이지) -> #e3f2fd (밝은 파랑) *!*/
|
||||
/*.tile-4 { background-color: #bbdefb; color: #333; } !* #ede0c8 (노란 베이지) -> #bbdefb (파랑) *!*/
|
||||
|
||||
/*!* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) *!*/
|
||||
/*.tile-8 { background-color: #f2b179; color: #f9f6f2; }*/
|
||||
/*.tile-16 { background-color: #f59563; color: #f9f6f2; }*/
|
||||
/*.tile-32 { background-color: #f67c5f; color: #f9f6f2; }*/
|
||||
/*.tile-64 { background-color: #f65e3b; color: #f9f6f2; }*/
|
||||
/*.tile-128 { background-color: #edcf72; color: #f9f6f2; }*/
|
||||
/*.tile-256 { background-color: #edcc61; color: #f9f6f2; }*/
|
||||
/*.tile-512 { background-color: #edc850; color: #f9f6f2; }*/
|
||||
/*.tile-1024 { background-color: #edc53f; color: #f9f6f2; }*/
|
||||
/*.tile-2048 { background-color: #edc22e; color: #f9f6f2; }*/
|
||||
/*.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }*/
|
||||
/*.tile-8192 { background-color: #ff3333; color: #f9f6f2; }*/
|
||||
/*.tile-16384 { background-color: #0077cc; color: #f9f6f2; }*/
|
||||
/*.tile-32768 { background-color: #9900cc; color: #f9f6f2; }*/
|
||||
|
||||
|
||||
/*!* =================================*/
|
||||
/* 게임 오버 팝업 (테마 적용)*/
|
||||
/* ================================= *!*/
|
||||
/*.popup-container {*/
|
||||
/* position: fixed;*/
|
||||
/* top: 0;*/
|
||||
/* left: 0;*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/* background-color: rgba(0, 0, 0, 0.5);*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* z-index: 100;*/
|
||||
/*}*/
|
||||
/*.popup {*/
|
||||
/* !* (★ 수정) 배경색을 테마에 맞게 흰색으로 변경 *!*/
|
||||
/* background-color: #ffffff; !* #faf8ef (베이지) -> #ffffff (흰색) *!*/
|
||||
/* padding: 20px;*/
|
||||
/* border-radius: 10px;*/
|
||||
/* text-align: center;*/
|
||||
/* width: 80vw;*/
|
||||
/* max-width: 300px;*/
|
||||
/* box-shadow: 0 4px 15px rgba(0,0,0,0.2); !* 흰색 배경이므로 그림자 추가 *!*/
|
||||
/*}*/
|
||||
/*.popup input {*/
|
||||
/* width: 100%;*/
|
||||
/* padding: 10px;*/
|
||||
/* margin: 10px 0;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/*}*/
|
||||
/*.popup button {*/
|
||||
/* padding: 10px 20px;*/
|
||||
/* !* (★ 삭제) background-color, color, border -> common_game_theme의 파란색 버튼 스타일을 상속받음 *!*/
|
||||
/* border-radius: 5px;*/
|
||||
/* cursor: pointer;*/
|
||||
/*}*/
|
||||
|
||||
/*!* =================================*/
|
||||
/* 랭킹 리스트 (테마 적용)*/
|
||||
/* ================================= *!*/
|
||||
/*.ranking-container {*/
|
||||
/* !**/
|
||||
/* (★ 참고) 이 컨테이너는 common_game_theme.css에서*/
|
||||
/* .game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.*/
|
||||
/* 여기서는 내부 정렬만 담당합니다.*/
|
||||
/* *!*/
|
||||
/* width: 100%;*/
|
||||
/* max-width: 500px; !* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) *!*/
|
||||
/* margin: 30px auto;*/
|
||||
/* text-align: left;*/
|
||||
/*}*/
|
||||
/*.ranking-container h3 {*/
|
||||
/* text-align: center;*/
|
||||
/*}*/
|
||||
/*#ranking-list {*/
|
||||
/* list-style-type: none;*/
|
||||
/* padding: 0;*/
|
||||
/*}*/
|
||||
/*#ranking-list li {*/
|
||||
/* !* (★ 수정) 배경색 변경 *!*/
|
||||
/* background-color: #f0f4f8; !* #eee4da (베이지) -> #f0f4f8 (밝은 회색) *!*/
|
||||
/* margin-bottom: 5px;*/
|
||||
/* padding: 10px;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: space-between;*/
|
||||
/*}*/
|
||||
@ -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; /* 혹시 모를 인라인 스타일 제거 */
|
||||
}
|
||||
}
|
||||
100
src/main/resources/static/css/common_game_theme.css
Normal file
100
src/main/resources/static/css/common_game_theme.css
Normal file
@ -0,0 +1,100 @@
|
||||
/* =================================
|
||||
common_game_theme.css
|
||||
(모든 게임이 공유하는 공통 테마)
|
||||
================================= */
|
||||
|
||||
/* (★ 신규) 모든 테마의 기준이 되는 CSS 변수 정의 */
|
||||
:root {
|
||||
--font-main: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* 기본 색상 */
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-text-secondary: #333;
|
||||
--color-bg-page: #f4f7f9;
|
||||
--color-bg-card: #ffffff;
|
||||
|
||||
/* 공통 UI 색상 */
|
||||
--color-primary: #007bff;
|
||||
--color-primary-hover: #0056b3;
|
||||
--color-disabled-bg: #cccccc;
|
||||
--color-disabled-opacity: 0.7;
|
||||
|
||||
/* 게임 상태별 색상 */
|
||||
--color-incorrect-bg: #ffdddd;
|
||||
--color-incorrect-text: #d8000c;
|
||||
--color-focus-bg: #dbeeff; /* 스도쿠 포커스 */
|
||||
--color-highlight-bg: #e6e6e6; /* 스도쿠 동일 숫자 */
|
||||
--color-selected-num-bg: #b3d7ff; /* 스도쿠 선택 숫자 */
|
||||
|
||||
/* 게임 고유 테마 색상 */
|
||||
--color-felt-green: #008000; /* 스파이더: 테이블 배경 */
|
||||
--color-felt-border: #004d00; /* 스파이더: 캔버스 테두리 */
|
||||
--color-grid-bg-2048: #b0bec5; /* 2048: 보드 배경 */
|
||||
--color-tile-empty: #eceff1; /* 2048: 빈 타일 */
|
||||
--color-tile-2: #e3f2fd;
|
||||
--color-tile-4: #bbdefb;
|
||||
|
||||
/* 공통 UI 속성 */
|
||||
--border-radius-main: 8px;
|
||||
--box-shadow-main: 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
|
||||
/* (★ 통일) 기본 폰트 및 배경색 정의 (변수 사용) */
|
||||
body {
|
||||
font-family: var(--font-main);
|
||||
background-color: var(--color-bg-page);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* (★ 통일) H1 (게임 제목) 스타일 통일 (변수 사용) */
|
||||
h1 {
|
||||
font-size: clamp(2.2em, 8vw, 3.2em);
|
||||
color: var(--color-text-primary);
|
||||
margin: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
/* (★ 통일) 모든 <button> 기본 스타일 통일 (변수 사용) */
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg-card);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: var(--color-disabled-bg);
|
||||
cursor: not-allowed;
|
||||
opacity: var(--color-disabled-opacity);
|
||||
}
|
||||
|
||||
/* (★ 통일) 게임 컨트롤/랭킹 등을 감싸는 공통 '카드' UI (변수 사용) */
|
||||
#sudoku-game-app .container,
|
||||
.ranking-container,
|
||||
#setup-container,
|
||||
#game-controls {
|
||||
background: var(--color-bg-card);
|
||||
padding: clamp(15px, 4vw, 25px);
|
||||
border-radius: var(--border-radius-main);
|
||||
box-shadow: var(--box-shadow-main);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 500px; /* 최대 너비 통일 */
|
||||
margin: 15px auto;
|
||||
}
|
||||
314
src/main/resources/static/css/nonogram.css
Normal file
314
src/main/resources/static/css/nonogram.css
Normal file
@ -0,0 +1,314 @@
|
||||
/*!* === nonogram.css (게임 플레이용) === *!*/
|
||||
/*body {*/
|
||||
/* !* (★ 삭제) font-family, align-items: center*/
|
||||
/* -> common_game_theme.css에서 관리합니다.*/
|
||||
/* *!*/
|
||||
/*}*/
|
||||
|
||||
/*#board-viewport {*/
|
||||
/* position: relative;*/
|
||||
/* width: 100%;*/
|
||||
/* max-width: 95vw; !* 화면 너비에 좀 더 맞춤 *!*/
|
||||
/* margin: 20px auto;*/
|
||||
/* !* (★ 핵심 수정) Flexbox로 자식 요소를 중앙 정렬합니다. *!*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center; !* 자식 요소를 수평 중앙 정렬 *!*/
|
||||
/* align-items: flex-start; !* 위쪽에 정렬 *!*/
|
||||
/*}*/
|
||||
/*.reveal-img {*/
|
||||
/* position: absolute;*/
|
||||
/* top: 0;*/
|
||||
/* left: 0;*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/* opacity: 0; !* Hidden by default *!*/
|
||||
/* pointer-events: none; !* Make them unclickable *!*/
|
||||
/* transition: opacity 1.5s ease-in-out; !* Fade animation *!*/
|
||||
/* transform-origin: top left; !* Align with the game board's scaling *!*/
|
||||
/*}*/
|
||||
|
||||
/*.guide-line-right {*/
|
||||
/* border-right: 2px solid #999 !important;*/
|
||||
/*}*/
|
||||
|
||||
/*.guide-line-bottom {*/
|
||||
/* border-bottom: 2px solid #999 !important;*/
|
||||
/*}*/
|
||||
|
||||
/*#game-board {*/
|
||||
/* display: grid;*/
|
||||
/* gap: 1px;*/
|
||||
/* background-color: #999;*/
|
||||
/* border: 2px solid #333;*/
|
||||
/* transform-origin: top;*/
|
||||
/*}*/
|
||||
/*#game-controls {*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: space-between;*/
|
||||
/* align-items: center;*/
|
||||
/* width: 100%;*/
|
||||
/* !* (★ 삭제) 아래 속성들은 common_game_theme.css의 #game-controls 셀렉터가 관리합니다.*/
|
||||
/* max-width: 500px;*/
|
||||
/* margin-bottom: 15px;*/
|
||||
/* *!*/
|
||||
/* font-size: 1.2em;*/
|
||||
/* flex-wrap: wrap;*/
|
||||
/* gap: 15px;*/
|
||||
|
||||
/* !* (★ 참고) common_game_theme에서 이미 background: white, padding, box-shadow 등이 적용된 상태입니다.*/
|
||||
/* 이 CSS는 그 내부의 flex 정렬만 담당하게 됩니다.*/
|
||||
/* *!*/
|
||||
/*}*/
|
||||
|
||||
/*#mode-selector {*/
|
||||
/* display: flex;*/
|
||||
/* gap: 5px;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/* border-radius: 8px;*/
|
||||
/* padding: 4px;*/
|
||||
/* background-color: #f0f0f0;*/
|
||||
/*}*/
|
||||
|
||||
/*#mode-selector label {*/
|
||||
/* cursor: pointer;*/
|
||||
/* user-select: none;*/
|
||||
/*}*/
|
||||
|
||||
/*#mode-selector span {*/
|
||||
/* padding: 8px 15px;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* display: block;*/
|
||||
/* transition: background-color 0.2s, color 0.2s;*/
|
||||
/*}*/
|
||||
|
||||
/*#mode-selector input[type="radio"] {*/
|
||||
/* display: none;*/
|
||||
/*}*/
|
||||
|
||||
/*#mode-selector input[type="radio"]:checked + span {*/
|
||||
/* background-color: #007bff; !* (★ 참고) 공통 테마의 파란색과 동일하므로 유지 *!*/
|
||||
/* color: white;*/
|
||||
/* box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);*/
|
||||
/*}*/
|
||||
/*#hint-btn {*/
|
||||
/* padding: 8px 15px;*/
|
||||
/* font-weight: bold;*/
|
||||
/* cursor: pointer;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/*}*/
|
||||
/*#hint-btn:disabled {*/
|
||||
/* cursor: not-allowed;*/
|
||||
/* opacity: 0.5;*/
|
||||
/*}*/
|
||||
|
||||
/*.col-clues-container, .row-clues-container {*/
|
||||
/* display: flex;*/
|
||||
/*}*/
|
||||
/*.row-clues-container {*/
|
||||
/* flex-direction: column;*/
|
||||
/*}*/
|
||||
|
||||
/*.puzzle-grid-container {*/
|
||||
/* display: grid;*/
|
||||
/* border: 2px solid #333;*/
|
||||
/*}*/
|
||||
|
||||
/*!* nonogram.css의 .clue-cell (게임용) *!*/
|
||||
/*.clue-cell {*/
|
||||
/* background-color: #f0f0f0;*/
|
||||
/* font-weight: bold;*/
|
||||
/* font-size: 14px;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* display: flex;*/
|
||||
/* padding: 5px;*/
|
||||
/*}*/
|
||||
|
||||
/*.row-clue {*/
|
||||
/* justify-content: flex-end; !* 힌트 오른쪽 정렬 *!*/
|
||||
/* align-items: center;*/
|
||||
/*}*/
|
||||
|
||||
/*.col-clue {*/
|
||||
/* justify-content: center; !* 힌트 가운데 정렬 *!*/
|
||||
/* align-items: flex-end; !* 힌트 아래쪽 정렬 *!*/
|
||||
/* text-align: center;*/
|
||||
/* line-height: 1.2; !* 줄 간격 *!*/
|
||||
/*}*/
|
||||
|
||||
/*!* nonogram.css의 .grid-cell (게임용) *!*/
|
||||
/*.grid-cell {*/
|
||||
/* background-color: #fff;*/
|
||||
/* border: 1px solid #ddd;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* cursor: pointer;*/
|
||||
/*}*/
|
||||
|
||||
/*!* nonogram.css의 .filled (게임용) *!*/
|
||||
/*.grid-cell.filled {*/
|
||||
/* background-color: #333;*/
|
||||
/*}*/
|
||||
|
||||
/*.grid-cell.marked::after {*/
|
||||
/* content: 'X';*/
|
||||
/* color: #ff5c5c;*/
|
||||
/* font-weight: bold;*/
|
||||
/* font-size: 1.2em;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/*}*/
|
||||
|
||||
/*.grid-cell.incorrect {*/
|
||||
/* background-color: #ffcccc;*/
|
||||
/* animation: shake 0.5s;*/
|
||||
/*}*/
|
||||
/*@keyframes shake {*/
|
||||
/* 0%, 100% { transform: translateX(0); }*/
|
||||
/* 25% { transform: translateX(-5px); }*/
|
||||
/* 75% { transform: translateX(5px); }*/
|
||||
/*}*/
|
||||
|
||||
|
||||
/*#result-overlay {*/
|
||||
/* position: fixed; !* 화면 전체에 고정 *!*/
|
||||
/* top: 0;*/
|
||||
/* left: 0;*/
|
||||
/* width: 100vw; !* 뷰포트 너비 100% *!*/
|
||||
/* height: 100vh; !* 뷰포트 높이 100% *!*/
|
||||
/* background-color: rgba(0, 0, 0, 0.75); !* 반투명 검은 배경 *!*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* z-index: 100;*/
|
||||
/* opacity: 0;*/
|
||||
/* pointer-events: none;*/
|
||||
/* transition: opacity 0.3s ease-in-out;*/
|
||||
/*}*/
|
||||
/*#result-overlay.visible {*/
|
||||
/* opacity: 1;*/
|
||||
/* pointer-events: auto;*/
|
||||
/*}*/
|
||||
/*#result-modal {*/
|
||||
/* background-color: white;*/
|
||||
/* padding: 20px 40px;*/
|
||||
/* border-radius: 10px;*/
|
||||
/* text-align: center;*/
|
||||
/* box-shadow: 0 5px 15px rgba(0,0,0,0.3);*/
|
||||
/*}*/
|
||||
/*#modal-title {*/
|
||||
/* margin-top: 0;*/
|
||||
/* font-size: 2.5em;*/
|
||||
/*}*/
|
||||
/*#modal-buttons button {*/
|
||||
/* padding: 10px 20px;*/
|
||||
/* margin: 0 10px;*/
|
||||
/* font-size: 1em;*/
|
||||
/* cursor: pointer;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/* min-width: 120px;*/
|
||||
/*}*/
|
||||
/*#modal-buttons button.primary {*/
|
||||
/* background-color: #4CAF50;*/
|
||||
/* color: white;*/
|
||||
/* border-color: #4CAF50;*/
|
||||
/*}*/
|
||||
|
||||
/*.hidden {*/
|
||||
/* display: none;*/
|
||||
/*}*/
|
||||
|
||||
/*.clue-cell.completed {*/
|
||||
/* color: #999; !* 색상을 회색으로 *!*/
|
||||
/* text-decoration: line-through; !* 취소선 *!*/
|
||||
/*}*/
|
||||
|
||||
/*.grid-cell.locked {*/
|
||||
/* opacity: 0.8; !* 약간 투명하게 *!*/
|
||||
/*}*/
|
||||
|
||||
/*.grid-cell.selecting {*/
|
||||
/* background-color: rgba(0, 123, 255, 0.3); !* 반투명 파란색 배경 *!*/
|
||||
/* border-color: rgba(0, 123, 255, 0.5);*/
|
||||
/*}*/
|
||||
|
||||
|
||||
/*!* === puzzle.css (업로드 미리보기용) - 충돌 해결됨 === *!*/
|
||||
|
||||
/*#puzzle-container {*/
|
||||
/* display: grid;*/
|
||||
/* !* We will set grid-template-columns/rows with JS *!*/
|
||||
/* grid-gap: 2px;*/
|
||||
/* margin-top: 20px;*/
|
||||
/* background-color: #333;*/
|
||||
/* border: 2px solid #333;*/
|
||||
/* width: fit-content;*/
|
||||
/*}*/
|
||||
|
||||
/*!* (★충돌 해결) #puzzle-container 내부의 .grid-cell (미리보기 코너 셀) *!*/
|
||||
/*#puzzle-container .grid-cell {*/
|
||||
/* width: 25px;*/
|
||||
/* height: 25px;*/
|
||||
/* background-color: #f0f0f0;*/
|
||||
/* text-align: center;*/
|
||||
/* line-height: 25px;*/
|
||||
/* font-size: 14px;*/
|
||||
/* !* nonogram.css의 .grid-cell 스타일과 겹치지 않음 *!*/
|
||||
/* cursor: default;*/
|
||||
/* border: none;*/
|
||||
/*}*/
|
||||
|
||||
/*!* (★충돌 해결) #puzzle-container 내부의 .clue-cell (미리보기 힌트 셀) *!*/
|
||||
/*#puzzle-container .clue-cell {*/
|
||||
/* background-color: #cce7ff;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* padding: 5px;*/
|
||||
/* min-height: 25px;*/
|
||||
/* font-weight: bold;*/
|
||||
/* !* nonogram.css의 .clue-cell 스타일과 겹치지 않음 *!*/
|
||||
/* font-size: 14px;*/
|
||||
/*}*/
|
||||
|
||||
/*.solution-cell {*/
|
||||
/* width: 25px;*/
|
||||
/* height: 25px;*/
|
||||
/*}*/
|
||||
|
||||
/*!* (★충돌 해결) .filled 대신 .solution-cell.filled 사용 *!*/
|
||||
/*.solution-cell.filled {*/
|
||||
/* background-color: #333;*/
|
||||
/*}*/
|
||||
|
||||
/*!* .empty는 .solution-cell.empty로 사용 (upload.js 기준) *!*/
|
||||
/*.solution-cell.empty {*/
|
||||
/* background-color: #fff;*/
|
||||
/*}*/
|
||||
|
||||
/*#puzzle-wrapper {*/
|
||||
/* position: relative; !* Needed for absolute positioning of children *!*/
|
||||
/*}*/
|
||||
|
||||
/*!* 이 ID는 nonogram.html에서 사용되지 않으므로 충돌 없음 *!*/
|
||||
/*#success-animation-container {*/
|
||||
/* position: absolute;*/
|
||||
/* top: 0;*/
|
||||
/* left: 0;*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/* pointer-events: none; !* Allows clicking through the container *!*/
|
||||
/*}*/
|
||||
|
||||
/*#success-animation-container img {*/
|
||||
/* position: absolute;*/
|
||||
/* top: 0;*/
|
||||
/* left: 0;*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100%;*/
|
||||
/* opacity: 0; !* Hidden by default *!*/
|
||||
/* transition: opacity 1.0s ease-in-out; !* Fade animation *!*/
|
||||
/*}*/
|
||||
@ -1,240 +0,0 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#board-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 95vw; /* 화면 너비에 좀 더 맞춤 */
|
||||
margin: 20px auto;
|
||||
/* (★ 핵심 수정) Flexbox로 자식 요소를 중앙 정렬합니다. */
|
||||
display: flex;
|
||||
justify-content: center; /* 자식 요소를 수평 중앙 정렬 */
|
||||
align-items: flex-start; /* 위쪽에 정렬 */
|
||||
}
|
||||
/* (★ ADD) Styles for the reveal images */
|
||||
.reveal-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0; /* Hidden by default */
|
||||
pointer-events: none; /* Make them unclickable */
|
||||
transition: opacity 1.5s ease-in-out; /* Fade animation */
|
||||
transform-origin: top left; /* Align with the game board's scaling */
|
||||
}
|
||||
|
||||
.guide-line-right {
|
||||
border-right: 2px solid #999 !important;
|
||||
}
|
||||
|
||||
.guide-line-bottom {
|
||||
border-bottom: 2px solid #999 !important;
|
||||
}
|
||||
|
||||
#game-board {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background-color: #999;
|
||||
border: 2px solid #333;
|
||||
/* transform-origin은 그대로 유지합니다. */
|
||||
transform-origin: top;
|
||||
}
|
||||
/* (★ 수정) 게임 컨트롤 영역 스타일 */
|
||||
#game-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.2em;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* (★ 수정) 모드 선택 버튼 스타일 */
|
||||
#mode-selector {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
#mode-selector label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#mode-selector span {
|
||||
padding: 8px 15px;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* 실제 라디오 버튼은 숨김 */
|
||||
#mode-selector input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* (★ 핵심) 선택된 라디오 버튼의 span 스타일 */
|
||||
#mode-selector input[type="radio"]:checked + span {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
#hint-btn {
|
||||
padding: 8px 15px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#hint-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.col-clues-container, .row-clues-container {
|
||||
display: flex;
|
||||
}
|
||||
.row-clues-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.puzzle-grid-container {
|
||||
display: grid;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.clue-cell {
|
||||
background-color: #f0f0f0;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.row-clue {
|
||||
justify-content: flex-end; /* 힌트 오른쪽 정렬 */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.col-clue {
|
||||
justify-content: center; /* 힌트 가운데 정렬 */
|
||||
align-items: flex-end; /* 힌트 아래쪽 정렬 */
|
||||
text-align: center;
|
||||
line-height: 1.2; /* 줄 간격 */
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-cell.filled {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.grid-cell.marked::after {
|
||||
content: 'X';
|
||||
color: #ff5c5c;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* (★ 추가) 실수했을 때 셀 스타일 */
|
||||
.grid-cell.incorrect {
|
||||
background-color: #ffcccc;
|
||||
animation: shake 0.5s;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
|
||||
#result-overlay {
|
||||
position: fixed; /* 화면 전체에 고정 */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw; /* 뷰포트 너비 100% */
|
||||
height: 100vh; /* 뷰포트 높이 100% */
|
||||
background-color: rgba(0, 0, 0, 0.75); /* 반투명 검은 배경 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
#result-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* (★ 추가) 모달 팝업 스타일 */
|
||||
#result-modal {
|
||||
background-color: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
#modal-title {
|
||||
margin-top: 0;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
#modal-buttons button {
|
||||
padding: 10px 20px;
|
||||
margin: 0 10px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
min-width: 120px;
|
||||
}
|
||||
#modal-buttons button.primary {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
/* 기존 .hidden 클래스는 이제 overlay의 visible/hidden 상태 관리에 사용됩니다. */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* play.css */
|
||||
|
||||
/* (★ 추가) 완성된 힌트 스타일 */
|
||||
.clue-cell.completed {
|
||||
color: #999; /* 색상을 회색으로 */
|
||||
text-decoration: line-through; /* 취소선 */
|
||||
}
|
||||
|
||||
/* (★ 추가) 잠긴 셀 스타일 */
|
||||
.grid-cell.locked {
|
||||
opacity: 0.8; /* 약간 투명하게 */
|
||||
/* 잠긴 셀은 더 이상 클릭할 수 없다는 것을 시각적으로 보여줌 */
|
||||
}
|
||||
|
||||
.grid-cell.selecting {
|
||||
background-color: rgba(0, 123, 255, 0.3); /* 반투명 파란색 배경 */
|
||||
border-color: rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
#puzzle-container {
|
||||
display: grid;
|
||||
/* We will set grid-template-columns/rows with JS */
|
||||
grid-gap: 2px;
|
||||
margin-top: 20px;
|
||||
background-color: #333;
|
||||
border: 2px solid #333;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
background-color: #f0f0f0;
|
||||
text-align: center;
|
||||
line-height: 25px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clue-cell {
|
||||
background-color: #cce7ff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
min-height: 25px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.solution-cell {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.filled {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#puzzle-wrapper {
|
||||
position: relative; /* Needed for absolute positioning of children */
|
||||
}
|
||||
|
||||
#success-animation-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none; /* Allows clicking through the container */
|
||||
}
|
||||
|
||||
#success-animation-container img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0; /* Hidden by default */
|
||||
transition: opacity 1.0s ease-in-out; /* Fade animation */
|
||||
}
|
||||
@ -1,17 +1,41 @@
|
||||
#game-container {
|
||||
display: flex;
|
||||
justify-content: center; /* 가로 중앙 정렬 */
|
||||
align-items: flex-start; /* 세로 상단 정렬 */
|
||||
background-color: #008000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/*!* (★ 수정) #game-container가 전체 화면(100vw/vh)을 차지하는 대신,*/
|
||||
/* common_game_theme의 body(#f4f7f9 배경) 위에 떠 있는*/
|
||||
/* '게임 테이블(카드)' 역할을 하도록 변경합니다. *!*/
|
||||
/*#game-container {*/
|
||||
/* !* (★ 남김) 내부 캔버스를 정렬하는 로직은 유지 *!*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: flex-start;*/
|
||||
|
||||
#gameCanvas {
|
||||
background-color: #008000;
|
||||
border: 2px solid #fff;
|
||||
width: 95%;
|
||||
max-height: min(95vw, 95vh);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* !* (★ 유지/수정) '펠트' 배경색은 유지하되, 공통 '카드' UI 요소를 추가 *!*/
|
||||
/* background-color: #008000;*/
|
||||
/* border-radius: 8px; !* (★ 추가) 공통 테마 둥근 모서리 *!*/
|
||||
/* box-shadow: 0 4px 10px rgba(0,0,0,0.08); !* (★ 추가) 공통 테마 그림자 *!*/
|
||||
/* padding: 15px; !* (★ 추가) 캔버스 주변 여백 *!*/
|
||||
/* box-sizing: border-box;*/
|
||||
|
||||
/* !* (★ 삭제) 100vw, 100vh 속성을 삭제하여 body의 중앙 정렬이 동작하도록 함 *!*/
|
||||
/* !* width: 100vw; (삭제) *!*/
|
||||
/* !* height: 100vh; (삭제) *!*/
|
||||
|
||||
/* !* (★ 수정) 너비 관리: 다른 게임(500px)보다 넓은 반응형 최대 너비를 가짐 *!*/
|
||||
/* width: 95%; !* 뷰포트의 95%를 사용 *!*/
|
||||
/* max-width: 1200px; !* 단, 스파이더 게임에 맞게 1200px까지 허용 *!*/
|
||||
/*}*/
|
||||
|
||||
/*#gameCanvas {*/
|
||||
/* !* (★ 삭제) 컨테이너가 이미 녹색이므로 캔버스 자체의 배경은 불필요 *!*/
|
||||
/* !* background-color: #008000; (삭제) *!*/
|
||||
|
||||
/* !* (★ 수정) 흰색 테두리보다 펠트 색과 대비되는 어두운 테두리로 변경 *!*/
|
||||
/* border: 2px solid #004d00; !* #fff (흰색) -> #004d00 (어두운 녹색) *!*/
|
||||
|
||||
/* !* (★ 수정) 너비: 부모(#game-container) 패딩 영역의 100%를 차지 *!*/
|
||||
/* width: 100%;*/
|
||||
/* height: auto; !* 너비에 맞춰 캔버스 비율(JS가 설정한)을 따름 *!*/
|
||||
|
||||
/* !* (★ 삭제) 뷰포트 기준 max-height 삭제. 컨테이너 너비와 캔버스 비율로 크기가 결정됨. *!*/
|
||||
/* !* max-height: min(95vw, 95vh); (삭제) *!*/
|
||||
|
||||
/* box-sizing: border-box; !* 유지 *!*/
|
||||
/*}*/
|
||||
@ -1,238 +1,260 @@
|
||||
/* 기본 스타일 초기화 및 폰트 설정 */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f7f9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
/*!* 기본 스타일 초기화 및 폰트 설정 *!*/
|
||||
/*body {*/
|
||||
/* !**/
|
||||
/* (★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.*/
|
||||
/* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;*/
|
||||
/* margin: 0;*/
|
||||
/* padding: 20px;*/
|
||||
/* background-color: #f4f7f9;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* min-height: 100vh;*/
|
||||
/* *!*/
|
||||
/*}*/
|
||||
|
||||
#sudoku-game-app {
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
/*#sudoku-game-app {*/
|
||||
/* width: 100%;*/
|
||||
/* margin: 20px 0;*/
|
||||
/*}*/
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: clamp(15px, 4vw, 30px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/*.container {*/
|
||||
/* !**/
|
||||
/* (★ 삭제) 아래 속성들은 common_game_theme.css에서*/
|
||||
/* '#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.*/
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
/* background: white;*/
|
||||
/* padding: clamp(15px, 4vw, 30px);*/
|
||||
/* border-radius: 8px;*/
|
||||
/* box-shadow: 0 4px 10px rgba(0,0,0,0.1);*/
|
||||
/* text-align: center;*/
|
||||
/* max-width: 500px;*/
|
||||
/* width: 100%;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* margin: 0 auto;*/
|
||||
/* *!*/
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* !* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 *!*/
|
||||
/* text-align: center;*/
|
||||
/*}*/
|
||||
|
||||
/* 게임 컨테이너 */
|
||||
#game-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/*h1 {*/
|
||||
/* font-size: 1.8em;*/
|
||||
/* color: #333;*/
|
||||
/* margin-top: 0;*/
|
||||
/* margin-bottom: 20px;*/
|
||||
/* !* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.*/
|
||||
/* 만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.*/
|
||||
/* 현재는 공통 스타일이 적용됩니다. *!*/
|
||||
/*}*/
|
||||
|
||||
/* 게임 정보 (점수, 타이머) */
|
||||
.game-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
#score { color: #007bff; }
|
||||
#timer { color: #333; }
|
||||
/*!* (★ 삭제) 아래의 'button' 공통 스타일은 common_game_theme.css가 처리합니다. *!*/
|
||||
/*!**/
|
||||
/*button {*/
|
||||
/* padding: 10px 20px;*/
|
||||
/* font-size: 1em;*/
|
||||
/* cursor: pointer;*/
|
||||
/* background-color: #007bff;*/
|
||||
/* color: white;*/
|
||||
/* border: none;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* transition: background-color 0.2s;*/
|
||||
/*}*/
|
||||
/*button:hover:not(:disabled) {*/
|
||||
/* background-color: #0056b3;*/
|
||||
/*}*/
|
||||
/*button:disabled {*/
|
||||
/* background-color: #cccccc;*/
|
||||
/* cursor: not-allowed;*/
|
||||
/*}*/
|
||||
/**!*/
|
||||
|
||||
/* 스도쿠 보드 */
|
||||
#sudoku-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr);
|
||||
width: 100%;
|
||||
border: 3px solid #333;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
/*!* ======================================= *!*/
|
||||
/*!* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) *!*/
|
||||
/*!* ======================================= *!*/
|
||||
|
||||
.cell:nth-child(3n) { border-right: 2px solid #333; }
|
||||
.cell:nth-child(9n) { border-right-width: 1px; }
|
||||
.cell:nth-child(n+19):nth-child(-n+27),
|
||||
.cell:nth-child(n+46):nth-child(-n+54) {
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
/*!* 게임 컨테이너 *!*/
|
||||
/*#game-container {*/
|
||||
/* display: flex;*/
|
||||
/* flex-direction: column;*/
|
||||
/* align-items: center;*/
|
||||
/* max-width: 500px;*/
|
||||
/* margin: 0 auto;*/
|
||||
/*}*/
|
||||
|
||||
.cell:not(.editable) {
|
||||
background-color: #f0f0f0;
|
||||
color: #222;
|
||||
cursor: default;
|
||||
}
|
||||
/*!* 게임 정보 (점수, 타이머) *!*/
|
||||
/*.game-info {*/
|
||||
/* width: 100%;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: space-between;*/
|
||||
/* align-items: center;*/
|
||||
/* margin-bottom: 15px;*/
|
||||
/* padding: 0 10px;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* font-size: 1.5em;*/
|
||||
/* font-weight: bold;*/
|
||||
/*}*/
|
||||
/*#score { color: #007bff; }*/
|
||||
/*#timer { color: #333; }*/
|
||||
|
||||
/* 하이라이트 & 오답 스타일 */
|
||||
.cell.incorrect {
|
||||
background-color: #ffdddd !important;
|
||||
color: #d8000c !important;
|
||||
}
|
||||
.highlight-focused {
|
||||
background-color: #dbeeff !important;
|
||||
}
|
||||
.highlight-same-number {
|
||||
background-color: #e6e6e6 !important;
|
||||
}
|
||||
.highlight-selected-number {
|
||||
background-color: #b3d7ff !important;
|
||||
}
|
||||
/*!* 스도쿠 보드 *!*/
|
||||
/*#sudoku-board {*/
|
||||
/* display: grid;*/
|
||||
/* grid-template-columns: repeat(9, 1fr);*/
|
||||
/* grid-template-rows: repeat(9, 1fr);*/
|
||||
/* width: 100%;*/
|
||||
/* border: 3px solid #333;*/
|
||||
/* aspect-ratio: 1 / 1;*/
|
||||
/*}*/
|
||||
|
||||
/* 숫자 입력 버튼 */
|
||||
#number-input-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
gap: 1%;
|
||||
}
|
||||
/*.cell {*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* font-size: clamp(1em, 4vw, 1.8em);*/
|
||||
/* font-weight: bold;*/
|
||||
/* color: #333;*/
|
||||
/* border: 1px solid #ddd;*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* cursor: pointer;*/
|
||||
/*}*/
|
||||
|
||||
#number-input-buttons .num-btn,
|
||||
#number-input-buttons #undo-btn {
|
||||
line-height: unset;
|
||||
min-width: unset;
|
||||
width: 9%;
|
||||
aspect-ratio: 1/1;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
/*.cell:nth-child(3n) { border-right: 2px solid #333; }*/
|
||||
/*.cell:nth-child(9n) { border-right-width: 1px; }*/
|
||||
/*.cell:nth-child(n+19):nth-child(-n+27),*/
|
||||
/*.cell:nth-child(n+46):nth-child(-n+54) {*/
|
||||
/* border-bottom: 2px solid #333;*/
|
||||
/*}*/
|
||||
|
||||
#number-input-buttons .num-btn.selected {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
/*.cell:not(.editable) {*/
|
||||
/* background-color: #f0f0f0;*/
|
||||
/* color: #222;*/
|
||||
/* cursor: default;*/
|
||||
/*}*/
|
||||
|
||||
#number-input-buttons .num-btn.completed {
|
||||
opacity: 0.4;
|
||||
background-color: #e9ecef;
|
||||
pointer-events: none;
|
||||
}
|
||||
/*!* 하이라이트 & 오답 스타일 *!*/
|
||||
/*.cell.incorrect {*/
|
||||
/* background-color: #ffdddd !important;*/
|
||||
/* color: #d8000c !important;*/
|
||||
/*}*/
|
||||
/*.highlight-focused {*/
|
||||
/* background-color: #dbeeff !important;*/
|
||||
/*}*/
|
||||
/*.highlight-same-number {*/
|
||||
/* background-color: #e6e6e6 !important;*/
|
||||
/*}*/
|
||||
/*.highlight-selected-number {*/
|
||||
/* background-color: #b3d7ff !important;*/
|
||||
/*}*/
|
||||
|
||||
#number-input-buttons #undo-btn {
|
||||
background-color: #f8f9fa;
|
||||
color: #dc3545;
|
||||
}
|
||||
/*!* 숫자 입력 버튼 *!*/
|
||||
/*#number-input-buttons {*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: space-between;*/
|
||||
/* width: 100%;*/
|
||||
/* margin-top: 15px;*/
|
||||
/* gap: 1%;*/
|
||||
/*}*/
|
||||
|
||||
/* 액션 버튼 (힌트, 정답확인) */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.action-buttons button {
|
||||
flex-grow: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
/*#number-input-buttons .num-btn,*/
|
||||
/*#number-input-buttons #undo-btn {*/
|
||||
/* line-height: unset;*/
|
||||
/* min-width: unset;*/
|
||||
/* width: 9%;*/
|
||||
/* aspect-ratio: 1/1;*/
|
||||
/* font-size: clamp(1em, 4vw, 1.8em);*/
|
||||
/* font-weight: bold;*/
|
||||
/* border-radius: 8px;*/
|
||||
/* background-color: #f0f0f0;*/
|
||||
/* color: #333;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* cursor: pointer;*/
|
||||
/* padding: 0;*/
|
||||
/* transition: background-color 0.2s, transform 0.1s, opacity 0.2s;*/
|
||||
/*}*/
|
||||
|
||||
/* 모달 및 숨김 처리 */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
#modal-overlay, #game-over-modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
#modal-content h2, #modal-content h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#username-input {
|
||||
width: calc(100% - 24px);
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: decimal;
|
||||
list-style-position: inside;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#ranking-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#ranking-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/*#number-input-buttons .num-btn.selected {*/
|
||||
/* background-color: #007bff;*/
|
||||
/* color: white;*/
|
||||
/* border-color: #007bff;*/
|
||||
/*}*/
|
||||
|
||||
/*#number-input-buttons .num-btn.completed {*/
|
||||
/* opacity: 0.4;*/
|
||||
/* background-color: #e9ecef;*/
|
||||
/* pointer-events: none;*/
|
||||
/*}*/
|
||||
|
||||
/*#number-input-buttons #undo-btn {*/
|
||||
/* background-color: #f8f9fa;*/
|
||||
/* color: #dc3545;*/
|
||||
/*}*/
|
||||
|
||||
/*!* 액션 버튼 (힌트, 정답확인) *!*/
|
||||
/*.action-buttons {*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* gap: 10px;*/
|
||||
/* margin-top: 15px;*/
|
||||
/* width: 100%;*/
|
||||
/*}*/
|
||||
/*.action-buttons button {*/
|
||||
/* flex-grow: 1;*/
|
||||
/* max-width: 200px;*/
|
||||
/*}*/
|
||||
|
||||
/*!* 모달 및 숨김 처리 *!*/
|
||||
/*.hidden {*/
|
||||
/* display: none !important;*/
|
||||
/*}*/
|
||||
/*#modal-overlay, #game-over-modal {*/
|
||||
/* position: fixed;*/
|
||||
/* top: 0; left: 0;*/
|
||||
/* width: 100%; height: 100%;*/
|
||||
/* background-color: rgba(0,0,0,0.7);*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* z-index: 1000;*/
|
||||
/*}*/
|
||||
/*#modal-content {*/
|
||||
/* background: white;*/
|
||||
/* padding: 30px;*/
|
||||
/* border-radius: 10px;*/
|
||||
/* text-align: center;*/
|
||||
/* width: 90%;*/
|
||||
/* max-width: 400px;*/
|
||||
/* box-shadow: 0 8px 20px rgba(0,0,0,0.2);*/
|
||||
/*}*/
|
||||
/*#modal-content h2, #modal-content h3 {*/
|
||||
/* color: #333;*/
|
||||
/* margin-bottom: 15px;*/
|
||||
/*}*/
|
||||
/*#username-input {*/
|
||||
/* width: calc(100% - 24px);*/
|
||||
/* padding: 10px;*/
|
||||
/* margin-bottom: 15px;*/
|
||||
/* border: 1px solid #ccc;*/
|
||||
/* border-radius: 5px;*/
|
||||
/* font-size: 1em;*/
|
||||
/*}*/
|
||||
/*#ranking-list {*/
|
||||
/* list-style-type: decimal;*/
|
||||
/* list-style-position: inside;*/
|
||||
/* padding: 0;*/
|
||||
/* text-align: left;*/
|
||||
/* margin-top: 20px;*/
|
||||
/*}*/
|
||||
/*#ranking-list li {*/
|
||||
/* padding: 8px 0;*/
|
||||
/* border-bottom: 1px solid #eee;*/
|
||||
/* display: flex;*/
|
||||
/* justify-content: space-between;*/
|
||||
/* align-items: center;*/
|
||||
/*}*/
|
||||
/*#ranking-list li:last-child {*/
|
||||
/* border-bottom: none;*/
|
||||
/*}*/
|
||||
@ -1,233 +1,238 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM 요소 가져오기
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
const scoreDisplay = document.getElementById('score');
|
||||
const gameOverPopup = document.getElementById('game-over-popup');
|
||||
const finalScoreDisplay = document.getElementById('final-score');
|
||||
const playerNameInput = document.getElementById('player-name');
|
||||
const saveScoreButton = document.getElementById('save-score');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
|
||||
// 게임 설정 및 변수
|
||||
const currentGameId = '2048';
|
||||
const gridSize = 4;
|
||||
let board = [];
|
||||
let score = 0;
|
||||
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
|
||||
|
||||
// ----- 게임 핵심 로직 -----
|
||||
function initializeBoard() {
|
||||
gameBoard.innerHTML = ''; // 기존 타일 초기화
|
||||
for (let i = 0; i < gridSize * gridSize; i++) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
gameBoard.appendChild(tile);
|
||||
}
|
||||
board = Array(gridSize * gridSize).fill(0);
|
||||
addNumber();
|
||||
addNumber();
|
||||
updateBoard();
|
||||
}
|
||||
|
||||
function updateBoard() {
|
||||
const tiles = gameBoard.children;
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
const value = board[i];
|
||||
const tile = tiles[i];
|
||||
tile.textContent = value === 0 ? '' : value;
|
||||
tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
|
||||
}
|
||||
scoreDisplay.textContent = score;
|
||||
}
|
||||
|
||||
function addNumber() {
|
||||
const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
|
||||
if (available.length > 0) {
|
||||
const spot = available[Math.floor(Math.random() * available.length)];
|
||||
board[spot] = Math.random() < 0.9 ? 2 : 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 타일 이동 및 병합 로직 -----
|
||||
function moveRow(row) {
|
||||
let arr = row.filter(val => val);
|
||||
for (let i = 0; i < arr.length - 1; i++) {
|
||||
if (arr[i] === arr[i + 1]) {
|
||||
arr[i] *= 2;
|
||||
score += arr[i];
|
||||
arr[i + 1] = 0;
|
||||
}
|
||||
}
|
||||
arr = arr.filter(val => val);
|
||||
const missing = gridSize - arr.length;
|
||||
const zeros = Array(missing).fill(0);
|
||||
return arr.concat(zeros);
|
||||
}
|
||||
|
||||
function moveLeft() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize);
|
||||
const newRow = moveRow(row);
|
||||
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveRight() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize).reverse();
|
||||
const newRow = moveRow(row).reverse();
|
||||
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
|
||||
const newCol = moveRow(col);
|
||||
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
|
||||
const newCol = moveRow(col).reverse();
|
||||
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ----- 게임 상태 관리 -----
|
||||
function isGameOver() {
|
||||
if (!board.includes(0)) {
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
const current = board[i * gridSize + j];
|
||||
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
|
||||
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMove(moveFunction) {
|
||||
if (moveFunction()) {
|
||||
addNumber();
|
||||
updateBoard();
|
||||
if (isGameOver()) {
|
||||
finalScoreDisplay.textContent = score;
|
||||
gameOverPopup.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 이벤트 리스너 -----
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': handleMove(moveUp); break;
|
||||
case 'ArrowDown': handleMove(moveDown); break;
|
||||
case 'ArrowLeft': handleMove(moveLeft); break;
|
||||
case 'ArrowRight': handleMove(moveRight); break;
|
||||
}
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||
|
||||
gameBoard.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
});
|
||||
|
||||
function handleSwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
const swipeThreshold = 30;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (Math.abs(deltaX) > swipeThreshold) {
|
||||
handleMove(deltaX > 0 ? moveRight : moveLeft);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(deltaY) > swipeThreshold) {
|
||||
handleMove(deltaY > 0 ? moveDown : moveUp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 랭킹 API 연동 -----
|
||||
saveScoreButton.addEventListener('click', async () => {
|
||||
const playerName = playerNameInput.value.trim();
|
||||
if (playerName === "") return alert("이름을 입력해주세요.");
|
||||
|
||||
const newScore = { gameId: currentGameId, name: playerName, score: score };
|
||||
try {
|
||||
const response = await fetch(getMainPath() + '/rank/ranks', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(newScore),
|
||||
});
|
||||
if (!response.ok) throw new Error('점수 저장 실패');
|
||||
|
||||
gameOverPopup.style.display = 'none';
|
||||
playerNameInput.value = '';
|
||||
score = 0; // 점수 초기화
|
||||
updateRankingList();
|
||||
initializeBoard();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('서버와 통신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
async function updateRankingList() {
|
||||
rankingList.innerHTML = '';
|
||||
try {
|
||||
const response = await fetch(getMainPath() +`/rank/ranks/${currentGameId}`);
|
||||
if (!response.ok) throw new Error('랭킹 로딩 실패');
|
||||
const rankings = await response.json();
|
||||
if (rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
|
||||
return;
|
||||
}
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span>${index + 1}. ${rank.name}</span><strong>${rank.score}점</strong>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 게임 시작 -----
|
||||
updateRankingList();
|
||||
initializeBoard();
|
||||
});
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// // ... (DOM 요소 가져오기 - 동일)
|
||||
// const gameBoard = document.getElementById('game-board');
|
||||
// const scoreDisplay = document.getElementById('score');
|
||||
// const gameOverPopup = document.getElementById('game-over-popup');
|
||||
// const finalScoreDisplay = document.getElementById('final-score');
|
||||
// const playerNameInput = document.getElementById('player-name');
|
||||
// const saveScoreButton = document.getElementById('save-score');
|
||||
// const rankingList = document.getElementById('ranking-list');
|
||||
//
|
||||
// // (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
|
||||
// const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
|
||||
// const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
|
||||
//
|
||||
// let gridSize = 4;
|
||||
// let board = [];
|
||||
// let score = 0;
|
||||
// let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
|
||||
//
|
||||
// // ----- 게임 핵심 로직 -----
|
||||
// function initializeBoard() {
|
||||
// gameBoard.innerHTML = ''; // 기존 타일 초기화
|
||||
// for (let i = 0; i < gridSize * gridSize; i++) {
|
||||
// const tile = document.createElement('div');
|
||||
// tile.className = 'tile';
|
||||
// gameBoard.appendChild(tile);
|
||||
// }
|
||||
// board = Array(gridSize * gridSize).fill(0);
|
||||
// addNumber();
|
||||
// addNumber();
|
||||
// updateBoard();
|
||||
// }
|
||||
//
|
||||
// function updateBoard() {
|
||||
// const tiles = gameBoard.children;
|
||||
// for (let i = 0; i < board.length; i++) {
|
||||
// const value = board[i];
|
||||
// const tile = tiles[i];
|
||||
// tile.textContent = value === 0 ? '' : value;
|
||||
// tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
|
||||
// }
|
||||
// scoreDisplay.textContent = score;
|
||||
// }
|
||||
//
|
||||
// function addNumber() {
|
||||
// const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
|
||||
// if (available.length > 0) {
|
||||
// const spot = available[Math.floor(Math.random() * available.length)];
|
||||
// board[spot] = Math.random() < 0.9 ? 2 : 4;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // ----- 타일 이동 및 병합 로직 -----
|
||||
// function moveRow(row) {
|
||||
// let arr = row.filter(val => val);
|
||||
// for (let i = 0; i < arr.length - 1; i++) {
|
||||
// if (arr[i] === arr[i + 1]) {
|
||||
// arr[i] *= 2;
|
||||
// score += arr[i];
|
||||
// arr[i + 1] = 0;
|
||||
// }
|
||||
// }
|
||||
// arr = arr.filter(val => val);
|
||||
// const missing = gridSize - arr.length;
|
||||
// const zeros = Array(missing).fill(0);
|
||||
// return arr.concat(zeros);
|
||||
// }
|
||||
//
|
||||
// function moveLeft() {
|
||||
// let changed = false;
|
||||
// for (let i = 0; i < gridSize; i++) {
|
||||
// const rowStart = i * gridSize;
|
||||
// const row = board.slice(rowStart, rowStart + gridSize);
|
||||
// const newRow = moveRow(row);
|
||||
// if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
|
||||
// board.splice(rowStart, gridSize, ...newRow);
|
||||
// }
|
||||
// return changed;
|
||||
// }
|
||||
//
|
||||
// function moveRight() {
|
||||
// let changed = false;
|
||||
// for (let i = 0; i < gridSize; i++) {
|
||||
// const rowStart = i * gridSize;
|
||||
// const row = board.slice(rowStart, rowStart + gridSize).reverse();
|
||||
// const newRow = moveRow(row).reverse();
|
||||
// if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
|
||||
// board.splice(rowStart, gridSize, ...newRow);
|
||||
// }
|
||||
// return changed;
|
||||
// }
|
||||
//
|
||||
// function moveUp() {
|
||||
// let changed = false;
|
||||
// for (let i = 0; i < gridSize; i++) {
|
||||
// const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
|
||||
// const newCol = moveRow(col);
|
||||
// if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
|
||||
// for (let j = 0; j < gridSize; j++) {
|
||||
// board[i + j * gridSize] = newCol[j];
|
||||
// }
|
||||
// }
|
||||
// return changed;
|
||||
// }
|
||||
//
|
||||
// function moveDown() {
|
||||
// let changed = false;
|
||||
// for (let i = 0; i < gridSize; i++) {
|
||||
// const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
|
||||
// const newCol = moveRow(col).reverse();
|
||||
// if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
|
||||
// for (let j = 0; j < gridSize; j++) {
|
||||
// board[i + j * gridSize] = newCol[j];
|
||||
// }
|
||||
// }
|
||||
// return changed;
|
||||
// }
|
||||
//
|
||||
// // ----- 게임 상태 관리 -----
|
||||
// function isGameOver() {
|
||||
// if (!board.includes(0)) {
|
||||
// for (let i = 0; i < gridSize; i++) {
|
||||
// for (let j = 0; j < gridSize; j++) {
|
||||
// const current = board[i * gridSize + j];
|
||||
// if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
|
||||
// (i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
//
|
||||
// function handleMove(moveFunction) {
|
||||
// if (moveFunction()) {
|
||||
// addNumber();
|
||||
// updateBoard();
|
||||
// if (isGameOver()) {
|
||||
// finalScoreDisplay.textContent = score;
|
||||
// gameOverPopup.style.display = 'flex';
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // ----- 이벤트 리스너 -----
|
||||
// document.addEventListener('keydown', (e) => {
|
||||
// switch (e.key) {
|
||||
// case 'ArrowUp': handleMove(moveUp); break;
|
||||
// case 'ArrowDown': handleMove(moveDown); break;
|
||||
// case 'ArrowLeft': handleMove(moveLeft); break;
|
||||
// case 'ArrowRight': handleMove(moveRight); break;
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// gameBoard.addEventListener('touchstart', (e) => {
|
||||
// touchStartX = e.changedTouches[0].screenX;
|
||||
// touchStartY = e.changedTouches[0].screenY;
|
||||
// });
|
||||
//
|
||||
// gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||
//
|
||||
// gameBoard.addEventListener('touchend', (e) => {
|
||||
// touchEndX = e.changedTouches[0].screenX;
|
||||
// touchEndY = e.changedTouches[0].screenY;
|
||||
// handleSwipe();
|
||||
// });
|
||||
//
|
||||
// function handleSwipe() {
|
||||
// const deltaX = touchEndX - touchStartX;
|
||||
// const deltaY = touchEndY - touchStartY;
|
||||
// const swipeThreshold = 30;
|
||||
//
|
||||
// if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
// if (Math.abs(deltaX) > swipeThreshold) {
|
||||
// handleMove(deltaX > 0 ? moveRight : moveLeft);
|
||||
// }
|
||||
// } else {
|
||||
// if (Math.abs(deltaY) > swipeThreshold) {
|
||||
// handleMove(deltaY > 0 ? moveDown : moveUp);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // ----- 랭킹 API 연동 -----
|
||||
// saveScoreButton.addEventListener('click', async () => {
|
||||
// const playerName = playerNameInput.value.trim();
|
||||
// if (playerName === "") return alert("이름을 입력해주세요.");
|
||||
//
|
||||
// try {
|
||||
// // (★ 수정) user.js의 공통 submitRank 함수 호출
|
||||
// // 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
|
||||
// await submitRank(currentGameType, currentContextId, playerName, score, null);
|
||||
//
|
||||
// gameOverPopup.style.display = 'none';
|
||||
// playerNameInput.value = '';
|
||||
// score = 0;
|
||||
// updateRankingList(); // 랭킹 리스트 새로고침
|
||||
// initializeBoard(); // 새 게임 시작
|
||||
//
|
||||
// } catch (error) {
|
||||
// console.error('Error submitting rank:', error);
|
||||
// alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// /**
|
||||
// * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
// */
|
||||
// async function updateRankingList() {
|
||||
// rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
// try {
|
||||
// // (★ 수정) user.js의 공통 fetchRanks 함수 호출
|
||||
// const rankings = await fetchRanks(currentGameType, currentContextId);
|
||||
//
|
||||
// rankingList.innerHTML = ''; // 리스트 비우기
|
||||
// if (!rankings || rankings.length === 0) {
|
||||
// rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// rankings.forEach((rank, index) => {
|
||||
// const li = document.createElement('li');
|
||||
// // (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
|
||||
// li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
|
||||
// rankingList.appendChild(li);
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Error fetching ranks:', error);
|
||||
// rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // ----- 게임 시작 -----
|
||||
// updateRankingList();
|
||||
// initializeBoard();
|
||||
// });
|
||||
File diff suppressed because it is too large
Load Diff
694
src/main/resources/static/js/nonogram.js
Normal file
694
src/main/resources/static/js/nonogram.js
Normal file
@ -0,0 +1,694 @@
|
||||
// /**
|
||||
// * ==============================================
|
||||
// * nonogram.js (게임 플레이 로직)
|
||||
// * (★ 리팩토링: 타이머 및 랭킹 등록 기능 추가)
|
||||
// * ==============================================
|
||||
// */
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// // 백엔드(Thymeleaf)로부터 puzzleData가 제대로 전달되었는지 확인
|
||||
// // 이 변수는 nonogram.html에만 존재하므로, upload.html에서는 이 로직이 실행되지 않음.
|
||||
// if (typeof puzzleData === 'undefined' || !puzzleData) {
|
||||
// // game-board가 없는 upload.html에서는 오류를 뱉지 않고 조용히 종료됨.
|
||||
// const gb = document.getElementById('game-board');
|
||||
// if (gb) {
|
||||
// gb.innerHTML = '<h2>오류: 퍼즐 데이터를 불러올 수 없습니다.</h2>';
|
||||
// }
|
||||
// return; // upload.html에서는 여기서 즉시 return됨.
|
||||
// }
|
||||
//
|
||||
// // --- DOM 요소 참조 (게임 페이지 전용) ---
|
||||
// const modeSelector = document.getElementById('mode-selector');
|
||||
// const gameBoard = document.getElementById('game-board');
|
||||
// const pointsDisplay = document.getElementById('points-display');
|
||||
// const hintBtn = document.getElementById('hint-btn');
|
||||
// const resultOverlay = document.getElementById('result-overlay');
|
||||
// const modalTitle = document.getElementById('modal-title');
|
||||
// const modalMessage = document.getElementById('modal-message');
|
||||
// const modalButtons = document.getElementById('modal-buttons');
|
||||
//
|
||||
//
|
||||
// // --- (★ 수정) 게임 상태 변수 (타이머 추가) ---
|
||||
// let currentMode = 'fill';
|
||||
// let points = 5;
|
||||
// let isGameFinished = false;
|
||||
// let gameStartTime = 0; // (★ 신규) 게임 시작 시간 (ms)
|
||||
//
|
||||
// let isDragging = false;
|
||||
// let dragAction = null;
|
||||
// let startCell = null;
|
||||
// let lastHoveredCell = null;
|
||||
// let currentSelection = new Set();
|
||||
// let affectedRows = new Set();
|
||||
// let affectedCols = new Set();
|
||||
//
|
||||
// // --- 퍼즐 데이터 및 플레이어 진행 상황 ---
|
||||
// const solution = puzzleData.solutionGrid;
|
||||
// const numRows = solution.length;
|
||||
// const numCols = solution[0].length;
|
||||
// let playerGrid = Array(numRows).fill(0).map(() => Array(numCols).fill(0));
|
||||
// let lockedRows = Array(numRows).fill(false);
|
||||
// let lockedCols = Array(numCols).fill(false);
|
||||
//
|
||||
//
|
||||
// function updateMode() {
|
||||
// currentMode = document.querySelector('input[name="play-mode"]:checked').value;
|
||||
// }
|
||||
//
|
||||
// function calculateCellSize() {
|
||||
// // ... (셀 크기 계산 로직 - 수정 없음) ...
|
||||
// const tempContainer = document.createElement('div');
|
||||
// tempContainer.style.position = 'absolute';
|
||||
// tempContainer.style.visibility = 'hidden';
|
||||
// const tempCell = document.createElement('div');
|
||||
// tempCell.className = 'clue-cell';
|
||||
// tempCell.textContent = '0';
|
||||
// tempContainer.appendChild(tempCell);
|
||||
// document.body.appendChild(tempContainer);
|
||||
// const fontHeight = tempCell.offsetHeight;
|
||||
// tempCell.textContent = '10';
|
||||
// const doubleDigitWidth = tempCell.offsetWidth;
|
||||
// document.body.removeChild(tempContainer);
|
||||
// const baseSize = Math.max(fontHeight, doubleDigitWidth, 30);
|
||||
// return baseSize + 10;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * (★ 수정) drawBoard (타이머 시작점 추가)
|
||||
// */
|
||||
// function drawBoard(cellSize) {
|
||||
// // ... (모든 보드 그리기 DOM 생성 로직 - 수정 없음) ...
|
||||
// gameBoard.style.gridTemplateColumns = `${cellSize * 2}px 1fr`;
|
||||
// gameBoard.style.gridTemplateRows = `${cellSize * 2}px 1fr`;
|
||||
// const corner = document.createElement('div');
|
||||
// const colCluesContainer = document.createElement('div');
|
||||
// colCluesContainer.className = 'col-clues-container';
|
||||
// const rowCluesContainer = document.createElement('div');
|
||||
// rowCluesContainer.className = 'row-clues-container';
|
||||
// const puzzleGridContainer = document.createElement('div');
|
||||
// puzzleGridContainer.className = 'puzzle-grid-container';
|
||||
// puzzleGridContainer.style.gridTemplateColumns = `repeat(${numCols}, ${cellSize}px)`;
|
||||
// puzzleGridContainer.style.gridTemplateRows = `repeat(${numRows}, ${cellSize}px)`;
|
||||
//
|
||||
// puzzleData.colClues.forEach((clues, index) => {
|
||||
// const clueCell = document.createElement('div');
|
||||
// clueCell.className = 'clue-cell col-clue';
|
||||
// clueCell.id = `col-clue-${index}`;
|
||||
// clueCell.style.width = `${cellSize}px`;
|
||||
// clueCell.innerHTML = clues.join('<br>');
|
||||
// if ((index + 1) % 5 === 0 && index < numCols - 1) clueCell.classList.add('guide-line-right');
|
||||
// colCluesContainer.appendChild(clueCell);
|
||||
// });
|
||||
// puzzleData.rowClues.forEach((clues, index) => {
|
||||
// const clueCell = document.createElement('div');
|
||||
// clueCell.className = 'clue-cell row-clue';
|
||||
// clueCell.id = `row-clue-${index}`;
|
||||
// clueCell.style.height = `${cellSize}px`;
|
||||
// clueCell.textContent = clues.join(' ');
|
||||
// if ((index + 1) % 5 === 0 && index < numRows - 1) clueCell.classList.add('guide-line-bottom');
|
||||
// rowCluesContainer.appendChild(clueCell);
|
||||
// });
|
||||
// for (let r = 0; r < numRows; r++) {
|
||||
// for (let c = 0; c < numCols; c++) {
|
||||
// const cell = document.createElement('div');
|
||||
// cell.className = 'grid-cell';
|
||||
// cell.dataset.row = r;
|
||||
// cell.dataset.col = c;
|
||||
// if ((c + 1) % 5 === 0 && c < numCols - 1) cell.classList.add('guide-line-right');
|
||||
// if ((r + 1) % 5 === 0 && r < numRows - 1) cell.classList.add('guide-line-bottom');
|
||||
// puzzleGridContainer.appendChild(cell);
|
||||
// }
|
||||
// }
|
||||
// gameBoard.appendChild(corner);
|
||||
// gameBoard.appendChild(colCluesContainer);
|
||||
// gameBoard.appendChild(rowCluesContainer);
|
||||
// gameBoard.appendChild(puzzleGridContainer);
|
||||
//
|
||||
// // (★ 신규) 보드가 그려지는 시점을 게임 시작 시간으로 기록
|
||||
// gameStartTime = Date.now();
|
||||
//
|
||||
// attachEventListeners(puzzleGridContainer);
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /**
|
||||
// * 화면 너비에 맞춰 게임 보드 전체를 비율 그대로 축소/확대합니다.
|
||||
// */
|
||||
// function fitBoardToScreen() {
|
||||
// const viewport = document.getElementById('board-viewport');
|
||||
// const board = document.getElementById('game-board');
|
||||
// board.style.transform = 'scale(1)';
|
||||
// const boardRect = board.getBoundingClientRect();
|
||||
// const viewportRect = viewport.getBoundingClientRect();
|
||||
// if (boardRect.width > viewportRect.width) {
|
||||
// const scale = viewportRect.width / boardRect.width;
|
||||
// board.style.transform = `scale(${scale})`;
|
||||
// viewport.style.height = `${boardRect.height * scale}px`;
|
||||
// } else {
|
||||
// board.style.transform = 'scale(1)';
|
||||
// viewport.style.height = `${boardRect.height}px`;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /**
|
||||
// * 셀의 상태를 변경하는 유일한 함수. 모든 사용자 입력은 이 함수를 거칩니다.
|
||||
// */
|
||||
// function updateCellState(cell, action) {
|
||||
// if (isGameFinished) return;
|
||||
// const row = parseInt(cell.dataset.row);
|
||||
// const col = parseInt(cell.dataset.col);
|
||||
// if (lockedRows[row] || lockedCols[col]) return;
|
||||
// affectedRows.add(row);
|
||||
// affectedCols.add(col);
|
||||
// const currentState = playerGrid[row][col];
|
||||
// let newState = currentState;
|
||||
// if (action === 'fill') {
|
||||
// if (solution[row][col] === 0) {
|
||||
// points--;
|
||||
// updatePointsDisplay();
|
||||
// cell.classList.add('incorrect');
|
||||
// setTimeout(() => cell.classList.remove('incorrect'), 500);
|
||||
// if (points <= 0) triggerGameOver();
|
||||
// return;
|
||||
// }
|
||||
// newState = 1;
|
||||
// } else if (action === 'mark') {
|
||||
// newState = -1;
|
||||
// } else if (action === 'clear') {
|
||||
// newState = 0;
|
||||
// }
|
||||
// if (currentState !== newState) {
|
||||
// playerGrid[row][col] = newState;
|
||||
// cell.classList.toggle('filled', newState === 1);
|
||||
// cell.classList.toggle('marked', newState === -1);
|
||||
// }
|
||||
// }
|
||||
// // --- (이벤트 리스너 및 드래그/터치 핸들러) ---
|
||||
// // (★ 수정 없음) attachEventListeners, handleDragStart, handleDragMove, handleDragEnd
|
||||
// // (★ 수정 없음) updateSelectionVisuals, clearSelectionVisuals
|
||||
// // --- (모두 동일하게 유지) ---
|
||||
// function attachEventListeners(grid) {
|
||||
// grid.addEventListener('mousedown', (e) => handleDragStart(e));
|
||||
// grid.addEventListener('mouseover', (e) => handleDragMove(e));
|
||||
// grid.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
// grid.addEventListener('touchstart', (e) => handleDragStart(e), { passive: false });
|
||||
// grid.addEventListener('touchmove', (e) => handleDragMove(e), { passive: false });
|
||||
// }
|
||||
// window.addEventListener('mouseup', () => handleDragEnd());
|
||||
// window.addEventListener('touchend', () => handleDragEnd());
|
||||
// modeSelector.addEventListener('change', updateMode);
|
||||
// function handleDragStart(e) {
|
||||
// if (isGameFinished || !e.target.classList.contains('grid-cell')) return;
|
||||
// isDragging = true;
|
||||
// e.preventDefault();
|
||||
// const cell = e.target;
|
||||
// const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
|
||||
// if (e.type === 'mousedown') {
|
||||
// if (e.button === 0) {
|
||||
// dragAction = (currentState === 1) ? 'clear' : 'fill';
|
||||
// document.querySelector('input[name="play-mode"][value="fill"]').checked = true;
|
||||
// } else if (e.button === 2) {
|
||||
// dragAction = (currentState === -1) ? 'clear' : 'mark';
|
||||
// document.querySelector('input[name="play-mode"][value="mark"]').checked = true;
|
||||
// }
|
||||
// } else {
|
||||
// const currentMode = document.querySelector('input[name="play-mode"]:checked').value;
|
||||
// if (currentMode === 'fill') {
|
||||
// dragAction = (currentState === 1) ? 'clear' : 'fill';
|
||||
// } else {
|
||||
// dragAction = (currentState === -1) ? 'clear' : 'mark';
|
||||
// }
|
||||
// }
|
||||
// startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
|
||||
// lastHoveredCell = startCell;
|
||||
// updateSelectionVisuals();
|
||||
// }
|
||||
// function handleDragMove(e) {
|
||||
// if (!isDragging) return;
|
||||
// e.preventDefault();
|
||||
// const target = (e.touches)
|
||||
// ? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)
|
||||
// : e.target;
|
||||
// if (target && target.classList.contains('grid-cell')) {
|
||||
// const row = parseInt(target.dataset.row);
|
||||
// const col = parseInt(target.dataset.col);
|
||||
// if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) {
|
||||
// lastHoveredCell = { row, col };
|
||||
// updateSelectionVisuals();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// function handleDragEnd() {
|
||||
// if (!isDragging) return;
|
||||
// currentSelection.forEach(cell => updateCellState(cell, dragAction));
|
||||
// clearSelectionVisuals();
|
||||
// if (dragAction === 'fill' || dragAction === 'clear') {
|
||||
// checkAndLockCompletedLines(affectedRows, affectedCols);
|
||||
// }
|
||||
// checkWinCondition();
|
||||
// isDragging = false;
|
||||
// dragAction = null;
|
||||
// startCell = null;
|
||||
// lastHoveredCell = null;
|
||||
// currentSelection.clear();
|
||||
// affectedRows.clear();
|
||||
// affectedCols.clear();
|
||||
// }
|
||||
// /**
|
||||
// * 드래그 중인 사각형 영역을 계산하고 시각적으로 업데이트하는 함수
|
||||
// */
|
||||
// function updateSelectionVisuals() {
|
||||
// const newSelection = new Set();
|
||||
// if (!startCell || !lastHoveredCell) return;
|
||||
// const r1 = Math.min(startCell.row, lastHoveredCell.row);
|
||||
// const r2 = Math.max(startCell.row, lastHoveredCell.row);
|
||||
// const c1 = Math.min(startCell.col, lastHoveredCell.col);
|
||||
// const c2 = Math.max(startCell.col, lastHoveredCell.col);
|
||||
// for (let r = r1; r <= r2; r++) {
|
||||
// for (let c = c1; c <= c2; c++) {
|
||||
// const cell = document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`);
|
||||
// if (cell) newSelection.add(cell);
|
||||
// }
|
||||
// }
|
||||
// currentSelection.forEach(cell => {
|
||||
// if (!newSelection.has(cell)) cell.classList.remove('selecting');
|
||||
// });
|
||||
// newSelection.forEach(cell => {
|
||||
// if (!currentSelection.has(cell)) cell.classList.add('selecting');
|
||||
// });
|
||||
// currentSelection = newSelection;
|
||||
// }
|
||||
// /**
|
||||
// * 모든 시각적 피드백을 제거하는 함수
|
||||
// */
|
||||
// function clearSelectionVisuals() {
|
||||
// currentSelection.forEach(cell => cell.classList.remove('selecting'));
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 특정 행이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||
// */
|
||||
// function isRowComplete(rowIndex) {
|
||||
// for (let c = 0; c < numCols; c++) {
|
||||
// if (solution[rowIndex][c] === 1 && playerGrid[rowIndex][c] !== 1) return false;
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// /**
|
||||
// * 특정 열이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||
// */
|
||||
// function isColComplete(colIndex) {
|
||||
// for (let r = 0; r < numRows; r++) {
|
||||
// if (solution[r][colIndex] === 1 && playerGrid[r][colIndex] !== 1) return false;
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
//
|
||||
// // --- (게임 완료 체크 로직) ---
|
||||
// /**
|
||||
// * 완성된 라인을 확인하고 잠금 처리 및 스타일 변경 (X 자동 완성 없음)
|
||||
// */
|
||||
// function checkAndLockCompletedLines(rowsToCheck, colsToCheck) {
|
||||
// rowsToCheck.forEach(r => {
|
||||
// if (!lockedRows[r] && isRowComplete(r)) {
|
||||
// lockedRows[r] = true;
|
||||
// document.getElementById(`row-clue-${r}`).classList.add('completed');
|
||||
// for (let c = 0; c < numCols; c++) {
|
||||
// document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// colsToCheck.forEach(c => {
|
||||
// if (!lockedCols[c] && isColComplete(c)) {
|
||||
// lockedCols[c] = true;
|
||||
// document.getElementById(`col-clue-${c}`).classList.add('completed');
|
||||
// for (let r = 0; r < numRows; r++) {
|
||||
// document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// function checkWinCondition() {
|
||||
// if (isGameFinished) return;
|
||||
// for (let r = 0; r < numRows; r++) {
|
||||
// for (let c = 0; c < numCols; c++) {
|
||||
// const playerState = (playerGrid[r][c] === 1) ? 1 : 0;
|
||||
// if (playerState !== solution[r][c]) return;
|
||||
// }
|
||||
// }
|
||||
// triggerGameSuccess();
|
||||
// }
|
||||
//
|
||||
//
|
||||
// function updatePointsDisplay() {
|
||||
// pointsDisplay.textContent = points;
|
||||
// hintBtn.disabled = (points <= 0 || isGameFinished);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * (★ 신규) 노노그램 랭킹 등록을 처리하는 함수
|
||||
// * 이 함수는 user.js에 정의된 공통 submitRank 함수를 호출합니다.
|
||||
// */
|
||||
// async function submitNonogramRank(completionTime, hintsUsed) {
|
||||
// const playerName = prompt("랭킹에 등록할 이름을 입력하세요:", "Player");
|
||||
// if (!playerName || playerName.trim() === "") return;
|
||||
//
|
||||
// try {
|
||||
// // (★ 신규) user.js의 공통 submitRank 함수 호출
|
||||
// // 주 점수(primaryScore) = 완료 시간(초) (낮을수록 좋음)
|
||||
// // 보조 점수(secondaryScore) = 사용한 힌트 수(5-남은포인트) (낮을수록 좋음)
|
||||
// await submitRank(
|
||||
// 'NONOGRAM', // GameType
|
||||
// puzzleData.id, // ContextId (퍼즐 고유 ID)
|
||||
// playerName.trim(), // playerName
|
||||
// completionTime, // primaryScore (시간)
|
||||
// hintsUsed // secondaryScore (힌트 사용 횟수)
|
||||
// );
|
||||
//
|
||||
// alert("랭킹이 등록되었습니다!");
|
||||
// // 랭킹 등록 버튼 비활성화 (중복 제출 방지)
|
||||
// const submitBtn = document.getElementById('modal-submit-rank-btn');
|
||||
// if (submitBtn) submitBtn.disabled = true;
|
||||
//
|
||||
// } catch (error) {
|
||||
// console.error("Rank submission failed:", error);
|
||||
// alert("랭킹 등록에 실패했습니다: " + error.message);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * (★ 수정) 성공/실패 모달 (랭킹 등록 버튼 추가를 위해 ID 할당 기능 추가)
|
||||
// */
|
||||
// function showResultModal(config) {
|
||||
// modalTitle.textContent = config.title;
|
||||
// modalMessage.textContent = config.message;
|
||||
// modalButtons.innerHTML = '';
|
||||
// config.buttons.forEach(btnInfo => {
|
||||
// const button = document.createElement('button');
|
||||
// button.textContent = btnInfo.text;
|
||||
// button.className = btnInfo.class || '';
|
||||
// button.onclick = btnInfo.action;
|
||||
// if (btnInfo.id) button.id = btnInfo.id; // (★ 신규) 버튼 ID 할당 기능
|
||||
// modalButtons.appendChild(button);
|
||||
// });
|
||||
// resultOverlay.classList.remove('hidden');
|
||||
// setTimeout(() => resultOverlay.classList.add('visible'), 10);
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /**
|
||||
// * 게임 실패 처리
|
||||
// */
|
||||
// function triggerGameOver() {
|
||||
// if (isGameFinished) return;
|
||||
// isGameFinished = true;
|
||||
// document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
||||
// hintBtn.disabled = true;
|
||||
// showResultModal({
|
||||
// title: 'Failure', message: '포인트를 모두 사용했습니다.', buttons: [
|
||||
// { text: '재시도 (Retry)', class: 'primary', action: () => window.location.reload() },
|
||||
// { text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
||||
// ]
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * (★ 수정) 게임 성공 처리 (타이머 계산 및 랭킹 버튼 추가)
|
||||
// */
|
||||
// function triggerGameSuccess() {
|
||||
// if (isGameFinished) return;
|
||||
// isGameFinished = true;
|
||||
//
|
||||
// // (★ 신규) 게임 완료 시간 및 힌트 사용량 계산
|
||||
// const completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000);
|
||||
// const hintsUsed = 5 - points; // 힌트 사용량 = 5 - 남은 포인트
|
||||
//
|
||||
// // --- 요소 참조 및 상호작용 비활성화 ---
|
||||
// const viewport = document.getElementById('board-viewport');
|
||||
// const puzzleGridContainer = document.querySelector('.puzzle-grid-container');
|
||||
// const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||
// const originalImg = document.getElementById('original-reveal');
|
||||
// puzzleGridContainer.style.pointerEvents = 'none';
|
||||
// hintBtn.disabled = true;
|
||||
//
|
||||
// // --- 애니메이션 위치 및 크기 계산 ---
|
||||
// const gridRect = puzzleGridContainer.getBoundingClientRect();
|
||||
// const viewportRect = viewport.getBoundingClientRect();
|
||||
// const top = gridRect.top - viewportRect.top;
|
||||
// const left = gridRect.left - viewportRect.top; // (오타 수정) viewportRect.top -> viewportRect.left
|
||||
//
|
||||
// [grayscaleImg, originalImg].forEach(img => {
|
||||
// img.style.top = `${top}px`;
|
||||
// img.style.left = `${left}px`;
|
||||
// img.style.width = `${gridRect.width}px`;
|
||||
// img.style.height = `${gridRect.height}px`;
|
||||
// img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage;
|
||||
// });
|
||||
//
|
||||
// // --- 애니메이션 순차 실행 ---
|
||||
// setTimeout(() => {
|
||||
// grayscaleImg.style.opacity = '1';
|
||||
// setTimeout(() => {
|
||||
// originalImg.style.opacity = '1';
|
||||
// setTimeout(() => {
|
||||
// // (★ 수정) 모달 버튼 설정에 "랭킹 등록" 버튼 추가
|
||||
// showResultModal({
|
||||
// title: 'Success! 🎉',
|
||||
// message: `퍼즐을 완성했습니다! (시간: ${completionTimeSeconds}초, 힌트 사용: ${hintsUsed}개)`,
|
||||
// buttons: [
|
||||
// {
|
||||
// text: '랭킹 등록',
|
||||
// class: 'primary',
|
||||
// id: 'modal-submit-rank-btn', // (★ 신규) 랭킹 제출 버튼
|
||||
// action: () => submitNonogramRank(completionTimeSeconds, hintsUsed)
|
||||
// },
|
||||
// { text: '다른 문제 풀기', action: () => window.location.href = '/puzzle/play' },
|
||||
// { text: '홈으로', action: () => window.location.href = '/' }
|
||||
// ]
|
||||
// });
|
||||
// }, 2000);
|
||||
// }, 2000);
|
||||
// }, 500);
|
||||
// }
|
||||
//
|
||||
// // 힌트 버튼 클릭 이벤트 처리
|
||||
// hintBtn.addEventListener('click', () => {
|
||||
// if (points <= 0 || isGameFinished) return;
|
||||
// points--;
|
||||
// updatePointsDisplay();
|
||||
// const hintCandidates = [];
|
||||
// for (let r = 0; r < numRows; r++) {
|
||||
// for (let c = 0; c < numCols; c++) {
|
||||
// if (solution[r][c] === 1 && playerGrid[r][c] !== 1) {
|
||||
// hintCandidates.push({ r, c });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (hintCandidates.length > 0) {
|
||||
// const hint = hintCandidates[Math.floor(Math.random() * hintCandidates.length)];
|
||||
// const cellToReveal = document.querySelector(`.grid-cell[data-row='${hint.r}'][data-col='${hint.c}']`);
|
||||
//
|
||||
// updateCellState(cellToReveal, 'fill');
|
||||
//
|
||||
// const hintAffectedRows = new Set([hint.r]);
|
||||
// const hintAffectedCols = new Set([hint.c]);
|
||||
// checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols);
|
||||
// checkWinCondition();
|
||||
// } else {
|
||||
// alert("더 이상 사용할 힌트가 없습니다!");
|
||||
// points++;
|
||||
// updatePointsDisplay();
|
||||
// }
|
||||
// if (points <= 0 && !isGameFinished) {
|
||||
// triggerGameOver();
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// // --- 초기 실행 ---
|
||||
// const optimalCellSize = calculateCellSize();
|
||||
// drawBoard(optimalCellSize);
|
||||
// updatePointsDisplay();
|
||||
// updateMode();
|
||||
//
|
||||
// requestAnimationFrame(() => {
|
||||
// fitBoardToScreen();
|
||||
// window.addEventListener('resize', fitBoardToScreen);
|
||||
// });
|
||||
// });
|
||||
//
|
||||
//
|
||||
// /**
|
||||
// * ==============================================
|
||||
// * upload.js (업로드 페이지 로직)
|
||||
// * (★ 리팩토링: 통합 API 경로 사용)
|
||||
// * ==============================================
|
||||
// */
|
||||
// let currentPuzzleData = null; // 업로드 성공 시 퍼즐 데이터 저장
|
||||
//
|
||||
// // (★ 수정 없음) 업로드 페이지용 성공 애니메이션 함수
|
||||
// function showSuccessAnimation() {
|
||||
// if (!currentPuzzleData) return;
|
||||
//
|
||||
// const puzzleContainer = document.getElementById('puzzle-container');
|
||||
// const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||
// const originalImg = document.getElementById('original-reveal');
|
||||
//
|
||||
// grayscaleImg.src = currentPuzzleData.grayscaleImage;
|
||||
// originalImg.src = currentPuzzleData.originalImage;
|
||||
//
|
||||
// puzzleContainer.style.transition = 'opacity 0.5s';
|
||||
// puzzleContainer.style.opacity = '0';
|
||||
// grayscaleImg.style.opacity = '1';
|
||||
//
|
||||
// setTimeout(() => {
|
||||
// grayscaleImg.style.opacity = '0';
|
||||
// originalImg.style.opacity = '1';
|
||||
// }, 2000);
|
||||
// }
|
||||
//
|
||||
// // (★ 수정 없음) 업로드 페이지용 퍼즐 미리보기 그리기 함수
|
||||
// function drawPuzzle(puzzleData) {
|
||||
// const container = document.getElementById('puzzle-container');
|
||||
// container.innerHTML = '';
|
||||
//
|
||||
// const { solutionGrid, rowClues, colClues } = puzzleData;
|
||||
// const numRows = solutionGrid.length;
|
||||
// const numCols = solutionGrid[0].length;
|
||||
//
|
||||
// container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`;
|
||||
// container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`;
|
||||
//
|
||||
// // 1. 코너
|
||||
// const corner = document.createElement('div');
|
||||
// corner.className = 'grid-cell';
|
||||
// container.appendChild(corner);
|
||||
//
|
||||
// // 2. 열 힌트
|
||||
// for (const clues of colClues) {
|
||||
// const clueCell = document.createElement('div');
|
||||
// clueCell.className = 'clue-cell';
|
||||
// clueCell.innerHTML = clues.join('<br>');
|
||||
// container.appendChild(clueCell);
|
||||
// }
|
||||
//
|
||||
// // 3. 행 힌트 및 정답 그리드
|
||||
// for (let i = 0; i < numRows; i++) {
|
||||
// const rowClueCell = document.createElement('div');
|
||||
// rowClueCell.className = 'clue-cell';
|
||||
// rowClueCell.textContent = rowClues[i].join(' ');
|
||||
// container.appendChild(rowClueCell);
|
||||
//
|
||||
// for (let j = 0; j < numCols; j++) {
|
||||
// const cell = document.createElement('div');
|
||||
// cell.className = 'solution-cell';
|
||||
// if (solutionGrid[i][j] === 1) {
|
||||
// cell.classList.add('filled');
|
||||
// } else {
|
||||
// cell.classList.add('empty');
|
||||
// }
|
||||
// container.appendChild(cell);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// // upload.js의 DOMContentLoaded 리스너
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
//
|
||||
// const createBtn = document.getElementById('createBtn');
|
||||
//
|
||||
// // createBtn이 없는 nonogram.html(게임 페이지)에서는 이 리스너가 아무것도 실행하지 않음.
|
||||
// if (!createBtn) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // (업로드 페이지 전용 로직)
|
||||
// createBtn.addEventListener('click', async () => {
|
||||
// const uploader = document.getElementById('imageUploader');
|
||||
// const statusDiv = document.getElementById('status');
|
||||
// const puzzleContainer = document.getElementById('puzzle-container');
|
||||
// const testSuccessBtn = document.getElementById('test-success-btn');
|
||||
// const deleteBtn = document.getElementById('delete-btn');
|
||||
// const playBtn = document.getElementById('play-btn');
|
||||
//
|
||||
// if (uploader.files.length === 0) {
|
||||
// statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const imageFile = uploader.files[0];
|
||||
// const formData = new FormData();
|
||||
// formData.append('imageFile', imageFile);
|
||||
//
|
||||
// statusDiv.textContent = '문제를 생성하는 중...';
|
||||
// puzzleContainer.innerHTML = '';
|
||||
//
|
||||
// try {
|
||||
// // (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/upload.bjx 호출
|
||||
// const response = await fetch('/puzzle/upload.bjx', {
|
||||
// method: 'POST',
|
||||
// body: formData,
|
||||
// });
|
||||
//
|
||||
// if (response.ok) {
|
||||
// const puzzleData = await response.json();
|
||||
// statusDiv.textContent = '문제 생성 성공!';
|
||||
// drawPuzzle(puzzleData); // 미리보기 그리기
|
||||
//
|
||||
// currentPuzzleData = puzzleData;
|
||||
// testSuccessBtn.addEventListener('click', showSuccessAnimation);
|
||||
//
|
||||
// testSuccessBtn.style.display = 'inline-block';
|
||||
// deleteBtn.style.display = 'inline-block';
|
||||
// playBtn.style.display = 'inline-block';
|
||||
// } else {
|
||||
// const errorMessage = await response.text();
|
||||
// statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('네트워크 오류:', error);
|
||||
// statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
||||
// }
|
||||
//
|
||||
// deleteBtn.addEventListener('click', async () => {
|
||||
// if (!currentPuzzleData || !currentPuzzleData.id) {
|
||||
// alert('삭제할 퍼즐이 선택되지 않았습니다.');
|
||||
// return;
|
||||
// }
|
||||
// if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
||||
// return;
|
||||
// }
|
||||
// try {
|
||||
// // (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/{id}.bjx 호출
|
||||
// const response = await fetch(`/puzzle/${currentPuzzleData.id}.bjx`, {
|
||||
// method: 'DELETE',
|
||||
// });
|
||||
//
|
||||
// if (response.ok) {
|
||||
// statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.';
|
||||
// puzzleContainer.innerHTML = '';
|
||||
// // (버그 수정) success-animation-container 내부의 img src를 초기화해야 함
|
||||
// document.getElementById('grayscale-reveal').src = "";
|
||||
// document.getElementById('original-reveal').src = "";
|
||||
//
|
||||
// testSuccessBtn.style.display = 'none';
|
||||
// deleteBtn.style.display = 'none';
|
||||
// playBtn.style.display = 'none';
|
||||
// currentPuzzleData = null;
|
||||
// } else {
|
||||
// statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('삭제 중 네트워크 오류:', error);
|
||||
// statusDiv.textContent = '삭제 중 오류가 발생했습니다.';
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// playBtn.addEventListener('click', () => {
|
||||
// if (currentPuzzleData && currentPuzzleData.id) {
|
||||
// // (★ 수정 없음) 이 경로는 PuzzleController의 페이지 서빙 경로와 일치하므로 올바름.
|
||||
// window.location.href = `/puzzle/play/${currentPuzzleData.id}`;
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@ -1,527 +0,0 @@
|
||||
/**
|
||||
* Nonogram Game Logic - 최종 통합 및 수정 완료 버전
|
||||
* * ## 포함된 모든 기능:
|
||||
* 1. 동적 셀 크기 계산 및 가독성을 위한 넓은 힌트 영역
|
||||
* 2. 게임 보드, 힌트, 상호작용 그리드 렌더링
|
||||
* 3. 화면 크기에 맞는 반응형 보드 크기 조절
|
||||
* 4. 사각형 선택 드래그 기능 등 모든 사용자 상호작용 처리
|
||||
* 5. 게임 규칙 (포인트, 힌트, 실수 페널티, 승리/패배 조건)
|
||||
* 6. '칠한 칸' 기준으로만 라인 완성 및 잠금 처리
|
||||
* 7. 사용자가 마지막으로 수정한 라인만 검사하는 최적화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 백엔드(Thymeleaf)로부터 puzzleData가 제대로 전달되었는지 확인
|
||||
if (typeof puzzleData === 'undefined' || !puzzleData) {
|
||||
document.getElementById('game-board').innerHTML = '<h2>오류: 퍼즐 데이터를 불러올 수 없습니다.</h2>';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- DOM 요소 참조 ---
|
||||
const modeSelector = document.getElementById('mode-selector');
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
const pointsDisplay = document.getElementById('points-display');
|
||||
const hintBtn = document.getElementById('hint-btn');
|
||||
const resultOverlay = document.getElementById('result-overlay');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalMessage = document.getElementById('modal-message');
|
||||
const modalButtons = document.getElementById('modal-buttons');
|
||||
|
||||
|
||||
// --- 게임 상태를 관리하는 변수 ---
|
||||
let currentMode = 'fill';
|
||||
let points = 5;
|
||||
let isGameFinished = false;
|
||||
let isDragging = false;
|
||||
let dragAction = null; // 'fill', 'mark', 'clear'
|
||||
let startCell = null; // 드래그 시작 셀 좌표 {row, col}
|
||||
let lastHoveredCell = null; // 마지막으로 마우스가 지나간 셀 좌표
|
||||
let currentSelection = new Set(); // 현재 드래그로 선택된 셀들의 Set
|
||||
let affectedRows = new Set(); // 마지막 행동으로 영향을 받은 행
|
||||
let affectedCols = new Set(); // 마지막 행동으로 영향을 받은 열
|
||||
|
||||
// --- 퍼즐 데이터 및 플레이어 진행 상황 ---
|
||||
const solution = puzzleData.solutionGrid;
|
||||
const numRows = solution.length;
|
||||
const numCols = solution[0].length;
|
||||
let playerGrid = Array(numRows).fill(0).map(() => Array(numCols).fill(0));
|
||||
let lockedRows = Array(numRows).fill(false);
|
||||
let lockedCols = Array(numCols).fill(false);
|
||||
|
||||
/**
|
||||
* Updates the currentMode based on the selected radio button
|
||||
*/
|
||||
function updateMode() {
|
||||
currentMode = document.querySelector('input[name="play-mode"]:checked').value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폰트 높이와 두 자릿수 숫자 너비를 기준으로 최적의 셀 크기를 계산합니다.
|
||||
* @returns {number} 계산된 각 그리드 셀의 크기 (px)
|
||||
*/
|
||||
function calculateCellSize() {
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.visibility = 'hidden';
|
||||
const tempCell = document.createElement('div');
|
||||
tempCell.className = 'clue-cell';
|
||||
tempCell.textContent = '0';
|
||||
tempContainer.appendChild(tempCell);
|
||||
document.body.appendChild(tempContainer);
|
||||
const fontHeight = tempCell.offsetHeight;
|
||||
tempCell.textContent = '10';
|
||||
const doubleDigitWidth = tempCell.offsetWidth;
|
||||
document.body.removeChild(tempContainer);
|
||||
const baseSize = Math.max(fontHeight, doubleDigitWidth, 30);
|
||||
return baseSize + 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산된 셀 크기를 사용하여 전체 게임 보드를 그립니다.
|
||||
* @param {number} cellSize - 계산된 셀의 통일된 크기
|
||||
*/
|
||||
function drawBoard(cellSize) {
|
||||
const rowClueAreaWidth = cellSize * 2;
|
||||
const colClueAreaHeight = cellSize * 2;
|
||||
gameBoard.style.gridTemplateColumns = `${rowClueAreaWidth}px 1fr`;
|
||||
gameBoard.style.gridTemplateRows = `${colClueAreaHeight}px 1fr`;
|
||||
const corner = document.createElement('div');
|
||||
const colCluesContainer = document.createElement('div');
|
||||
colCluesContainer.className = 'col-clues-container';
|
||||
const rowCluesContainer = document.createElement('div');
|
||||
rowCluesContainer.className = 'row-clues-container';
|
||||
const puzzleGridContainer = document.createElement('div');
|
||||
puzzleGridContainer.className = 'puzzle-grid-container';
|
||||
puzzleGridContainer.style.gridTemplateColumns = `repeat(${numCols}, ${cellSize}px)`;
|
||||
puzzleGridContainer.style.gridTemplateRows = `repeat(${numRows}, ${cellSize}px)`;
|
||||
puzzleData.colClues.forEach((clues, index) => {
|
||||
const clueCell = document.createElement('div');
|
||||
clueCell.className = 'clue-cell col-clue';
|
||||
clueCell.id = `col-clue-${index}`;
|
||||
clueCell.style.width = `${cellSize}px`;
|
||||
clueCell.innerHTML = clues.join('<br>');
|
||||
if ((index + 1) % 5 === 0 && index < numCols - 1) clueCell.classList.add('guide-line-right');
|
||||
colCluesContainer.appendChild(clueCell);
|
||||
});
|
||||
puzzleData.rowClues.forEach((clues, index) => {
|
||||
const clueCell = document.createElement('div');
|
||||
clueCell.className = 'clue-cell row-clue';
|
||||
clueCell.id = `row-clue-${index}`;
|
||||
clueCell.style.height = `${cellSize}px`;
|
||||
clueCell.textContent = clues.join(' ');
|
||||
if ((index + 1) % 5 === 0 && index < numRows - 1) clueCell.classList.add('guide-line-bottom');
|
||||
rowCluesContainer.appendChild(clueCell);
|
||||
});
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'grid-cell';
|
||||
cell.dataset.row = r;
|
||||
cell.dataset.col = c;
|
||||
if ((c + 1) % 5 === 0 && c < numCols - 1) cell.classList.add('guide-line-right');
|
||||
if ((r + 1) % 5 === 0 && r < numRows - 1) cell.classList.add('guide-line-bottom');
|
||||
puzzleGridContainer.appendChild(cell);
|
||||
}
|
||||
}
|
||||
gameBoard.appendChild(corner);
|
||||
gameBoard.appendChild(colCluesContainer);
|
||||
gameBoard.appendChild(rowCluesContainer);
|
||||
gameBoard.appendChild(puzzleGridContainer);
|
||||
attachEventListeners(puzzleGridContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 너비에 맞춰 게임 보드 전체를 비율 그대로 축소/확대합니다.
|
||||
*/
|
||||
function fitBoardToScreen() {
|
||||
const viewport = document.getElementById('board-viewport');
|
||||
const board = document.getElementById('game-board');
|
||||
board.style.transform = 'scale(1)';
|
||||
const boardRect = board.getBoundingClientRect();
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
if (boardRect.width > viewportRect.width) {
|
||||
const scale = viewportRect.width / boardRect.width;
|
||||
board.style.transform = `scale(${scale})`;
|
||||
viewport.style.height = `${boardRect.height * scale}px`;
|
||||
} else {
|
||||
board.style.transform = 'scale(1)';
|
||||
viewport.style.height = `${boardRect.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 셀의 상태를 변경하는 유일한 함수. 모든 사용자 입력은 이 함수를 거칩니다.
|
||||
*/
|
||||
function updateCellState(cell, action) {
|
||||
if (isGameFinished) return;
|
||||
const row = parseInt(cell.dataset.row);
|
||||
const col = parseInt(cell.dataset.col);
|
||||
if (lockedRows[row] || lockedCols[col]) return;
|
||||
affectedRows.add(row);
|
||||
affectedCols.add(col);
|
||||
const currentState = playerGrid[row][col];
|
||||
let newState = currentState;
|
||||
if (action === 'fill') {
|
||||
if (solution[row][col] === 0) {
|
||||
points--;
|
||||
updatePointsDisplay();
|
||||
cell.classList.add('incorrect');
|
||||
setTimeout(() => cell.classList.remove('incorrect'), 500);
|
||||
if (points <= 0) triggerGameOver();
|
||||
return;
|
||||
}
|
||||
newState = 1;
|
||||
} else if (action === 'mark') {
|
||||
newState = -1;
|
||||
} else if (action === 'clear') {
|
||||
newState = 0;
|
||||
}
|
||||
if (currentState !== newState) {
|
||||
playerGrid[row][col] = newState;
|
||||
cell.classList.toggle('filled', newState === 1);
|
||||
cell.classList.toggle('marked', newState === -1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ REVISED) Event Listeners for Mouse and Touch
|
||||
*/
|
||||
function attachEventListeners(grid) {
|
||||
// --- MOUSE EVENTS ---
|
||||
grid.addEventListener('mousedown', (e) => handleDragStart(e));
|
||||
grid.addEventListener('mouseover', (e) => handleDragMove(e));
|
||||
grid.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// --- TOUCH EVENTS ---
|
||||
grid.addEventListener('touchstart', (e) => handleDragStart(e), { passive: false });
|
||||
grid.addEventListener('touchmove', (e) => handleDragMove(e), { passive: false });
|
||||
}
|
||||
|
||||
// End drag for both mouse and touch
|
||||
window.addEventListener('mouseup', () => handleDragEnd());
|
||||
window.addEventListener('touchend', () => handleDragEnd());
|
||||
|
||||
// Listen for changes on the mode selector
|
||||
modeSelector.addEventListener('change', updateMode);
|
||||
|
||||
/**
|
||||
* (★ NEW) Handles the start of a drag (mousedown or touchstart)
|
||||
*/
|
||||
function handleDragStart(e) {
|
||||
if (isGameFinished || !e.target.classList.contains('grid-cell')) return;
|
||||
isDragging = true;
|
||||
e.preventDefault();
|
||||
|
||||
const cell = e.target;
|
||||
const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
|
||||
|
||||
// --- 하이브리드 로직 ---
|
||||
if (e.type === 'mousedown') { // 마우스 이벤트일 경우
|
||||
if (e.button === 0) { // 좌클릭
|
||||
dragAction = (currentState === 1) ? 'clear' : 'fill';
|
||||
// UI를 실제 행동에 맞춰 동기화
|
||||
document.querySelector('input[name="play-mode"][value="fill"]').checked = true;
|
||||
} else if (e.button === 2) { // 우클릭
|
||||
dragAction = (currentState === -1) ? 'clear' : 'mark';
|
||||
// UI를 실제 행동에 맞춰 동기화
|
||||
document.querySelector('input[name="play-mode"][value="mark"]').checked = true;
|
||||
}
|
||||
} else { // 터치 이벤트일 경우 ('touchstart')
|
||||
// 현재 선택된 라디오 버튼 모드를 읽어옴
|
||||
const currentMode = document.querySelector('input[name="play-mode"]:checked').value;
|
||||
if (currentMode === 'fill') {
|
||||
dragAction = (currentState === 1) ? 'clear' : 'fill';
|
||||
} else { // 'mark' 모드
|
||||
dragAction = (currentState === -1) ? 'clear' : 'mark';
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 시작점 기록 및 시각적 피드백 시작
|
||||
startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
|
||||
lastHoveredCell = startCell;
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ NEW) Handles movement during a drag (mouseover or touchmove)
|
||||
*/
|
||||
function handleDragMove(e) {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
|
||||
// For touch events, we need to find the element under the finger
|
||||
const target = (e.touches)
|
||||
? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)
|
||||
: e.target;
|
||||
|
||||
if (target && target.classList.contains('grid-cell')) {
|
||||
const row = parseInt(target.dataset.row);
|
||||
const col = parseInt(target.dataset.col);
|
||||
|
||||
if (row !== lastHoveredCell.row || col !== lastHoveredCell.col) {
|
||||
lastHoveredCell = { row, col };
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ NEW) Handles the end of a drag (mouseup or touchend)
|
||||
*/
|
||||
function handleDragEnd() {
|
||||
if (!isDragging) return;
|
||||
|
||||
currentSelection.forEach(cell => updateCellState(cell, dragAction));
|
||||
clearSelectionVisuals();
|
||||
|
||||
if (dragAction === 'fill' || dragAction === 'clear') {
|
||||
checkAndLockCompletedLines(affectedRows, affectedCols);
|
||||
}
|
||||
checkWinCondition();
|
||||
|
||||
// Reset state
|
||||
isDragging = false;
|
||||
dragAction = null;
|
||||
startCell = null;
|
||||
lastHoveredCell = null;
|
||||
currentSelection.clear();
|
||||
affectedRows.clear();
|
||||
affectedCols.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 중인 사각형 영역을 계산하고 시각적으로 업데이트하는 함수
|
||||
*/
|
||||
function updateSelectionVisuals() {
|
||||
const newSelection = new Set();
|
||||
if (!startCell || !lastHoveredCell) return;
|
||||
const r1 = Math.min(startCell.row, lastHoveredCell.row);
|
||||
const r2 = Math.max(startCell.row, lastHoveredCell.row);
|
||||
const c1 = Math.min(startCell.col, lastHoveredCell.col);
|
||||
const c2 = Math.max(startCell.col, lastHoveredCell.col);
|
||||
for (let r = r1; r <= r2; r++) {
|
||||
for (let c = c1; c <= c2; c++) {
|
||||
const cell = document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`);
|
||||
if (cell) newSelection.add(cell);
|
||||
}
|
||||
}
|
||||
currentSelection.forEach(cell => {
|
||||
if (!newSelection.has(cell)) cell.classList.remove('selecting');
|
||||
});
|
||||
newSelection.forEach(cell => {
|
||||
if (!currentSelection.has(cell)) cell.classList.add('selecting');
|
||||
});
|
||||
currentSelection = newSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 시각적 피드백을 제거하는 함수
|
||||
*/
|
||||
function clearSelectionVisuals() {
|
||||
currentSelection.forEach(cell => cell.classList.remove('selecting'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 행이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||
*/
|
||||
function isRowComplete(rowIndex) {
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
if (solution[rowIndex][c] === 1 && playerGrid[rowIndex][c] !== 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 특정 열이 '칠해야 할 칸'을 모두 만족했는지 검사
|
||||
*/
|
||||
function isColComplete(colIndex) {
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
if (solution[r][colIndex] === 1 && playerGrid[r][colIndex] !== 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 완성된 라인을 확인하고 잠금 처리 및 스타일 변경 (X 자동 완성 없음)
|
||||
*/
|
||||
function checkAndLockCompletedLines(rowsToCheck, colsToCheck) {
|
||||
rowsToCheck.forEach(r => {
|
||||
if (!lockedRows[r] && isRowComplete(r)) {
|
||||
lockedRows[r] = true;
|
||||
document.getElementById(`row-clue-${r}`).classList.add('completed');
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||
}
|
||||
}
|
||||
});
|
||||
colsToCheck.forEach(c => {
|
||||
if (!lockedCols[c] && isColComplete(c)) {
|
||||
lockedCols[c] = true;
|
||||
document.getElementById(`col-clue-${c}`).classList.add('completed');
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
document.querySelector(`.grid-cell[data-row='${r}'][data-col='${c}']`).classList.add('locked');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 게임 승리 조건 (모든 셀이 정답과 완벽하게 일치)을 확인
|
||||
*/
|
||||
function checkWinCondition() {
|
||||
if (isGameFinished) return;
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
const playerState = (playerGrid[r][c] === 1) ? 1 : 0;
|
||||
if (playerState !== solution[r][c]) return;
|
||||
}
|
||||
}
|
||||
triggerGameSuccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면에 현재 포인트를 업데이트
|
||||
*/
|
||||
function updatePointsDisplay() {
|
||||
pointsDisplay.textContent = points;
|
||||
hintBtn.disabled = (points <= 0 || isGameFinished);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공/실패 모달을 동적으로 생성하여 표시
|
||||
*/
|
||||
function showResultModal(config) {
|
||||
modalTitle.textContent = config.title;
|
||||
modalMessage.textContent = config.message;
|
||||
modalButtons.innerHTML = '';
|
||||
config.buttons.forEach(btnInfo => {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = btnInfo.text;
|
||||
button.className = btnInfo.class || '';
|
||||
button.onclick = btnInfo.action;
|
||||
modalButtons.appendChild(button);
|
||||
});
|
||||
resultOverlay.classList.remove('hidden');
|
||||
setTimeout(() => resultOverlay.classList.add('visible'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게임 실패 처리
|
||||
*/
|
||||
function triggerGameOver() {
|
||||
if (isGameFinished) return;
|
||||
isGameFinished = true;
|
||||
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
||||
hintBtn.disabled = true;
|
||||
showResultModal({
|
||||
title: 'Failure', message: '포인트를 모두 사용했습니다.', buttons: [
|
||||
{ text: '재시도 (Retry)', class: 'primary', action: () => window.location.reload() },
|
||||
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
||||
]
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 게임 성공 처리
|
||||
*/
|
||||
function triggerGameSuccess() {
|
||||
if (isGameFinished) return;
|
||||
isGameFinished = true;
|
||||
|
||||
// --- 요소 참조 ---
|
||||
const viewport = document.getElementById('board-viewport');
|
||||
const puzzleGridContainer = document.querySelector('.puzzle-grid-container');
|
||||
const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||
const originalImg = document.getElementById('original-reveal');
|
||||
|
||||
// --- 상호작용 비활성화 ---
|
||||
puzzleGridContainer.style.pointerEvents = 'none';
|
||||
hintBtn.disabled = true;
|
||||
|
||||
// --- (★ 핵심) 애니메이션 위치 및 크기 계산 ---
|
||||
// 1. 렌더링된 퍼즐 격자와 뷰포트의 실제 위치/크기 정보를 가져옵니다.
|
||||
const gridRect = puzzleGridContainer.getBoundingClientRect();
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
|
||||
// 2. 뷰포트를 기준으로 퍼즐 격자의 상대적인 위치(top, left)를 계산합니다.
|
||||
const top = gridRect.top - viewportRect.top;
|
||||
const left = gridRect.left - viewportRect.left;
|
||||
|
||||
// 3. 애니메이션 이미지들에 계산된 위치와 크기를 적용합니다.
|
||||
[grayscaleImg, originalImg].forEach(img => {
|
||||
img.style.top = `${top}px`;
|
||||
img.style.left = `${left}px`;
|
||||
img.style.width = `${gridRect.width}px`;
|
||||
img.style.height = `${gridRect.height}px`;
|
||||
|
||||
// 이미지 소스 설정
|
||||
img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage;
|
||||
});
|
||||
|
||||
// --- 애니메이션 순차 실행 (기존과 동일) ---
|
||||
setTimeout(() => {
|
||||
grayscaleImg.style.opacity = '1'; // 그레이스케일 페이드 인
|
||||
|
||||
setTimeout(() => {
|
||||
originalImg.style.opacity = '1'; // 컬러 이미지 페이드 인
|
||||
|
||||
setTimeout(() => {
|
||||
// 최종 결과 모달 표시
|
||||
showResultModal({
|
||||
title: 'Success! 🎉',
|
||||
message: '퍼즐을 완성했습니다!',
|
||||
buttons: [
|
||||
{ text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' },
|
||||
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
||||
]
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
}, 2000);
|
||||
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 힌트 버튼 클릭 이벤트 처리
|
||||
hintBtn.addEventListener('click', () => {
|
||||
if (points <= 0 || isGameFinished) return;
|
||||
points--;
|
||||
updatePointsDisplay();
|
||||
const hintCandidates = [];
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
if (solution[r][c] === 1 && playerGrid[r][c] !== 1) {
|
||||
hintCandidates.push({ r, c });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hintCandidates.length > 0) {
|
||||
const hint = hintCandidates[Math.floor(Math.random() * hintCandidates.length)];
|
||||
const cellToReveal = document.querySelector(`.grid-cell[data-row='${hint.r}'][data-col='${hint.c}']`);
|
||||
|
||||
// 힌트 사용 시에도 updateCellState를 통해 상태를 변경합니다.
|
||||
updateCellState(cellToReveal, 'fill');
|
||||
|
||||
// 힌트로 변경된 셀의 행과 열만 확인합니다.
|
||||
const hintAffectedRows = new Set([hint.r]);
|
||||
const hintAffectedCols = new Set([hint.c]);
|
||||
checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols);
|
||||
checkWinCondition();
|
||||
} else {
|
||||
alert("더 이상 사용할 힌트가 없습니다!");
|
||||
points++;
|
||||
updatePointsDisplay();
|
||||
}
|
||||
if (points <= 0 && !isGameFinished) {
|
||||
triggerGameOver();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 초기 실행 ---
|
||||
const optimalCellSize = calculateCellSize();
|
||||
drawBoard(optimalCellSize);
|
||||
updatePointsDisplay();
|
||||
updateMode(); // Set initial mode
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
fitBoardToScreen();
|
||||
window.addEventListener('resize', fitBoardToScreen);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,356 +1,363 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM 요소
|
||||
const setupContainer = document.getElementById('setup-container');
|
||||
const gameContainer = document.getElementById('game-container');
|
||||
const startBtn = document.getElementById('start-btn');
|
||||
const boardElement = document.getElementById('sudoku-board');
|
||||
const timerElement = document.getElementById('timer');
|
||||
const scoreElement = document.getElementById('score');
|
||||
const hintBtn = document.getElementById('hint-btn');
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
const completeBtn = document.getElementById('complete-btn');
|
||||
const numberInputButtons = document.getElementById('number-input-buttons');
|
||||
const modalOverlay = document.getElementById('modal-overlay');
|
||||
const gameOverModal = document.getElementById('game-over-modal');
|
||||
const retryBtn = document.getElementById('retry-btn');
|
||||
const submitRankBtn = document.getElementById('submit-rank-btn');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
|
||||
// 게임 상태 변수
|
||||
let currentPuzzleId = null;
|
||||
let solvedPuzzle = null;
|
||||
let timerInterval = null;
|
||||
let secondsElapsed = 0;
|
||||
let selectedNumber = null;
|
||||
let focusedCell = null;
|
||||
let score = 5;
|
||||
let history = [];
|
||||
|
||||
// --- 게임 초기화 및 시작 ---
|
||||
startBtn.addEventListener('click', async () => {
|
||||
const difficulty = document.getElementById('difficulty-select').value;
|
||||
try {
|
||||
const response = await fetch(`/sudoku/start?difficulty=${difficulty}`);
|
||||
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
||||
const gameData = await response.json();
|
||||
|
||||
currentPuzzleId = gameData.puzzleId;
|
||||
solvedPuzzle = gameData.solution;
|
||||
|
||||
history = [];
|
||||
score = 5;
|
||||
updateScoreDisplay();
|
||||
|
||||
renderBoard(gameData.question);
|
||||
startTimer();
|
||||
updateButtonStates();
|
||||
|
||||
setupContainer.classList.add('hidden');
|
||||
gameContainer.classList.remove('hidden');
|
||||
numberInputButtons.classList.remove('hidden');
|
||||
gameOverModal.classList.add('hidden');
|
||||
} catch (error) {
|
||||
alert('게임 로딩에 실패했습니다: ' + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
function renderBoard(puzzleString) {
|
||||
boardElement.innerHTML = '';
|
||||
for (let i = 0; i < 81; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('cell');
|
||||
cell.dataset.index = i;
|
||||
|
||||
if (puzzleString[i] !== '0') {
|
||||
cell.textContent = puzzleString[i];
|
||||
} else {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
boardElement.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
secondsElapsed = 0;
|
||||
timerElement.textContent = '00:00';
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => {
|
||||
secondsElapsed++;
|
||||
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
|
||||
const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
|
||||
timerElement.textContent = `${minutes}:${seconds}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreElement.textContent = `SCORE: ${score}`;
|
||||
if (score <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
gameOverModal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStates() {
|
||||
const counts = {};
|
||||
for (let i = 1; i <= 9; i++) counts[i] = 0;
|
||||
boardElement.querySelectorAll('.cell').forEach(cell => {
|
||||
const num = cell.textContent;
|
||||
if (num && counts[num] !== undefined) counts[num]++;
|
||||
});
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
|
||||
if (btn) {
|
||||
if (counts[i] >= 9) {
|
||||
btn.classList.add('completed');
|
||||
if (selectedNumber == i) {
|
||||
selectedNumber = null;
|
||||
btn.classList.remove('selected');
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 플레이 이벤트 핸들링 ---
|
||||
numberInputButtons.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('button');
|
||||
if (!target) return;
|
||||
|
||||
if (target === undoBtn) {
|
||||
undoAction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('completed')) return;
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||
|
||||
if (target.classList.contains('num-btn')) {
|
||||
const num = target.dataset.number;
|
||||
selectedNumber = (selectedNumber === num) ? null : num;
|
||||
if (selectedNumber) target.classList.add('selected');
|
||||
}
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
boardElement.addEventListener('click', (event) => {
|
||||
const targetCell = event.target.closest('.cell.editable');
|
||||
if (!targetCell) {
|
||||
if (focusedCell) focusedCell = null;
|
||||
highlightCells();
|
||||
return;
|
||||
}
|
||||
focusedCell = targetCell;
|
||||
|
||||
if (selectedNumber) {
|
||||
const previousValue = targetCell.textContent;
|
||||
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||
targetCell.textContent = newValue;
|
||||
|
||||
recordAction(targetCell, previousValue, newValue);
|
||||
validateCell(targetCell);
|
||||
updateButtonStates();
|
||||
checkIfBoardIsFull();
|
||||
}
|
||||
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
hintBtn.addEventListener('click', () => {
|
||||
if (score <= 0) return;
|
||||
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
||||
if (emptyCells.length === 0) {
|
||||
alert('모든 칸이 채워져 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
const cellIndex = parseInt(randomCell.dataset.index);
|
||||
const correctAnswer = solvedPuzzle[cellIndex];
|
||||
const previousValue = randomCell.textContent;
|
||||
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
recordAction(randomCell, previousValue, correctAnswer, true);
|
||||
|
||||
randomCell.textContent = correctAnswer;
|
||||
randomCell.classList.remove('editable', 'incorrect');
|
||||
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
checkIfBoardIsFull();
|
||||
});
|
||||
|
||||
function undoAction() {
|
||||
if (history.length === 0) return;
|
||||
const lastAction = history.pop();
|
||||
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||
|
||||
if (cell) {
|
||||
cell.textContent = lastAction.previousValue;
|
||||
if (lastAction.wasHint) {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
validateCell(cell, false);
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
}
|
||||
}
|
||||
|
||||
function recordAction(cell, previousValue, newValue, wasHint = false) {
|
||||
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
|
||||
}
|
||||
|
||||
function validateCell(cell, deductPoint = true) {
|
||||
if (!cell.textContent) {
|
||||
cell.classList.remove('incorrect');
|
||||
return;
|
||||
}
|
||||
const cellIndex = parseInt(cell.dataset.index);
|
||||
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
|
||||
if (!isCorrect) {
|
||||
cell.classList.add('incorrect');
|
||||
if (deductPoint && score > 0) {
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
} else {
|
||||
cell.classList.remove('incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 하이라이트 기능 ---
|
||||
function highlightCells() {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
if (focusedCell) {
|
||||
focusedCell.classList.add('highlight-focused');
|
||||
const focusedValue = focusedCell.textContent;
|
||||
if (focusedValue) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedNumber) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 완료 및 모달 ---
|
||||
async function checkSolution() {
|
||||
let answerString = "";
|
||||
boardElement.childNodes.forEach(cell => {
|
||||
answerString += cell.textContent || '0';
|
||||
});
|
||||
|
||||
if (answerString.includes('0')) {
|
||||
alert('모든 칸을 채워주세요!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/sudoku/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.correct) {
|
||||
clearInterval(timerInterval);
|
||||
alert('🎉 정답입니다!');
|
||||
showRankingModal();
|
||||
} else {
|
||||
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('정답 확인 중 오류 발생:', error);
|
||||
alert('정답 확인 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfBoardIsFull() {
|
||||
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
|
||||
if (!emptyEditableCells) {
|
||||
checkSolution();
|
||||
}
|
||||
}
|
||||
|
||||
completeBtn.addEventListener('click', checkSolution);
|
||||
|
||||
async function showRankingModal() {
|
||||
modalOverlay.classList.remove('hidden');
|
||||
document.getElementById('username-input').value = '';
|
||||
submitRankBtn.disabled = false;
|
||||
try {
|
||||
const response = await fetch(`/sudoku/ranking/${currentPuzzleId}`);
|
||||
const rankings = await response.json();
|
||||
rankingList.innerHTML = '';
|
||||
if (rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||
} else {
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
const minutes = Math.floor(rank.completionTime / 60).toString().padStart(2, '0');
|
||||
const seconds = (rank.completionTime % 60).toString().padStart(2, '0');
|
||||
li.innerHTML = `<span>${index + 1}위: ${rank.userName}</span> <span>${minutes}:${seconds}</span>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('랭킹 조회 중 오류 발생:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
submitRankBtn.addEventListener('click', async () => {
|
||||
const userName = document.getElementById('username-input').value.trim();
|
||||
if (!userName) return alert('이름을 입력해주세요.');
|
||||
try {
|
||||
await fetch('/sudoku/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
puzzleId: currentPuzzleId,
|
||||
userName: userName,
|
||||
completionTime: secondsElapsed
|
||||
})
|
||||
});
|
||||
alert('랭킹이 성공적으로 등록되었습니다!');
|
||||
showRankingModal();
|
||||
submitRankBtn.disabled = true;
|
||||
} catch (error) {
|
||||
console.error('랭킹 등록 중 오류 발생:', error);
|
||||
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
function resetGameView() {
|
||||
setupContainer.classList.remove('hidden');
|
||||
gameContainer.classList.add('hidden');
|
||||
numberInputButtons.classList.add('hidden');
|
||||
clearInterval(timerInterval);
|
||||
selectedNumber = null;
|
||||
focusedCell = null;
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
|
||||
}
|
||||
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
modalOverlay.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
|
||||
retryBtn.addEventListener('click', () => {
|
||||
gameOverModal.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
});
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// // DOM 요소
|
||||
// const setupContainer = document.getElementById('setup-container');
|
||||
// const gameContainer = document.getElementById('game-container');
|
||||
// const startBtn = document.getElementById('start-btn');
|
||||
// const boardElement = document.getElementById('sudoku-board');
|
||||
// const timerElement = document.getElementById('timer');
|
||||
// const scoreElement = document.getElementById('score');
|
||||
// const hintBtn = document.getElementById('hint-btn');
|
||||
// const undoBtn = document.getElementById('undo-btn');
|
||||
// const completeBtn = document.getElementById('complete-btn');
|
||||
// const numberInputButtons = document.getElementById('number-input-buttons');
|
||||
// const modalOverlay = document.getElementById('modal-overlay');
|
||||
// const gameOverModal = document.getElementById('game-over-modal');
|
||||
// const retryBtn = document.getElementById('retry-btn');
|
||||
// const submitRankBtn = document.getElementById('submit-rank-btn');
|
||||
// const rankingList = document.getElementById('ranking-list');
|
||||
// const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
//
|
||||
// // 게임 상태 변수
|
||||
// let currentPuzzleId = null;
|
||||
// let solvedPuzzle = null;
|
||||
// let timerInterval = null;
|
||||
// let secondsElapsed = 0;
|
||||
// let selectedNumber = null;
|
||||
// let focusedCell = null;
|
||||
// let score = 5;
|
||||
// let history = [];
|
||||
//
|
||||
// // (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
|
||||
// startBtn.addEventListener('click', async () => {
|
||||
// const difficulty = document.getElementById('difficulty-select').value;
|
||||
// try {
|
||||
// // (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
|
||||
// const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
|
||||
// if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
||||
// const gameData = await response.json();
|
||||
//
|
||||
// currentContextId = gameData.puzzleId;
|
||||
// solvedPuzzle = gameData.solution;
|
||||
//
|
||||
// history = [];
|
||||
// score = 5;
|
||||
// updateScoreDisplay();
|
||||
//
|
||||
// renderBoard(gameData.question);
|
||||
// startTimer();
|
||||
// updateButtonStates();
|
||||
//
|
||||
// setupContainer.classList.add('hidden');
|
||||
// gameContainer.classList.remove('hidden');
|
||||
// numberInputButtons.classList.remove('hidden');
|
||||
// gameOverModal.classList.add('hidden');
|
||||
// } catch (error) {
|
||||
// alert('게임 로딩에 실패했습니다: ' + error.message);
|
||||
// console.error(error);
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// function renderBoard(puzzleString) {
|
||||
// boardElement.innerHTML = '';
|
||||
// for (let i = 0; i < 81; i++) {
|
||||
// const cell = document.createElement('div');
|
||||
// cell.classList.add('cell');
|
||||
// cell.dataset.index = i;
|
||||
//
|
||||
// if (puzzleString[i] !== '0') {
|
||||
// cell.textContent = puzzleString[i];
|
||||
// } else {
|
||||
// cell.classList.add('editable');
|
||||
// }
|
||||
// boardElement.appendChild(cell);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// function startTimer() {
|
||||
// secondsElapsed = 0;
|
||||
// timerElement.textContent = '00:00';
|
||||
// clearInterval(timerInterval);
|
||||
// timerInterval = setInterval(() => {
|
||||
// secondsElapsed++;
|
||||
// const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
|
||||
// const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
|
||||
// timerElement.textContent = `${minutes}:${seconds}`;
|
||||
// }, 1000);
|
||||
// }
|
||||
//
|
||||
// function updateScoreDisplay() {
|
||||
// scoreElement.textContent = `SCORE: ${score}`;
|
||||
// if (score <= 0) {
|
||||
// clearInterval(timerInterval);
|
||||
// gameOverModal.classList.remove('hidden');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// function updateButtonStates() {
|
||||
// const counts = {};
|
||||
// for (let i = 1; i <= 9; i++) counts[i] = 0;
|
||||
// boardElement.querySelectorAll('.cell').forEach(cell => {
|
||||
// const num = cell.textContent;
|
||||
// if (num && counts[num] !== undefined) counts[num]++;
|
||||
// });
|
||||
// for (let i = 1; i <= 9; i++) {
|
||||
// const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
|
||||
// if (btn) {
|
||||
// if (counts[i] >= 9) {
|
||||
// btn.classList.add('completed');
|
||||
// if (selectedNumber == i) {
|
||||
// selectedNumber = null;
|
||||
// btn.classList.remove('selected');
|
||||
// }
|
||||
// } else {
|
||||
// btn.classList.remove('completed');
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // --- 게임 플레이 이벤트 핸들링 ---
|
||||
// numberInputButtons.addEventListener('click', (event) => {
|
||||
// const target = event.target.closest('button');
|
||||
// if (!target) return;
|
||||
//
|
||||
// if (target === undoBtn) {
|
||||
// undoAction();
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// if (target.classList.contains('completed')) return;
|
||||
// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||
//
|
||||
// if (target.classList.contains('num-btn')) {
|
||||
// const num = target.dataset.number;
|
||||
// selectedNumber = (selectedNumber === num) ? null : num;
|
||||
// if (selectedNumber) target.classList.add('selected');
|
||||
// }
|
||||
// highlightCells();
|
||||
// });
|
||||
//
|
||||
// boardElement.addEventListener('click', (event) => {
|
||||
// const targetCell = event.target.closest('.cell.editable');
|
||||
// if (!targetCell) {
|
||||
// if (focusedCell) focusedCell = null;
|
||||
// highlightCells();
|
||||
// return;
|
||||
// }
|
||||
// focusedCell = targetCell;
|
||||
//
|
||||
// if (selectedNumber) {
|
||||
// const previousValue = targetCell.textContent;
|
||||
// let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||
// targetCell.textContent = newValue;
|
||||
//
|
||||
// recordAction(targetCell, previousValue, newValue);
|
||||
// validateCell(targetCell);
|
||||
// updateButtonStates();
|
||||
// checkIfBoardIsFull();
|
||||
// }
|
||||
//
|
||||
// highlightCells();
|
||||
// });
|
||||
//
|
||||
// hintBtn.addEventListener('click', () => {
|
||||
// if (score <= 0) return;
|
||||
// const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
||||
// if (emptyCells.length === 0) {
|
||||
// alert('모든 칸이 채워져 있습니다.');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
// const cellIndex = parseInt(randomCell.dataset.index);
|
||||
// const correctAnswer = solvedPuzzle[cellIndex];
|
||||
// const previousValue = randomCell.textContent;
|
||||
//
|
||||
// score--;
|
||||
// updateScoreDisplay();
|
||||
// recordAction(randomCell, previousValue, correctAnswer, true);
|
||||
//
|
||||
// randomCell.textContent = correctAnswer;
|
||||
// randomCell.classList.remove('editable', 'incorrect');
|
||||
//
|
||||
// updateButtonStates();
|
||||
// highlightCells();
|
||||
// checkIfBoardIsFull();
|
||||
// });
|
||||
//
|
||||
// function undoAction() {
|
||||
// if (history.length === 0) return;
|
||||
// const lastAction = history.pop();
|
||||
// const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||
//
|
||||
// if (cell) {
|
||||
// cell.textContent = lastAction.previousValue;
|
||||
// if (lastAction.wasHint) {
|
||||
// cell.classList.add('editable');
|
||||
// }
|
||||
// validateCell(cell, false);
|
||||
// updateButtonStates();
|
||||
// highlightCells();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// function recordAction(cell, previousValue, newValue, wasHint = false) {
|
||||
// history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
|
||||
// }
|
||||
//
|
||||
// function validateCell(cell, deductPoint = true) {
|
||||
// if (!cell.textContent) {
|
||||
// cell.classList.remove('incorrect');
|
||||
// return;
|
||||
// }
|
||||
// const cellIndex = parseInt(cell.dataset.index);
|
||||
// const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
|
||||
// if (!isCorrect) {
|
||||
// cell.classList.add('incorrect');
|
||||
// if (deductPoint && score > 0) {
|
||||
// score--;
|
||||
// updateScoreDisplay();
|
||||
// }
|
||||
// } else {
|
||||
// cell.classList.remove('incorrect');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // --- 하이라이트 기능 ---
|
||||
// function highlightCells() {
|
||||
// document.querySelectorAll('.cell').forEach(cell => {
|
||||
// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
// });
|
||||
// if (focusedCell) {
|
||||
// focusedCell.classList.add('highlight-focused');
|
||||
// const focusedValue = focusedCell.textContent;
|
||||
// if (focusedValue) {
|
||||
// document.querySelectorAll('.cell').forEach(cell => {
|
||||
// if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// if (selectedNumber) {
|
||||
// document.querySelectorAll('.cell').forEach(cell => {
|
||||
// if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // --- 게임 완료 및 모달 ---
|
||||
// async function checkSolution() {
|
||||
// let answerString = "";
|
||||
// boardElement.childNodes.forEach(cell => {
|
||||
// answerString += cell.textContent || '0';
|
||||
// });
|
||||
//
|
||||
// if (answerString.includes('0')) {
|
||||
// alert('모든 칸을 채워주세요!');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// try {
|
||||
// // (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
|
||||
// // (★ 수정) contextId(puzzleId) 변수 사용
|
||||
// const response = await fetch('/puzzle/sudoku/validate', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ puzzleId: currentContextId, answer: answerString })
|
||||
// });
|
||||
// const result = await response.json();
|
||||
// if (result.correct) {
|
||||
// clearInterval(timerInterval);
|
||||
// alert('🎉 정답입니다!');
|
||||
// showRankingModal(); // 랭킹 모달 표시
|
||||
// } else {
|
||||
// alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('정답 확인 중 오류 발생:', error);
|
||||
// alert('정답 확인 중 오류가 발생했습니다.');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// function checkIfBoardIsFull() {
|
||||
// const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
|
||||
// if (!emptyEditableCells) {
|
||||
// checkSolution();
|
||||
// }
|
||||
// }
|
||||
// completeBtn.addEventListener('click', checkSolution);
|
||||
// /**
|
||||
// * (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
// */
|
||||
// async function showRankingModal() {
|
||||
// modalOverlay.classList.remove('hidden');
|
||||
// document.getElementById('username-input').value = '';
|
||||
// submitRankBtn.disabled = false;
|
||||
// rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
//
|
||||
// try {
|
||||
// // (★ 수정) user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID(ContextId) 전달)
|
||||
// const rankings = await fetchRanks(currentGameType, currentContextId);
|
||||
//
|
||||
// rankingList.innerHTML = '';
|
||||
// if (rankings.length === 0) {
|
||||
// rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||
// } else {
|
||||
// rankings.forEach((rank, index) => {
|
||||
// const li = document.createElement('li');
|
||||
// // (★ 수정) 공통 모델 필드(primaryScore)를 시간(초)으로 변환
|
||||
// const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
|
||||
// const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
|
||||
// li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
|
||||
// rankingList.appendChild(li);
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('랭킹 조회 중 오류 발생:', error);
|
||||
// rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
|
||||
// */
|
||||
// submitRankBtn.addEventListener('click', async () => {
|
||||
// const userName = document.getElementById('username-input').value.trim();
|
||||
// if (!userName) return alert('이름을 입력해주세요.');
|
||||
//
|
||||
// try {
|
||||
// // (★ 수정) user.js의 공통 submitRank 함수 호출
|
||||
// // 스도쿠의 주 점수(primaryScore)는 완료 시간(secondsElapsed), 보조 점수는 없음.
|
||||
// await submitRank(currentGameType, currentContextId, userName, secondsElapsed, null);
|
||||
//
|
||||
// alert('랭킹이 성공적으로 등록되었습니다!');
|
||||
// showRankingModal(); // 랭킹 목록 새로고침
|
||||
// submitRankBtn.disabled = true; // 중복 등록 방지
|
||||
// } catch (error) {
|
||||
// console.error('랭킹 등록 중 오류 발생:', error);
|
||||
// alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// function resetGameView() {
|
||||
// setupContainer.classList.remove('hidden');
|
||||
// gameContainer.classList.add('hidden');
|
||||
// numberInputButtons.classList.add('hidden');
|
||||
// clearInterval(timerInterval);
|
||||
// selectedNumber = null;
|
||||
// focusedCell = null;
|
||||
// document.querySelectorAll('.cell').forEach(cell => {
|
||||
// cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
// });
|
||||
// document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
|
||||
// }
|
||||
//
|
||||
// closeModalBtn.addEventListener('click', () => {
|
||||
// modalOverlay.classList.add('hidden');
|
||||
// resetGameView();
|
||||
// });
|
||||
//
|
||||
// retryBtn.addEventListener('click', () => {
|
||||
// gameOverModal.classList.add('hidden');
|
||||
// resetGameView();
|
||||
// });
|
||||
// });₩
|
||||
@ -1,166 +0,0 @@
|
||||
let currentPuzzleData = null;
|
||||
|
||||
// The success animation function
|
||||
function showSuccessAnimation() {
|
||||
if (!currentPuzzleData) return;
|
||||
|
||||
const puzzleContainer = document.getElementById('puzzle-container');
|
||||
const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||
const originalImg = document.getElementById('original-reveal');
|
||||
|
||||
// 1. Set the image sources from the saved Base64 data
|
||||
grayscaleImg.src = currentPuzzleData.grayscaleImage;
|
||||
originalImg.src = currentPuzzleData.originalImage;
|
||||
|
||||
// 2. Hide the puzzle grid and fade in the grayscale image
|
||||
puzzleContainer.style.transition = 'opacity 0.5s';
|
||||
puzzleContainer.style.opacity = '0';
|
||||
grayscaleImg.style.opacity = '1';
|
||||
|
||||
// 3. After a delay, fade out the grayscale and fade in the color image
|
||||
setTimeout(() => {
|
||||
grayscaleImg.style.opacity = '0';
|
||||
originalImg.style.opacity = '1';
|
||||
}, 2000); // 2-second delay
|
||||
}
|
||||
|
||||
|
||||
function drawPuzzle(puzzleData) {
|
||||
const container = document.getElementById('puzzle-container');
|
||||
container.innerHTML = ''; // Clear previous puzzle
|
||||
|
||||
const { solutionGrid, rowClues, colClues } = puzzleData;
|
||||
const numRows = solutionGrid.length;
|
||||
const numCols = solutionGrid[0].length;
|
||||
|
||||
// Set up the CSS Grid layout
|
||||
container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`;
|
||||
container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`;
|
||||
|
||||
// 1. Create top-left empty corner
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'grid-cell';
|
||||
container.appendChild(corner);
|
||||
|
||||
// 2. Create column clues (top row)
|
||||
for (const clues of colClues) {
|
||||
const clueCell = document.createElement('div');
|
||||
clueCell.className = 'clue-cell';
|
||||
clueCell.innerHTML = clues.join('<br>'); // Display clues vertically
|
||||
container.appendChild(clueCell);
|
||||
}
|
||||
|
||||
// 3. Create row clues and the solution grid
|
||||
for (let i = 0; i < numRows; i++) {
|
||||
// Create row clue cell for this row
|
||||
const rowClueCell = document.createElement('div');
|
||||
rowClueCell.className = 'clue-cell';
|
||||
rowClueCell.textContent = rowClues[i].join(' ');
|
||||
container.appendChild(rowClueCell);
|
||||
|
||||
// Create the solution cells for this row
|
||||
for (let j = 0; j < numCols; j++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'solution-cell';
|
||||
if (solutionGrid[i][j] === 1) {
|
||||
cell.classList.add('filled'); // Black square
|
||||
} else {
|
||||
cell.classList.add('empty'); // White square
|
||||
}
|
||||
container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Modify your existing click listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('createBtn').addEventListener('click', async () => {
|
||||
const uploader = document.getElementById('imageUploader');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const puzzleContainer = document.getElementById('puzzle-container');
|
||||
const testSuccessBtn = document.getElementById('test-success-btn');
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
const playBtn = document.getElementById('play-btn'); // Get the new button
|
||||
|
||||
if (uploader.files.length === 0) {
|
||||
statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFile = uploader.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('imageFile', imageFile);
|
||||
|
||||
statusDiv.textContent = '문제를 생성하는 중...';
|
||||
puzzleContainer.innerHTML = ''; // Clear old puzzle while loading
|
||||
|
||||
try {
|
||||
const response = await fetch(getMainPath()+'/puzzle/upload.bjx', { // Make sure this URL is correct
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const puzzleData = await response.json();
|
||||
statusDiv.textContent = '문제 생성 성공!';
|
||||
|
||||
// Call the new function to draw the puzzle!
|
||||
drawPuzzle(puzzleData);
|
||||
|
||||
currentPuzzleData = puzzleData;
|
||||
testSuccessBtn.addEventListener('click', showSuccessAnimation);
|
||||
// 성공/삭제 버튼 모두 표시
|
||||
testSuccessBtn.style.display = 'inline-block';
|
||||
deleteBtn.style.display = 'inline-block';
|
||||
playBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('네트워크 오류:', error);
|
||||
statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
||||
}
|
||||
// 삭제 버튼에 클릭 이벤트 리스너 추가
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentPuzzleData || !currentPuzzleData.id) {
|
||||
alert('삭제할 퍼즐이 선택되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자에게 삭제 여부 확인
|
||||
if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 백엔드에 DELETE 요청 보내기
|
||||
const response = await fetch(getMainPath() + `/puzzle/${currentPuzzleData.id}.bjx`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) { // 204 No Content 포함
|
||||
statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.';
|
||||
// 화면 정리
|
||||
puzzleContainer.innerHTML = '';
|
||||
document.getElementById('success-animation-container').innerHTML = ''; // 이미지 컨테이너도 비움
|
||||
testSuccessBtn.style.display = 'none';
|
||||
deleteBtn.style.display = 'none';
|
||||
currentPuzzleData = null;
|
||||
} else {
|
||||
statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 중 네트워크 오류:', error);
|
||||
statusDiv.textContent = '삭제 중 오류가 발생했습니다.';
|
||||
}
|
||||
});
|
||||
playBtn.addEventListener('click', () => {
|
||||
if (currentPuzzleData && currentPuzzleData.id) {
|
||||
// Navigate to the play page with the puzzle ID
|
||||
window.location.href = `/puzzle/play/${currentPuzzleData.id}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@ -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("비번이 다름요")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<section class="wrapper style1">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-8 col-12-narrower">
|
||||
<div class="col-12">
|
||||
<div id="content_inner">
|
||||
<article>
|
||||
<section th:each="post : ${postsPage.content}">
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
<button class="button" onclick="handleVote(this, 'like')">
|
||||
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
|
||||
</button>
|
||||
<button class="button" onclick="handleVote(this, 'unlike')" style="margin-left: 1em;">
|
||||
<button class="button" onclick="handleVote(this, 'unlike')">
|
||||
👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -5,17 +5,14 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}">
|
||||
<th:block layout:fragment="content" id="content">
|
||||
<section id="banner">
|
||||
<section id="banner"
|
||||
th:styleappend="${randomBannerImage != null} ? 'background-image: url(\'' + @{${randomBannerImage}} + '\');' : ''">
|
||||
<header>
|
||||
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
||||
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<!-- Highlights -->
|
||||
|
||||
|
||||
<!-- Gigantic Heading -->
|
||||
<section class="wrapper style2">
|
||||
<div class="container">
|
||||
<header class="major">
|
||||
@ -31,24 +28,38 @@
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Posts -->
|
||||
<section class="wrapper style2">
|
||||
|
||||
<section class="wrapper style1">
|
||||
<div class="container">
|
||||
<div class="row" th:each="row : ${Posts}">
|
||||
<section class="col-6 col-12-narrower" th:each="post : ${row}">
|
||||
<!-- <span th:text="${cell}"></span>-->
|
||||
<div class="box post" onclick="goToViewer(this)" th:id="${post.id}">
|
||||
<a href="#" class="image left"><img th:src="${#strings.length(post.thumb) > 0} ? ${post.thumb} : 'images/pic01.jpg'" alt="" /></a>
|
||||
<div class="inner">
|
||||
<h3 th:text="${#strings.length(post.title) > 0} ? ${post.title} : ('untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')} + ']')"></h3>
|
||||
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="content_inner">
|
||||
<article>
|
||||
<section th:each="post : ${Posts}">
|
||||
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
|
||||
<span class="image left">
|
||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
||||
</span>
|
||||
|
||||
<div class="inner">
|
||||
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
|
||||
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;">
|
||||
(읽음: <span th:text="${post.readCount}">0</span>)
|
||||
</span>
|
||||
</h3>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
|
||||
|
||||
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wrapper style1">
|
||||
<div class="container">
|
||||
<div class="row gtr-200">
|
||||
@ -82,7 +93,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- CTA -->
|
||||
<section id="cta2" class="wrapper style3">
|
||||
<div class="container">
|
||||
<header>
|
||||
@ -91,4 +101,4 @@
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</html>
|
||||
</html>
|
||||
@ -5,16 +5,186 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/2048.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/2048.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<head>
|
||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
|
||||
<style>
|
||||
/* =================================
|
||||
기본 및 전체 레이아웃 (수정됨)
|
||||
================================= */
|
||||
body {
|
||||
/* (★ 삭제) font-family, text-align, background-color, color, margin, padding
|
||||
-> 이 속성들은 모두 common_game_theme.css에서 관리합니다.
|
||||
*/
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 15vw; /* 2048 고유의 큰 폰트 크기는 유지 */
|
||||
margin: 20px 0;
|
||||
/* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 */
|
||||
}
|
||||
|
||||
.score-container {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* =================================
|
||||
게임 보드 (테마 적용)
|
||||
================================= */
|
||||
#game-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 2vw;
|
||||
width: 95vw;
|
||||
max-width: 500px; /* (★ 수정) 400px -> 500px (다른 게임과 통일) */
|
||||
margin: 0 auto;
|
||||
|
||||
/* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 */
|
||||
background-color: #b0bec5; /* #bbada0 (갈색) -> #b0bec5 (블루 그레이) */
|
||||
|
||||
padding: 2vw;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 1 / 1;
|
||||
touch-action: none;
|
||||
|
||||
/* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 */
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
@media (min-width: 481px) {
|
||||
#game-board {
|
||||
grid-gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================================
|
||||
타일 공통 스타일 (테마 적용)
|
||||
================================= */
|
||||
.tile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
|
||||
/* (★ 수정) 빈 타일 색상 변경 */
|
||||
background-color: #eceff1; /* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) */
|
||||
|
||||
font-size: 5vw;
|
||||
}
|
||||
|
||||
@media (min-width: 481px) {
|
||||
.tile {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================
|
||||
타일 색상 (테마 적용)
|
||||
================================= */
|
||||
|
||||
/* (★ 수정) 2, 4 타일은 베이지색 계열이라 테마와 충돌하므로 파란색 계열로 변경 */
|
||||
.tile-2 { background-color: #e3f2fd; color: #333; } /* #eee4da (베이지) -> #e3f2fd (밝은 파랑) */
|
||||
.tile-4 { background-color: #bbdefb; color: #333; } /* #ede0c8 (노란 베이지) -> #bbdefb (파랑) */
|
||||
|
||||
/* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) */
|
||||
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
|
||||
.tile-16 { background-color: #f59563; color: #f9f6f2; }
|
||||
.tile-32 { background-color: #f67c5f; color: #f9f6f2; }
|
||||
.tile-64 { background-color: #f65e3b; color: #f9f6f2; }
|
||||
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
|
||||
.tile-256 { background-color: #edcc61; color: #f9f6f2; }
|
||||
.tile-512 { background-color: #edc850; color: #f9f6f2; }
|
||||
.tile-1024 { background-color: #edc53f; color: #f9f6f2; }
|
||||
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }
|
||||
.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }
|
||||
.tile-8192 { background-color: #ff3333; color: #f9f6f2; }
|
||||
.tile-16384 { background-color: #0077cc; color: #f9f6f2; }
|
||||
.tile-32768 { background-color: #9900cc; color: #f9f6f2; }
|
||||
|
||||
|
||||
/* =================================
|
||||
게임 오버 팝업 (테마 적용)
|
||||
================================= */
|
||||
.popup-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.popup {
|
||||
/* (★ 수정) 배경색을 테마에 맞게 흰색으로 변경 */
|
||||
background-color: #ffffff; /* #faf8ef (베이지) -> #ffffff (흰색) */
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 80vw;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2); /* 흰색 배경이므로 그림자 추가 */
|
||||
}
|
||||
.popup input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.popup button {
|
||||
padding: 10px 20px;
|
||||
/* (★ 삭제) background-color, color, border -> common_game_theme의 파란색 버튼 스타일을 상속받음 */
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* =================================
|
||||
랭킹 리스트 (테마 적용)
|
||||
================================= */
|
||||
.ranking-container {
|
||||
/*
|
||||
(★ 참고) 이 컨테이너는 common_game_theme.css에서
|
||||
.game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.
|
||||
여기서는 내부 정렬만 담당합니다.
|
||||
*/
|
||||
width: 100%;
|
||||
max-width: 500px; /* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) */
|
||||
margin: 30px auto;
|
||||
text-align: left;
|
||||
}
|
||||
.ranking-container h3 {
|
||||
text-align: center;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
#ranking-list li {
|
||||
/* (★ 수정) 배경색 변경 */
|
||||
background-color: #f0f4f8; /* #eee4da (베이지) -> #f0f4f8 (밝은 회색) */
|
||||
margin-bottom: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<h1>2048</h1>
|
||||
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
|
||||
@ -24,18 +194,261 @@
|
||||
</div>
|
||||
<div id="game-board"></div>
|
||||
|
||||
<div id="game-over-popup" class="popup-container" style="display:none;">
|
||||
<div class="popup">
|
||||
<h2>게임 오버!</h2>
|
||||
<p>최종 점수: <span id="final-score">0</span></p>
|
||||
<input type="text" id="player-name" placeholder="이름을 입력하세요">
|
||||
<button id="save-score">점수 저장</button>
|
||||
<div id="game-over-popup" class="popup-container" style="display:none;">
|
||||
<div class="popup">
|
||||
<h2>게임 오버!</h2>
|
||||
<p>최종 점수: <span id="final-score">0</span></p>
|
||||
<input type="text" id="player-name" placeholder="이름을 입력하세요">
|
||||
<button id="save-score">점수 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ranking-container">
|
||||
<h3>🏆 랭킹</h3>
|
||||
<ol id="ranking-list"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ranking-container">
|
||||
<h3>🏆 랭킹</h3>
|
||||
<ol id="ranking-list"></ol>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ... (DOM 요소 가져오기 - 동일)
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
const scoreDisplay = document.getElementById('score');
|
||||
const gameOverPopup = document.getElementById('game-over-popup');
|
||||
const finalScoreDisplay = document.getElementById('final-score');
|
||||
const playerNameInput = document.getElementById('player-name');
|
||||
const saveScoreButton = document.getElementById('save-score');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
|
||||
// (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
|
||||
const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
|
||||
const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
|
||||
|
||||
let gridSize = 4;
|
||||
let board = [];
|
||||
let score = 0;
|
||||
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
|
||||
|
||||
// ----- 게임 핵심 로직 -----
|
||||
function initializeBoard() {
|
||||
gameBoard.innerHTML = ''; // 기존 타일 초기화
|
||||
for (let i = 0; i < gridSize * gridSize; i++) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
gameBoard.appendChild(tile);
|
||||
}
|
||||
board = Array(gridSize * gridSize).fill(0);
|
||||
addNumber();
|
||||
addNumber();
|
||||
updateBoard();
|
||||
}
|
||||
|
||||
function updateBoard() {
|
||||
const tiles = gameBoard.children;
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
const value = board[i];
|
||||
const tile = tiles[i];
|
||||
tile.textContent = value === 0 ? '' : value;
|
||||
tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
|
||||
}
|
||||
scoreDisplay.textContent = score;
|
||||
}
|
||||
|
||||
function addNumber() {
|
||||
const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
|
||||
if (available.length > 0) {
|
||||
const spot = available[Math.floor(Math.random() * available.length)];
|
||||
board[spot] = Math.random() < 0.9 ? 2 : 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 타일 이동 및 병합 로직 -----
|
||||
function moveRow(row) {
|
||||
let arr = row.filter(val => val);
|
||||
for (let i = 0; i < arr.length - 1; i++) {
|
||||
if (arr[i] === arr[i + 1]) {
|
||||
arr[i] *= 2;
|
||||
score += arr[i];
|
||||
arr[i + 1] = 0;
|
||||
}
|
||||
}
|
||||
arr = arr.filter(val => val);
|
||||
const missing = gridSize - arr.length;
|
||||
const zeros = Array(missing).fill(0);
|
||||
return arr.concat(zeros);
|
||||
}
|
||||
|
||||
function moveLeft() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize);
|
||||
const newRow = moveRow(row);
|
||||
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveRight() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize).reverse();
|
||||
const newRow = moveRow(row).reverse();
|
||||
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
|
||||
const newCol = moveRow(col);
|
||||
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
|
||||
const newCol = moveRow(col).reverse();
|
||||
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ----- 게임 상태 관리 -----
|
||||
function isGameOver() {
|
||||
if (!board.includes(0)) {
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
const current = board[i * gridSize + j];
|
||||
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
|
||||
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMove(moveFunction) {
|
||||
if (moveFunction()) {
|
||||
addNumber();
|
||||
updateBoard();
|
||||
if (isGameOver()) {
|
||||
finalScoreDisplay.textContent = score;
|
||||
gameOverPopup.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 이벤트 리스너 -----
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': handleMove(moveUp); break;
|
||||
case 'ArrowDown': handleMove(moveDown); break;
|
||||
case 'ArrowLeft': handleMove(moveLeft); break;
|
||||
case 'ArrowRight': handleMove(moveRight); break;
|
||||
}
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||
|
||||
gameBoard.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
});
|
||||
|
||||
function handleSwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
const swipeThreshold = 30;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (Math.abs(deltaX) > swipeThreshold) {
|
||||
handleMove(deltaX > 0 ? moveRight : moveLeft);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(deltaY) > swipeThreshold) {
|
||||
handleMove(deltaY > 0 ? moveDown : moveUp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 랭킹 API 연동 -----
|
||||
saveScoreButton.addEventListener('click', async () => {
|
||||
const playerName = playerNameInput.value.trim();
|
||||
if (playerName === "") return alert("이름을 입력해주세요.");
|
||||
|
||||
try {
|
||||
// (★ 수정) user.js의 공통 submitRank 함수 호출
|
||||
// 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
|
||||
await submitRank(currentGameType, currentContextId, playerName, score, null);
|
||||
|
||||
gameOverPopup.style.display = 'none';
|
||||
playerNameInput.value = '';
|
||||
score = 0;
|
||||
updateRankingList(); // 랭킹 리스트 새로고침
|
||||
initializeBoard(); // 새 게임 시작
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting rank:', error);
|
||||
alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
*/
|
||||
async function updateRankingList() {
|
||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
try {
|
||||
// (★ 수정) user.js의 공통 fetchRanks 함수 호출
|
||||
const rankings = await fetchRanks(currentGameType, currentContextId);
|
||||
|
||||
rankingList.innerHTML = ''; // 리스트 비우기
|
||||
if (!rankings || rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
// (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
|
||||
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ranks:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 게임 시작 -----
|
||||
updateRankingList();
|
||||
initializeBoard();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
1052
src/main/resources/templates/content/puzzle/nonogram.html
Normal file
1052
src/main/resources/templates/content/puzzle/nonogram.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,58 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/play.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/play.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
<th:block layout:fragment="content">
|
||||
<h1>Solve the Puzzle! 🧩</h1>
|
||||
|
||||
<div id="game-controls">
|
||||
<div id="mode-selector">
|
||||
<label>
|
||||
<input type="radio" name="play-mode" value="fill" checked><span> Fill</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="play-mode" value="mark"><span> Mark (X)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="points-info">
|
||||
❤️ Points: <span id="points-display">5</span>
|
||||
</div>
|
||||
<button id="hint-btn">Hint (-1 Point)</button>
|
||||
</div>
|
||||
|
||||
<div id="board-viewport">
|
||||
<div id="game-board">
|
||||
</div>
|
||||
<img id="grayscale-reveal" class="reveal-img" src="" alt="Grayscale version">
|
||||
<img id="original-reveal" class="reveal-img" src="" alt="Original version">
|
||||
|
||||
<div id="result-overlay" class="hidden">
|
||||
<div id="result-modal">
|
||||
<h2 id="modal-title"></h2>
|
||||
<p id="modal-message"></p>
|
||||
<div id="modal-buttons">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
const puzzleData = /*[[${puzzle}]]*/ null;
|
||||
/*]]>*/
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,16 +5,253 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/sudoku.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<head layout:fragment="head" id="head">
|
||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
|
||||
<style>
|
||||
/* sudoku.css의 내용을 여기에 삽입 */
|
||||
body {
|
||||
/*
|
||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f7f9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
*/
|
||||
}
|
||||
|
||||
#sudoku-game-app {
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
/*
|
||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서
|
||||
'#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.
|
||||
|
||||
background: white;
|
||||
padding: clamp(15px, 4vw, 30px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
*/
|
||||
|
||||
/* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
/* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.
|
||||
만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.
|
||||
현재는 공통 스타일이 적용됩니다. */
|
||||
}
|
||||
|
||||
/* ======================================= */
|
||||
/* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) */
|
||||
/* ======================================= */
|
||||
|
||||
/* 게임 컨테이너 */
|
||||
#game-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 게임 정보 (점수, 타이머) */
|
||||
.game-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
#score { color: #007bff; }
|
||||
#timer { color: #333; }
|
||||
|
||||
/* 스도쿠 보드 */
|
||||
#sudoku-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr);
|
||||
width: 100%;
|
||||
border: 3px solid #333;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell:nth-child(3n) { border-right: 2px solid #333; }
|
||||
.cell:nth-child(9n) { border-right-width: 1px; }
|
||||
.cell:nth-child(n+19):nth-child(-n+27),
|
||||
.cell:nth-child(n+46):nth-child(-n+54) {
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.cell:not(.editable) {
|
||||
background-color: #f0f0f0;
|
||||
color: #222;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 하이라이트 & 오답 스타일 */
|
||||
.cell.incorrect {
|
||||
background-color: #ffdddd !important;
|
||||
color: #d8000c !important;
|
||||
}
|
||||
.highlight-focused {
|
||||
background-color: #dbeeff !important;
|
||||
}
|
||||
.highlight-same-number {
|
||||
background-color: #e6e6e6 !important;
|
||||
}
|
||||
.highlight-selected-number {
|
||||
background-color: #b3d7ff !important;
|
||||
}
|
||||
|
||||
/* 숫자 입력 버튼 */
|
||||
#number-input-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
gap: 1%;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn,
|
||||
#number-input-buttons #undo-btn {
|
||||
line-height: unset;
|
||||
min-width: unset;
|
||||
width: 9%;
|
||||
aspect-ratio: 1/1;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn.selected {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn.completed {
|
||||
opacity: 0.4;
|
||||
background-color: #e9ecef;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#number-input-buttons #undo-btn {
|
||||
background-color: #f8f9fa;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* 액션 버튼 (힌트, 정답확인) */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.action-buttons button {
|
||||
flex-grow: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 모달 및 숨김 처리 */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
#modal-overlay, #game-over-modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
#modal-content h2, #modal-content h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#username-input {
|
||||
width: calc(100% - 24px);
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: decimal;
|
||||
list-style-position: inside;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#ranking-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#ranking-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="sudoku-game-app">
|
||||
<div class="container">
|
||||
@ -76,5 +313,373 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// sudoku.js의 내용을 여기에 삽입
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM 요소
|
||||
const setupContainer = document.getElementById('setup-container');
|
||||
const gameContainer = document.getElementById('game-container');
|
||||
const startBtn = document.getElementById('start-btn');
|
||||
const boardElement = document.getElementById('sudoku-board');
|
||||
const timerElement = document.getElementById('timer');
|
||||
const scoreElement = document.getElementById('score');
|
||||
const hintBtn = document.getElementById('hint-btn');
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
const completeBtn = document.getElementById('complete-btn');
|
||||
const numberInputButtons = document.getElementById('number-input-buttons');
|
||||
const modalOverlay = document.getElementById('modal-overlay');
|
||||
const gameOverModal = document.getElementById('game-over-modal');
|
||||
const retryBtn = document.getElementById('retry-btn');
|
||||
const submitRankBtn = document.getElementById('submit-rank-btn');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
|
||||
// 게임 상태 변수
|
||||
let currentPuzzleId = null;
|
||||
let solvedPuzzle = null;
|
||||
let timerInterval = null;
|
||||
let secondsElapsed = 0;
|
||||
let selectedNumber = null;
|
||||
let focusedCell = null;
|
||||
let score = 5;
|
||||
let history = [];
|
||||
|
||||
// (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
|
||||
startBtn.addEventListener('click', async () => {
|
||||
const difficulty = document.getElementById('difficulty-select').value;
|
||||
try {
|
||||
// (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
|
||||
const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
|
||||
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
||||
const gameData = await response.json();
|
||||
|
||||
currentPuzzleId = gameData.puzzleId;
|
||||
solvedPuzzle = gameData.solution;
|
||||
|
||||
history = [];
|
||||
score = 5;
|
||||
updateScoreDisplay();
|
||||
|
||||
renderBoard(gameData.question);
|
||||
startTimer();
|
||||
updateButtonStates();
|
||||
|
||||
setupContainer.classList.add('hidden');
|
||||
gameContainer.classList.remove('hidden');
|
||||
numberInputButtons.classList.remove('hidden');
|
||||
gameOverModal.classList.add('hidden');
|
||||
} catch (error) {
|
||||
alert('게임 로딩에 실패했습니다: ' + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
function renderBoard(puzzleString) {
|
||||
boardElement.innerHTML = '';
|
||||
for (let i = 0; i < 81; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('cell');
|
||||
cell.dataset.index = i;
|
||||
|
||||
if (puzzleString[i] !== '0') {
|
||||
cell.textContent = puzzleString[i];
|
||||
} else {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
boardElement.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
secondsElapsed = 0;
|
||||
timerElement.textContent = '00:00';
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => {
|
||||
secondsElapsed++;
|
||||
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
|
||||
const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
|
||||
timerElement.textContent = `${minutes}:${seconds}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreElement.textContent = `SCORE: ${score}`;
|
||||
if (score <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
gameOverModal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStates() {
|
||||
const counts = {};
|
||||
for (let i = 1; i <= 9; i++) counts[i] = 0;
|
||||
boardElement.querySelectorAll('.cell').forEach(cell => {
|
||||
const num = cell.textContent;
|
||||
if (num && counts[num] !== undefined) counts[num]++;
|
||||
});
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
|
||||
if (btn) {
|
||||
if (counts[i] >= 9) {
|
||||
btn.classList.add('completed');
|
||||
if (selectedNumber == i) {
|
||||
selectedNumber = null;
|
||||
btn.classList.remove('selected');
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 플레이 이벤트 핸들링 ---
|
||||
numberInputButtons.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('button');
|
||||
if (!target) return;
|
||||
|
||||
if (target === undoBtn) {
|
||||
undoAction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('completed')) return;
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||
|
||||
if (target.classList.contains('num-btn')) {
|
||||
const num = target.dataset.number;
|
||||
selectedNumber = (selectedNumber === num) ? null : num;
|
||||
if (selectedNumber) target.classList.add('selected');
|
||||
}
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
boardElement.addEventListener('click', (event) => {
|
||||
const targetCell = event.target.closest('.cell.editable');
|
||||
if (!targetCell) {
|
||||
if (focusedCell) focusedCell = null;
|
||||
highlightCells();
|
||||
return;
|
||||
}
|
||||
focusedCell = targetCell;
|
||||
|
||||
if (selectedNumber) {
|
||||
const previousValue = targetCell.textContent;
|
||||
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||
targetCell.textContent = newValue;
|
||||
|
||||
recordAction(targetCell, previousValue, newValue);
|
||||
validateCell(targetCell);
|
||||
updateButtonStates();
|
||||
checkIfBoardIsFull();
|
||||
}
|
||||
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
hintBtn.addEventListener('click', () => {
|
||||
if (score <= 0) return;
|
||||
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
||||
if (emptyCells.length === 0) {
|
||||
alert('모든 칸이 채워져 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
const cellIndex = parseInt(randomCell.dataset.index);
|
||||
const correctAnswer = solvedPuzzle[cellIndex];
|
||||
const previousValue = randomCell.textContent;
|
||||
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
recordAction(randomCell, previousValue, correctAnswer, true);
|
||||
|
||||
randomCell.textContent = correctAnswer;
|
||||
randomCell.classList.remove('editable', 'incorrect');
|
||||
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
checkIfBoardIsFull();
|
||||
});
|
||||
|
||||
function undoAction() {
|
||||
if (history.length === 0) return;
|
||||
const lastAction = history.pop();
|
||||
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||
|
||||
if (cell) {
|
||||
cell.textContent = lastAction.previousValue;
|
||||
if (lastAction.wasHint) {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
validateCell(cell, false);
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
}
|
||||
}
|
||||
|
||||
function recordAction(cell, previousValue, newValue, wasHint = false) {
|
||||
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
|
||||
}
|
||||
|
||||
function validateCell(cell, deductPoint = true) {
|
||||
if (!cell.textContent) {
|
||||
cell.classList.remove('incorrect');
|
||||
return;
|
||||
}
|
||||
const cellIndex = parseInt(cell.dataset.index);
|
||||
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
|
||||
if (!isCorrect) {
|
||||
cell.classList.add('incorrect');
|
||||
if (deductPoint && score > 0) {
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
} else {
|
||||
cell.classList.remove('incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 하이라이트 기능 ---
|
||||
function highlightCells() {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
if (focusedCell) {
|
||||
focusedCell.classList.add('highlight-focused');
|
||||
const focusedValue = focusedCell.textContent;
|
||||
if (focusedValue) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedNumber) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 완료 및 모달 ---
|
||||
async function checkSolution() {
|
||||
let answerString = "";
|
||||
boardElement.childNodes.forEach(cell => {
|
||||
answerString += cell.textContent || '0';
|
||||
});
|
||||
|
||||
if (answerString.includes('0')) {
|
||||
alert('모든 칸을 채워주세요!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
|
||||
// (★ 수정) currentPuzzleId 변수 사용
|
||||
const response = await fetch('/puzzle/sudoku/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.correct) {
|
||||
clearInterval(timerInterval);
|
||||
alert('🎉 정답입니다!');
|
||||
showRankingModal(); // 랭킹 모달 표시
|
||||
} else {
|
||||
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('정답 확인 중 오류 발생:', error);
|
||||
alert('정답 확인 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfBoardIsFull() {
|
||||
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
|
||||
if (!emptyEditableCells) {
|
||||
checkSolution();
|
||||
}
|
||||
}
|
||||
completeBtn.addEventListener('click', checkSolution);
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
*/
|
||||
async function showRankingModal() {
|
||||
modalOverlay.classList.remove('hidden');
|
||||
document.getElementById('username-input').value = '';
|
||||
submitRankBtn.disabled = false;
|
||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
|
||||
try {
|
||||
// user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID 전달)
|
||||
// currentGameType 변수가 정의되어 있어야 합니다. 예: const currentGameType = 'sudoku';
|
||||
const currentGameType = 'sudoku';
|
||||
const rankings = await fetchRanks(currentGameType, currentPuzzleId);
|
||||
|
||||
rankingList.innerHTML = '';
|
||||
if (rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||
} else {
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
|
||||
const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
|
||||
li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('랭킹 조회 중 오류 발생:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
|
||||
*/
|
||||
submitRankBtn.addEventListener('click', async () => {
|
||||
const userName = document.getElementById('username-input').value.trim();
|
||||
if (!userName) return alert('이름을 입력해주세요.');
|
||||
|
||||
try {
|
||||
// user.js의 공통 submitRank 함수 호출
|
||||
const currentGameType = 'sudoku';
|
||||
await submitRank(currentGameType, currentPuzzleId, userName, secondsElapsed, null);
|
||||
|
||||
alert('랭킹이 성공적으로 등록되었습니다!');
|
||||
showRankingModal(); // 랭킹 목록 새로고침
|
||||
submitRankBtn.disabled = true; // 중복 등록 방지
|
||||
} catch (error) {
|
||||
console.error('랭킹 등록 중 오류 발생:', error);
|
||||
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
function resetGameView() {
|
||||
setupContainer.classList.remove('hidden');
|
||||
gameContainer.classList.add('hidden');
|
||||
numberInputButtons.classList.add('hidden');
|
||||
clearInterval(timerInterval);
|
||||
selectedNumber = null;
|
||||
focusedCell = null;
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
|
||||
}
|
||||
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
modalOverlay.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
|
||||
retryBtn.addEventListener('click', () => {
|
||||
gameOverModal.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
@ -6,8 +6,8 @@
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/upload.js}"></script>
|
||||
<link th:href="@{/css/puzzle.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/nonogram.js}"></script>
|
||||
<link th:href="@{/css/nonogram.css}" rel="stylesheet" />
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
|
||||
@ -6,12 +6,12 @@
|
||||
<meta name="Referrer" content="origin"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1"/>
|
||||
<script type="text/javascript" th:src="@{https://code.jquery.com/jquery-3.5.1.min.js}" crossorigin="anonymous"></script>
|
||||
|
||||
<title>BUM'sPace</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<script async th:src="@{https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716}" crossorigin="anonymous"></script>
|
||||
<script type="text/javascript" th:src="@{/js/common.js}"></script>
|
||||
|
||||
<link th:href="@{/css/common.css}" rel="stylesheet" />
|
||||
<link th:href="@{/css/main.css}" rel="stylesheet" />
|
||||
<meta name="_csrf" th:content="${_csrf.token}"/>
|
||||
@ -20,6 +20,7 @@
|
||||
/*
|
||||
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
|
||||
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
|
||||
* (이 블록은 <head>에 남아있어도 괜찮습니다. 전역 변수를 정의하는 역할입니다.)
|
||||
*/
|
||||
var serverData = {
|
||||
// --- Key IDs ---
|
||||
|
||||
@ -56,8 +56,10 @@
|
||||
<th:block layout:fragment="head"></th:block>
|
||||
<th:block th:replace="~{fragments/footer :: footer}"></th:block>
|
||||
</div>
|
||||
<!-- Scripts -->
|
||||
<script th:src="@{/js/jquery.min.js}"></script>
|
||||
|
||||
<script th:src="@{/js/common.js}"></script>
|
||||
<script th:src="@{/js/user}"></script>
|
||||
<script th:src="@{/js/jquery.dropotron.min.js}"></script>
|
||||
<script th:src="@{/js/browser.min.js}"></script>
|
||||
<script th:src="@{/js/breakpoints.min.js}"></script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user