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

다이스 드랍 (주사위 수박게임) 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: 수박게임 방식의 물리 낙하 퍼즐(주사위 진화 테마)을 회원 전용 페이지로 추가하고, 전체/이번 주 리더보드를 붙인다.

Architecture: 순수 로직(단계 테이블·합체 규칙·주차 계산)은 의존성 없는 ES 모듈로 분리해 node --test로 검증한다. 물리는 Matter.js(CDN, 전역 Matter)로 돌리고 렌더링은 캔버스 2D 커스텀 루프(색 원 + 라벨 텍스트). 점수는 Firestore suika_scores/{uid} 문서 하나로 전체 최고점과 주간 최고점을 함께 관리한다(주차 키가 바뀌면 제출 시 주간 점수를 교체 — 크론 불필요).

Tech Stack: Jekyll 정적 페이지, Vanilla JS ES 모듈, Matter.js 0.20.0 (jsdelivr CDN), Firebase 10.12.2 (gstatic CDN, 기존 firebase-init.js/auth-guard.js 재사용), node --test.

Global Constraints


Task 1: 순수 로직 모듈 (단계 테이블·합체·주차 키)

Files:

Interfaces:

assets/js/suika-logic.test.mjs:

import assert from "node:assert";
import { test } from "node:test";
import {
  TIERS, getTier, randomDropTier, mergeResult, weekKey
} from "./suika-logic.js";

test("TIERS: 9단계, 반지름 단조 증가", () => {
  assert.strictEqual(TIERS.length, 9);
  for (let i = 1; i < TIERS.length; i++) {
    assert.ok(TIERS[i].radius > TIERS[i - 1].radius);
  }
});

test("getTier: 1-기반 조회", () => {
  assert.strictEqual(getTier(1).label, "d4");
  assert.strictEqual(getTier(6).label, "d20");
  assert.strictEqual(getTier(9).label, "");
});

test("mergeResult: 다음 단계와 그 단계의 점수를 반환", () => {
  assert.deepStrictEqual(mergeResult(1), { next: 2, score: 3 });
  assert.deepStrictEqual(mergeResult(2), { next: 3, score: 6 });
  assert.deepStrictEqual(mergeResult(5), { next: 6, score: 21 });
  assert.deepStrictEqual(mergeResult(8), { next: 9, score: 66 });
});

test("mergeResult: 최종 단계(9)끼리는 소멸하고 66점", () => {
  assert.deepStrictEqual(mergeResult(9), { next: null, score: 66 });
});

test("randomDropTier: 항상 1~5 범위", () => {
  assert.strictEqual(randomDropTier(() => 0), 1);
  assert.strictEqual(randomDropTier(() => 0.999999), 5);
  for (let i = 0; i < 100; i++) {
    const t = randomDropTier();
    assert.ok(t >= 1 && t <= 5);
  }
});

test("weekKey: ISO 주차 (연말 경계 포함)", () => {
  assert.strictEqual(weekKey(new Date(2026, 6, 13)), "2026-W29");  // 월요일
  assert.strictEqual(weekKey(new Date(2026, 0, 4)), "2026-W01");   // 일요일, 1주차 끝
  assert.strictEqual(weekKey(new Date(2026, 0, 5)), "2026-W02");   // 월요일
  assert.strictEqual(weekKey(new Date(2025, 11, 29)), "2026-W01"); // 2025-12-29(월)는 2026년 1주차
});

Run: node --test assets/js/suika-logic.test.mjs Expected: FAIL — Cannot find module ... suika-logic.js

assets/js/suika-logic.js:

// 순수 로직 모듈 — DOM/Matter.js 의존 없음 (게임 규칙은 스펙 문서 참조)
export const TIERS = [
  { tier: 1, label: "d4",   radius: 14, color: "#e6a23c", score: 0 },
  { tier: 2, label: "d6",   radius: 20, color: "#f56c6c", score: 3 },
  { tier: 3, label: "d8",   radius: 27, color: "#9b59b6", score: 6 },
  { tier: 4, label: "d10",  radius: 35, color: "#3498db", score: 10 },
  { tier: 5, label: "d12",  radius: 44, color: "#1abc9c", score: 15 },
  { tier: 6, label: "d20",  radius: 54, color: "#2ecc71", score: 21 },
  { tier: 7, label: "d30",  radius: 65, color: "#f1c40f", score: 28 },
  { tier: 8, label: "d100", radius: 78, color: "#e67e22", score: 36 },
  { tier: 9, label: "",   radius: 92, color: "#8b5a2b", score: 66 }
];

const MAX_DROP_TIER = 5;

export function getTier(tier) {
  return TIERS[tier - 1];
}

export function randomDropTier(rand = Math.random) {
  return Math.floor(rand() * MAX_DROP_TIER) + 1;
}

export function mergeResult(tier) {
  if (tier === TIERS.length) return { next: null, score: getTier(tier).score };
  return { next: tier + 1, score: getTier(tier + 1).score };
}

export function weekKey(date) {
  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
  const day = d.getUTCDay() || 7;
  d.setUTCDate(d.getUTCDate() + 4 - day);
  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
  const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
  return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}

Run: node --test assets/js/suika-logic.test.mjs Expected: PASS (6 tests)

기존 테스트 회귀 확인: Run: node --test assets/js/game2048.test.mjs Expected: PASS

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

Task 2: 페이지 마크업 + 스타일 + 네비게이션

Files:

Interfaces:

suika.html (game.html과 같은 구조):

---
layout: default
title: 다이스 드랍
permalink: /suika/
---
<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">다이스 드랍</p>
    <p style="color:var(--text-secondary);font-size:0.9rem;margin-bottom:24px">
      주사위를 떨어뜨려 같은 주사위끼리 합치세요. d4에서 장단콩까지!
    </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="suika-score">0</div>
      </div>
    </div>

    <div style="text-align:center;font-size:0.85rem;color:var(--text-secondary);margin-bottom:8px">
      다음: <span id="suika-next">-</span>
    </div>

    <canvas id="suika-canvas" class="suika-canvas" width="360" height="540"></canvas>

    <div id="suika-over" style="display:none;text-align:center;margin-top:12px">
      게임 종료! <button id="btn-suika-restart" class="btn-write" style="border:none;cursor:pointer">다시 시작</button>
    </div>

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

    <p class="board-section-title">랭킹보드 TOP 10</p>
    <div class="lb-tabs">
      <button id="lb-tab-all" class="lb-tab active">전체</button>
      <button id="lb-tab-week" class="lb-tab">이번 주</button>
    </div>
    <div id="leaderboard-list">불러오는 중...</div>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/matter-js@0.20.0/build/matter.min.js"></script>
<script type="module" src="/assets/js/suika.js"></script>

assets/css/game.css 파일 끝에 추가:

/* =========================================
   다이스 드랍 (수박게임)
   ========================================= */

.suika-canvas {
  display: block;
  width: min(100%, 400px);
  margin: 0 auto;
  background: var(--card-bg);
  border: 2px solid var(--input-border);
  border-radius: var(--radius);
  touch-action: none;
}

.lb-tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

.lb-tab {
  padding: 4px 14px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--input-border);
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 0.85rem;
}

.lb-tab.active {
  background: var(--input-border);
  color: var(--text-primary);
  font-weight: 700;
}

_config.yml의 navigation에서 - title: 2048 항목 바로 아래에 추가 (들여쓰기 동일하게):

    - title: 다이스 드랍
      url: /suika

Run: bundle exec jekyll build 2>&1 | tail -3 Expected: done in X.XXX seconds. (에러 없음) 확인: ls _site/suika/index.html → 파일 존재

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

Task 3: 게임 컨트롤러 (물리·조작·렌더·리더보드)

Files:

Interfaces:

assets/js/suika.js 전체:

import { db } from "./firebase-init.js";
import { requireApproved } from "./auth-guard.js";
import { getTier, randomDropTier, mergeResult, weekKey } from "./suika-logic.js";
import {
  doc, getDoc, setDoc, collection, getDocs, serverTimestamp
} from "https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js";

const { Engine, Bodies, Composite, Events } = Matter;

const W = 360, H = 540;
const WALL = 40;          // 벽 두께 (캔버스 밖에 배치)
const DROP_Y = 50;        // 공을 떨어뜨리는 높이
const DANGER_Y = 90;      // 이 선 위에 공이 머물면 게임오버
const GRACE_MS = 2000;    // 갓 생성된 공의 게임오버 판정 유예
const DROP_COOLDOWN = 600;

const canvas = document.getElementById("suika-canvas");
const ctx = canvas.getContext("2d");

let engine = null;
let score = 0;
let gameOver = false;
let currentTier = 1;
let nextTier = 1;
let canDrop = false;
let aimX = W / 2;
let currentUser = null;
let currentUserData = null;
let lbTab = "all";

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

function startNewGame() {
  engine = Engine.create();
  Composite.add(engine.world, [
    Bodies.rectangle(W / 2, H + WALL / 2, W + WALL * 2, WALL, { isStatic: true }),
    Bodies.rectangle(-WALL / 2, H / 2, WALL, H * 2, { isStatic: true }),
    Bodies.rectangle(W + WALL / 2, H / 2, WALL, H * 2, { isStatic: true })
  ]);
  Events.on(engine, "collisionStart", onCollision);
  score = 0;
  gameOver = false;
  currentTier = randomDropTier();
  nextTier = randomDropTier();
  canDrop = true;
  document.getElementById("suika-over").style.display = "none";
  updateHud();
}

function makeBall(tier, x, y) {
  const t = getTier(tier);
  const body = Bodies.circle(x, y, t.radius, { restitution: 0.2, friction: 0.4 });
  body.plugin.tier = tier;
  body.plugin.bornAt = performance.now();
  return body;
}

function onCollision(e) {
  if (gameOver) return;
  for (const pair of e.pairs) {
    const a = pair.bodyA, b = pair.bodyB;
    if (!a.plugin.tier || a.plugin.tier !== b.plugin.tier) continue;
    if (a.plugin.merged || b.plugin.merged) continue;
    a.plugin.merged = b.plugin.merged = true;
    const res = mergeResult(a.plugin.tier);
    const mx = (a.position.x + b.position.x) / 2;
    const my = (a.position.y + b.position.y) / 2;
    Composite.remove(engine.world, a);
    Composite.remove(engine.world, b);
    if (res.next) Composite.add(engine.world, makeBall(res.next, mx, my));
    score += res.score;
    updateHud();
  }
}

function balls() {
  return Composite.allBodies(engine.world).filter(b => b.plugin.tier);
}

function checkGameOver() {
  const now = performance.now();
  for (const b of balls()) {
    if (now - b.plugin.bornAt < GRACE_MS) continue;
    if (b.position.y - b.circleRadius < DANGER_Y) return true;
  }
  return false;
}

function endGame() {
  gameOver = true;
  canDrop = false;
  document.getElementById("suika-over").style.display = "block";
  submitScore(currentUser.uid, currentUserData.nickname, score)
    .then(loadLeaderboard);
}

function loop() {
  Engine.update(engine, 1000 / 60);
  if (!gameOver && checkGameOver()) endGame();
  draw();
  requestAnimationFrame(loop);
}

function draw() {
  ctx.clearRect(0, 0, W, H);

  ctx.strokeStyle = "rgba(220, 60, 60, 0.5)";
  ctx.setLineDash([6, 6]);
  ctx.beginPath();
  ctx.moveTo(0, DANGER_Y);
  ctx.lineTo(W, DANGER_Y);
  ctx.stroke();
  ctx.setLineDash([]);

  if (canDrop && !gameOver) {
    drawBall(getTier(currentTier), clampAimX(aimX, currentTier), DROP_Y, 0.5);
  }
  for (const b of balls()) {
    drawBall(getTier(b.plugin.tier), b.position.x, b.position.y, 1);
  }
}

function drawBall(t, x, y, alpha) {
  ctx.globalAlpha = alpha;
  ctx.fillStyle = t.color;
  ctx.beginPath();
  ctx.arc(x, y, t.radius, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = "#fff";
  ctx.font = `700 ${Math.max(12, Math.round(t.radius * 0.6))}px sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(t.label, x, y);
  ctx.globalAlpha = 1;
}

function clampAimX(x, tier) {
  const r = getTier(tier).radius;
  return Math.min(W - r, Math.max(r, x));
}

function canvasX(e) {
  const rect = canvas.getBoundingClientRect();
  return (e.clientX - rect.left) * (W / rect.width);
}

canvas.addEventListener("pointerdown", (e) => { aimX = canvasX(e); });
canvas.addEventListener("pointermove", (e) => { aimX = canvasX(e); });
canvas.addEventListener("pointerup", (e) => {
  if (!canDrop || gameOver) return;
  aimX = canvasX(e);
  dropBall();
});

function dropBall() {
  canDrop = false;
  Composite.add(engine.world, makeBall(currentTier, clampAimX(aimX, currentTier), DROP_Y));
  setTimeout(() => {
    currentTier = nextTier;
    nextTier = randomDropTier();
    canDrop = true;
    updateHud();
  }, DROP_COOLDOWN);
}

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

function updateHud() {
  document.getElementById("suika-score").textContent = String(score);
  document.getElementById("suika-next").textContent = getTier(nextTier).label;
}

async function submitScore(uid, nickname, finalScore) {
  const ref = doc(db, "suika_scores", uid);
  const snap = await getDoc(ref);
  const prev = snap.exists() ? snap.data() : {};
  const wk = weekKey(new Date());
  await setDoc(ref, {
    nickname,
    best: Math.max(prev.best || 0, finalScore),
    weekBest: prev.weekKey === wk ? Math.max(prev.weekBest || 0, finalScore) : finalScore,
    weekKey: wk,
    updatedAt: serverTimestamp()
  });
}

async function loadLeaderboard() {
  const snap = await getDocs(collection(db, "suika_scores"));
  const all = snap.docs.map(d => ({ uid: d.id, ...d.data() }));
  const wk = weekKey(new Date());
  const entries = (lbTab === "week"
    ? all.filter(e => e.weekKey === wk).map(e => ({ ...e, value: e.weekBest || 0 }))
    : all.map(e => ({ ...e, value: e.best || 0 })))
    .sort((a, b) => b.value - a.value);

  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.value}</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].value}점)`;
}

document.getElementById("lb-tab-all").addEventListener("click", () => setTab("all"));
document.getElementById("lb-tab-week").addEventListener("click", () => setTab("week"));

function setTab(tab) {
  lbTab = tab;
  document.getElementById("lb-tab-all").classList.toggle("active", tab === "all");
  document.getElementById("lb-tab-week").classList.toggle("active", tab === "week");
  loadLeaderboard();
}

Run: node --check assets/js/suika.js Expected: 출력 없음 (문법 오류 없음)

Run: node --test assets/js/suika-logic.test.mjs assets/js/game2048.test.mjs Expected: 전부 PASS

Run: bundle exec jekyll serve --detach 2>&1 | tail -1curl -s http://127.0.0.1:4000/suika/ | grep -c "suika-canvas" Expected: 1 (페이지가 서빙되고 캔버스 마크업 존재)

확인 후 정리: pkill -f jekyll

참고: 실제 게임플레이(터치 조작, 합체, 게임오버, 점수 제출)는 로그인이 필요하므로 사람이 브라우저에서 확인한다 — 마지막 “런칭 전 확인” 섹션 참조.

git add assets/js/suika.js
git commit -m "feat: wire up dice drop game controller with physics and leaderboard"

런칭 전 확인 (사람 작업)

구현 완료 후 사용자가 직접:

  1. Firestore 보안 규칙 추가 — Firebase 콘솔에서 suika_scores 컬렉션에 기존 game_scores와 동일한 규칙 추가 (규칙 파일이 저장소에 없으므로 콘솔에서 관리). 이거 없으면 점수 제출이 권한 오류로 실패한다.
  2. 로그인 후 /suika/ 접속: 드래그로 조준 → 놓으면 낙하 → 같은 주사위 합체 → 점수 증가 확인.
  3. 모바일(카톡 인앱 브라우저 포함)에서 터치 조작 확인.
  4. 게임오버 후 리더보드 “전체”/”이번 주” 탭 각각에 점수 반영 확인.
  5. 배포 후 단톡방에 링크 공유.