This commit is contained in:
2025-09-02 17:32:02 +09:00
parent 543b1209e6
commit 4d44a4838b
22 changed files with 1770 additions and 62 deletions
+156
View File
@@ -0,0 +1,156 @@
/* =================================
기본 및 전체 레이아웃
================================= */
body {
font-family: Arial, sans-serif;
text-align: center;
background-color: #faf8ef;
color: #776e65;
margin: 0;
padding: 10px;
box-sizing: border-box;
}
h1 {
font-size: 15vw;
margin: 20px 0;
}
.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; /* 모바일에서 터치 시 화면 확대/이동 방지 */
}
/* =================================
타일 공통 스타일
================================= */
.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;
}
/* =================================
타일 색상
================================= */
.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; }
/* =================================
게임 오버 팝업
================================= */
.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;
}
/* =================================
랭킹 리스트
================================= */
.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;
}
/* =================================
반응형: 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; }
}
@@ -0,0 +1,73 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
background-color: #f4f7f9;
padding: 20px;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
text-align: center;
}
#generate-btn {
padding: 12px 24px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
transition: background-color 0.2s;
}
#generate-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
#generate-btn:hover:not(:disabled) {
background-color: #0056b3;
}
#status-message {
margin-top: 15px;
font-size: 14px;
color: #555;
height: 20px;
}
#sudoku-board {
display: grid;
grid-template-columns: repeat(9, 40px);
grid-template-rows: repeat(9, 40px);
border: 3px solid #333;
margin: 20px auto 0;
width: 366px; /* (40px * 9) + 6px border */
}
.cell {
width: 40px;
height: 40px;
border: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
font-size: 20px;
font-weight: bold;
color: #333;
}
/* 3x3 구역을 위한 굵은 선 */
.cell:nth-child(3n) { border-right: 2px solid #333; }
.cell:nth-child(9n) { border-right: none; }
.cell:nth-of-type(n+19):nth-of-type(-n+27),
.cell:nth-of-type(n+46):nth-of-type(-n+54) {
border-bottom: 2px solid #333;
}
+25 -15
View File
@@ -42,41 +42,51 @@ body {
/* transform-origin은 그대로 유지합니다. */
transform-origin: top;
}
/* (★ 추가) 게임 컨트롤 영역 스타일 */
/* (★ 수정) 게임 컨트롤 영역 스타일 */
#game-controls {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 500px; /* 보드와 비슷한 너비로 설정 */
max-width: 500px;
margin-bottom: 15px;
font-size: 1.2em;
flex-wrap: wrap; /* Allow controls to wrap on small screens */
flex-wrap: wrap;
gap: 15px;
}
/* (★ ADD) Styles for the mode selector */
/* (★ 수정) 모드 선택 버튼 스타일 */
#mode-selector {
display: flex;
gap: 10px;
background-color: #eee;
padding: 8px;
gap: 5px;
border: 1px solid #ccc;
border-radius: 8px;
padding: 4px;
background-color: #f0f0f0;
}
#mode-selector label {
cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
user-select: none;
}
/* Style for the selected radio button's label */
#mode-selector input:checked + span {
#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;
}
/* Hide the actual radio buttons */
#mode-selector input {
display: none;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
}
#hint-btn {
padding: 8px 15px;
+238
View File
@@ -0,0 +1,238 @@
/* 기본 스타일 초기화 및 폰트 설정 */
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;
}
#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;
}
h1 {
font-size: 1.8em;
color: #333;
margin-top: 0;
margin-bottom: 20px;
}
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;
}
/* 게임 컨테이너 */
#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;
}
+233
View File
@@ -0,0 +1,233 @@
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();
});
@@ -0,0 +1,53 @@
document.addEventListener('DOMContentLoaded', () => {
const generateBtn = document.getElementById('generate-btn');
const boardElement = document.getElementById('sudoku-board');
const statusMessage = document.getElementById('status-message');
// 스도쿠 보드를 화면에 그리는 함수
function renderBoard(puzzleString) {
boardElement.innerHTML = ''; // 기존 보드 초기화
if (!puzzleString || puzzleString.length !== 81) {
statusMessage.textContent = '잘못된 퍼즐 데이터입니다.';
return;
}
for (const char of puzzleString) {
const cell = document.createElement('div');
cell.classList.add('cell');
cell.textContent = char;
boardElement.appendChild(cell);
}
}
// 생성 버튼 클릭 이벤트 리스너
generateBtn.addEventListener('click', async () => {
// 버튼 비활성화 및 상태 메시지 업데이트
generateBtn.disabled = true;
statusMessage.textContent = '새로운 스도쿠 퍼즐을 생성 중입니다... 🧠';
boardElement.innerHTML = ''; // 보드 비우기
try {
// 백엔드 API 호출 (POST 요청)
const response = await fetch('/sudoku/generate', {
method: 'POST',
});
if (!response.ok) {
throw new Error(`서버 오류: ${response.statusText}`);
}
const result = await response.json();
// 성공 시 보드 렌더링
renderBoard(result.puzzle);
statusMessage.textContent = `✅ 퍼즐 생성 완료! (ID: ${result.puzzleKey})`;
} catch (error) {
console.error('Error generating Sudoku:', error);
statusMessage.textContent = `❌ 생성 실패: ${error.message}`;
} finally {
// 성공/실패 여부와 관계없이 버튼 다시 활성화
generateBtn.disabled = false;
}
});
});
+5
View File
@@ -266,6 +266,11 @@ function gotoPuzzleUpload() {
document.location.replace(getMainPath()+"/puzzle/upload.bs")
}
function gotoPuzzleUpload() {
document.location.replace(getMainPath()+"/puzzle/sudoku_gen.bs")
}
function gotoWhere() {
document.location.replace(getMainPath()+"/bums/where.bs")
}
+48 -35
View File
@@ -26,6 +26,7 @@ document.addEventListener('DOMContentLoaded', () => {
const modalMessage = document.getElementById('modal-message');
const modalButtons = document.getElementById('modal-buttons');
// --- 게임 상태를 관리하는 변수 ---
let currentMode = 'fill';
let points = 5;
@@ -213,14 +214,28 @@ document.addEventListener('DOMContentLoaded', () => {
const cell = e.target;
const currentState = playerGrid[parseInt(cell.dataset.row)][parseInt(cell.dataset.col)];
// Determine action based on mode, not button
if (currentMode === 'fill') {
dragAction = (currentState === 1) ? 'clear' : 'fill';
} else { // currentMode === 'mark'
dragAction = (currentState === -1) ? 'clear' : 'mark';
// --- 하이브리드 로직 ---
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';
}
}
// Update selection visuals
// 드래그 시작점 기록 및 시각적 피드백 시작
startCell = { row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
lastHoveredCell = startCell;
updateSelectionVisuals();
@@ -409,9 +424,9 @@ document.addEventListener('DOMContentLoaded', () => {
function triggerGameSuccess() {
if (isGameFinished) return;
isGameFinished = true;
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
hintBtn.disabled = 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');
@@ -420,31 +435,35 @@ document.addEventListener('DOMContentLoaded', () => {
puzzleGridContainer.style.pointerEvents = 'none';
hintBtn.disabled = true;
// --- 애니메이션 준비 ---
// 1. 이미지 소스 설정
// (puzzleData에 저장해 둔 Base64 인코딩된 이미지 데이터를 사용합니다)
grayscaleImg.src = puzzleData.grayscaleImage;
originalImg.src = puzzleData.originalImage;
// --- (★ 핵심) 애니메이션 위치 및 크기 계산 ---
// 1. 렌더링된 퍼즐 격자와 뷰포트의 실제 위치/크기 정보를 가져옵니다.
const gridRect = puzzleGridContainer.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
// 2. 현재 보드의 transform(스케일) 값을 이미지에도 동일하게 적용하여 정렬
// 이렇게 해야 반응형으로 크기가 조절된 보드 위에도 이미지가 정확히 겹칩니다.
const boardTransform = gameBoard.style.transform;
grayscaleImg.style.transform = boardTransform;
originalImg.style.transform = boardTransform;
// 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(() => {
// 3. 그레이스케일 이미지 페이드 인
// (CSS transition 속성에 의해 부드럽게 나타납니다)
grayscaleImg.style.opacity = '1';
grayscaleImg.style.opacity = '1'; // 그레이스케일 페이드 인
setTimeout(() => {
// 4. 컬러 이미지 페이드 인 (크로스페이드 효과)
// (그레이스케일 이미지 위에 컬러 이미지가 겹쳐지며 나타납니다)
originalImg.style.opacity = '1';
originalImg.style.opacity = '1'; // 컬러 이미지 페이드 인
setTimeout(() => {
// 5. 애니메이션이 모두 끝난 후 최종 결과 모달 표시
// 최종 결과 모달 표시
showResultModal({
title: 'Success! 🎉',
message: '퍼즐을 완성했습니다!',
@@ -453,17 +472,11 @@ document.addEventListener('DOMContentLoaded', () => {
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
]
});
}, 2000); // 컬러 이미지가 나타나고 2초 후
}, 2000);
}, 2000); // 그레이스케일 이미지가 나타나고 2초 후
}, 2000);
}, 500); // 마지막 셀을 채우고 0.5초 후 시작
// showResultModal({
// title: 'Success! 🎉', message: '퍼즐을 완성했습니다!', buttons: [
// { text: '다른 문제 풀기', class: 'primary', action: () => window.location.href = '/puzzle/play' },
// { text: '홈으로 (Home)', action: () => window.location.href = '/' }
// ]
// });
}, 500);
}
// 힌트 버튼 클릭 이벤트 처리
+356
View File
@@ -0,0 +1,356 @@
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();
});
});
@@ -0,0 +1,41 @@
<!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/2048.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/2048.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<th:block layout:fragment="content">
<h1>2048</h1>
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
<div class="game-container">
<div class="score-container">
<strong>점수:</strong> <span id="score">0</span>
</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>
</div>
<div class="ranking-container">
<h3>🏆 랭킹</h3>
<ol id="ranking-list"></ol>
</div>
</th:block>
</html>
@@ -21,10 +21,10 @@
<div id="game-controls">
<div id="mode-selector">
<label>
<input type="radio" name="play-mode" value="fill" checked> Fill
<input type="radio" name="play-mode" value="fill" checked><span> Fill</span>
</label>
<label>
<input type="radio" name="play-mode" value="mark"> Mark (X)
<input type="radio" name="play-mode" value="mark"><span> Mark (X)</span>
</label>
</div>
@@ -0,0 +1,80 @@
<!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/sudoku.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
<script th:inline="javascript">
</script>
<script type="text/javascript" th:src="@{/js/user.js}"></script>
</th:block >
<th:block layout:fragment="content">
<div id="sudoku-game-app">
<div class="container">
<h1>스도쿠를 즐겨보세요!</h1>
<div id="setup-container">
<select id="difficulty-select">
<option value="easy">쉬움</option>
<option value="medium">중간</option>
<option value="hard">어려움</option>
</select>
<button id="start-btn">게임 시작</button>
</div>
<div id="game-container" class="hidden">
<div class="game-info">
<div id="score">SCORE: 5</div>
<div id="timer">00:00</div>
</div>
<div id="sudoku-board"></div>
<div id="number-input-buttons">
<button class="num-btn" data-number="1">1</button>
<button class="num-btn" data-number="2">2</button>
<button class="num-btn" data-number="3">3</button>
<button class="num-btn" data-number="4">4</button>
<button class="num-btn" data-number="5">5</button>
<button class="num-btn" data-number="6">6</button>
<button class="num-btn" data-number="7">7</button>
<button class="num-btn" data-number="8">8</button>
<button class="num-btn" data-number="9">9</button>
<button id="undo-btn" class="clear-btn">실행취소</button>
</div>
<div class="action-buttons">
<button id="hint-btn">힌트 사용 (-1점)</button>
<button id="complete-btn">정답 확인</button>
</div>
</div>
</div>
</div>
<div id="modal-overlay" class="hidden">
<div id="modal-content">
<h2>🎉 성공! 기록을 남겨주세요.</h2>
<input type="text" id="username-input" placeholder="이름을 입력하세요" maxlength="10">
<button id="submit-rank-btn">랭킹 등록</button>
<hr>
<h3>🏆 명예의 전당</h3>
<ol id="ranking-list"></ol>
<button id="close-modal-btn">닫기</button>
</div>
</div>
<div id="game-over-modal" class="hidden">
<div id="modal-content">
<h2>GAME OVER</h2>
<p>포인트를 모두 사용했습니다.</p>
<button id="retry-btn">새 게임 시작</button>
</div>
</div>
</th:block>
</html>
@@ -0,0 +1,31 @@
<!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/admin-sudoku.js}"></script>
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
<link th:href="@{/css/admin-sudoku.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="container">
<h1>스도쿠 퍼즐 생성기</h1>
<p>버튼을 누르면 새로운 스도쿠 퍼즐을 생성하여 DB에 저장하고 화면에 보여줍니다.</p>
<button id="generate-btn">새 스도쿠 생성</button>
<div id="status-message"></div>
<h2>생성된 퍼즐</h2>
<div id="sudoku-board">
</div>
</div>
</th:block>
</html>
@@ -16,6 +16,8 @@
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
<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_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>-->
@@ -31,7 +33,8 @@
<th:block sec:authorize="isAuthenticated()">
<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:gotoPuzzleUpload()">스도쿠 문제 생성</a></li>
</th:block>
<li><a href="#">Phasellus magna</a></li>
<!-- <li><a href="#">Magna phasellus</a></li>-->