diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index 164a73c..0002ae0 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -72,7 +72,7 @@ class SecurityConfig( "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx", "/blog/post/images/**","/puzzle/**","/puzzle/play/**", - "/rank/**", + "/rank/**","/spider/**", "/sudoku/**", ) // 여기 예외 추가 }.authorizeHttpRequests { auth -> @@ -86,8 +86,9 @@ class SecurityConfig( "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", // "/blog/post/imageUpload.bjx", "/blog/post/images/**", - "/rank/**","/sudoku/**", - "/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku", + "/spider/new**", + "/rank/**","/sudoku/**","/spider/**", + "/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() .anyRequest().authenticated() }.formLogin { form -> diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt index 94211fd..951760a 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/PuzzleController.kt @@ -106,6 +106,11 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 } + @GetMapping("/spider") + suspend fun spider(): ResultMV { + val vm = ResultMV("content/puzzle/spider") + return vm + } @GetMapping("/","/upload.bs") diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt new file mode 100644 index 0000000..68ba14c --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SpiderController.kt @@ -0,0 +1,58 @@ +package kr.lunaticbum.back.lun.controllers + +import kr.lunaticbum.back.lun.model.SpiderGame +import kr.lunaticbum.back.lun.model.SpiderRank +import kr.lunaticbum.back.lun.model.SpiderService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@RestController +@RequestMapping("/spider") +class SpiderController(private val spiderService: SpiderService,) { + + @GetMapping("/new") + fun newGame(@RequestParam numSuits: Int, @RequestParam numCards: String): Mono { + return spiderService.newGame(numSuits, numCards) + } + + @GetMapping("/{id}") + fun getGame(@PathVariable id: String): Mono { + return spiderService.getGame(id) + } + + @PostMapping("/update") + fun updateGame(@RequestBody game: SpiderGame): Mono { + return spiderService.updateGame(game) + } + + + // 랭킹 등록 엔드포인트 + @PostMapping("/register") + fun registerRank(@RequestBody rank: SpiderRank): Mono> { + return spiderService.registerRank(rank) + .map { savedRank -> ResponseEntity.ok(savedRank) } + .onErrorResume(IllegalArgumentException::class.java) { e -> + Mono.just(ResponseEntity.badRequest().body(null)) + } + } + + // 게임 ID별 랭킹 조회 엔드포인트 + @GetMapping("/list/{gameId}") + fun getRanks(@PathVariable gameId: String): Flux { + return spiderService.getRanksByGameId(gameId) + } + + @PostMapping("/deal") + fun dealCards(@RequestBody request: Map): Mono { + val gameId = request["gameId"] ?: return Mono.error(IllegalArgumentException("Game ID is required.")) + return spiderService.dealCardsFromStock(gameId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt new file mode 100644 index 0000000..08c24a4 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Spider.kt @@ -0,0 +1,202 @@ +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 cards: List, + val moves: Int, + val isCompleted: Boolean, + val timestamp: Long = System.currentTimeMillis() +) + +data class SpiderCard( + val suit: String, // 스페이드, 하트, 클로버, 다이아 + val rank: Int, // 1(A), 2, ..., 13(K) + var isFaceUp: Boolean, // 앞면인지 뒷면인지 + var stack: String // 어느 더미에 속하는지 (ex: 'tableau-1', 'stock', 'foundation') +) + +interface SpiderGameRepository : ReactiveMongoRepository { + override fun findById(id: String): Mono +} + +@Service +class SpiderService(private val spiderGameRepository: SpiderGameRepository, + private val spiderRankRepository: SpiderRankRepository ) { + + // 🔴 수정: 무늬 수(numSuits)와 카드 수 문자열(numCards)을 인자로 받습니다. + fun newGame(numSuits: Int, numCards: String): Mono { + // numCards 문자열 "4,5"를 List [4, 5]로 파싱합니다. + val initialCards = numCards.split(",").map { it.trim().toInt() } + val cardsPerStack = listOf( + initialCards[0], initialCards[0], initialCards[0], initialCards[0], + initialCards[1], initialCards[1], initialCards[1], initialCards[1], initialCards[1], initialCards[1] + ) + + val allCards = createDeck(numSuits) + val shuffledCards = allCards.shuffled(Random) + val initialGame = SpiderGame( + id = null, + cards = dealCards(shuffledCards, cardsPerStack), + moves = 0, + isCompleted = false + ) + return spiderGameRepository.save(initialGame) + } + + // 🔴 수정: 무늬 수를 인자로 받는 createDeck 함수 + private fun createDeck(numSuits: Int): List { + val allSuits = listOf("spade", "heart", "club", "diamond") + val suits = allSuits.take(numSuits) + + val deck = mutableListOf() + val setsPerSuit = 8 / numSuits // 각 무늬별로 생성해야 할 세트 수 계산 + + repeat(setsPerSuit) { // 각 무늬별로 필요한 세트 수만큼 반복 + for (suit in suits) { + for (rank in 1..13) { + deck.add(SpiderCard(suit, rank, isFaceUp = false, stack = "stock")) + } + } + } + return deck + } + + // 🔴 수정: 스택별 카드 수를 인자로 받는 dealCards 함수 + private fun dealCards(shuffledCards: List, cardsPerStack: List): List { + val cards = shuffledCards.toMutableList() + val tableauCards = mutableListOf() + + cardsPerStack.forEachIndexed { i, numCards -> + repeat(numCards) { + val card = cards.removeFirst() + card.stack = "tableau-${i + 1}" + card.isFaceUp = (it == numCards - 1) + tableauCards.add(card) + } + } + + cards.forEach { it.stack = "stock" } + + return tableauCards + cards + } + + private fun createDeck(): List { + val suits = listOf("spade", "heart", "club", "diamond") + val deck = mutableListOf() + repeat(2) { + for (suit in suits) { + for (rank in 1..13) { + deck.add(SpiderCard(suit, rank, isFaceUp = false, stack = "stock")) + } + } + } + return deck + } + + private fun dealCards(shuffledCards: List): List { + val cards = shuffledCards.toMutableList() + val tableauCards = mutableListOf() + + for (i in 0 until 10) { + val numCards = if (i < 4) 5 else 4 + repeat(numCards) { + val card = cards.removeFirst() + card.stack = "tableau-${i + 1}" + card.isFaceUp = (it == numCards - 1) + tableauCards.add(card) + } + } + + cards.forEach { it.stack = "stock" } + + return tableauCards + cards + } + + fun dealCardsFromStock(gameId: String): Mono { + return spiderGameRepository.findById(gameId) + .flatMap { game -> + val stockCards = game.cards.filter { it.stack == "stock" } + if (stockCards.size >= 10) { + // A map to hold the updated cards + val updatedCards = game.cards.toMutableList() + + // Take 10 cards from the stock + val cardsToDeal = stockCards.take(10) + + // Update their stack and face-up status + cardsToDeal.forEachIndexed { index, card -> + val targetStack = "tableau-${index + 1}" + val originalCard = updatedCards.find { it == card } + if (originalCard != null) { + originalCard.stack = targetStack + originalCard.isFaceUp = true + } + } + + // Update the game and save it + val updatedGame = game.copy(cards = updatedCards, moves = game.moves + 1) + spiderGameRepository.save(updatedGame) + } else { + // No more cards to deal + Mono.error(IllegalArgumentException("No more cards in stock.")) + } + } + } + + fun updateGame(game: SpiderGame): Mono { + // ... 카드 이동, 묶음 제거 등 실제 게임 로직 구현 ... + return spiderGameRepository.save(game) + } + + fun getGame(id: String): Mono { + return spiderGameRepository.findById(id) + } + // 게임 완료 후 랭킹 등록 + fun registerRank(rank: SpiderRank): Mono { + // 게임의 유효성 검증 로직 (선택적): + // 해당 gameId가 실제로 존재하는지, 게임이 완료 상태인지 확인 가능 + return spiderGameRepository.findById(rank.gameId) + .flatMap { game -> + if (game.isCompleted) { + spiderRankRepository.save(rank) + } else { + Mono.error(IllegalArgumentException("게임이 완료되지 않았습니다.")) + } + } + .switchIfEmpty(Mono.error(IllegalArgumentException("존재하지 않는 게임 ID입니다."))) + } + + // 게임 ID별 랭킹 조회 + fun getRanksByGameId(gameId: String): Flux { + return spiderRankRepository.findByGameIdOrderByMovesAscCompletionTimeAsc(gameId) + } +} + +@Document(collection = "spider_ranks") +data class SpiderRank( + @Id + val id: String? = null, + val gameId: String, // 어떤 게임의 기록인지 + val playerName: String, // 플레이어 이름 + val moves: Int, // 이동 횟수 (낮을수록 좋은 기록) + val completionTime: Long, // 완료 시간 (낮을수록 좋은 기록) + val timestamp: Long = System.currentTimeMillis() +) + +interface SpiderRankRepository : ReactiveMongoRepository { + // gameId를 기준으로 랭킹을 가져오는 메서드 + // moves와 completionTime이 낮을수록 높은 순위이므로, 두 필드로 정렬하여 반환합니다. + fun findByGameIdOrderByMovesAscCompletionTimeAsc(gameId: String): Flux +} + + diff --git a/src/main/resources/static/assets/css/images/card-back.png b/src/main/resources/static/assets/css/images/card-back.png new file mode 100644 index 0000000..0209f4a Binary files /dev/null and b/src/main/resources/static/assets/css/images/card-back.png differ diff --git a/src/main/resources/static/css/spider.css b/src/main/resources/static/css/spider.css new file mode 100644 index 0000000..478eaca --- /dev/null +++ b/src/main/resources/static/css/spider.css @@ -0,0 +1,40 @@ +/* src/main/resources/static/css/spider.css */ + +/* + * 전체 게임 컨테이너 (Canvas를 담는 역할) + * - 기존 블로그 레이아웃을 해치지 않도록 최소한의 스타일만 적용 + */ +#game-container { + display: flex; + justify-content: center; + align-items: center; + background-color: #008000; + width: 100vw; + height: 100vh; + box-sizing: border-box; +} + +/* + * 캔버스 스타일 + * - 배경을 초록색으로 설정하고, 테두리를 추가 + * - 화면 크기에 맞춰 비율을 유지하며 최대 크기 제한 + */ +#gameCanvas { + background-color: #008000; + border: 2px solid #fff; + max-width: 90vw; + max-height: 90vh; + box-sizing: border-box; +} + +/* + * 카드 이동 애니메이션 (Canvas 로직에서는 사용하지 않지만, 향후 재사용을 위해 남겨둠) + */ +@keyframes moveCard { + from { + transform: translate(var(--fromX), var(--fromY)); + } + to { + transform: translate(var(--toX), var(--toY)); + } +} \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index eaa06fd..91bc2b7 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -267,7 +267,7 @@ function gotoPuzzleUpload() { } -function gotoPuzzleUpload() { +function gotoSudoKuGen() { document.location.replace(getMainPath()+"/puzzle/sudoku_gen.bs") } diff --git a/src/main/resources/static/js/spider.js b/src/main/resources/static/js/spider.js new file mode 100644 index 0000000..8489d19 --- /dev/null +++ b/src/main/resources/static/js/spider.js @@ -0,0 +1,754 @@ +// src/main/resources/static/js/spider.js + +document.addEventListener('DOMContentLoaded', () => { + // === 1. 게임 상태 및 Canvas 설정 === + const canvas = document.getElementById('gameCanvas'); + const ctx = canvas.getContext('2d'); + + let gameId = null; + let currentGame = null; + const API_BASE_URL = '/spider'; + + let cardWidth = 0; + let cardHeight = 0; + let cardOverlapY = 0; + + let isAnimating = false; + let isDragging = false; + let draggedCards = []; // 🔴 드래그 중인 카드 묶음 (배열) + let dragOffsetX = 0; + let dragOffsetY = 0; + let lastTapTime = 0; + + let animatedCard = null; + let animationProgress = 0; + let shakeOffset = 0; + let dimOpacity = 0; + let shakenCard = null; + + // 카드 뒷면 이미지 로드 + const cardBackImage = new Image(); + cardBackImage.src = '../assets/css/images/card-back.png'; + + let assetsLoaded = false; + cardBackImage.onload = () => { + assetsLoaded = true; + startNewGame(); + }; + + // Canvas 크기 조절 + function resizeCanvas() { + canvas.width = window.innerWidth * 0.9; + canvas.height = window.innerHeight * 0.9; + cardWidth = canvas.width / 12; + cardHeight = cardWidth * 1.4; + cardOverlapY = cardHeight * 0.2; + } + window.addEventListener('resize', resizeCanvas); + resizeCanvas(); + + // === 2. 렌더링 및 게임 루프 === + + + /** + * 🔴 추가: 스톡 더미와 파운데이션 영역을 그리는 로직 + */ + function drawStockAndFoundation(cards) { + const stockCards = cards.filter(card => card.stack === 'stock'); + const foundationCards = cards.filter(card => card.stack.startsWith('foundation')); + + // 스톡 더미 그리기 (오른쪽 아래) + const stockX = canvas.width - cardWidth * 1.5; + const stockY = canvas.height - cardHeight * 1.5; + + // 카드가 10장 이상 있을 때만 스톡 더미를 표시 + if (stockCards.length > 0) { + ctx.drawImage(cardBackImage, stockX, stockY, cardWidth, cardHeight); + } + + // 파운데이션 영역 그리기 (왼쪽 아래) + const foundationY = canvas.height - cardHeight * 1.5; + const foundationStacks = foundationCards.reduce((acc, card) => { + const stackId = card.stack; + if (!acc[stackId]) acc[stackId] = []; + acc[stackId].push(card); + return acc; + }, {}); + + Object.keys(foundationStacks).forEach((stackId, index) => { + const stackCards = foundationStacks[stackId]; + const foundationX = cardWidth * 0.5 + index * (cardWidth + 10); + + if (stackCards.length > 0) { + // 완성된 스택의 맨 위 카드만 그리기 + const topCard = stackCards[stackCards.length - 1]; + drawSingleCard(topCard, foundationX, foundationY); + } + }); + } + + /** + * 🔴 추가: 단일 카드를 그리는 헬퍼 함수 + */ + function drawSingleCard(card, x, y) { + if (card.isFaceUp) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x, y, cardWidth, cardHeight); + ctx.strokeStyle = '#333333'; + ctx.strokeRect(x, y, cardWidth, cardHeight); + + const isRed = (card.suit === 'heart' || card.suit === 'diamond'); + ctx.fillStyle = isRed ? '#ff0000' : '#000000'; + ctx.font = `${cardWidth * 0.25}px Arial`; + ctx.fillText(getRankText(card.rank), x + cardWidth * 0.1, y + cardHeight * 0.25); + ctx.fillText(getSuitSymbol(card.suit), x + cardWidth * 0.1, y + cardHeight * 0.5); + } else { + ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); + } + } + + function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#008000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + if (currentGame && assetsLoaded) { + // 테이블로 스택을 그립니다. + drawTableauCards(currentGame.cards); + + // 추가: 스톡과 파운데이션을 그립니다. + drawStockAndFoundation(currentGame.cards); + + if (animatedCard) drawAnimatedCard(); + if (shakeOffset !== 0) drawShakingCard(); + if (dimOpacity > 0) drawDimOverlay(); + } + + requestAnimationFrame(draw); + } + + /** + * 테이블로 스택의 카드들을 그리는 로직 (기존 drawCards 함수명 변경) + */ + function drawTableauCards(cards) { + const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2; + const startY = cardHeight * 0.5; + + for (let i = 0; i < 10; i++) { + const stackCards = cards.filter(card => card.stack === `tableau-${i + 1}`); + stackCards.forEach((card, index) => { + if (isDragging && draggedCards.some(dc => dc.suit === card.suit && dc.rank === card.rank && dc.stack === card.stack)) { + return; + } + + const x = startX + i * (cardWidth + cardWidth * 0.5); + const y = startY + index * cardOverlapY; + + drawSingleCard(card, x, y); + }); + } + + if (isDragging && draggedCards.length > 0) { + drawDraggedCards(draggedCards); + } + } + + + + /** + * 드래그 중인 카드 묶음을 그리는 함수 + */ + function drawDraggedCards(cards) { + cards.forEach((card, index) => { + const x = cards[0].x; + const y = cards[0].y + index * cardOverlapY; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x, y, cardWidth, cardHeight); + ctx.strokeStyle = '#333333'; + ctx.strokeRect(x, y, cardWidth, cardHeight); + + const isRed = (card.suit === 'heart' || card.suit === 'diamond'); + ctx.fillStyle = isRed ? '#ff0000' : '#000000'; + ctx.font = `${cardWidth * 0.25}px Arial`; + ctx.fillText(getRankText(card.rank), x + cardWidth * 0.1, y + cardHeight * 0.25); + ctx.fillText(getSuitSymbol(card.suit), x + cardWidth * 0.1, y + cardHeight * 0.5); + }); + } + + /** + * 이동 애니메이션 중인 카드를 그리는 로직 + */ + function drawAnimatedCard() { + if (!animatedCard) return; + + const startX = animatedCard.startX + (animatedCard.endX - animatedCard.startX) * animationProgress; + const startY = animatedCard.startY + (animatedCard.endY - animatedCard.startY) * animationProgress; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(startX, startY, cardWidth, cardHeight); + ctx.strokeStyle = '#333333'; + ctx.strokeRect(startX, startY, cardWidth, cardHeight); + + const isRed = (animatedCard.card.suit === 'heart' || animatedCard.card.suit === 'diamond'); + ctx.fillStyle = isRed ? '#ff0000' : '#000000'; + ctx.font = `${cardWidth * 0.25}px Arial`; + ctx.fillText(getRankText(animatedCard.card.rank), startX + cardWidth * 0.1, startY + cardHeight * 0.25); + ctx.fillText(getSuitSymbol(animatedCard.card.suit), startX + cardWidth * 0.1, startY + cardHeight * 0.5); + + animationProgress += 0.05; + if (animationProgress >= 1) { + isAnimating = false; + animatedCard = null; + } + } + + /** + * 무효화 시 흔들리는 카드를 그리는 로직 + */ + function drawShakingCard() { + if (!shakenCard) return; + + const x = shakenCard.originalX + shakeOffset; + const y = shakenCard.originalY; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x, y, cardWidth, cardHeight); + ctx.strokeStyle = '#333333'; + ctx.strokeRect(x, y, cardWidth, cardHeight); + + const isRed = (shakenCard.card.suit === 'heart' || shakenCard.card.suit === 'diamond'); + ctx.fillStyle = isRed ? '#ff0000' : '#000000'; + ctx.font = `${cardWidth * 0.25}px Arial`; + ctx.fillText(getRankText(shakenCard.card.rank), x + cardWidth * 0.1, y + cardHeight * 0.25); + ctx.fillText(getSuitSymbol(shakenCard.card.suit), x + cardWidth * 0.1, y + cardHeight * 0.5); + } + + /** + * 붉은색 딤 오버레이를 그리는 로직 + */ + function drawDimOverlay() { + ctx.fillStyle = `rgba(255, 0, 0, ${dimOpacity})`; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + // === 3. 이벤트 리스너 연결 === + canvas.addEventListener('mousedown', handlePointerDown); + canvas.addEventListener('mousemove', handlePointerMove); + canvas.addEventListener('mouseup', handlePointerUp); + canvas.addEventListener('touchstart', handlePointerDown); + canvas.addEventListener('touchmove', handlePointerMove); + canvas.addEventListener('touchend', handlePointerUp); + canvas.addEventListener('dblclick', handleDoubleClick); + + function getCanvasCoordinates(event) { + const rect = canvas.getBoundingClientRect(); + const clientX = event.touches ? event.touches[0].clientX : event.clientX; + const clientY = event.touches ? event.touches[0].clientY : event.clientY; + return { + x: clientX - rect.left, + y: clientY - rect.top + }; + } + + function findCardAt(x, y, cards) { + const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2; + const startY = cardHeight * 0.5; + + // 원본 배열의 참조를 유지하면서 복사본을 만들어 정렬 + const sortedCards = cards.slice().sort((a, b) => { + const stackA = parseInt(a.stack.split('-')[1]) || 0; + const stackB = parseInt(b.stack.split('-')[1]) || 0; + if (stackA !== stackB) return stackB - stackA; + const indexA = cards.filter(c => c.stack === a.stack).indexOf(a); + const indexB = cards.filter(c => c.stack === b.stack).indexOf(b); + return indexB - indexA; + }); + + for (const card of sortedCards) { + if (!card.isFaceUp) continue; + const stackIndex = (parseInt(card.stack.split('-')[1]) || 1) - 1; + const cardIndexInStack = cards.filter(c => c.stack === card.stack).indexOf(card); + const cardX = startX + stackIndex * (cardWidth + cardWidth * 0.5); + const cardY = startY + cardIndexInStack * cardOverlapY; + + // 드래그 가능한 카드 묶음의 첫 번째 카드만 선택 가능하도록 높이 범위를 수정 + const stackCards = cards.filter(c => c.stack === card.stack); + const lastCardInStack = stackCards[stackCards.length - 1]; + const isFirstCardOfMovableStack = (card.suit === lastCardInStack.suit && card.rank === lastCardInStack.rank); + const touchHeight = isFirstCardOfMovableStack ? cardHeight : cardOverlapY; + + if (x >= cardX && x <= cardX + cardWidth && y >= cardY && y <= cardY + touchHeight) { + return card; + } + } + return null; + } + + function findStackAt(x, y) { + const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2; + const startY = cardHeight * 0.5; + + for (let i = 0; i < 10; i++) { + const stackX = startX + i * (cardWidth + cardWidth * 0.5); + const stackCards = currentGame.cards.filter(card => card.stack === `tableau-${i + 1}`); + const lastCardIndex = stackCards.length > 0 ? stackCards.length - 1 : -1; + const stackY = lastCardIndex >= 0 ? startY + lastCardIndex * cardOverlapY : startY; + const stackHeight = lastCardIndex >= 0 ? cardHeight : cardHeight + (15 * cardOverlapY); + + if (x >= stackX && x <= stackX + cardWidth && y >= stackY && y <= stackY + stackHeight) { + return `tableau-${i + 1}`; + } + } + return null; + } + + /** + * 🔴 수정: 클릭된 카드 아래의 묶음을 반환 + */ + function getCardStackForMove(card) { + const stack = currentGame.cards.filter(c => c.stack === card.stack); + const startIndex = stack.findIndex(c => c.suit === card.suit && c.rank === card.rank); + if (startIndex === -1) return null; + + const movableStack = stack.slice(startIndex); + for (let i = 0; i < movableStack.length - 1; i++) { + if (movableStack[i].suit !== movableStack[i+1].suit || movableStack[i].rank !== movableStack[i+1].rank + 1) { + return null; + } + } + return movableStack; + } + + function handlePointerDown(event) { + if (isAnimating) return; + const coords = getCanvasCoordinates(event); + + // 🔴 추가: 스톡 더미 클릭 감지 + const stockX = canvas.width - cardWidth * 1.5; + const stockY = canvas.height - cardHeight * 1.5; + + if (coords.x >= stockX && coords.x <= stockX + cardWidth && coords.y >= stockY && coords.y <= stockY + cardHeight) { + handleStockClick(); + return; + } + + const clickedCard = findCardAt(coords.x, coords.y, currentGame.cards); + if (clickedCard) { + const movableStack = getCardStackForMove(clickedCard); + if (movableStack) { + isDragging = true; + draggedCards = movableStack; + const cardPos = getCardPosition(clickedCard); + dragOffsetX = coords.x - cardPos.x; + dragOffsetY = coords.y - cardPos.y; + } + } + } + + function handlePointerMove(event) { + if (!isDragging || draggedCards.length === 0) return; + event.preventDefault(); + const coords = getCanvasCoordinates(event); + draggedCards[0].x = coords.x - dragOffsetX; + draggedCards[0].y = coords.y - dragOffsetY; + draw(); + } + + function handlePointerUp(event) { + if (!isDragging || draggedCards.length === 0) return; + isDragging = false; + const coords = getCanvasCoordinates(event); + const dropTargetStackId = findStackAt(coords.x, coords.y); + + if (dropTargetStackId) { + const isValid = isValidMove(draggedCards, dropTargetStackId); + if (isValid) { + moveCardLocally(draggedCards, dropTargetStackId); + updateGameOnServer(currentGame); + } else { + draggedCards.forEach(card => { + delete card.x; + delete card.y; + }); + } + } else { + draggedCards.forEach(card => { + delete card.x; + delete card.y; + }); + } + draggedCards = []; + draw(); + } + + function handleStockClick() { + if (isAnimating || isDragging) return; + dealFromStock(); + } + + // src/main/resources/static/js/spider.js + function getBestMoveForStack(cardsToMove) { + if (cardsToMove.length === 0) return null; + const firstCardToMove = cardsToMove[0]; + + for (let i = 1; i <= 10; i++) { + const destStackId = `tableau-${i}`; + if (destStackId === firstCardToMove.stack) continue; + + const destStackCards = currentGame.cards.filter(c => c.stack === destStackId); + + // 목적지 스택이 비어있는 경우 + if (destStackCards.length === 0) { + // 킹(K)은 빈 공간으로 이동 가능하지만, 여기서는 모든 카드에 대해 허용 + return destStackId; + } + + const destTopCard = destStackCards[destStackCards.length - 1]; + + // 이동하려는 카드 묶음의 첫 번째 카드 랭크가 목적지 스택의 맨 위 카드 랭크보다 정확히 1 작으면 유효 + if (firstCardToMove.rank === destTopCard.rank - 1) { + // 첫 번째 유효한 이동 장소를 반환 + return destStackId; + } + } + return null; // 유효한 이동 장소를 찾지 못함 + } + function handleDoubleClick(event) { + // 오타 수정: Date.Now() -> Date.now() + const now = Date.now(); + const timeSinceLastTap = now - lastTapTime; + + if (timeSinceLastTap < 300 && timeSinceLastTap > 0) { + const coords = getCanvasCoordinates(event); + const clickedCard = findCardAt(coords.x, coords.y, currentGame.cards); + + if (clickedCard) { + // 클릭된 카드가 이동 가능한 묶음의 시작점인지 확인 + const movableStack = getCardStackForMove(clickedCard); + if (movableStack) { + // 이동 가능한 묶음에 대해 최적의 목적지 스택을 찾음 + const destinationStackId = getBestMoveForStack(movableStack); + + if (destinationStackId) { + animateCardMove(movableStack, destinationStackId); + // TODO: 애니메이션 완료 후 moveCardLocally 호출 + } else { + animateInvalidMove(clickedCard); + } + } else { + animateInvalidMove(clickedCard); + } + } + } + lastTapTime = now; + } + + // === 4. 게임 로직 및 애니메이션 === + + function getBestMove(cardData) { + let bestDestination = null; + let bestScore = -1; + + for (let i = 1; i <= 10; i++) { + const destStackId = `tableau-${i}`; + if (destStackId === cardData.stack) continue; + + const destStackCards = currentGame.cards.filter(c => c.stack === destStackId); + const destTopCard = destStackCards.length > 0 ? destStackCards[destStackCards.length - 1] : null; + + if (destTopCard) { + const isRankConsecutive = (destTopCard.rank === cardData.rank + 1); + if (isRankConsecutive) { + const isSuitMatch = (destTopCard.suit === cardData.suit); + if (isSuitMatch) { + bestDestination = destStackId; + bestScore = 2; + } + if (bestScore < 2) { + bestDestination = destStackId; + bestScore = 1; + } + } + } else { + if (bestScore < 0) { + bestDestination = destStackId; + bestScore = 0; + } + } + } + return bestDestination; + } + + /** + * 🔴 수정: 카드 묶음을 이동시키는 로직 (완전한 구현) + */ + function moveCardLocally(cardsToMove, destinationStackId) { + const oldStackId = cardsToMove[0].stack; + + // 1. 드래그된 카드 묶음의 stack 속성을 업데이트 + cardsToMove.forEach(card => { + card.stack = destinationStackId; + delete card.x; // 드래그 시 추가된 임시 속성 제거 + delete card.y; + }); + + // 2. 기존 게임 상태에서 드래그된 카드를 제거 (참조를 유지하면서) + currentGame.cards = currentGame.cards.filter(card => !cardsToMove.includes(card)); + + // 3. 업데이트된 카드 묶음을 다시 게임 상태에 추가 + currentGame.cards = currentGame.cards.concat(cardsToMove); + + // 4. 이전 스택의 마지막 카드 뒤집기 + const oldStackCards = currentGame.cards.filter(c => c.stack === oldStackId); + if (oldStackCards.length > 0) { + const lastCardInOldStack = oldStackCards[oldStackCards.length - 1]; + lastCardInOldStack.isFaceUp = true; + } + + currentGame.moves++; + } + + /** + * 🔴 수정: 이동 가능 여부를 확인하는 함수 + */ + function isValidMove(cardsToMove, destinationStackId) { + if (cardsToMove.length === 0) return false; + + const firstCardToMove = cardsToMove[0]; + const destStackCards = currentGame.cards.filter(c => c.stack === destinationStackId); + + if (destStackCards.length === 0) { + return true; + } + + const destTopCard = destStackCards[destStackCards.length - 1]; + if (firstCardToMove.rank === destTopCard.rank - 1) { + return true; + } + return false; + } + + function animateCardMove(cardsToMove, destinationStackId) { + isAnimating = true; + animationProgress = 0; + + // 묶음의 각 카드에 대해 애니메이션 시작/끝 위치 계산 + const animatedCards = cardsToMove.map(card => { + const startPos = getCardPosition(card); + const endPos = getCardDestinationPosition(card, destinationStackId); + return { + card: card, + startX: startPos.x, + startY: startPos.y, + endX: endPos.x, + endY: endPos.y, + destinationStack: destinationStackId + }; + }); + + // 애니메이션 루프 + function updateAnimation() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + draw(); // 현재 게임 상태 그리기 + + animatedCards.forEach((animCard, index) => { + const startX = animCard.startX + (animCard.endX - animCard.startX) * animationProgress; + const startY = animCard.startY + (animCard.endY - animCard.startY) * animationProgress + index * cardOverlapY; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(startX, startY, cardWidth, cardHeight); + ctx.strokeStyle = '#333333'; + ctx.strokeRect(startX, startY, cardWidth, cardHeight); + + const isRed = (animCard.card.suit === 'heart' || animCard.card.suit === 'diamond'); + ctx.fillStyle = isRed ? '#ff0000' : '#000000'; + ctx.font = `${cardWidth * 0.25}px Arial`; + ctx.fillText(getRankText(animCard.card.rank), startX + cardWidth * 0.1, startY + cardHeight * 0.25); + ctx.fillText(getSuitSymbol(animCard.card.suit), startX + cardWidth * 0.1, startY + cardHeight * 0.5); + }); + + animationProgress += 0.05; + if (animationProgress >= 1) { + // 애니메이션 완료 후 실제 게임 로직 처리 + moveCardLocally(cardsToMove, destinationStackId); + updateGameOnServer(currentGame); + isAnimating = false; + } else { + requestAnimationFrame(updateAnimation); + } + } + + updateAnimation(); + } + + function animateInvalidMove(card) { + let shakeStartTime = Date.Now(); + const shakeDuration = 500; + shakenCard = { + card: card, + originalX: getCardPosition(card).x, + originalY: getCardPosition(card).y + }; + + function updateShake() { + const elapsedTime = Date.Now() - shakeStartTime; + if (elapsedTime > shakeDuration) { + shakeOffset = 0; + dimOpacity = 0; + shakenCard = null; + return; + } + + shakeOffset = Math.sin(elapsedTime * 0.1) * 5; + dimOpacity = 0.5 * (1 - elapsedTime / shakeDuration); + requestAnimationFrame(updateShake); + } + updateShake(); + } + + async function dealFromStock() { + if (!currentGame || isAnimating) return; + isAnimating = true; + try { + const response = await fetch(`${API_BASE_URL}/deal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameId: currentGame.id }) + }); + const newGame = await response.json(); + currentGame = newGame; + draw(); + } catch (error) { + console.error('카드 분배 실패:', error); + } finally { + isAnimating = false; + } + } + + async function updateGameOnServer(updatedGame) { + try { + const response = await fetch(`${API_BASE_URL}/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedGame) + }); + const newGame = await response.json(); + currentGame = newGame; + draw(); + } catch (error) { + console.error('게임 업데이트 실패:', error); + } + } + + // === 5. 헬퍼 함수 === + + function getCardPosition(card) { + const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2; + const startY = cardHeight * 0.5; + const stackIndex = (parseInt(card.stack.split('-')[1]) || 1) - 1; + const cardIndexInStack = currentGame.cards.filter(c => c.stack === card.stack).indexOf(card); + const x = startX + stackIndex * (cardWidth + cardWidth * 0.5); + const y = startY + cardIndexInStack * cardOverlapY; + return { x, y }; + } + + function getCardDestinationPosition(card, destinationStackId) { + const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2; + const startY = cardHeight * 0.5; + const destStackCards = currentGame.cards.filter(c => c.stack === destinationStackId); + const stackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1; + const x = startX + stackIndex * (cardWidth + cardWidth * 0.5); + const y = startY + destStackCards.length * cardOverlapY; + return { x, y }; + } + + function getRankText(rank) { + if (rank === 1) return 'A'; + if (rank === 11) return 'J'; + if (rank === 12) return 'Q'; + if (rank === 13) return 'K'; + return String(rank); + } + + function getSuitSymbol(suit) { + if (suit === 'spade') return '♠️'; + if (suit === 'heart') return '♥️'; + if (suit === 'club') return '♣️'; + if (suit === 'diamond') return '♦️'; + } + + // UI 요소에 대한 참조를 추가합니다. + const suitCountSelect = document.getElementById('suitCountSelect'); + const cardCountSelect = document.getElementById('cardCountSelect'); + const startButton = document.getElementById('startButton'); + + // 시작 버튼에 이벤트 리스너 추가 + startButton.addEventListener('click', startNewGame); + + // 🔴 수정: 무늬 수에 따른 카드 분배 옵션 정의 + const cardDistributionOptions = { + '1': [ // 1 무늬: 맞출 확률이 매우 높으므로 쉬운 난이도만 제공 + { value: '3,3', text: '쉬움 (총 30장)' }, + { value: '4,3', text: '보통 (총 34장)' }, + { value: '5,4', text: '어려움 (총 44장)' } + ], + '2': [ // 2 무늬: 난이도가 높아져 4단계까지 제공 + { value: '3,3', text: '쉬움 (총 30장)' }, + { value: '4,3', text: '보통 (총 34장)' }, + { value: '5,4', text: '어려움 (총 44장)' }, + { value: '5,5', text: '매우 어려움 (총 50장)' } + ], + '4': [ // 4 무늬: 가장 어려우므로 쉬움 난이도는 제외하고 제공 + { value: '4,3', text: '보통 (총 34장)' }, + { value: '5,4', text: '어려움 (총 44장)' }, + { value: '5,5', text: '매우 어려움 (총 50장)' }, + { value: '6,6', text: '극악 (총 60장)' } + ] + }; + + /** + * 무늬 수 선택에 따라 카드 수 옵션을 업데이트하는 함수 + */ + function updateCardCountOptions() { + const selectedSuits = suitCountSelect.value; + const options = cardDistributionOptions[selectedSuits] || []; + + // 기존 옵션 제거 + cardCountSelect.innerHTML = ''; + + // 새 옵션 추가 + options.forEach(option => { + const newOption = document.createElement('option'); + newOption.value = option.value; + newOption.textContent = option.text; + cardCountSelect.appendChild(newOption); + }); + } + + // 무늬 수 선택 변경 시 옵션 업데이트 + suitCountSelect.addEventListener('change', updateCardCountOptions); + + // 페이지 로드 시 초기 옵션 설정 + updateCardCountOptions(); + + // 시작 버튼에 이벤트 리스너 추가 + startButton.addEventListener('click', startNewGame); + + async function startNewGame() { + if (!assetsLoaded) return; + const numSuits = suitCountSelect.value; + const numCards = cardCountSelect.value; // "3,4" 또는 "4,5" 등 문자열로 가져옵니다. + + try { + // API 요청 URL에 두 가지 파라미터를 모두 포함시킵니다. + const response = await fetch(`${API_BASE_URL}/new?numSuits=${numSuits}&numCards=${numCards}`); + currentGame = await response.json(); + draw(); + } catch (error) { + console.error('새 게임 시작 실패:', error); + } + } + + startNewGame(); +}); \ No newline at end of file diff --git a/src/main/resources/templates/content/puzzle/spider.html b/src/main/resources/templates/content/puzzle/spider.html new file mode 100644 index 0000000..2609900 --- /dev/null +++ b/src/main/resources/templates/content/puzzle/spider.html @@ -0,0 +1,40 @@ + + + + + + + + + + + +
+ + + + + + +
+
+ +
+ +
+ diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index b04953b..b14722c 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -18,6 +18,7 @@ + @@ -34,7 +35,7 @@
  • 글쓰기
  • 수정하기
  • 네모로직 문제 생성
  • -
  • 스도쿠 문제 생성
  • +
  • 스도쿠 문제 생성
  • Phasellus magna