Compare commits

...

2 Commits

Author SHA1 Message Date
2987825cb2 ... 2025-09-03 18:38:06 +09:00
903292b246 ... 2025-09-03 18:00:39 +09:00
10 changed files with 960 additions and 5 deletions

View File

@ -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 ->

View File

@ -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")

View File

@ -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<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)
}
}

View File

@ -0,0 +1,149 @@
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 timestamp: Long = System.currentTimeMillis()
)
data class SpiderCard(
val suit: String,
val rank: Int,
var isFaceUp: Boolean,
)
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 deck = mutableListOf<SpiderCard>()
val setsPerSuit = 8 / numSuits
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 = listOf(
initialCards[0], initialCards[0], initialCards[0], initialCards[0],
initialCards[1], initialCards[1], initialCards[1], initialCards[1], initialCards[1], initialCards[1]
)
val cards = shuffledCards.toMutableList()
val tableau = mutableListOf<MutableList<SpiderCard>>()
cardsPerStack.forEach { num ->
val stack = mutableListOf<SpiderCard>()
repeat(num) {
val card = cards.removeFirst()
card.isFaceUp = (it == num - 1)
stack.add(card)
}
tableau.add(stack)
}
return Pair(tableau, cards)
}
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
)
return spiderGameRepository.save(initialGame)
}
fun getGame(id: String): Mono<SpiderGame> {
return spiderGameRepository.findById(id)
}
fun updateGame(game: SpiderGame): Mono<SpiderGame> {
return spiderGameRepository.save(game)
}
fun dealCardsFromStock(gameId: String): Mono<SpiderGame> {
return spiderGameRepository.findById(gameId)
.flatMap { game ->
val stockCards = game.stock.toMutableList()
if (stockCards.size >= 10) {
val cardsToDeal = stockCards.take(10)
val updatedTableau = game.tableau.toMutableList()
val remainingStock = stockCards.drop(10)
updatedTableau.forEachIndexed { index, stack ->
val cardToDeal = cardsToDeal[index]
cardToDeal.isFaceUp = true
(stack as MutableList).add(cardToDeal)
}
val updatedGame = game.copy(
tableau = updatedTableau,
stock = remainingStock,
moves = game.moves + 1
)
spiderGameRepository.save(updatedGame)
} else {
Mono.error(IllegalArgumentException("No more cards in stock."))
}
}
}
// 🔴 추가: 랭킹 등록 함수
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>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

View File

@ -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));
}
}

View File

@ -267,7 +267,7 @@ function gotoPuzzleUpload() {
}
function gotoPuzzleUpload() {
function gotoSudoKuGen() {
document.location.replace(getMainPath()+"/puzzle/sudoku_gen.bs")
}

View File

@ -0,0 +1,661 @@
// 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;
updateCardCountOptions();
};
const suitCountSelect = document.getElementById('suitCountSelect');
const cardCountSelect = document.getElementById('cardCountSelect');
const startButton = document.getElementById('startButton');
// 🔴 수정: 난이도별 카드 분배 옵션을 세분화
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);
});
}
// 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 draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#008000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (currentGame && assetsLoaded) {
drawGame(currentGame);
if (animatedCard) drawAnimatedCard();
if (shakeOffset !== 0) drawShakingCard();
if (dimOpacity > 0) drawDimOverlay();
}
requestAnimationFrame(draw);
}
/**
* 🔴 수정: 새로운 데이터 구조에 맞게 게임 전체를 그리는 함수
*/
function drawGame(game) {
const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2;
const startY = cardHeight * 0.5;
// 테이블로 스택 그리기
game.tableau.forEach((stack, stackIndex) => {
stack.forEach((card, cardIndex) => {
// 드래그 중인 카드는 숨김
if (isDragging && draggedCards.includes(card)) {
return;
}
const x = startX + stackIndex * (cardWidth + cardWidth * 0.5);
const y = startY + cardIndex * cardOverlapY;
drawSingleCard(card, x, y);
});
});
// 드래그 중인 카드 묶음 그리기
if (isDragging && draggedCards.length > 0) {
drawDraggedCards(draggedCards);
}
// 스톡과 파운데이션 그리기
drawStockAndFoundation(game.stock, game.foundation);
}
/**
* 드래그 중인 카드 묶음을 그리는 함수
*/
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 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`;
// 🔴 수정: 텍스트의 Y 좌표를 위로 올립니다.
ctx.fillText(getRankText(card.rank), x + cardWidth * 0.1, y + cardHeight * 0.15);
ctx.fillText(getSuitSymbol(card.suit), x + cardWidth * 0.1, y + cardHeight * 0.4);
} else {
ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight);
}
}
/**
* 이동 애니메이션 중인 카드를 그리는 로직
*/
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);
}
/**
* 🔴 추가: 스톡 더미와 파운데이션 영역을 그리는 로직
*/
function drawStockAndFoundation(stock, foundation) {
const stockX = canvas.width - cardWidth * 1.5;
const stockY = canvas.height - cardHeight * 1.5;
if (stock.length > 0) {
ctx.drawImage(cardBackImage, stockX, stockY, cardWidth, cardHeight);
}
const foundationY = canvas.height - cardHeight * 1.5;
foundation.forEach((stack, index) => {
const foundationX = cardWidth * 0.5 + index * (cardWidth + 10);
if (stack.length > 0) {
const topCard = stack[stack.length - 1];
drawSingleCard(topCard, foundationX, foundationY);
}
});
}
// === 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);
suitCountSelect.addEventListener('change', updateCardCountOptions);
startButton.addEventListener('click', startNewGame);
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
};
}
/**
* 🔴 수정: 새로운 데이터 구조를 사용하도록 findCardAt 함수 변경
*/
function findCardAt(x, y) {
const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2;
const startY = cardHeight * 0.5;
for (let stackIndex = 9; stackIndex >= 0; stackIndex--) {
const stackCards = currentGame.tableau[stackIndex];
for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) {
const card = stackCards[cardIndex];
if (!card.isFaceUp) continue;
const cardX = startX + stackIndex * (cardWidth + cardWidth * 0.5);
const cardY = startY + cardIndex * cardOverlapY;
const touchHeight = (cardIndex === stackCards.length - 1) ? cardHeight : cardOverlapY;
if (x >= cardX && x <= cardX + cardWidth && y >= cardY && y <= cardY + touchHeight) {
return { card, stackIndex, cardIndex };
}
}
}
return null;
}
/**
* 🔴 수정: 클릭된 카드 아래의 묶음을 반환 (새로운 데이터 구조용)
*/
function getCardStackForMove(cardData, stackIndex) {
const stack = currentGame.tableau[stackIndex];
const startIndex = stack.findIndex(c => c.suit === cardData.suit && c.rank === cardData.rank);
if (startIndex === -1 || !cardData.isFaceUp) return null;
const movableStack = stack.slice(startIndex);
for (let i = 0; i < movableStack.length - 1; i++) {
// 🔴 수정: 랭크와 무늬를 모두 확인하여 유효한 묶음인지 체크
if (movableStack[i].rank !== movableStack[i+1].rank + 1 || movableStack[i].suit !== movableStack[i+1].suit) {
return null;
}
}
return movableStack;
}
function handlePointerDown(event) {
// 🔴 로그 추가: 이 로그가 찍히는지 확인하세요.
console.log('--- handlePointerDown 실행 ---');
if (isAnimating) {
console.log('애니메이션 중이므로 드래그 시작 불가.');
return;
}
const coords = getCanvasCoordinates(event);
const clickedCardData = findCardAt(coords.x, coords.y);
// 🔴 추가: 스톡 더미 클릭 감지
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;
}
if (clickedCardData) {
const { card, stackIndex } = clickedCardData;
const movableStack = getCardStackForMove(card, stackIndex);
if (movableStack) {
isDragging = true;
draggedCards = movableStack;
draggedCards.sourceStackIndex = stackIndex; // 드래그 시작 스택 인덱스 저장
const cardPos = getCardPosition(card, stackIndex);
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);
const sourceStackIndex = draggedCards.sourceStackIndex;
// 🔴 로그 추가: 드래그가 끝났을 때의 상태를 확인
console.log('--- 드래그 종료 ---');
console.log('드래그된 카드 묶음:', draggedCards.map(c => `${getRankText(c.rank)} of ${c.suit}`));
console.log('드롭 대상 스택 ID:', dropTargetStackId);
console.log('------------------');
if (dropTargetStackId) {
const destinationStackIndex = (parseInt(dropTargetStackId.split('-')[1]) || 1) - 1;
const isValid = isValidMove(draggedCards, destinationStackIndex);
// 🔴 로그 추가: isValidMove가 실행될 때의 정보를 확인
console.log('isValidMove 체크 시작');
console.log('대상 스택의 맨 위 카드:', currentGame.tableau[destinationStackIndex].length > 0 ? currentGame.tableau[destinationStackIndex][currentGame.tableau[destinationStackIndex].length - 1] : '없음');
console.log('이동할 묶음의 첫 카드:', draggedCards[0]);
console.log('결과:', isValid);
console.log('------------------');
if (isValid) {
moveCardLocally(draggedCards, sourceStackIndex, destinationStackIndex);
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 (!currentGame || isAnimating) return;
dealFromStock();
}
function handleDoubleClick(event) {
const coords = getCanvasCoordinates(event);
const clickedCardData = findCardAt(coords.x, coords.y);
if (clickedCardData) {
const { card, stackIndex } = clickedCardData;
const movableStack = getCardStackForMove(card, stackIndex);
if (movableStack) {
const destinationStackId = getBestMoveForStack(movableStack);
if (destinationStackId) {
const destinationStackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1;
moveCardLocally(movableStack, stackIndex, destinationStackIndex);
updateGameOnServer(currentGame);
} else {
animateInvalidMove(card);
}
} else {
animateInvalidMove(card);
}
}
}
// === 4. 게임 로직 및 애니메이션 ===
function getBestMoveForStack(cardsToMove) {
if (cardsToMove.length === 0) return null;
const firstCardToMove = cardsToMove[0];
for (let i = 0; i < 10; i++) {
const destStackId = `tableau-${i + 1}`;
const destStackCards = currentGame.tableau[i];
if (destStackCards.length === 0) {
if (firstCardToMove.rank === 13) {
return destStackId;
}
}
const destTopCard = destStackCards[destStackCards.length - 1];
if (firstCardToMove.rank === destTopCard.rank - 1) {
return destStackId;
}
}
return null;
}
function isValidMove(cardsToMove, destinationStackIndex) {
if (cardsToMove.length === 0) return false;
const firstCardToMove = cardsToMove[0];
const destStackCards = currentGame.tableau[destinationStackIndex];
if (destStackCards.length === 0) {
return true;
}
const destTopCard = destStackCards[destStackCards.length - 1];
if (firstCardToMove.rank === destTopCard.rank - 1) {
return true;
}
return false;
}
/**
* 🔴 수정: moveCardLocally 함수를 새로운 데이터 구조에 맞게 변경
*/
function moveCardLocally(cardsToMove, sourceStackIndex, destinationStackIndex) {
// 이동할 카드들을 소스 스택에서 제거
const sourceStack = currentGame.tableau[sourceStackIndex];
const newSourceStack = sourceStack.slice(0, sourceStack.length - cardsToMove.length);
// 대상 스택에 카드들을 추가
const destinationStack = currentGame.tableau[destinationStackIndex];
const newDestinationStack = [...destinationStack, ...cardsToMove];
// 게임 상태 업데이트
const newTableau = [...currentGame.tableau];
newTableau[sourceStackIndex] = newSourceStack;
newTableau[destinationStackIndex] = newDestinationStack;
// 이전 스택의 마지막 카드 뒤집기
if (newSourceStack.length > 0) {
newSourceStack[newSourceStack.length - 1].isFaceUp = true;
}
// 서버에 보낼 데이터 구조 업데이트
currentGame.tableau = newTableau;
currentGame.moves++;
}
function animateCardMove(cardsToMove, destinationStackId) {
isAnimating = true;
animationProgress = 0;
const startPos = getCardPosition(cardsToMove[0], draggedCards.sourceStackIndex);
const endPos = getCardDestinationPosition(cardsToMove, destinationStackId);
animatedCard = {
cards: cardsToMove,
startX: startPos.x,
startY: startPos.y,
endX: endPos.x,
endY: endPos.y,
destinationStack: destinationStackId
};
}
function animateInvalidMove(card) {
let shakeStartTime = Date.now();
const shakeDuration = 500;
shakenCard = {
card: card,
originalX: getCardPosition(card, findStackIndexForCard(card)).x,
originalY: getCardPosition(card, findStackIndexForCard(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 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.tableau[i];
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 findStackIndexForCard(card) {
for(let i = 0; i < currentGame.tableau.length; i++) {
if (currentGame.tableau[i].includes(card)) {
return i;
}
}
return -1;
}
function getCardPosition(card, stackIndex) {
const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2;
const startY = cardHeight * 0.5;
const stackCards = currentGame.tableau[stackIndex];
const cardIndexInStack = stackCards.findIndex(c => c.suit === card.suit && c.rank === card.rank);
const x = startX + stackIndex * (cardWidth + cardWidth * 0.5);
const y = startY + cardIndexInStack * cardOverlapY;
return { x, y };
}
function getCardDestinationPosition(cardsToMove, destinationStackId) {
const startX = (canvas.width - cardWidth * 10 - (cardWidth * 0.5 * 9)) / 2;
const startY = cardHeight * 0.5;
const destStackIndex = (parseInt(destinationStackId.split('-')[1]) || 1) - 1;
const destStackCards = currentGame.tableau[destStackIndex];
const x = startX + destStackIndex * (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 '♦️';
}
async function startNewGame() {
if (!assetsLoaded) return;
const numSuits = suitCountSelect.value;
const numCards = cardCountSelect.value;
try {
const response = await fetch(`${API_BASE_URL}/new?numSuits=${numSuits}&numCards=${numCards}`);
currentGame = await response.json();
draw();
} catch (error) {
console.error('새 게임 시작 실패:', error);
}
}
updateCardCountOptions();
});

View File

@ -0,0 +1,40 @@
<!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/spider.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/spider.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">
<div class="game-controls">
<label for="suitCountSelect">무늬 수:</label>
<select id="suitCountSelect">
<option value="1" selected>1개</option>
<option value="2" >2개</option>
<option value="4">4개</option>
</select>
<label for="cardCountSelect">초기 카드 수:</label>
<select id="cardCountSelect">
<option value="3,2">적음 (숨겨진 카드 3/2장)</option>
<option value="4,3" selected>보통 (숨겨진 카드 4/3장)</option>
<option value="5,6">많음 (숨겨진 카드 5/6장)</option>
</select>
<button id="startButton">새 게임 시작</button>
</div>
<div id="game-container">
<canvas id="gameCanvas"></canvas>
</div>
</th:block>
</html>

View File

@ -18,6 +18,7 @@
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
<!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>-->
<!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>-->
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>-->
@ -34,7 +35,7 @@
<li><a href="javascript:gotoWrite()">글쓰기</a></li>
<li><a href="javascript:gotoModify()">수정하기</a></li>
<li><a href="javascript:gotoPuzzleUpload()">네모로직 문제 생성</a></li>
<li><a href="javascript:gotoPuzzleUpload()">스도쿠 문제 생성</a></li>
<li><a href="javascript:gotoSudoKuGen()">스도쿠 문제 생성</a></li>
</th:block>
<li><a href="#">Phasellus magna</a></li>
<!-- <li><a href="#">Magna phasellus</a></li>-->