454 lines
15 KiB
HTML
454 lines
15 KiB
HTML
<!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}"
|
|
>
|
|
<head>
|
|
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
|
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
|
|
|
<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>
|
|
<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>
|
|
</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>
|
|
</body>
|
|
</html> |