...
This commit is contained in:
@@ -5,16 +5,186 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/2048.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/2048.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<head>
|
||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
|
||||
<style>
|
||||
/* =================================
|
||||
기본 및 전체 레이아웃 (수정됨)
|
||||
================================= */
|
||||
body {
|
||||
/* (★ 삭제) font-family, text-align, background-color, color, margin, padding
|
||||
-> 이 속성들은 모두 common_game_theme.css에서 관리합니다.
|
||||
*/
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 15vw; /* 2048 고유의 큰 폰트 크기는 유지 */
|
||||
margin: 20px 0;
|
||||
/* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 */
|
||||
}
|
||||
|
||||
.score-container {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* =================================
|
||||
게임 보드 (테마 적용)
|
||||
================================= */
|
||||
#game-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 2vw;
|
||||
width: 95vw;
|
||||
max-width: 500px; /* (★ 수정) 400px -> 500px (다른 게임과 통일) */
|
||||
margin: 0 auto;
|
||||
|
||||
/* (★ 수정) 2048의 갈색/베이지 테마를 차가운 회색/파란색 테마로 변경 */
|
||||
background-color: #b0bec5; /* #bbada0 (갈색) -> #b0bec5 (블루 그레이) */
|
||||
|
||||
padding: 2vw;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 1 / 1;
|
||||
touch-action: none;
|
||||
|
||||
/* (★ 추가) 공통 카드 UI와 유사한 그림자 효과 추가 */
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
@media (min-width: 481px) {
|
||||
#game-board {
|
||||
grid-gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================================
|
||||
타일 공통 스타일 (테마 적용)
|
||||
================================= */
|
||||
.tile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
|
||||
/* (★ 수정) 빈 타일 색상 변경 */
|
||||
background-color: #eceff1; /* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) */
|
||||
|
||||
font-size: 5vw;
|
||||
}
|
||||
|
||||
@media (min-width: 481px) {
|
||||
.tile {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================
|
||||
타일 색상 (테마 적용)
|
||||
================================= */
|
||||
|
||||
/* (★ 수정) 2, 4 타일은 베이지색 계열이라 테마와 충돌하므로 파란색 계열로 변경 */
|
||||
.tile-2 { background-color: #e3f2fd; color: #333; } /* #eee4da (베이지) -> #e3f2fd (밝은 파랑) */
|
||||
.tile-4 { background-color: #bbdefb; color: #333; } /* #ede0c8 (노란 베이지) -> #bbdefb (파랑) */
|
||||
|
||||
/* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) */
|
||||
.tile-8 { background-color: #f2b179; color: #f9f6f2; }
|
||||
.tile-16 { background-color: #f59563; color: #f9f6f2; }
|
||||
.tile-32 { background-color: #f67c5f; color: #f9f6f2; }
|
||||
.tile-64 { background-color: #f65e3b; color: #f9f6f2; }
|
||||
.tile-128 { background-color: #edcf72; color: #f9f6f2; }
|
||||
.tile-256 { background-color: #edcc61; color: #f9f6f2; }
|
||||
.tile-512 { background-color: #edc850; color: #f9f6f2; }
|
||||
.tile-1024 { background-color: #edc53f; color: #f9f6f2; }
|
||||
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }
|
||||
.tile-4096 { background-color: #3c3a32; color: #f9f6f2; }
|
||||
.tile-8192 { background-color: #ff3333; color: #f9f6f2; }
|
||||
.tile-16384 { background-color: #0077cc; color: #f9f6f2; }
|
||||
.tile-32768 { background-color: #9900cc; color: #f9f6f2; }
|
||||
|
||||
|
||||
/* =================================
|
||||
게임 오버 팝업 (테마 적용)
|
||||
================================= */
|
||||
.popup-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.popup {
|
||||
/* (★ 수정) 배경색을 테마에 맞게 흰색으로 변경 */
|
||||
background-color: #ffffff; /* #faf8ef (베이지) -> #ffffff (흰색) */
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 80vw;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2); /* 흰색 배경이므로 그림자 추가 */
|
||||
}
|
||||
.popup input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.popup button {
|
||||
padding: 10px 20px;
|
||||
/* (★ 삭제) background-color, color, border -> common_game_theme의 파란색 버튼 스타일을 상속받음 */
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* =================================
|
||||
랭킹 리스트 (테마 적용)
|
||||
================================= */
|
||||
.ranking-container {
|
||||
/*
|
||||
(★ 참고) 이 컨테이너는 common_game_theme.css에서
|
||||
.game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.
|
||||
여기서는 내부 정렬만 담당합니다.
|
||||
*/
|
||||
width: 100%;
|
||||
max-width: 500px; /* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) */
|
||||
margin: 30px auto;
|
||||
text-align: left;
|
||||
}
|
||||
.ranking-container h3 {
|
||||
text-align: center;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
#ranking-list li {
|
||||
/* (★ 수정) 배경색 변경 */
|
||||
background-color: #f0f4f8; /* #eee4da (베이지) -> #f0f4f8 (밝은 회색) */
|
||||
margin-bottom: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<h1>2048</h1>
|
||||
<p>화살표를 터치하거나 키보드 방향키를 이용해 타일을 합쳐보세요!</p>
|
||||
@@ -24,18 +194,261 @@
|
||||
</div>
|
||||
<div id="game-board"></div>
|
||||
|
||||
<div id="game-over-popup" class="popup-container" style="display:none;">
|
||||
<div class="popup">
|
||||
<h2>게임 오버!</h2>
|
||||
<p>최종 점수: <span id="final-score">0</span></p>
|
||||
<input type="text" id="player-name" placeholder="이름을 입력하세요">
|
||||
<button id="save-score">점수 저장</button>
|
||||
<div id="game-over-popup" class="popup-container" style="display:none;">
|
||||
<div class="popup">
|
||||
<h2>게임 오버!</h2>
|
||||
<p>최종 점수: <span id="final-score">0</span></p>
|
||||
<input type="text" id="player-name" placeholder="이름을 입력하세요">
|
||||
<button id="save-score">점수 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ranking-container">
|
||||
<h3>🏆 랭킹</h3>
|
||||
<ol id="ranking-list"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ranking-container">
|
||||
<h3>🏆 랭킹</h3>
|
||||
<ol id="ranking-list"></ol>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ... (DOM 요소 가져오기 - 동일)
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
const scoreDisplay = document.getElementById('score');
|
||||
const gameOverPopup = document.getElementById('game-over-popup');
|
||||
const finalScoreDisplay = document.getElementById('final-score');
|
||||
const playerNameInput = document.getElementById('player-name');
|
||||
const saveScoreButton = document.getElementById('save-score');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
|
||||
// (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
|
||||
const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
|
||||
const currentContextId = null; // 2048은 별도 컨텍스트 ID가 없음
|
||||
|
||||
let gridSize = 4;
|
||||
let board = [];
|
||||
let score = 0;
|
||||
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
|
||||
|
||||
// ----- 게임 핵심 로직 -----
|
||||
function initializeBoard() {
|
||||
gameBoard.innerHTML = ''; // 기존 타일 초기화
|
||||
for (let i = 0; i < gridSize * gridSize; i++) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
gameBoard.appendChild(tile);
|
||||
}
|
||||
board = Array(gridSize * gridSize).fill(0);
|
||||
addNumber();
|
||||
addNumber();
|
||||
updateBoard();
|
||||
}
|
||||
|
||||
function updateBoard() {
|
||||
const tiles = gameBoard.children;
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
const value = board[i];
|
||||
const tile = tiles[i];
|
||||
tile.textContent = value === 0 ? '' : value;
|
||||
tile.className = 'tile' + (value > 0 ? ' tile-' + value : '');
|
||||
}
|
||||
scoreDisplay.textContent = score;
|
||||
}
|
||||
|
||||
function addNumber() {
|
||||
const available = board.map((val, i) => val === 0 ? i : -1).filter(i => i !== -1);
|
||||
if (available.length > 0) {
|
||||
const spot = available[Math.floor(Math.random() * available.length)];
|
||||
board[spot] = Math.random() < 0.9 ? 2 : 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 타일 이동 및 병합 로직 -----
|
||||
function moveRow(row) {
|
||||
let arr = row.filter(val => val);
|
||||
for (let i = 0; i < arr.length - 1; i++) {
|
||||
if (arr[i] === arr[i + 1]) {
|
||||
arr[i] *= 2;
|
||||
score += arr[i];
|
||||
arr[i + 1] = 0;
|
||||
}
|
||||
}
|
||||
arr = arr.filter(val => val);
|
||||
const missing = gridSize - arr.length;
|
||||
const zeros = Array(missing).fill(0);
|
||||
return arr.concat(zeros);
|
||||
}
|
||||
|
||||
function moveLeft() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize);
|
||||
const newRow = moveRow(row);
|
||||
if (JSON.stringify(row) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveRight() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const rowStart = i * gridSize;
|
||||
const row = board.slice(rowStart, rowStart + gridSize).reverse();
|
||||
const newRow = moveRow(row).reverse();
|
||||
if (JSON.stringify(board.slice(rowStart, rowStart + gridSize)) !== JSON.stringify(newRow)) changed = true;
|
||||
board.splice(rowStart, gridSize, ...newRow);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]];
|
||||
const newCol = moveRow(col);
|
||||
if (JSON.stringify(col) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
const col = [board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]].reverse();
|
||||
const newCol = moveRow(col).reverse();
|
||||
if (JSON.stringify([board[i], board[i + gridSize], board[i + gridSize * 2], board[i + gridSize * 3]]) !== JSON.stringify(newCol)) changed = true;
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
board[i + j * gridSize] = newCol[j];
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ----- 게임 상태 관리 -----
|
||||
function isGameOver() {
|
||||
if (!board.includes(0)) {
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
const current = board[i * gridSize + j];
|
||||
if ((j < gridSize - 1 && current === board[i * gridSize + j + 1]) ||
|
||||
(i < gridSize - 1 && current === board[(i + 1) * gridSize + j])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMove(moveFunction) {
|
||||
if (moveFunction()) {
|
||||
addNumber();
|
||||
updateBoard();
|
||||
if (isGameOver()) {
|
||||
finalScoreDisplay.textContent = score;
|
||||
gameOverPopup.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 이벤트 리스너 -----
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': handleMove(moveUp); break;
|
||||
case 'ArrowDown': handleMove(moveDown); break;
|
||||
case 'ArrowLeft': handleMove(moveLeft); break;
|
||||
case 'ArrowRight': handleMove(moveRight); break;
|
||||
}
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
});
|
||||
|
||||
gameBoard.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||
|
||||
gameBoard.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
});
|
||||
|
||||
function handleSwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
const swipeThreshold = 30;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (Math.abs(deltaX) > swipeThreshold) {
|
||||
handleMove(deltaX > 0 ? moveRight : moveLeft);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(deltaY) > swipeThreshold) {
|
||||
handleMove(deltaY > 0 ? moveDown : moveUp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 랭킹 API 연동 -----
|
||||
saveScoreButton.addEventListener('click', async () => {
|
||||
const playerName = playerNameInput.value.trim();
|
||||
if (playerName === "") return alert("이름을 입력해주세요.");
|
||||
|
||||
try {
|
||||
// (★ 수정) user.js의 공통 submitRank 함수 호출
|
||||
// 2048의 주 점수(primaryScore)는 score, 보조 점수(secondaryScore)는 없음.
|
||||
await submitRank(currentGameType, currentContextId, playerName, score, null);
|
||||
|
||||
gameOverPopup.style.display = 'none';
|
||||
playerNameInput.value = '';
|
||||
score = 0;
|
||||
updateRankingList(); // 랭킹 리스트 새로고침
|
||||
initializeBoard(); // 새 게임 시작
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting rank:', error);
|
||||
alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
*/
|
||||
async function updateRankingList() {
|
||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
try {
|
||||
// (★ 수정) user.js의 공통 fetchRanks 함수 호출
|
||||
const rankings = await fetchRanks(currentGameType, currentContextId);
|
||||
|
||||
rankingList.innerHTML = ''; // 리스트 비우기
|
||||
if (!rankings || rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
// (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
|
||||
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ranks:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 게임 시작 -----
|
||||
updateRankingList();
|
||||
initializeBoard();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/play.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/play.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
<th:block layout:fragment="content">
|
||||
<h1>Solve the Puzzle! 🧩</h1>
|
||||
|
||||
<div id="game-controls">
|
||||
<div id="mode-selector">
|
||||
<label>
|
||||
<input type="radio" name="play-mode" value="fill" checked><span> Fill</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="play-mode" value="mark"><span> Mark (X)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="points-info">
|
||||
❤️ Points: <span id="points-display">5</span>
|
||||
</div>
|
||||
<button id="hint-btn">Hint (-1 Point)</button>
|
||||
</div>
|
||||
|
||||
<div id="board-viewport">
|
||||
<div id="game-board">
|
||||
</div>
|
||||
<img id="grayscale-reveal" class="reveal-img" src="" alt="Grayscale version">
|
||||
<img id="original-reveal" class="reveal-img" src="" alt="Original version">
|
||||
|
||||
<div id="result-overlay" class="hidden">
|
||||
<div id="result-modal">
|
||||
<h2 id="modal-title"></h2>
|
||||
<p id="modal-message"></p>
|
||||
<div id="modal-buttons">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
const puzzleData = /*[[${puzzle}]]*/ null;
|
||||
/*]]>*/
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,253 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/sudoku.js}"></script>
|
||||
<!-- <link th:href="@{/css/puzzle.css}" rel="stylesheet" />-->
|
||||
<link th:href="@{/css/sudoku.css}" rel="stylesheet" />
|
||||
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<head layout:fragment="head" id="head">
|
||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
|
||||
<style>
|
||||
/* sudoku.css의 내용을 여기에 삽입 */
|
||||
body {
|
||||
/*
|
||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f7f9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
*/
|
||||
}
|
||||
|
||||
#sudoku-game-app {
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
/*
|
||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서
|
||||
'#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.
|
||||
|
||||
background: white;
|
||||
padding: clamp(15px, 4vw, 30px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
*/
|
||||
|
||||
/* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
/* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.
|
||||
만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.
|
||||
현재는 공통 스타일이 적용됩니다. */
|
||||
}
|
||||
|
||||
/* ======================================= */
|
||||
/* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) */
|
||||
/* ======================================= */
|
||||
|
||||
/* 게임 컨테이너 */
|
||||
#game-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 게임 정보 (점수, 타이머) */
|
||||
.game-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
#score { color: #007bff; }
|
||||
#timer { color: #333; }
|
||||
|
||||
/* 스도쿠 보드 */
|
||||
#sudoku-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr);
|
||||
width: 100%;
|
||||
border: 3px solid #333;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell:nth-child(3n) { border-right: 2px solid #333; }
|
||||
.cell:nth-child(9n) { border-right-width: 1px; }
|
||||
.cell:nth-child(n+19):nth-child(-n+27),
|
||||
.cell:nth-child(n+46):nth-child(-n+54) {
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.cell:not(.editable) {
|
||||
background-color: #f0f0f0;
|
||||
color: #222;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 하이라이트 & 오답 스타일 */
|
||||
.cell.incorrect {
|
||||
background-color: #ffdddd !important;
|
||||
color: #d8000c !important;
|
||||
}
|
||||
.highlight-focused {
|
||||
background-color: #dbeeff !important;
|
||||
}
|
||||
.highlight-same-number {
|
||||
background-color: #e6e6e6 !important;
|
||||
}
|
||||
.highlight-selected-number {
|
||||
background-color: #b3d7ff !important;
|
||||
}
|
||||
|
||||
/* 숫자 입력 버튼 */
|
||||
#number-input-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
gap: 1%;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn,
|
||||
#number-input-buttons #undo-btn {
|
||||
line-height: unset;
|
||||
min-width: unset;
|
||||
width: 9%;
|
||||
aspect-ratio: 1/1;
|
||||
font-size: clamp(1em, 4vw, 1.8em);
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn.selected {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
#number-input-buttons .num-btn.completed {
|
||||
opacity: 0.4;
|
||||
background-color: #e9ecef;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#number-input-buttons #undo-btn {
|
||||
background-color: #f8f9fa;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* 액션 버튼 (힌트, 정답확인) */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.action-buttons button {
|
||||
flex-grow: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 모달 및 숨김 처리 */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
#modal-overlay, #game-over-modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
#modal-content h2, #modal-content h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#username-input {
|
||||
width: calc(100% - 24px);
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
}
|
||||
#ranking-list {
|
||||
list-style-type: decimal;
|
||||
list-style-position: inside;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#ranking-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#ranking-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="sudoku-game-app">
|
||||
<div class="container">
|
||||
@@ -76,5 +313,373 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// sudoku.js의 내용을 여기에 삽입
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM 요소
|
||||
const setupContainer = document.getElementById('setup-container');
|
||||
const gameContainer = document.getElementById('game-container');
|
||||
const startBtn = document.getElementById('start-btn');
|
||||
const boardElement = document.getElementById('sudoku-board');
|
||||
const timerElement = document.getElementById('timer');
|
||||
const scoreElement = document.getElementById('score');
|
||||
const hintBtn = document.getElementById('hint-btn');
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
const completeBtn = document.getElementById('complete-btn');
|
||||
const numberInputButtons = document.getElementById('number-input-buttons');
|
||||
const modalOverlay = document.getElementById('modal-overlay');
|
||||
const gameOverModal = document.getElementById('game-over-modal');
|
||||
const retryBtn = document.getElementById('retry-btn');
|
||||
const submitRankBtn = document.getElementById('submit-rank-btn');
|
||||
const rankingList = document.getElementById('ranking-list');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
|
||||
// 게임 상태 변수
|
||||
let currentPuzzleId = null;
|
||||
let solvedPuzzle = null;
|
||||
let timerInterval = null;
|
||||
let secondsElapsed = 0;
|
||||
let selectedNumber = null;
|
||||
let focusedCell = null;
|
||||
let score = 5;
|
||||
let history = [];
|
||||
|
||||
// (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
|
||||
startBtn.addEventListener('click', async () => {
|
||||
const difficulty = document.getElementById('difficulty-select').value;
|
||||
try {
|
||||
// (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
|
||||
const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
|
||||
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
||||
const gameData = await response.json();
|
||||
|
||||
currentPuzzleId = gameData.puzzleId;
|
||||
solvedPuzzle = gameData.solution;
|
||||
|
||||
history = [];
|
||||
score = 5;
|
||||
updateScoreDisplay();
|
||||
|
||||
renderBoard(gameData.question);
|
||||
startTimer();
|
||||
updateButtonStates();
|
||||
|
||||
setupContainer.classList.add('hidden');
|
||||
gameContainer.classList.remove('hidden');
|
||||
numberInputButtons.classList.remove('hidden');
|
||||
gameOverModal.classList.add('hidden');
|
||||
} catch (error) {
|
||||
alert('게임 로딩에 실패했습니다: ' + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
function renderBoard(puzzleString) {
|
||||
boardElement.innerHTML = '';
|
||||
for (let i = 0; i < 81; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('cell');
|
||||
cell.dataset.index = i;
|
||||
|
||||
if (puzzleString[i] !== '0') {
|
||||
cell.textContent = puzzleString[i];
|
||||
} else {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
boardElement.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
secondsElapsed = 0;
|
||||
timerElement.textContent = '00:00';
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => {
|
||||
secondsElapsed++;
|
||||
const minutes = Math.floor(secondsElapsed / 60).toString().padStart(2, '0');
|
||||
const seconds = (secondsElapsed % 60).toString().padStart(2, '0');
|
||||
timerElement.textContent = `${minutes}:${seconds}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreElement.textContent = `SCORE: ${score}`;
|
||||
if (score <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
gameOverModal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStates() {
|
||||
const counts = {};
|
||||
for (let i = 1; i <= 9; i++) counts[i] = 0;
|
||||
boardElement.querySelectorAll('.cell').forEach(cell => {
|
||||
const num = cell.textContent;
|
||||
if (num && counts[num] !== undefined) counts[num]++;
|
||||
});
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
|
||||
if (btn) {
|
||||
if (counts[i] >= 9) {
|
||||
btn.classList.add('completed');
|
||||
if (selectedNumber == i) {
|
||||
selectedNumber = null;
|
||||
btn.classList.remove('selected');
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 플레이 이벤트 핸들링 ---
|
||||
numberInputButtons.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('button');
|
||||
if (!target) return;
|
||||
|
||||
if (target === undoBtn) {
|
||||
undoAction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('completed')) return;
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||
|
||||
if (target.classList.contains('num-btn')) {
|
||||
const num = target.dataset.number;
|
||||
selectedNumber = (selectedNumber === num) ? null : num;
|
||||
if (selectedNumber) target.classList.add('selected');
|
||||
}
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
boardElement.addEventListener('click', (event) => {
|
||||
const targetCell = event.target.closest('.cell.editable');
|
||||
if (!targetCell) {
|
||||
if (focusedCell) focusedCell = null;
|
||||
highlightCells();
|
||||
return;
|
||||
}
|
||||
focusedCell = targetCell;
|
||||
|
||||
if (selectedNumber) {
|
||||
const previousValue = targetCell.textContent;
|
||||
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||
targetCell.textContent = newValue;
|
||||
|
||||
recordAction(targetCell, previousValue, newValue);
|
||||
validateCell(targetCell);
|
||||
updateButtonStates();
|
||||
checkIfBoardIsFull();
|
||||
}
|
||||
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
hintBtn.addEventListener('click', () => {
|
||||
if (score <= 0) return;
|
||||
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
||||
if (emptyCells.length === 0) {
|
||||
alert('모든 칸이 채워져 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
const cellIndex = parseInt(randomCell.dataset.index);
|
||||
const correctAnswer = solvedPuzzle[cellIndex];
|
||||
const previousValue = randomCell.textContent;
|
||||
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
recordAction(randomCell, previousValue, correctAnswer, true);
|
||||
|
||||
randomCell.textContent = correctAnswer;
|
||||
randomCell.classList.remove('editable', 'incorrect');
|
||||
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
checkIfBoardIsFull();
|
||||
});
|
||||
|
||||
function undoAction() {
|
||||
if (history.length === 0) return;
|
||||
const lastAction = history.pop();
|
||||
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||
|
||||
if (cell) {
|
||||
cell.textContent = lastAction.previousValue;
|
||||
if (lastAction.wasHint) {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
validateCell(cell, false);
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
}
|
||||
}
|
||||
|
||||
function recordAction(cell, previousValue, newValue, wasHint = false) {
|
||||
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
|
||||
}
|
||||
|
||||
function validateCell(cell, deductPoint = true) {
|
||||
if (!cell.textContent) {
|
||||
cell.classList.remove('incorrect');
|
||||
return;
|
||||
}
|
||||
const cellIndex = parseInt(cell.dataset.index);
|
||||
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
|
||||
if (!isCorrect) {
|
||||
cell.classList.add('incorrect');
|
||||
if (deductPoint && score > 0) {
|
||||
score--;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
} else {
|
||||
cell.classList.remove('incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 하이라이트 기능 ---
|
||||
function highlightCells() {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
if (focusedCell) {
|
||||
focusedCell.classList.add('highlight-focused');
|
||||
const focusedValue = focusedCell.textContent;
|
||||
if (focusedValue) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedNumber) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 게임 완료 및 모달 ---
|
||||
async function checkSolution() {
|
||||
let answerString = "";
|
||||
boardElement.childNodes.forEach(cell => {
|
||||
answerString += cell.textContent || '0';
|
||||
});
|
||||
|
||||
if (answerString.includes('0')) {
|
||||
alert('모든 칸을 채워주세요!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
|
||||
// (★ 수정) currentPuzzleId 변수 사용
|
||||
const response = await fetch('/puzzle/sudoku/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ puzzleId: currentPuzzleId, answer: answerString })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.correct) {
|
||||
clearInterval(timerInterval);
|
||||
alert('🎉 정답입니다!');
|
||||
showRankingModal(); // 랭킹 모달 표시
|
||||
} else {
|
||||
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('정답 확인 중 오류 발생:', error);
|
||||
alert('정답 확인 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfBoardIsFull() {
|
||||
const emptyEditableCells = boardElement.querySelector('.cell.editable:empty');
|
||||
if (!emptyEditableCells) {
|
||||
checkSolution();
|
||||
}
|
||||
}
|
||||
completeBtn.addEventListener('click', checkSolution);
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
||||
*/
|
||||
async function showRankingModal() {
|
||||
modalOverlay.classList.remove('hidden');
|
||||
document.getElementById('username-input').value = '';
|
||||
submitRankBtn.disabled = false;
|
||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
||||
|
||||
try {
|
||||
// user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID 전달)
|
||||
// currentGameType 변수가 정의되어 있어야 합니다. 예: const currentGameType = 'sudoku';
|
||||
const currentGameType = 'sudoku';
|
||||
const rankings = await fetchRanks(currentGameType, currentPuzzleId);
|
||||
|
||||
rankingList.innerHTML = '';
|
||||
if (rankings.length === 0) {
|
||||
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||
} else {
|
||||
rankings.forEach((rank, index) => {
|
||||
const li = document.createElement('li');
|
||||
const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
|
||||
const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
|
||||
li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
|
||||
rankingList.appendChild(li);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('랭킹 조회 중 오류 발생:', error);
|
||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
|
||||
*/
|
||||
submitRankBtn.addEventListener('click', async () => {
|
||||
const userName = document.getElementById('username-input').value.trim();
|
||||
if (!userName) return alert('이름을 입력해주세요.');
|
||||
|
||||
try {
|
||||
// user.js의 공통 submitRank 함수 호출
|
||||
const currentGameType = 'sudoku';
|
||||
await submitRank(currentGameType, currentPuzzleId, userName, secondsElapsed, null);
|
||||
|
||||
alert('랭킹이 성공적으로 등록되었습니다!');
|
||||
showRankingModal(); // 랭킹 목록 새로고침
|
||||
submitRankBtn.disabled = true; // 중복 등록 방지
|
||||
} catch (error) {
|
||||
console.error('랭킹 등록 중 오류 발생:', error);
|
||||
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
function resetGameView() {
|
||||
setupContainer.classList.remove('hidden');
|
||||
gameContainer.classList.add('hidden');
|
||||
numberInputButtons.classList.add('hidden');
|
||||
clearInterval(timerInterval);
|
||||
selectedNumber = null;
|
||||
focusedCell = null;
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
|
||||
}
|
||||
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
modalOverlay.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
|
||||
retryBtn.addEventListener('click', () => {
|
||||
gameOverModal.classList.add('hidden');
|
||||
resetGameView();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,8 +6,8 @@
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/upload.js}"></script>
|
||||
<link th:href="@{/css/puzzle.css}" rel="stylesheet" />
|
||||
<script type="text/javascript" th:src="@{/js/nonogram.js}"></script>
|
||||
<link th:href="@{/css/nonogram.css}" rel="stylesheet" />
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user