장단콩 보드게임 & 미니어처 게임 클럽

2048 퍼즐게임 + 랭킹보드 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 로그인(승인 회원) 전용 2048 게임 페이지를 추가하고, 최고 점수 기준 TOP 10 랭킹보드를 붙인다.

Architecture: Jekyll 정적 페이지(/game.html) + vanilla JS 게임 로직(순수 함수로 분리해 테스트 가능) + Firestore game_scores 컬렉션. 기존 board.html/stats.js/auth-guard.js 패턴을 그대로 따른다. 빌드 스텝 없음, CDN import 방식의 Firebase SDK 사용.

Tech Stack: Jekyll, vanilla JS (ES modules), Firebase Auth + Firestore (firebasejs 10.12.2 CDN), Node node:test/node:assert (로직 자기검증용, 브라우저에는 미포함).

Global Constraints


File Structure


Task 1: 순수 게임 로직 (game2048.js) + 테스트

Files:

Interfaces:

// assets/js/game2048.test.mjs
import assert from "node:assert";
import { test } from "node:test";
import {
  createEmptyGrid, addRandomTile, move, isGameOver
} from "./game2048.js";

test("createEmptyGrid: 4x4 모두 0", () => {
  const grid = createEmptyGrid();
  assert.strictEqual(grid.length, 4);
  grid.forEach(row => {
    assert.strictEqual(row.length, 4);
    row.forEach(cell => assert.strictEqual(cell, 0));
  });
});

test("addRandomTile: 빈 칸 하나에만 2 또는 4가 채워진다", () => {
  const grid = createEmptyGrid();
  const next = addRandomTile(grid);
  const nonZero = next.flat().filter(v => v !== 0);
  assert.strictEqual(nonZero.length, 1);
  assert.ok(nonZero[0] === 2 || nonZero[0] === 4);
});

test("addRandomTile: 빈 칸이 없으면 그대로 반환", () => {
  const full = [[2,2,2,2],[2,2,2,2],[2,2,2,2],[2,2,2,2]];
  const next = addRandomTile(full);
  assert.deepStrictEqual(next, full);
});

test("move left: 같은 값 두 개가 만나면 병합되고 점수가 오른다", () => {
  const grid = [
    [2, 2, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "left");
  assert.deepStrictEqual(result.grid[0], [4, 0, 0, 0]);
  assert.strictEqual(result.moved, true);
  assert.strictEqual(result.scoreGained, 4);
});

test("move left: 세 개 연속은 앞쪽 두 개만 병합된다 (2 2 2 0 -> 4 2 0 0)", () => {
  const grid = [
    [2, 2, 2, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "left");
  assert.deepStrictEqual(result.grid[0], [4, 2, 0, 0]);
});

test("move right: 오른쪽 정렬 및 병합", () => {
  const grid = [
    [0, 0, 2, 2],
    [0, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "right");
  assert.deepStrictEqual(result.grid[0], [0, 0, 0, 4]);
});

test("move up: 위쪽 정렬 및 병합", () => {
  const grid = [
    [2, 0, 0, 0],
    [2, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "up");
  assert.strictEqual(result.grid[0][0], 4);
  assert.strictEqual(result.grid[1][0], 0);
});

test("move down: 아래쪽 정렬 및 병합", () => {
  const grid = [
    [2, 0, 0, 0],
    [2, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "down");
  assert.strictEqual(result.grid[3][0], 4);
  assert.strictEqual(result.grid[2][0], 0);
});

test("move: 변화가 없으면 moved=false", () => {
  const grid = [
    [2, 4, 8, 16],
    [0, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  const result = move(grid, "left");
  assert.strictEqual(result.moved, false);
  assert.strictEqual(result.scoreGained, 0);
});

test("isGameOver: 빈 칸이 있으면 false", () => {
  const grid = [
    [2, 4, 8, 16],
    [0, 0, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]
  ];
  assert.strictEqual(isGameOver(grid), false);
});

test("isGameOver: 꽉 찼지만 인접 병합 가능하면 false", () => {
  const grid = [
    [2, 4, 2, 4],
    [4, 2, 4, 2],
    [2, 4, 2, 4],
    [4, 2, 4, 4]
  ];
  assert.strictEqual(isGameOver(grid), false);
});

test("isGameOver: 꽉 차고 병합 불가능하면 true", () => {
  const grid = [
    [2, 4, 2, 4],
    [4, 2, 4, 2],
    [2, 4, 2, 4],
    [4, 2, 4, 2]
  ];
  assert.strictEqual(isGameOver(grid), true);
});

Run: node --test assets/js/game2048.test.mjs Expected: FAIL (game2048.js가 아직 없어서 모듈을 찾을 수 없다는 에러)

// assets/js/game2048.js
const SIZE = 4;

export function createEmptyGrid() {
  return Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
}

export function addRandomTile(grid) {
  const empties = [];
  grid.forEach((row, r) => row.forEach((v, c) => { if (v === 0) empties.push([r, c]); }));
  if (empties.length === 0) return grid;

  const next = grid.map(row => [...row]);
  const [r, c] = empties[Math.floor(Math.random() * empties.length)];
  next[r][c] = Math.random() < 0.9 ? 2 : 4;
  return next;
}

function slideAndMergeRow(row) {
  const compact = row.filter(v => v !== 0);
  let scoreGained = 0;
  const merged = [];

  for (let i = 0; i < compact.length; i++) {
    if (compact[i] === compact[i + 1]) {
      const value = compact[i] * 2;
      merged.push(value);
      scoreGained += value;
      i++;
    } else {
      merged.push(compact[i]);
    }
  }

  while (merged.length < SIZE) merged.push(0);
  return { row: merged, scoreGained };
}

export function moveLeft(grid) {
  let scoreGained = 0;
  const newGrid = grid.map(row => {
    const { row: newRow, scoreGained: gained } = slideAndMergeRow(row);
    scoreGained += gained;
    return newRow;
  });
  const moved = JSON.stringify(newGrid) !== JSON.stringify(grid);
  return { grid: newGrid, moved, scoreGained };
}

function rotateClockwise(grid) {
  return grid[0].map((_, c) => grid.map(row => row[c]).reverse());
}

function rotateCounterClockwise(grid) {
  return grid[0].map((_, c) => grid.map(row => row[row.length - 1 - c]));
}

export function move(grid, direction) {
  if (direction === "left") return moveLeft(grid);

  if (direction === "right") {
    const rotated = grid.map(row => [...row].reverse());
    const result = moveLeft(rotated);
    return { ...result, grid: result.grid.map(row => [...row].reverse()) };
  }

  if (direction === "up") {
    const rotated = rotateCounterClockwise(grid);
    const result = moveLeft(rotated);
    return { ...result, grid: rotateClockwise(result.grid) };
  }

  if (direction === "down") {
    const rotated = rotateClockwise(grid);
    const result = moveLeft(rotated);
    return { ...result, grid: rotateCounterClockwise(result.grid) };
  }

  throw new Error(`Unknown direction: ${direction}`);
}

export function isGameOver(grid) {
  const hasEmpty = grid.some(row => row.some(v => v === 0));
  if (hasEmpty) return false;

  for (let r = 0; r < SIZE; r++) {
    for (let c = 0; c < SIZE; c++) {
      const v = grid[r][c];
      if (c < SIZE - 1 && grid[r][c + 1] === v) return false;
      if (r < SIZE - 1 && grid[r + 1][c] === v) return false;
    }
  }
  return true;
}

Run: node --test assets/js/game2048.test.mjs Expected: PASS (모든 테스트 통과)

git add assets/js/game2048.js assets/js/game2048.test.mjs
git commit -m "feat: add pure 2048 game logic with tests"

Task 2: 게임 페이지 마크업 + 스타일

Files:

Interfaces:

_config.yml:23-30을 다음으로 교체:

navigation:
    # - title: 소개
    #   url: /index.html /about /contact
    - title: 회칙
      url: /rules
    - title: 명예의 전당
      url: /honor
    - title: 콩툰
      url: /toon
    - title: 게임 일정
      url: /board
    - title: 2048
      url: /game
    # - title: 공식 이벤트
    #   url: /event
/* =========================================
   game.css - 2048 게임 페이지
   pages.css의 CSS 변수 재사용
   ========================================= */

.game-board {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: 10px;
  width: min(100%, 400px);
  aspect-ratio: 1 / 1;
  margin: 0 auto;
  padding: 10px;
  background: var(--input-border);
  border-radius: var(--radius);
  touch-action: none;
}

.game-tile {
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-sm);
  font-weight: 700;
  font-size: 1.4rem;
  background: var(--card-bg);
  color: var(--text-primary);
}

.game-tile[data-value="0"] { background: rgba(255,255,255,0.4); }
.game-tile[data-value="2"] { background: #eee4da; }
.game-tile[data-value="4"] { background: #ede0c8; }
.game-tile[data-value="8"] { background: #f2b179; color: #fff; }
.game-tile[data-value="16"] { background: #f59563; color: #fff; }
.game-tile[data-value="32"] { background: #f67c5f; color: #fff; }
.game-tile[data-value="64"] { background: #f65e3b; color: #fff; }
.game-tile[data-value="128"] { background: #edcf72; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="256"] { background: #edcc61; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="512"] { background: #edc850; color: #fff; font-size: 1.2rem; }
.game-tile[data-value="1024"] { background: #edc53f; color: #fff; font-size: 1rem; }
.game-tile[data-value="2048"] { background: #edc22e; color: #fff; font-size: 1rem; }

.game-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  max-width: 400px;
  margin: 0 auto 16px;
}

.game-score-box {
  background: var(--text-primary);
  color: #fff;
  border-radius: var(--radius-sm);
  padding: 8px 16px;
  text-align: center;
  min-width: 80px;
}

.game-score-box .label { font-size: 0.7rem; opacity: 0.7; }
.game-score-box .value { font-size: 1.2rem; font-weight: 700; }

#game-over-msg {
  text-align: center;
  margin: 16px auto;
  max-width: 400px;
  color: var(--danger);
  font-weight: 700;
}

.leaderboard-row {
  display: flex;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid var(--divider);
  font-size: 0.9rem;
}

.leaderboard-row.is-me {
  background: var(--accent-light);
  font-weight: 700;
}

.leaderboard-rank {
  color: var(--text-muted);
  width: 28px;
  display: inline-block;
}
---
layout: default
title: 2048
permalink: /game/
---
<link rel="stylesheet" href="/assets/css/pages.css">
<link rel="stylesheet" href="/assets/css/game.css">

<div class="page-wrapper">
  <div class="page-card page-card--wide">
    <p class="page-card__title">2048</p>
    <p style="color:var(--text-secondary);font-size:0.9rem;margin-bottom:24px">
      화살표 키 또는 스와이프로 타일을 움직여 2048을 만들어보세요.
    </p>

    <div class="game-header">
      <div id="my-rank" style="font-size:0.85rem;color:var(--text-secondary)"></div>
      <div class="game-score-box">
        <div class="label">SCORE</div>
        <div class="value" id="game-score">0</div>
      </div>
    </div>

    <div class="game-board" id="game-grid"></div>

    <div id="game-over-msg" style="display:none">
      게임 종료! <button id="btn-restart" class="btn-write" style="border:none;cursor:pointer">다시 시작</button>
    </div>

    <div style="text-align:center;margin-top:12px" id="restart-wrap">
      <button id="btn-restart-top" class="btn-view">다시 시작</button>
    </div>

    <div class="section-divider" style="margin:32px 0"></div>

    <p class="board-section-title">랭킹보드 TOP 10</p>
    <div id="leaderboard-list">불러오는 중...</div>
  </div>
</div>

<script type="module" src="/assets/js/game.js"></script>

Run: bundle exec jekyll build Expected: 빌드 성공, _site/game/index.html 생성됨

git add game.html assets/css/game.css _config.yml
git commit -m "feat: add game page markup and styles"

Task 3: 게임 컨트롤러 (game.js) — 인증가드, 입력, Firestore 연동

Files:

Interfaces:

동작 요구사항:

import { db } from "./firebase-init.js";
import { requireApproved } from "./auth-guard.js";
import { createEmptyGrid, addRandomTile, move, isGameOver } from "./game2048.js";
import {
  doc, getDoc, setDoc, collection, getDocs, serverTimestamp
} from "https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js";

let grid = createEmptyGrid();
let score = 0;
let gameOver = false;
let currentUser = null;
let currentUserData = null;

requireApproved((user, userData) => {
  currentUser = user;
  currentUserData = userData;
  startNewGame();
  loadLeaderboard();
});

function startNewGame() {
  grid = addRandomTile(addRandomTile(createEmptyGrid()));
  score = 0;
  gameOver = false;
  document.getElementById("game-over-msg").style.display = "none";
  render();
}

function render() {
  const gridEl = document.getElementById("game-grid");
  gridEl.innerHTML = "";
  grid.flat().forEach(value => {
    const tile = document.createElement("div");
    tile.className = "game-tile";
    tile.dataset.value = String(value);
    tile.textContent = value === 0 ? "" : String(value);
    gridEl.appendChild(tile);
  });
  document.getElementById("game-score").textContent = String(score);
}

function handleMove(direction) {
  if (gameOver) return;
  const result = move(grid, direction);
  if (!result.moved) return;

  grid = addRandomTile(result.grid);
  score += result.scoreGained;
  render();

  if (isGameOver(grid)) {
    gameOver = true;
    document.getElementById("game-over-msg").style.display = "block";
    submitScore(currentUser.uid, currentUserData.nickname, score)
      .then(loadLeaderboard);
  }
}

document.addEventListener("keydown", (e) => {
  const map = {
    ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down"
  };
  if (map[e.key]) {
    e.preventDefault();
    handleMove(map[e.key]);
  }
});

let touchStartX = 0;
let touchStartY = 0;
document.getElementById("game-grid").addEventListener("touchstart", (e) => {
  touchStartX = e.touches[0].clientX;
  touchStartY = e.touches[0].clientY;
}, { passive: true });

document.getElementById("game-grid").addEventListener("touchend", (e) => {
  const dx = e.changedTouches[0].clientX - touchStartX;
  const dy = e.changedTouches[0].clientY - touchStartY;
  if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;

  if (Math.abs(dx) > Math.abs(dy)) {
    handleMove(dx > 0 ? "right" : "left");
  } else {
    handleMove(dy > 0 ? "down" : "up");
  }
});

document.getElementById("btn-restart").addEventListener("click", startNewGame);
document.getElementById("btn-restart-top").addEventListener("click", startNewGame);

async function submitScore(uid, nickname, finalScore) {
  const ref = doc(db, "game_scores", uid);
  const snap = await getDoc(ref);
  const currentBest = snap.exists() ? (snap.data().bestScore || 0) : 0;
  if (finalScore <= currentBest) return;

  await setDoc(ref, {
    nickname,
    bestScore: finalScore,
    updatedAt: serverTimestamp()
  });
}

async function loadLeaderboard() {
  const snap = await getDocs(collection(db, "game_scores"));
  const entries = snap.docs
    .map(d => ({ uid: d.id, ...d.data() }))
    .sort((a, b) => (b.bestScore || 0) - (a.bestScore || 0));

  const top10 = entries.slice(0, 10);
  const listEl = document.getElementById("leaderboard-list");
  listEl.innerHTML = top10.length
    ? top10.map((e, i) => `
        <div class="leaderboard-row ${currentUser && e.uid === currentUser.uid ? "is-me" : ""}">
          <span><span class="leaderboard-rank">${i + 1}</span>${e.nickname}</span>
          <span>${e.bestScore}</span>
        </div>`).join("")
    : `<p class="text-muted small">아직 기록이 없습니다.</p>`;

  const myRankEl = document.getElementById("my-rank");
  if (!currentUser) { myRankEl.textContent = ""; return; }
  const myIndex = entries.findIndex(e => e.uid === currentUser.uid);
  myRankEl.textContent = myIndex === -1
    ? "아직 기록이 없습니다"
    : `내 순위: ${myIndex + 1}위 (최고 ${entries[myIndex].bestScore}점)`;
}

Run: bundle exec jekyll serve 브라우저에서 http://localhost:4000/game/ 접속:

  1. 로그인 안 한 상태로 접속 → /login/으로 리다이렉트되는지 확인
  2. 로그인(승인 회원)한 상태로 접속 → 게임 그리드가 타일 2개로 시작하는지 확인
  3. 화살표 키로 타일이 이동/병합되는지 확인
  4. 모바일 뷰(브라우저 개발자도구)에서 스와이프 동작 확인
  5. “다시 시작” 버튼 클릭 시 새 게임 시작 확인
  6. 게임오버까지 플레이(또는 그리드를 임시로 거의 채운 상태로 테스트)해서 점수 제출 및 랭킹보드 갱신 확인

Expected: 위 6가지 모두 정상 동작

git add assets/js/game.js
git commit -m "feat: wire up 2048 game controller with auth guard and leaderboard"

Task 4: Firestore 보안 규칙 확인 및 반영

Files:

Interfaces:

Firebase 콘솔(https://console.firebase.google.com/project/jdk-member-board/firestore/rules)에서 현재 규칙을 확인한다. (firebase-init.js:8의 projectId 참고)

기존 규칙에 다음 블록이 없다면 추가한다 (기존 users/posts 규칙 스타일에 맞춰 조정):

match /game_scores/{uid} {
  allow read: if request.auth != null;
  allow write: if request.auth != null && request.auth.uid == uid;
}

/game/에서 점수 제출이 실패하지 않는지, 다른 사용자의 game_scores 문서를 임의로 덮어쓸 수 없는지(브라우저 콘솔에서 다른 uid로 setDoc 시도 시 permission-denied 에러가 나는지) 확인한다.

프로젝트에 firestore.rules 파일이 없으므로 이 프로젝트는 콘솔 관리 방식을 유지한다. 별도 커밋 불필요. 확인 완료 후 다음 태스크로 진행.


Self-Review Notes