2025-09-12 16:55:21 +09:00

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>