๐ฏ g5se ์ค๋์ ์ค๋์ฟ ํผ์ฆ ์์ฑ๊ธฐ - ๋์ด๋๋ณ ํผ์ฆ ํ์ด & ํ์ด๋จธ ๊ธฐ๋ก
g5se-sudoku-puzzle-generator
๋ฌธ์ ์ค๋ช
๐งฉ ํ๋ก์ ํธ ๊ฐ์
๋งค์ผ ์๋ก์ด ์ค๋์ฟ ํผ์ฆ์ ํ ์ ์๋ ์น ๊ธฐ๋ฐ ํผ์ฆ ํ๋ซํผ์ ๋ง๋ญ๋๋ค. ์ฌ์ฉ์๋ ๋์ด๋(์ฌ์/๋ณดํต/์ด๋ ค์/์ต์คํธ๋ฆผ)๋ฅผ ์ ํํ๊ณ , ๋๋ค ์์ฑ๋ 9ร9 ์ค๋์ฟ ๋ณด๋์์ ๋น์นธ์ ์ฑ์ ํผ์ฆ์ ์์ฑํฉ๋๋ค. ํ์ด ์๊ฐ์ ์ธก์ ํ๊ณ , ์๋ฃํ ํผ์ฆ์ ํ์คํ ๋ฆฌ์ ๊ธฐ๋ก๋์ด ์์ ์ ์ค๋ ฅ ํฅ์์ ํ์ธํ ์ ์์ต๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
- ๋์ด๋๋ณ ํผ์ฆ ์์ฑ: ๋ฐฑํธ๋ํน ์๊ณ ๋ฆฌ์ฆ์ผ๋ก ์ ํจํ ์ค๋์ฟ ๋ณด๋๋ฅผ ์์ฑํ๊ณ , ๋์ด๋์ ๋ฐ๋ผ ํํธ ๊ฐ์ ์กฐ์ (์ฌ์: 40๊ฐ, ๋ณดํต: 30๊ฐ, ์ด๋ ค์: 25๊ฐ, ์ต์คํธ๋ฆผ: 20๊ฐ)
- ์ค์๊ฐ ์ ๋ ฅ ๊ฒ์ฆ: ์ฌ์ฉ์๊ฐ ์ซ์๋ฅผ ์ ๋ ฅํ ๋ ํ/์ด/3ร3 ๋ฐ์ค ๊ท์น ์๋ฐ์ ์ฆ์ ๊ฐ์งํ์ฌ ๋นจ๊ฐ์์ผ๋ก ํ์
- ํ์ด ํ์ด๋จธ: ํผ์ฆ ์์ ์ ํ์ด๋จธ๊ฐ ์๋์ผ๋ก ์์๋๋ฉฐ, ์๋ฃ ์ ์์ ์๊ฐ ๊ธฐ๋ก
- ํํธ ์์คํ : ๋งํ ๋ ์ฌ์ฉํ ์ ์๋ ํํธ ๋ฒํผ (๊ฐ ํผ์ฆ๋น 3ํ ์ ํ, ํํธ ์ฌ์ฉ ์ ๋๋ค ๋น์นธ์ ์ ๋ต ํ์)
- ํผ์ฆ ํ์คํ ๋ฆฌ: ์๋ฃํ ํผ์ฆ์ ๋์ด๋, ์์ ์๊ฐ, ํํธ ์ฌ์ฉ ํ์๋ฅผ SQLite DB์ ์ ์ฅํ์ฌ ๋ง์ด ํ์ด์ง์์ ํ์ธ
- ๋ฉ๋ชจ ๋ชจ๋: ์ ์ ์ฐํด๋ฆญํ๋ฉด ์์ ์ซ์๋ก ํ๋ณด ์ซ์๋ฅผ ๋ฉ๋ชจํ ์ ์๋ ๋ชจ๋ (ํ ๊ธ ๋ฒํผ์ผ๋ก ํ์ฑํ)
๋ณด๋์ค ๊ธฐ๋ฅ
- ์ผ์ผ ์ฑ๋ฆฐ์ง ๋ชจ๋: ๋งค์ผ ์์ ์ ์๋๊ฐ์ด ๋ฐ๋์ด ๋ชจ๋ ์ฌ์ฉ์๊ฐ ๋์ผํ ํผ์ฆ์ ํ๋๋ก (์ผ์ผ ์์ํ ๊ธฐ๋ฅ)
- ํผ์ฆ ๊ณต์ : ํน์ ํผ์ฆ์ ์๋๊ฐ์ URL๋ก ๊ณต์ ํ์ฌ ์น๊ตฌ์ ๋์ผํ ํผ์ฆ ๋์
- ๋คํฌ๋ชจ๋ ๋์: ๋ณด๋ ์์์ด ๋คํฌ๋ชจ๋์์๋ ๋์ด ํธํ๋๋ก ๋์์ธ ํ ํฐ ํ์ฉ
- ์๋ ์ ์ฅ: ํ์ด ์ค์ธ ๋ณด๋๋ฅผ localStorage์ ์๋ ์ ์ฅํ์ฌ ์๋ก๊ณ ์นจ ํ์๋ ์ด์ด์ ํ ์ ์๋๋ก
๐จ UI/UX ์๊ตฌ์ฌํญ
- 9ร9 ๊ทธ๋ฆฌ๋๋ 3ร3 ๋๊บผ์ด ๊ฒฝ๊ณ์ ์ผ๋ก ๊ตฌ๋ถํ์ฌ ์ค๋์ฟ ๋ณด๋ ํํ ๋ช ํํ ํํ
- ์ด๊ธฐ ํํธ ์ซ์๋ ํ์ ๋ฐฐ๊ฒฝ, ์ฌ์ฉ์ ์ ๋ ฅ ์ซ์๋ ํฐ์ ๋ฐฐ๊ฒฝ์ผ๋ก ๊ตฌ๋ถ
- ๊ท์น ์๋ฐ ์ ์ ๋นจ๊ฐ์ ํ ๋๋ฆฌ + ํ๋ค๋ฆผ ์ ๋๋ฉ์ด์ ์ผ๋ก ๊ฒฝ๊ณ
- ํผ์ฆ ์์ฑ ์ ์ถํ ๋ชจ๋ฌ ํ์ (์์ ์๊ฐ, ํํธ ์ฌ์ฉ ํ์ ํ์)
- ํฐ์น ๊ธฐ๊ธฐ์์๋ ํธํ๊ฒ ํ๋ ์ดํ ์ ์๋๋ก ์ ํฌ๊ธฐ ์ถฉ๋ถํ ํ๋ณด (์ต์ 40px)
๐ ๊ธฐ์ ์คํ
- ํ๋ ์์ํฌ: ๊ทธ๋๋ณด๋5 SE (g5se, PHP ๊ธฐ๋ฐ CMS)
- DB: SQLite (ํผ์ฆ ํ์คํ ๋ฆฌ ์ ์ฅ์ฉ, g5se ๋ฉ์ธ DB์ ๊ฒฉ๋ฆฌ)
- ํ๋ก ํธ์๋: Vanilla JS + CSS Grid (React/Vue ์ฌ์ฉ ๊ธ์ง)
- ์คํ์ผ๋ง: g5se modern shell ๋์์ธ ํ ํฐ + ์ปค์คํ CSS
๐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง
CREATE TABLE puzzle_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT, -- ๋ก๊ทธ์ธ ์ฌ์ฉ์์ mb_id (๋นํ์์ 'guest')
difficulty TEXT, -- 'easy', 'medium', 'hard', 'extreme'
seed TEXT, -- ํผ์ฆ ์๋๊ฐ (์ฌํ ๊ฐ๋ฅ)
solve_time INTEGER, -- ์์ ์๊ฐ(์ด)
hints_used INTEGER, -- ํํธ ์ฌ์ฉ ํ์
completed_at TEXT -- ์๋ฃ ์๊ฐ (ISO 8601)
);
๐ฏ ํ์ต ๋ชฉํ
- g5se ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ ์์คํ ํ์ฉ
- ๋ณต์กํ ์๊ณ ๋ฆฌ์ฆ(๋ฐฑํธ๋ํน)์ PHP๋ก ๊ตฌํํ๋ ๊ฒฝํ
- ์ค์๊ฐ ์ ๋ ฅ ๊ฒ์ฆ ๋ก์ง์ ์์ JavaScript๋ก ๊ตฌํ
- SQLite ํธ๋์ญ์ ๊ณผ ์ธ๋ฑ์ค ์ต์ ํ (๋์ด๋๋ณ ํต๊ณ ์กฐํ ์ ๋น ๋ฅธ ์๋ต)
- ์บ์ฑ ์ ๋ต (์ผ์ผ ์ฑ๋ฆฐ์ง ํผ์ฆ์ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ์ ์บ์ฑํ์ฌ ์ค๋ณต ์์ฑ ๋ฐฉ์ง)
- ์ฌ์ฉ์ ๊ฒฝํ ํฅ์์ ์ํ ์ ๋๋ฉ์ด์ ๋ฐ ํผ๋๋ฐฑ ์ฒ๋ฆฌ
์ ์ฝ ์กฐ๊ฑด
- โข g5se ํ๋ ์์ํฌ ๊ธฐ๋ฐ (Laravel/React/Vue ์ฌ์ฉ ๊ธ์ง)
- โข ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ ์ฌ์ฉ (app/modules/sudoku/index.php โ /sudoku)
- โข modern shell ํ์ ์ ์ฉ (๋คํฌ๋ชจ๋ ์๋ ๋์)
- โข SQLite DB๋ G5_DATA_PATH.'/modules/sudoku.sqlite'์ ์ ์ฅ
- โข ์ค๋์ฟ ์์ฑ ์๊ณ ๋ฆฌ์ฆ์ ๋ฐฑํธ๋ํน์ผ๋ก ๊ตฌํ (์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธ์ง)
- โข ํ๋ก ํธ์๋๋ Vanilla JS๋ง ์ฌ์ฉ (jQuery/React/Vue ๊ธ์ง)
- โข CDN ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๊ธ์ง (์์ HTML/CSS/JS๋ก ๊ตฌํ)
- โข ๋ชจ๋ ์ฌ์ฉ์ ์ ๋ ฅ์ htmlspecialchars()๋ก ์ด์ค์ผ์ดํ
- โข DB ์ฟผ๋ฆฌ๋ prepared statement๋ก ์ธ์ ์ ๋ฐฉ์ง
- โข ํผ์ฆ ์์ฑ์ ์๋ฒ ์ฌ์ด๋์์ ์ฒ๋ฆฌ (ํด๋ผ์ด์ธํธ์์ ์์ฑ ๊ธ์ง)
- โข ์ผ์ผ ์ฑ๋ฆฐ์ง ์๋๋ date('Y-m-d') ๊ธฐ๋ฐ์ผ๋ก ๊ณ ์
- โข ํผ์ฆ ์์ฑ ๊ฒ์ฆ์ ์๋ฒ์์ ์ด์ค ์ฒดํฌ (ํด๋ผ์ด์ธํธ ์กฐ์ ๋ฐฉ์ง)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
**Step 1: ํ๋ก์ ํธ ๊ณจ๊ฒฉ ์์ฑ ๋ฐ modern shell ์ ์ฉ**
1. `app/modules/sudoku/index.php` ํ์ผ์ ์์ฑํ๊ณ ๋ค์ ๊ณจ๊ฒฉ์ ์์ฑํ์ธ์:
```php
<?php
include_once('./_common.php');
// ํ์ด์ง ํ์ดํ
$g5['title'] = '์ค๋์ ์ค๋์ฟ ํผ์ฆ';
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="sudoku-header">
<h1 style="font-size: 28px; font-weight: 700; margin-bottom: 8px;">๐งฉ ์ค๋์ ์ค๋์ฟ </h1>
<p style="color: var(--m-text-muted); margin-bottom: 24px;">๋์ด๋๋ฅผ ์ ํํ๊ณ ํผ์ฆ์ ํ์ด๋ณด์ธ์!</p>
</div>
<div class="difficulty-selector" style="margin-bottom: 24px;">
<button class="diff-btn" data-level="easy">์ฌ์</button>
<button class="diff-btn" data-level="medium">๋ณดํต</button>
<button class="diff-btn" data-level="hard">์ด๋ ค์</button>
<button class="diff-btn" data-level="extreme">์ต์คํธ๋ฆผ</button>
</div>
<div id="sudoku-board" style="display: none;">
<!-- ์ค๋์ฟ ๋ณด๋๊ฐ ์ฌ๊ธฐ์ ๋์ ์ผ๋ก ์ฝ์
๋ฉ๋๋ค -->
</div>
<div id="game-controls" style="display: none; margin-top: 24px;">
<div style="display: flex; gap: 12px; align-items: center;">
<button id="hint-btn">๐ก ํํธ (3)</button>
<button id="reset-btn">๐ ์ด๊ธฐํ</button>
<button id="check-btn">โ
์ ๋ต ํ์ธ</button>
<div id="timer" style="margin-left: auto; font-size: 18px; font-weight: 600;">00:00</div>
</div>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<style>
.difficulty-selector {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.diff-btn {
padding: 12px 24px;
background: var(--m-surface);
border: 2px solid var(--m-primary);
border-radius: var(--m-radius-lg);
color: var(--m-primary);
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.diff-btn:hover {
background: var(--m-primary);
color: white;
}
#game-controls button {
padding: 10px 20px;
background: var(--m-primary);
color: white;
border: none;
border-radius: var(--m-radius-lg);
font-weight: 600;
cursor: pointer;
}
#game-controls button:hover {
opacity: 0.9;
}
</style>
<script>
document.querySelectorAll('.diff-btn').forEach(btn => {
btn.addEventListener('click', function() {
const level = this.dataset.level;
alert(`${level} ๋์ด๋ ์ ํ๋จ! (๋ค์ ๋จ๊ณ์์ ํผ์ฆ ์์ฑ ๊ตฌํ)`);
});
});
</script>
<?php include_once('./tail.sub.php'); ?>
```
2. ๋ธ๋ผ์ฐ์ ์์ `/sudoku` ๊ฒฝ๋ก๋ก ์ ์ํ์ฌ ํ์ด์ง๊ฐ ์ ์์ ์ผ๋ก ํ์๋๋์ง ํ์ธํ์ธ์.
3. ๋์ด๋ ๋ฒํผ์ ํด๋ฆญํ๋ฉด alert๊ฐ ๋จ๋์ง ํ
์คํธํ์ธ์.
**ํ์ธ ์ฌํญ:**
- modern shell์ด ์ ์ฉ๋์ด ๋ค๋น๊ฒ์ด์
๊ณผ ํธํฐ๊ฐ ํ์๋๋๊ฐ?
- ๋คํฌ๋ชจ๋ ํ ๊ธ ์ ๋ฐฐ๊ฒฝ์๊ณผ ํ
์คํธ ์์์ด ์๋์ผ๋ก ๋ณํ๋๊ฐ?
- ๋์ด๋ ๋ฒํผ ํธ๋ฒ ์ ์์ ์ ํ ์ ๋๋ฉ์ด์
์ด ๋ถ๋๋ฌ์ด๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: `/sudoku` URL๋ก ์ ์ ์ modern shell์ด ์ ์ฉ๋ ํ์ด์ง๊ฐ ํ์๋๊ณ , 4๊ฐ์ ๋์ด๋ ๋ฒํผ๊ณผ ๊ฒ์ ์ปจํธ๋กค ์์ญ์ด ๋ณด์ ๋๋ค. ๋ฒํผ ํด๋ฆญ ์ alert๊ฐ ์ ์ ๋์ํ๋ฉฐ, ๋คํฌ๋ชจ๋ ์ ํ ์ ๋์์ธ ํ ํฐ์ด ์๋ ์ ์ฉ๋์ด ๊ฐ๋ ์ฑ์ด ์ ์ง๋ฉ๋๋ค.
**Step 2: SQLite DB ์ด๊ธฐํ ๋ฐ ์ค๋์ฟ ์์ฑ ๋ฐฑ์๋ ๊ตฌํ**
1. `app/modules/sudoku/index.php` ์๋จ(modern shell ์ธํด๋ฃจ๋ ์ )์ ๋ค์ DB ์ด๊ธฐํ ์ฝ๋๋ฅผ ์ถ๊ฐํ์ธ์:
```php
// SQLite DB ์ด๊ธฐํ
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
$db_path = G5_DATA_PATH.'/modules/sudoku.sqlite';
$pdo = new PDO('sqlite:'.$db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํ
์ด๋ธ ์์ฑ (์ฒซ ์ ๊ทผ ์ ์๋ ์์ฑ)
$pdo->exec("
CREATE TABLE IF NOT EXISTS puzzle_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
difficulty TEXT,
seed TEXT,
solve_time INTEGER,
hints_used INTEGER,
completed_at TEXT
)
");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_user_difficulty ON puzzle_history(user_id, difficulty)");
```
2. ๊ฐ์ ํ์ผ์ ์ค๋์ฟ ์์ฑ ํจ์๋ฅผ ์ถ๊ฐํ์ธ์ (modern shell ์ธํด๋ฃจ๋ ์ ):
```php
// ์ค๋์ฟ ์์ฑ ํจ์ (๋ฐฑํธ๋ํน ์๊ณ ๋ฆฌ์ฆ)
function generateSudoku($seed) {
mt_srand($seed);
$board = array_fill(0, 9, array_fill(0, 9, 0));
function isValid($board, $row, $col, $num) {
// ํ ๊ฒ์ฌ
for ($x = 0; $x < 9; $x++) {
if ($board[$row][$x] == $num) return false;
}
// ์ด ๊ฒ์ฌ
for ($x = 0; $x < 9; $x++) {
if ($board[$x][$col] == $num) return false;
}
// 3x3 ๋ฐ์ค ๊ฒ์ฌ
$startRow = $row - $row % 3;
$startCol = $col - $col % 3;
for ($i = 0; $i < 3; $i++) {
for ($j = 0; $j < 3; $j++) {
if ($board[$i + $startRow][$j + $startCol] == $num) return false;
}
}
return true;
}
function solve(&$board) {
for ($row = 0; $row < 9; $row++) {
for ($col = 0; $col < 9; $col++) {
if ($board[$row][$col] == 0) {
$nums = range(1, 9);
shuffle($nums);
foreach ($nums as $num) {
if (isValid($board, $row, $col, $num)) {
$board[$row][$col] = $num;
if (solve($board)) return true;
$board[$row][$col] = 0;
}
}
return false;
}
}
}
return true;
}
solve($board);
return $board;
}
// ๋์ด๋๋ณ ํํธ ๊ฐ์ ์ค์
function removeCells($board, $difficulty) {
$hints = ['easy' => 40, 'medium' => 30, 'hard' => 25, 'extreme' => 20];
$cellsToRemove = 81 - $hints[$difficulty];
$puzzle = $board;
$removed = 0;
while ($removed < $cellsToRemove) {
$row = mt_rand(0, 8);
$col = mt_rand(0, 8);
if ($puzzle[$row][$col] != 0) {
$puzzle[$row][$col] = 0;
$removed++;
}
}
return $puzzle;
}
```
3. AJAX ์์ฒญ ์ฒ๋ฆฌ ๋ก์ง์ ์ถ๊ฐํ์ธ์ (DB ์ด๊ธฐํ ์ฝ๋ ๋ฐ๋ก ์๋):
```php
// AJAX: ํผ์ฆ ์์ฑ ์์ฒญ ์ฒ๋ฆฌ
if (isset($_POST['action']) && $_POST['action'] === 'generate') {
$difficulty = $_POST['difficulty'] ?? 'medium';
$seed = isset($_POST['daily']) ? strtotime(date('Y-m-d')) : time();
$solution = generateSudoku($seed);
$puzzle = removeCells($solution, $difficulty);
header('Content-Type: application/json');
echo json_encode([
'puzzle' => $puzzle,
'solution' => $solution,
'seed' => $seed
]);
exit;
}
```
4. ํ๋ก ํธ์๋ JavaScript๋ฅผ ์์ ํ์ฌ AJAX๋ก ํผ์ฆ์ ๋ฐ์์ค๋๋ก ๋ณ๊ฒฝํ์ธ์:
```javascript
let currentPuzzle = null;
let currentSolution = null;
let hintsLeft = 3;
document.querySelectorAll('.diff-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const level = this.dataset.level;
const formData = new FormData();
formData.append('action', 'generate');
formData.append('difficulty', level);
const response = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await response.json();
currentPuzzle = data.puzzle;
currentSolution = data.solution;
hintsLeft = 3;
renderBoard(data.puzzle);
document.getElementById('sudoku-board').style.display = 'block';
document.getElementById('game-controls').style.display = 'block';
});
});
function renderBoard(puzzle) {
const boardHTML = puzzle.map((row, i) =>
row.map((cell, j) => {
const isGiven = cell !== 0;
return `<input type="text"
class="sudoku-cell ${isGiven ? 'given' : ''}"
data-row="${i}" data-col="${j}"
value="${isGiven ? cell : ''}"
${isGiven ? 'readonly' : ''}
maxlength="1">`;
}).join('')
).join('');
document.getElementById('sudoku-board').innerHTML = `
<div class="sudoku-grid">${boardHTML}</div>
`;
}
```
5. CSS์ ๋ณด๋ ์คํ์ผ์ ์ถ๊ฐํ์ธ์:
```css
.sudoku-grid {
display: grid;
grid-template-columns: repeat(9, 50px);
gap: 0;
border: 3px solid var(--m-text);
width: fit-content;
margin: 0 auto;
}
.sudoku-cell {
width: 50px;
height: 50px;
text-align: center;
font-size: 20px;
font-weight: 600;
border: 1px solid var(--m-text-muted);
background: var(--m-surface);
color: var(--m-text);
}
.sudoku-cell.given {
background: rgba(128, 128, 128, 0.2);
cursor: not-allowed;
}
.sudoku-cell:not(.given):focus {
outline: 2px solid var(--m-primary);
}
.sudoku-cell:nth-child(3n) {
border-right: 2px solid var(--m-text);
}
.sudoku-cell:nth-child(n+19):nth-child(-n+27),
.sudoku-cell:nth-child(n+46):nth-child(-n+54) {
border-bottom: 2px solid var(--m-text);
}
```
**ํ์ธ ์ฌํญ:**
- ๋์ด๋ ๋ฒํผ ํด๋ฆญ ์ AJAX๋ก ํผ์ฆ์ด ์์ฑ๋๋๊ฐ?
- 9ร9 ๊ทธ๋ฆฌ๋๊ฐ 3ร3 ๋๊บผ์ด ๊ฒฝ๊ณ์ ์ผ๋ก ๊ตฌ๋ถ๋์ด ํ์๋๋๊ฐ?
- ์ด๊ธฐ ํํธ ์
์ ํ์ ๋ฐฐ๊ฒฝ, ๋น์นธ์ ํฐ์ ๋ฐฐ๊ฒฝ์ผ๋ก ๊ตฌ๋ถ๋๋๊ฐ?
- SQLite DB ํ์ผ์ด `data/modules/sudoku.sqlite` ๊ฒฝ๋ก์ ์์ฑ๋์๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋์ด๋ ๋ฒํผ ํด๋ฆญ ์ ์๋ฒ์์ ๋ฐฑํธ๋ํน ์๊ณ ๋ฆฌ์ฆ์ผ๋ก ์์ฑ๋ ์ค๋์ฟ ํผ์ฆ์ด 9ร9 ๊ทธ๋ฆฌ๋๋ก ๋ ๋๋ง๋ฉ๋๋ค. ์ด๊ธฐ ํํธ๋ ํ์ ๋ฐฐ๊ฒฝ์ผ๋ก ํ์๋๊ณ ์์ ๋ถ๊ฐ๋ฅํ๋ฉฐ, ๋น์นธ์ ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ์ ์์ต๋๋ค. SQLite DB๊ฐ ์ ์ ์์ฑ๋์ด ํ ์ด๋ธ ์คํค๋ง๊ฐ ์ค๋น๋ฉ๋๋ค.
**Step 3: ์ค์๊ฐ ์
๋ ฅ ๊ฒ์ฆ ๋ฐ ํ์ด๋จธ ๊ตฌํ**
1. JavaScript์ ์
๋ ฅ ๊ฒ์ฆ ํจ์๋ฅผ ์ถ๊ฐํ์ธ์:
```javascript
function validateCell(row, col, value) {
if (value === '') return true;
const num = parseInt(value);
if (isNaN(num) || num < 1 || num > 9) return false;
// ํ ๊ฒ์ฌ
const cells = document.querySelectorAll('.sudoku-cell');
for (let i = 0; i < 9; i++) {
const cell = cells[row * 9 + i];
if (i !== col && cell.value === value) return false;
}
// ์ด ๊ฒ์ฌ
for (let i = 0; i < 9; i++) {
const cell = cells[i * 9 + col];
if (i !== row && cell.value === value) return false;
}
// 3x3 ๋ฐ์ค ๊ฒ์ฌ
const boxRow = Math.floor(row / 3) * 3;
const boxCol = Math.floor(col / 3) * 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
const r = boxRow + i;
const c = boxCol + j;
if (r !== row || c !== col) {
const cell = cells[r * 9 + c];
if (cell.value === value) return false;
}
}
}
return true;
}
function renderBoard(puzzle) {
// ๊ธฐ์กด ์ฝ๋ ์ ์งํ๊ณ , ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ
const boardHTML = puzzle.map((row, i) =>
row.map((cell, j) => {
const isGiven = cell !== 0;
return `<input type="text"
class="sudoku-cell ${isGiven ? 'given' : ''}"
data-row="${i}" data-col="${j}"
value="${isGiven ? cell : ''}"
${isGiven ? 'readonly' : ''}
maxlength="1">`;
}).join('')
).join('');
document.getElementById('sudoku-board').innerHTML = `
<div class="sudoku-grid">${boardHTML}</div>
`;
// ์
๋ ฅ ์ด๋ฒคํธ ๋ฆฌ์ค๋
document.querySelectorAll('.sudoku-cell:not(.given)').forEach(cell => {
cell.addEventListener('input', function(e) {
const row = parseInt(this.dataset.row);
const col = parseInt(this.dataset.col);
const value = this.value;
if (!validateCell(row, col, value)) {
this.classList.add('invalid');
this.style.animation = 'shake 0.3s';
} else {
this.classList.remove('invalid');
}
});
});
startTimer();
}
```
2. ํ์ด๋จธ ๊ตฌํ:
```javascript
let timerInterval = null;
let elapsedSeconds = 0;
function startTimer() {
elapsedSeconds = 0;
clearInterval(timerInterval);
timerInterval = setInterval(() => {
elapsedSeconds++;
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
document.getElementById('timer').textContent =
`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}, 1000);
}
function stopTimer() {
clearInterval(timerInterval);
}
```
3. CSS์ ์ ๋๋ฉ์ด์
์ถ๊ฐ:
```css
.sudoku-cell.invalid {
border: 2px solid #ef4444;
background: rgba(239, 68, 68, 0.1);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
```
**ํ์ธ ์ฌํญ:**
- ๋น์นธ์ ์ซ์ ์
๋ ฅ ์ ํ/์ด/๋ฐ์ค ๊ท์น ์๋ฐ์ด ์ฆ์ ๊ฐ์ง๋๋๊ฐ?
- ์๋ฐ ์ ๋นจ๊ฐ์ ํ
๋๋ฆฌ์ ํ๋ค๋ฆผ ์ ๋๋ฉ์ด์
์ด ํ์๋๋๊ฐ?
- ํ์ด๋จธ๊ฐ ํผ์ฆ ๋ก๋ ์ ์๋์ผ๋ก ์์๋๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ฌ์ฉ์๊ฐ ์ซ์๋ฅผ ์ ๋ ฅํ ๋ ์ค์๊ฐ์ผ๋ก ์ค๋์ฟ ๊ท์น์ ๊ฒ์ฆํ๋ฉฐ, ์๋ฐ ์ ๋นจ๊ฐ์ ํ ๋๋ฆฌ์ ํ๋ค๋ฆผ ์ ๋๋ฉ์ด์ ์ด ํ์๋ฉ๋๋ค. ํ์ด๋จธ๊ฐ 00:00๋ถํฐ ์์ํ์ฌ 1์ด๋ง๋ค ์ฆ๊ฐํ๋ฉฐ, ์๋ฃ ์ ์ ์ง๋ฉ๋๋ค.
**Step 4: ํํธ ์์คํ
๋ฐ ์ ๋ต ํ์ธ ๊ตฌํ**
1. ํํธ ๋ฒํผ ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ:
```javascript
document.getElementById('hint-btn').addEventListener('click', function() {
if (hintsLeft <= 0) {
alert('ํํธ๋ฅผ ๋ชจ๋ ์ฌ์ฉํ์ต๋๋ค!');
return;
}
const emptyCells = [];
document.querySelectorAll('.sudoku-cell:not(.given)').forEach((cell, idx) => {
if (cell.value === '') {
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
emptyCells.push({ cell, row, col });
}
});
if (emptyCells.length === 0) {
alert('๋ชจ๋ ์นธ์ด ์ฑ์์ก์ต๋๋ค!');
return;
}
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
randomCell.cell.value = currentSolution[randomCell.row][randomCell.col];
randomCell.cell.classList.add('hint-cell');
randomCell.cell.readOnly = true;
hintsLeft--;
this.textContent = `๐ก ํํธ (${hintsLeft})`;
});
```
2. ์ ๋ต ํ์ธ ๋ฒํผ ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ:
```javascript
document.getElementById('check-btn').addEventListener('click', async function() {
const cells = document.querySelectorAll('.sudoku-cell');
let isComplete = true;
let isCorrect = true;
cells.forEach((cell, idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const value = parseInt(cell.value);
if (!value) {
isComplete = false;
} else if (value !== currentSolution[row][col]) {
isCorrect = false;
}
});
if (!isComplete) {
alert('์์ง ๋น์นธ์ด ๋จ์์์ต๋๋ค!');
return;
}
if (!isCorrect) {
alert('ํ๋ฆฐ ์นธ์ด ์์ต๋๋ค. ๋ค์ ํ์ธํด๋ณด์ธ์!');
return;
}
stopTimer();
// ์๋ฒ์ ์๋ฃ ๊ธฐ๋ก ์ ์ฅ
const formData = new FormData();
formData.append('action', 'save_result');
formData.append('difficulty', currentDifficulty);
formData.append('seed', currentSeed);
formData.append('solve_time', elapsedSeconds);
formData.append('hints_used', 3 - hintsLeft);
await fetch(window.location.href, {
method: 'POST',
body: formData
});
showCompletionModal();
});
function showCompletionModal() {
const modal = document.createElement('div');
modal.className = 'completion-modal';
modal.innerHTML = `
<div class="modal-content">
<h2>๐ ์ถํํฉ๋๋ค!</h2>
<p>ํผ์ฆ์ ์์ฑํ์ต๋๋ค!</p>
<div class="stats">
<div>โฑ๏ธ ์์ ์๊ฐ: ${Math.floor(elapsedSeconds / 60)}๋ถ ${elapsedSeconds % 60}์ด</div>
<div>๐ก ํํธ ์ฌ์ฉ: ${3 - hintsLeft}ํ</div>
</div>
<button onclick="location.reload()">์ ํผ์ฆ ๋์ ํ๊ธฐ</button>
</div>
`;
document.body.appendChild(modal);
}
```
3. PHP์ ์๋ฃ ๊ธฐ๋ก ์ ์ฅ ๋ก์ง ์ถ๊ฐ (AJAX ์ฒ๋ฆฌ ์น์
์):
```php
// AJAX: ์๋ฃ ๊ธฐ๋ก ์ ์ฅ
if (isset($_POST['action']) && $_POST['action'] === 'save_result') {
$user_id = $is_member ? $member['mb_id'] : 'guest';
$difficulty = $_POST['difficulty'];
$seed = $_POST['seed'];
$solve_time = (int)$_POST['solve_time'];
$hints_used = (int)$_POST['hints_used'];
$completed_at = date('c');
$stmt = $pdo->prepare("
INSERT INTO puzzle_history (user_id, difficulty, seed, solve_time, hints_used, completed_at)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$user_id, $difficulty, $seed, $solve_time, $hints_used, $completed_at]);
echo json_encode(['success' => true]);
exit;
}
```
4. ๋ชจ๋ฌ CSS ์ถ๊ฐ:
```css
.completion-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--m-surface);
padding: 32px;
border-radius: var(--m-radius-lg);
text-align: center;
box-shadow: var(--m-shadow);
max-width: 400px;
}
.modal-content h2 {
font-size: 32px;
margin-bottom: 16px;
}
.stats {
margin: 24px 0;
font-size: 18px;
}
.stats div {
margin: 8px 0;
}
.hint-cell {
background: rgba(59, 130, 246, 0.2);
}
```
5. ์ด๊ธฐํ ๋ฒํผ ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ:
```javascript
document.getElementById('reset-btn').addEventListener('click', function() {
if (confirm('ํ์ฌ ์
๋ ฅํ ๋ด์ฉ์ด ๋ชจ๋ ์ง์์ง๋๋ค. ๊ณ์ํ์๊ฒ ์ต๋๊น?')) {
renderBoard(currentPuzzle);
hintsLeft = 3;
document.getElementById('hint-btn').textContent = '๐ก ํํธ (3)';
}
});
```
**ํ์ธ ์ฌํญ:**
- ํํธ ๋ฒํผ ํด๋ฆญ ์ ๋๋ค ๋น์นธ์ ์ ๋ต์ด ํ์๋๋๊ฐ?
- ํํธ ์ฌ์ฉ ํ์๊ฐ ๊ฐ์ํ๊ณ , 3ํ ์ด๊ณผ ์ ๊ฒฝ๊ณ ๊ฐ ๋จ๋๊ฐ?
- ์ ๋ต ํ์ธ ์ ๋ชจ๋ ์นธ์ด ๋ง์ผ๋ฉด ์ถํ ๋ชจ๋ฌ์ด ํ์๋๋๊ฐ?
- ์๋ฃ ๊ธฐ๋ก์ด SQLite DB์ ์ ์ ์ ์ฅ๋๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ํํธ ๋ฒํผ ํด๋ฆญ ์ ๋๋ค ๋น์นธ์ ํ๋์ ๋ฐฐ๊ฒฝ์ผ๋ก ์ ๋ต์ด ํ์๋๋ฉฐ, ์ฌ์ฉ ํ์๊ฐ ๊ฐ์ํฉ๋๋ค. ์ ๋ต ํ์ธ ์ ๋ชจ๋ ์นธ์ด ๋ง์ผ๋ฉด ์ถํ ๋ชจ๋ฌ์ด ํ์ ๋๊ณ , ์์ ์๊ฐ๊ณผ ํํธ ์ฌ์ฉ ํ์๊ฐ ํ์๋ฉ๋๋ค. ์๋ฃ ๊ธฐ๋ก์ด DB์ ์ ์ฅ๋ฉ๋๋ค.
**Step 5: ํผ์ฆ ํ์คํ ๋ฆฌ ํ์ด์ง ๊ตฌํ**
1. `app/modules/sudoku/history.php` ํ์ผ์ ์์ฑํ์ธ์:
```php
<?php
include_once('./_common.php');
// SQLite DB ์ฐ๊ฒฐ
$db_path = G5_DATA_PATH.'/modules/sudoku.sqlite';
$pdo = new PDO('sqlite:'.$db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$user_id = $is_member ? $member['mb_id'] : 'guest';
// ๋์ด๋๋ณ ํต๊ณ
$stats = [];
foreach (['easy', 'medium', 'hard', 'extreme'] as $diff) {
$stmt = $pdo->prepare("
SELECT COUNT(*) as count, AVG(solve_time) as avg_time, MIN(solve_time) as best_time
FROM puzzle_history
WHERE user_id = ? AND difficulty = ?
");
$stmt->execute([$user_id, $diff]);
$stats[$diff] = $stmt->fetch(PDO::FETCH_ASSOC);
}
// ์ต๊ทผ 10๊ฐ ๊ธฐ๋ก
$stmt = $pdo->prepare("
SELECT * FROM puzzle_history
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT 10
");
$stmt->execute([$user_id]);
$recent = $stmt->fetchAll(PDO::FETCH_ASSOC);
$g5['title'] = '๋ด ํผ์ฆ ํ์คํ ๋ฆฌ';
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<h1 style="font-size: 28px; font-weight: 700; margin-bottom: 24px;">๐ ๋ด ํผ์ฆ ํ์คํ ๋ฆฌ</h1>
<div class="stats-grid">
<?php foreach ($stats as $diff => $data): ?>
<div class="stat-card">
<h3><?php echo ucfirst($diff); ?></h3>
<div class="stat-item">์๋ฃ: <?php echo $data['count']; ?>ํ</div>
<div class="stat-item">ํ๊ท : <?php echo $data['avg_time'] ? gmdate('i:s', $data['avg_time']) : '-'; ?></div>
<div class="stat-item">์ต๊ณ : <?php echo $data['best_time'] ? gmdate('i:s', $data['best_time']) : '-'; ?></div>
</div>
<?php endforeach; ?>
</div>
<h2 style="font-size: 24px; font-weight: 700; margin: 32px 0 16px;">์ต๊ทผ ๊ธฐ๋ก</h2>
<div class="history-table">
<?php if (empty($recent)): ?>
<p style="color: var(--m-text-muted);">์์ง ์๋ฃํ ํผ์ฆ์ด ์์ต๋๋ค.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>๋์ด๋</th>
<th>์์ ์๊ฐ</th>
<th>ํํธ ์ฌ์ฉ</th>
<th>์๋ฃ ์ผ์</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $row): ?>
<tr>
<td><?php echo htmlspecialchars($row['difficulty']); ?></td>
<td><?php echo gmdate('i:s', $row['solve_time']); ?></td>
<td><?php echo $row['hints_used']; ?>ํ</td>
<td><?php echo date('Y-m-d H:i', strtotime($row['completed_at'])); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div style="margin-top: 32px;">
<a href="/sudoku" style="display: inline-block; padding: 12px 24px; background: var(--m-primary); color: white; border-radius: var(--m-radius-lg); text-decoration: none; font-weight: 600;">์ ํผ์ฆ ํ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--m-surface);
padding: 20px;
border-radius: var(--m-radius-lg);
box-shadow: var(--m-shadow);
}
.stat-card h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 12px;
text-transform: capitalize;
}
.stat-item {
margin: 8px 0;
color: var(--m-text-muted);
}
.history-table table {
width: 100%;
border-collapse: collapse;
}
.history-table th,
.history-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--m-text-muted);
}
.history-table th {
font-weight: 700;
background: var(--m-surface);
}
</style>
<?php include_once('./tail.sub.php'); ?>
```
2. `app/modules/sudoku/index.php`์ ๊ฒ์ ์ปจํธ๋กค ์์ญ์ ํ์คํ ๋ฆฌ ๋งํฌ ์ถ๊ฐ:
```html
<div id="game-controls" style="display: none; margin-top: 24px;">
<div style="display: flex; gap: 12px; align-items: center;">
<button id="hint-btn">๐ก ํํธ (3)</button>
<button id="reset-btn">๐ ์ด๊ธฐํ</button>
<button id="check-btn">โ
์ ๋ต ํ์ธ</button>
<a href="/sudoku/history" style="margin-left: auto; padding: 10px 20px; background: var(--m-surface); border: 2px solid var(--m-primary); border-radius: var(--m-radius-lg); color: var(--m-primary); text-decoration: none; font-weight: 600;">๐ ํ์คํ ๋ฆฌ</a>
<div id="timer" style="font-size: 18px; font-weight: 600;">00:00</div>
</div>
</div>
```
**ํ์ธ ์ฌํญ:**
- `/sudoku/history` ๊ฒฝ๋ก๋ก ์ ์ ์ ๋์ด๋๋ณ ํต๊ณ๊ฐ ํ์๋๋๊ฐ?
- ์ต๊ทผ 10๊ฐ ์๋ฃ ๊ธฐ๋ก์ด ํ
์ด๋ธ๋ก ํ์๋๋๊ฐ?
- ๋คํฌ๋ชจ๋์์๋ ํ
์ด๋ธ์ด ์ฝ๊ธฐ ์ฌ์ด๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: `/sudoku/history` ํ์ด์ง์์ ๋์ด๋๋ณ ์๋ฃ ํ์, ํ๊ท /์ต๊ณ ์์ ์๊ฐ์ด ์นด๋ ํํ๋ก ํ์๋๋ฉฐ, ์ต๊ทผ 10๊ฐ ๊ธฐ๋ก์ด ํ ์ด๋ธ๋ก ๋์ด๋ฉ๋๋ค. ๋คํฌ๋ชจ๋์์๋ ๊ฐ๋ ์ฑ์ด ์ ์ง๋ฉ๋๋ค.
**Step 6: ์ผ์ผ ์ฑ๋ฆฐ์ง ๋ชจ๋ ๋ฐ ๋ฉ๋ชจ ๊ธฐ๋ฅ ๊ตฌํ**
1. `app/modules/sudoku/index.php`์ ๋์ด๋ ์ ํ ์์ญ ์์ ์ผ์ผ ์ฑ๋ฆฐ์ง ๋ฒํผ ์ถ๊ฐ:
```html
<div class="daily-challenge" style="margin-bottom: 24px; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: var(--m-radius-lg); color: white;">
<h2 style="font-size: 20px; font-weight: 700; margin-bottom: 8px;">๐ ์ค๋์ ์ฑ๋ฆฐ์ง</h2>
<p style="margin-bottom: 12px; opacity: 0.9;">๋ชจ๋ ์ฌ์ฉ์๊ฐ ๋์ผํ ํผ์ฆ์ ๋์ ํฉ๋๋ค!</p>
<button id="daily-btn" style="padding: 10px 24px; background: white; color: #667eea; border: none; border-radius: var(--m-radius-lg); font-weight: 600; cursor: pointer;">์ค๋์ ํผ์ฆ ์์ํ๊ธฐ</button>
</div>
```
2. JavaScript์ ์ผ์ผ ์ฑ๋ฆฐ์ง ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ถ๊ฐ:
```javascript
let currentDifficulty = 'medium';
let currentSeed = null;
document.getElementById('daily-btn').addEventListener('click', async function() {
const formData = new FormData();
formData.append('action', 'generate');
formData.append('difficulty', 'medium');
formData.append('daily', '1');
const response = await fetch(window.location.href, {
method: 'POST',
body: formData
});
const data = await response.json();
currentPuzzle = data.puzzle;
currentSolution = data.solution;
currentDifficulty = 'medium';
currentSeed = data.seed;
hintsLeft = 3;
renderBoard(data.puzzle);
document.getElementById('sudoku-board').style.display = 'block';
document.getElementById('game-controls').style.display = 'block';
});
// ๊ธฐ์กด ๋์ด๋ ๋ฒํผ ์ด๋ฒคํธ์๋ currentDifficulty, currentSeed ์ ์ฅ ์ถ๊ฐ
document.querySelectorAll('.diff-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const level = this.dataset.level;
currentDifficulty = level;
// ... ๊ธฐ์กด ์ฝ๋ ...
currentSeed = data.seed;
});
});
```
3. ๋ฉ๋ชจ ๋ชจ๋ ๊ตฌํ:
```javascript
let memoMode = false;
// ๊ฒ์ ์ปจํธ๋กค์ ๋ฉ๋ชจ ๋ชจ๋ ๋ฒํผ ์ถ๊ฐ (HTML)
<button id="memo-btn" style="background: var(--m-surface); border: 2px solid var(--m-primary); color: var(--m-primary);">๐ ๋ฉ๋ชจ ๋ชจ๋</button>
// JavaScript ์ด๋ฒคํธ ๋ฆฌ์ค๋
document.getElementById('memo-btn').addEventListener('click', function() {
memoMode = !memoMode;
this.style.background = memoMode ? 'var(--m-primary)' : 'var(--m-surface)';
this.style.color = memoMode ? 'white' : 'var(--m-primary)';
this.textContent = memoMode ? 'โ๏ธ ์
๋ ฅ ๋ชจ๋' : '๐ ๋ฉ๋ชจ ๋ชจ๋';
});
// renderBoard ํจ์ ์์ (์
์ด๋ฒคํธ ๋ฆฌ์ค๋ ๋ถ๋ถ)
cell.addEventListener('input', function(e) {
const row = parseInt(this.dataset.row);
const col = parseInt(this.dataset.col);
const value = this.value;
if (memoMode) {
// ๋ฉ๋ชจ ๋ชจ๋: ์์ ์ซ์๋ก ํ์
this.classList.add('memo-cell');
this.style.fontSize = '12px';
this.style.color = 'var(--m-text-muted)';
} else {
// ์ผ๋ฐ ๋ชจ๋: ๊ฒ์ฆ ์ํ
this.classList.remove('memo-cell');
this.style.fontSize = '20px';
this.style.color = 'var(--m-text)';
if (!validateCell(row, col, value)) {
this.classList.add('invalid');
this.style.animation = 'shake 0.3s';
} else {
this.classList.remove('invalid');
}
}
});
```
4. CSS์ ๋ฉ๋ชจ ์
์คํ์ผ ์ถ๊ฐ:
```css
.sudoku-cell.memo-cell {
font-size: 12px !important;
color: var(--m-text-muted) !important;
font-weight: 400;
}
```
5. PHP์ ์ผ์ผ ์ฑ๋ฆฐ์ง ์์ํ ์กฐํ ๋ก์ง ์ถ๊ฐ (history.php์):
```php
// ์ค๋์ ์ฑ๋ฆฐ์ง ์์ (์์ 10๋ช
)
$today_seed = strtotime(date('Y-m-d'));
$stmt = $pdo->prepare("
SELECT user_id, solve_time, hints_used, completed_at
FROM puzzle_history
WHERE seed = ? AND difficulty = 'medium'
ORDER BY solve_time ASC, hints_used ASC
LIMIT 10
");
$stmt->execute([$today_seed]);
$daily_leaderboard = $stmt->fetchAll(PDO::FETCH_ASSOC);
```
6. history.php์ ์์ํ ์น์
์ถ๊ฐ:
```html
<h2 style="font-size: 24px; font-weight: 700; margin: 32px 0 16px;">๐ ์ค๋์ ์ฑ๋ฆฐ์ง ์์</h2>
<div class="leaderboard">
<?php if (empty($daily_leaderboard)): ?>
<p style="color: var(--m-text-muted);">์์ง ์ค๋์ ์ฑ๋ฆฐ์ง๋ฅผ ์๋ฃํ ์ฌ๋์ด ์์ต๋๋ค.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>์์</th>
<th>์ฌ์ฉ์</th>
<th>์์ ์๊ฐ</th>
<th>ํํธ ์ฌ์ฉ</th>
</tr>
</thead>
<tbody>
<?php foreach ($daily_leaderboard as $idx => $row): ?>
<tr>
<td><?php echo $idx + 1; ?></td>
<td><?php echo htmlspecialchars($row['user_id']); ?></td>
<td><?php echo gmdate('i:s', $row['solve_time']); ?></td>
<td><?php echo $row['hints_used']; ?>ํ</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
```
**ํ์ธ ์ฌํญ:**
- ์ค๋์ ์ฑ๋ฆฐ์ง ๋ฒํผ ํด๋ฆญ ์ ๋์ผํ ์๋๋ก ํผ์ฆ์ด ์์ฑ๋๋๊ฐ?
- ๋ฉ๋ชจ ๋ชจ๋ ํ ๊ธ ์ ์
๋ ฅ ์ซ์๊ฐ ์์ ํ์ ๊ธ์จ๋ก ํ์๋๋๊ฐ?
- ํ์คํ ๋ฆฌ ํ์ด์ง์ ์ค๋์ ์ฑ๋ฆฐ์ง ์์ํ๊ฐ ํ์๋๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ค๋์ ์ฑ๋ฆฐ์ง ๋ฒํผ ํด๋ฆญ ์ ๋ชจ๋ ์ฌ์ฉ์๊ฐ ๋์ผํ ํผ์ฆ์ ๋ฐ์ผ๋ฉฐ, ๋ฉ๋ชจ ๋ชจ๋์์๋ ์์ ํ์ ๊ธ์จ๋ก ํ๋ณด ์ซ์๋ฅผ ๋ฉ๋ชจํ ์ ์์ต๋๋ค. ํ์คํ ๋ฆฌ ํ์ด์ง์ ์ค๋์ ์ฑ๋ฆฐ์ง ์์ํ๊ฐ ์์ ์๊ฐ ๊ธฐ์ค์ผ๋ก ์ ๋ ฌ๋์ด ํ์๋ฉ๋๋ค.
**Step 7: ์๋ ์ ์ฅ ๋ฐ ํผ์ฆ ๊ณต์ ๊ธฐ๋ฅ ๊ตฌํ**
1. localStorage ์๋ ์ ์ฅ ๊ตฌํ:
```javascript
function saveProgress() {
const cells = document.querySelectorAll('.sudoku-cell');
const progress = [];
cells.forEach(cell => {
progress.push({
row: cell.dataset.row,
col: cell.dataset.col,
value: cell.value,
isMemo: cell.classList.contains('memo-cell')
});
});
localStorage.setItem('sudoku_progress', JSON.stringify({
puzzle: currentPuzzle,
solution: currentSolution,
difficulty: currentDifficulty,
seed: currentSeed,
progress: progress,
elapsedSeconds: elapsedSeconds,
hintsLeft: hintsLeft,
timestamp: Date.now()
}));
}
function loadProgress() {
const saved = localStorage.getItem('sudoku_progress');
if (!saved) return false;
const data = JSON.parse(saved);
// 24์๊ฐ ์ด์ ์ง๋ ๋ฐ์ดํฐ๋ ๋ฌด์
if (Date.now() - data.timestamp > 86400000) {
localStorage.removeItem('sudoku_progress');
return false;
}
currentPuzzle = data.puzzle;
currentSolution = data.solution;
currentDifficulty = data.difficulty;
currentSeed = data.seed;
elapsedSeconds = data.elapsedSeconds;
hintsLeft = data.hintsLeft;
renderBoard(data.puzzle);
// ์งํ ์ํฉ ๋ณต์
data.progress.forEach(item => {
const cell = document.querySelector(`[data-row="${item.row}"][data-col="${item.col}"]`);
if (cell && !cell.classList.contains('given')) {
cell.value = item.value;
if (item.isMemo) {
cell.classList.add('memo-cell');
cell.style.fontSize = '12px';
cell.style.color = 'var(--m-text-muted)';
}
}
});
document.getElementById('hint-btn').textContent = `๐ก ํํธ (${hintsLeft})`;
document.getElementById('sudoku-board').style.display = 'block';
document.getElementById('game-controls').style.display = 'block';
return true;
}
// ํ์ด์ง ๋ก๋ ์ ์๋ ๋ณต์
window.addEventListener('DOMContentLoaded', function() {
if (loadProgress()) {
if (confirm('์ด์ ์ ํ๋ ํผ์ฆ์ ์ด์ด์ ํ์๊ฒ ์ต๋๊น?')) {
startTimer();
} else {
localStorage.removeItem('sudoku_progress');
location.reload();
}
}
});
// ์
๋ ฅ ์๋ง๋ค ์๋ ์ ์ฅ
document.addEventListener('input', function(e) {
if (e.target.classList.contains('sudoku-cell')) {
saveProgress();
}
});
```
2. ํผ์ฆ ๊ณต์ ๊ธฐ๋ฅ ๊ตฌํ:
```javascript
// ๊ฒ์ ์ปจํธ๋กค์ ๊ณต์ ๋ฒํผ ์ถ๊ฐ (HTML)
<button id="share-btn" style="background: var(--m-surface); border: 2px solid var(--m-primary); color: var(--m-primary);">๐ ํผ์ฆ ๊ณต์ </button>
// JavaScript ์ด๋ฒคํธ ๋ฆฌ์ค๋
document.getElementById('share-btn').addEventListener('click', function() {
if (!currentSeed) {
alert('๋จผ์ ํผ์ฆ์ ์์ํด์ฃผ์ธ์!');
return;
}
const shareUrl = `${window.location.origin}/sudoku?seed=${currentSeed}&difficulty=${currentDifficulty}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(shareUrl).then(() => {
alert('ํผ์ฆ ๋งํฌ๊ฐ ํด๋ฆฝ๋ณด๋์ ๋ณต์ฌ๋์์ต๋๋ค!\n\n' + shareUrl);
});
} else {
prompt('์ด ๋งํฌ๋ฅผ ๋ณต์ฌํ์ฌ ์น๊ตฌ์๊ฒ ๊ณต์ ํ์ธ์:', shareUrl);
}
});
```
3. URL ํ๋ผ๋ฏธํฐ๋ก ํผ์ฆ ๋ก๋ ๊ธฐ๋ฅ ์ถ๊ฐ:
```javascript
// ํ์ด์ง ๋ก๋ ์ URL ํ๋ผ๋ฏธํฐ ํ์ธ
window.addEventListener('DOMContentLoaded', async function() {
const params = new URLSearchParams(window.location.search);
const seed = params.get('seed');
const difficulty = params.get('difficulty');
if (seed && difficulty) {
const formData = new FormData();
formData.append('action', 'generate');
formData.append('difficulty', difficulty);
formData.append('seed', seed);
const response = await fetch(window.location.href.split('?')[0], {
method: 'POST',
body: formData
});
const data = await response.json();
currentPuzzle = data.puzzle;
currentSolution = data.solution;
currentDifficulty = difficulty;
currentSeed = seed;
hintsLeft = 3;
renderBoard(data.puzzle);
document.getElementById('sudoku-board').style.display = 'block';
document.getElementById('game-controls').style.display = 'block';
alert('๊ณต์ ๋ฐ์ ํผ์ฆ์ ์์ํฉ๋๋ค!');
} else {
// ๊ธฐ์กด ์๋ ๋ณต์ ๋ก์ง
if (loadProgress()) {
if (confirm('์ด์ ์ ํ๋ ํผ์ฆ์ ์ด์ด์ ํ์๊ฒ ์ต๋๊น?')) {
startTimer();
} else {
localStorage.removeItem('sudoku_progress');
location.reload();
}
}
}
});
```
4. PHP์์ ์๋ ๊ธฐ๋ฐ ํผ์ฆ ์์ฑ ์ง์ (AJAX ์ฒ๋ฆฌ ์น์
์์ ):
```php
// AJAX: ํผ์ฆ ์์ฑ ์์ฒญ ์ฒ๋ฆฌ
if (isset($_POST['action']) && $_POST['action'] === 'generate') {
$difficulty = $_POST['difficulty'] ?? 'medium';
if (isset($_POST['seed'])) {
// ๊ณต์ ๋ฐ์ ํผ์ฆ (์๋๊ฐ ์ง์ ์ฌ์ฉ)
$seed = (int)$_POST['seed'];
} elseif (isset($_POST['daily'])) {
// ์ผ์ผ ์ฑ๋ฆฐ์ง (์ค๋ ๋ ์ง ๊ธฐ๋ฐ)
$seed = strtotime(date('Y-m-d'));
} else {
// ์ผ๋ฐ ํผ์ฆ (ํ์ฌ ์๊ฐ ๊ธฐ๋ฐ)
$seed = time();
}
$solution = generateSudoku($seed);
$puzzle = removeCells($solution, $difficulty);
header('Content-Type: application/json');
echo json_encode([
'puzzle' => $puzzle,
'solution' => $solution,
'seed' => $seed
]);
exit;
}
```
**ํ์ธ ์ฌํญ:**
- ํผ์ฆ ํ์ด ์ค ์๋ก๊ณ ์นจํด๋ ์งํ ์ํฉ์ด ๋ณต์๋๋๊ฐ?
- ๊ณต์ ๋ฒํผ ํด๋ฆญ ์ URL์ด ํด๋ฆฝ๋ณด๋์ ๋ณต์ฌ๋๋๊ฐ?
- ๊ณต์ ๋ฐ์ ๋งํฌ๋ก ์ ์ ์ ๋์ผํ ํผ์ฆ์ด ๋ก๋๋๋๊ฐ?
- 24์๊ฐ ์ด์ ์ง๋ ์๋ ์ ์ฅ ๋ฐ์ดํฐ๋ ๋ฌด์๋๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ํผ์ฆ ํ์ด ์ค ํ์ด์ง๋ฅผ ์๋ก๊ณ ์นจํด๋ ์งํ ์ํฉ์ด localStorage์์ ๋ณต์๋๋ฉฐ, ๊ณต์ ๋ฒํผ์ผ๋ก ์์ฑ๋ ๋งํฌ๋ฅผ ๋ค๋ฅธ ์ฌ์ฉ์๊ฐ ์ด๋ฉด ๋์ผํ ํผ์ฆ์ ํ๋ ์ดํ ์ ์์ต๋๋ค. 24์๊ฐ ์ด์ ์ง๋ ์ ์ฅ ๋ฐ์ดํฐ๋ ์๋์ผ๋ก ์ญ์ ๋ฉ๋๋ค.
**Step 8: ๋ฐ์ํ ๋์์ธ ๋ฐ ํฐ์น ์ต์ ํ**
1. CSS์ ๋ฐ์ํ ๋ฏธ๋์ด ์ฟผ๋ฆฌ ์ถ๊ฐ:
```css
/* ํ๋ธ๋ฆฟ ์ดํ */
@media (max-width: 768px) {
.sudoku-grid {
grid-template-columns: repeat(9, 40px);
}
.sudoku-cell {
width: 40px;
height: 40px;
font-size: 16px;
}
.difficulty-selector {
flex-direction: column;
}
.diff-btn {
width: 100%;
}
#game-controls > div {
flex-wrap: wrap;
}
#game-controls button,
#game-controls a {
flex: 1 1 45%;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
/* ๋ชจ๋ฐ์ผ */
@media (max-width: 480px) {
.sudoku-grid {
grid-template-columns: repeat(9, 35px);
}
.sudoku-cell {
width: 35px;
height: 35px;
font-size: 14px;
}
.sudoku-header h1 {
font-size: 24px;
}
.modal-content {
padding: 24px;
margin: 0 16px;
}
}
```
2. ํฐ์น ๋๋ฐ์ด์ค ์ต์ ํ:
```javascript
// ํฐ์น ๋๋ฐ์ด์ค ๊ฐ์ง
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (isTouchDevice) {
// ์ซ์ ์
๋ ฅ ํจ๋ ์ถ๊ฐ
const numpad = document.createElement('div');
numpad.className = 'numpad';
numpad.innerHTML = `
<div class="numpad-title">์ซ์ ์ ํ</div>
<div class="numpad-grid">
${[1,2,3,4,5,6,7,8,9].map(n => `<button class="numpad-btn" data-num="${n}">${n}</button>`).join('')}
<button class="numpad-btn" data-num="0" style="grid-column: span 3;">์ง์ฐ๊ธฐ</button>
</div>
`;
document.getElementById('sudoku-board').appendChild(numpad);
// ์
์ ํ ์ ํ์ฑํ
let selectedCell = null;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('sudoku-cell') && !e.target.classList.contains('given')) {
if (selectedCell) selectedCell.classList.remove('selected');
selectedCell = e.target;
selectedCell.classList.add('selected');
numpad.style.display = 'block';
}
});
// ์ซ์ ํจ๋ ํด๋ฆญ
document.querySelectorAll('.numpad-btn').forEach(btn => {
btn.addEventListener('click', function() {
if (!selectedCell) return;
const num = this.dataset.num;
if (num === '0') {
selectedCell.value = '';
} else {
selectedCell.value = num;
}
// ๊ฒ์ฆ ํธ๋ฆฌ๊ฑฐ
selectedCell.dispatchEvent(new Event('input'));
});
});
}
```
3. ์ซ์ ํจ๋ CSS ์ถ๊ฐ:
```css
.numpad {
display: none;
margin-top: 24px;
padding: 16px;
background: var(--m-surface);
border-radius: var(--m-radius-lg);
box-shadow: var(--m-shadow);
}
.numpad-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
text-align: center;
}
.numpad-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.numpad-btn {
padding: 16px;
background: var(--m-primary);
color: white;
border: none;
border-radius: var(--m-radius-lg);
font-size: 18px;
font-weight: 600;
cursor: pointer;
}
.numpad-btn:active {
transform: scale(0.95);
}
.sudoku-cell.selected {
outline: 3px solid var(--m-primary);
background: rgba(59, 130, 246, 0.1);
}
```
4. ๊ฐ๋ก ์คํฌ๋กค ๋ฐฉ์ง:
```css
body {
overflow-x: hidden;
}
.m-container {
max-width: 100%;
overflow-x: auto;
}
```
**ํ์ธ ์ฌํญ:**
- ๋ชจ๋ฐ์ผ์์ ๋ณด๋๊ฐ ํ๋ฉด์ ๋ง๊ฒ ์ถ์๋๋๊ฐ?
- ํฐ์น ๋๋ฐ์ด์ค์์ ์ซ์ ํจ๋๊ฐ ํ์๋๋๊ฐ?
- ์
์ ํ ์ ํ๋์ ํ
๋๋ฆฌ๊ฐ ํ์๋๋๊ฐ?
- ๊ฐ๋ก ์คํฌ๋กค์ด ๋ฐ์ํ์ง ์๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ชจ๋ฐ์ผ ๋ฐ ํ๋ธ๋ฆฟ์์ ๋ณด๋ ํฌ๊ธฐ๊ฐ ํ๋ฉด์ ๋ง๊ฒ ๋ฐ์ํ์ผ๋ก ์กฐ์ ๋๋ฉฐ, ํฐ์น ๋๋ฐ์ด์ค์์๋ ์ซ์ ํจ๋๊ฐ ํ์๋์ด ํธ๋ฆฌํ๊ฒ ์ ๋ ฅํ ์ ์์ต๋๋ค. ์ ํ๋ ์ ์ ํ๋์ ํ ๋๋ฆฌ๋ก ๊ฐ์กฐ๋๊ณ , ๊ฐ๋ก ์คํฌ๋กค์ด ๋ฐ์ํ์ง ์์ต๋๋ค.
**Step 9: ์ฑ๋ฅ ์ต์ ํ ๋ฐ ์บ์ฑ ์ ๋ต**
1. ์ผ์ผ ์ฑ๋ฆฐ์ง ํผ์ฆ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ ์บ์ฑ ๊ตฌํ (index.php ์๋จ์):
```php
// ์ผ์ผ ์ฑ๋ฆฐ์ง ์บ์ฑ (APCu ์ฌ์ฉ)
function getDailyChallenge($difficulty) {
$cache_key = 'sudoku_daily_'.date('Y-m-d').'_'.$difficulty;
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch($cache_key, $success);
if ($success) return $cached;
}
// ์บ์ ๋ฏธ์ค ์ ์์ฑ
$seed = strtotime(date('Y-m-d'));
$solution = generateSudoku($seed);
$puzzle = removeCells($solution, $difficulty);
$data = [
'puzzle' => $puzzle,
'solution' => $solution,
'seed' => $seed
];
if (function_exists('apcu_store')) {
apcu_store($cache_key, $data, 86400); // 24์๊ฐ ์บ์ฑ
}
return $data;
}
// AJAX ์ฒ๋ฆฌ ๋ถ๋ถ ์์
if (isset($_POST['action']) && $_POST['action'] === 'generate') {
$difficulty = $_POST['difficulty'] ?? 'medium';
if (isset($_POST['daily'])) {
// ์ผ์ผ ์ฑ๋ฆฐ์ง๋ ์บ์์์ ๊ฐ์ ธ์ค๊ธฐ
$data = getDailyChallenge($difficulty);
} else {
// ์ผ๋ฐ ํผ์ฆ์ ๋งค๋ฒ ์๋ก ์์ฑ
if (isset($_POST['seed'])) {
$seed = (int)$_POST['seed'];
} else {
$seed = time();
}
$solution = generateSudoku($seed);
$puzzle = removeCells($solution, $difficulty);
$data = [
'puzzle' => $puzzle,
'solution' => $solution,
'seed' => $seed
];
}
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
```
2. DB ์ฟผ๋ฆฌ ์ต์ ํ (์ธ๋ฑ์ค ์ถ๊ฐ):
```php
// ํ
์ด๋ธ ์์ฑ ์ ๋ณตํฉ ์ธ๋ฑ์ค ์ถ๊ฐ
$pdo->exec("
CREATE INDEX IF NOT EXISTS idx_seed_difficulty
ON puzzle_history(seed, difficulty)
");
$pdo->exec("
CREATE INDEX IF NOT EXISTS idx_user_completed
ON puzzle_history(user_id, completed_at DESC)
");
```
3. ํ๋ก ํธ์๋ ๋๋ฐ์ด์ฑ (์๋ ์ ์ฅ ์ต์ ํ):
```javascript
let saveTimeout = null;
function debouncedSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
saveProgress();
}, 500); // 500ms ๋๋ฐ์ด์ฑ
}
// ๊ธฐ์กด ์๋ ์ ์ฅ ์ด๋ฒคํธ ์์
document.addEventListener('input', function(e) {
if (e.target.classList.contains('sudoku-cell')) {
debouncedSave();
}
});
```
4. ์ด๋ฏธ์ง/๋ฆฌ์์ค ์ต์ ํ (CSS์ ํ๋ฆฌ๋ก๋ฉ ํํธ ์ถ๊ฐ):
```html
<!-- head.sub.php ๋๋ _head.inc.php์ ์ถ๊ฐ (์ด๋ฏธ ์๋ค๋ฉด ์๋ต) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
```
5. ์๋ฌ ํธ๋ค๋ง ๊ฐํ:
```javascript
// AJAX ์์ฒญ์ ์๋ฌ ํธ๋ค๋ง ์ถ๊ฐ
async function generatePuzzle(difficulty, options = {}) {
try {
const formData = new FormData();
formData.append('action', 'generate');
formData.append('difficulty', difficulty);
if (options.daily) formData.append('daily', '1');
if (options.seed) formData.append('seed', options.seed);
const response = await fetch(window.location.href.split('?')[0], {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('์๋ฒ ์๋ต ์ค๋ฅ');
}
const data = await response.json();
return data;
} catch (error) {
console.error('ํผ์ฆ ์์ฑ ์คํจ:', error);
alert('ํผ์ฆ์ ์์ฑํ๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
return null;
}
}
// ๊ธฐ์กด ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ค์ ์ด ํจ์ ์ฌ์ฉํ๋๋ก ์์
document.getElementById('daily-btn').addEventListener('click', async function() {
const data = await generatePuzzle('medium', { daily: true });
if (data) {
currentPuzzle = data.puzzle;
currentSolution = data.solution;
currentDifficulty = 'medium';
currentSeed = data.seed;
hintsLeft = 3;
renderBoard(data.puzzle);
document.getElementById('sudoku-board').style.display = 'block';
document.getElementById('game-controls').style.display = 'block';
}
});
```
6. PHP ์๋ฌ ๋ก๊น
:
```php
// AJAX ์ฒ๋ฆฌ ๋ถ๋ถ์ try-catch ์ถ๊ฐ
try {
if (isset($_POST['action']) && $_POST['action'] === 'generate') {
// ... ๊ธฐ์กด ์ฝ๋ ...
} elseif (isset($_POST['action']) && $_POST['action'] === 'save_result') {
// ... ๊ธฐ์กด ์ฝ๋ ...
}
} catch (Exception $e) {
error_log('Sudoku Error: ' . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode(['error' => '์๋ฒ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.']);
exit;
}
```
**ํ์ธ ์ฌํญ:**
- ์ผ์ผ ์ฑ๋ฆฐ์ง๋ฅผ ์ฌ๋ฌ ๋ฒ ์์ฒญํด๋ ๋์ผํ ํผ์ฆ์ด ๋ฐํ๋๋๊ฐ? (์บ์ฑ ํ์ธ)
- ์๋ ์ ์ฅ์ด ์ฐ์ ์
๋ ฅ ์ 500ms๋ง๋ค ํ ๋ฒ๋ง ์คํ๋๋๊ฐ? (๋๋ฐ์ด์ฑ ํ์ธ)
- ๋คํธ์ํฌ ์ค๋ฅ ์ ์ฌ์ฉ์์๊ฒ ์ ์ ํ ๋ฉ์์ง๊ฐ ํ์๋๋๊ฐ?
- DB ์ธ๋ฑ์ค๊ฐ ์ ์ ์์ฑ๋์๋๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ผ์ผ ์ฑ๋ฆฐ์ง ํผ์ฆ์ด ์๋ฒ ๋ฉ๋ชจ๋ฆฌ(APCu)์ ์บ์ฑ๋์ด ์ค๋ณต ์์ฑ์ ๋ฐฉ์งํ๋ฉฐ, ์๋ ์ ์ฅ์ด ๋๋ฐ์ด์ฑ๋์ด ์ฑ๋ฅ์ด ํฅ์๋ฉ๋๋ค. ์๋ฌ ๋ฐ์ ์ ์ฌ์ฉ์์๊ฒ ์น์ ํ ๋ฉ์์ง๊ฐ ํ์๋๊ณ , DB ์ฟผ๋ฆฌ๊ฐ ์ธ๋ฑ์ค๋ก ์ต์ ํ๋ฉ๋๋ค.
**Step 10: ์ต์ข
ํ
์คํธ ๋ฐ ๋ณด์ ๊ฐํ**
1. XSS ๋ฐฉ์ง ๊ฐํ (๋ชจ๋ ์ถ๋ ฅ์ ์ด์ค์ผ์ดํ ์ ์ฉ ํ์ธ):
```php
// history.php์ ์ฌ์ฉ์ ID ์ถ๋ ฅ ๋ถ๋ถ ์์
<td><?php echo htmlspecialchars($row['user_id'], ENT_QUOTES, 'UTF-8'); ?></td>
// index.php์ ๋ชจ๋ ์ฌ์ฉ์ ์
๋ ฅ ์ฒ๋ฆฌ ๋ถ๋ถ์ ์ด์ค์ผ์ดํ ์ ์ฉ
$difficulty = htmlspecialchars($_POST['difficulty'] ?? 'medium', ENT_QUOTES, 'UTF-8');
```
2. SQL ์ธ์ ์
๋ฐฉ์ง ์ฌํ์ธ (๋ชจ๋ ์ฟผ๋ฆฌ๊ฐ prepared statement ์ฌ์ฉ ํ์ธ):
```php
// ์์: ๋ชจ๋ DB ์ฟผ๋ฆฌ๊ฐ ๋ค์ ํํ์ธ์ง ํ์ธ
$stmt = $pdo->prepare("SELECT * FROM puzzle_history WHERE user_id = ?");
$stmt->execute([$user_id]);
```
3. CSRF ๋ฐฉ์ง (g5se ๋ด์ฅ ํ ํฐ ํ์ฉ):
```php
// AJAX ์์ฒญ ์ฒ๋ฆฌ ์ ์ ํ ํฐ ๊ฒ์ฆ ์ถ๊ฐ
if (isset($_POST['action'])) {
// g5se๋ ์ธ์
๊ธฐ๋ฐ ์ธ์ฆ์ ์ฌ์ฉํ๋ฏ๋ก ์ถ๊ฐ CSRF ํ ํฐ์ ์ ํ์ฌํญ
// ํ์ ์ $_SESSION['ss_token'] ๊ฒ์ฆ ๋ก์ง ์ถ๊ฐ
}
```
4. ์
๋ ฅ ๊ฒ์ฆ ๊ฐํ:
```php
// ๋์ด๋ ํ์ดํธ๋ฆฌ์คํธ ๊ฒ์ฆ
$allowed_difficulties = ['easy', 'medium', 'hard', 'extreme'];
$difficulty = $_POST['difficulty'] ?? 'medium';
if (!in_array($difficulty, $allowed_difficulties)) {
$difficulty = 'medium';
}
// ์๋๊ฐ ๋ฒ์ ๊ฒ์ฆ
if (isset($_POST['seed'])) {
$seed = (int)$_POST['seed'];
if ($seed < 0 || $seed > PHP_INT_MAX) {
$seed = time();
}
}
```
5. ์ต์ข
ํ
์คํธ ์ฒดํฌ๋ฆฌ์คํธ ์์ฑ:
```markdown
## ํ
์คํธ ์ฒดํฌ๋ฆฌ์คํธ
### ๊ธฐ๋ฅ ํ
์คํธ
- [ ] ๋์ด๋๋ณ ํผ์ฆ ์์ฑ (์ฌ์/๋ณดํต/์ด๋ ค์/์ต์คํธ๋ฆผ)
- [ ] ์ค์๊ฐ ์
๋ ฅ ๊ฒ์ฆ (ํ/์ด/๋ฐ์ค ๊ท์น)
- [ ] ํ์ด๋จธ ์์/์ ์ง
- [ ] ํํธ ๊ธฐ๋ฅ (3ํ ์ ํ)
- [ ] ์ ๋ต ํ์ธ ๋ฐ ์๋ฃ ๋ชจ๋ฌ
- [ ] ์๋ฃ ๊ธฐ๋ก DB ์ ์ฅ
- [ ] ํ์คํ ๋ฆฌ ํ์ด์ง ํต๊ณ ํ์
- [ ] ์ผ์ผ ์ฑ๋ฆฐ์ง ๋ชจ๋
- [ ] ๋ฉ๋ชจ ๋ชจ๋ ํ ๊ธ
- [ ] ์๋ ์ ์ฅ/๋ณต์
- [ ] ํผ์ฆ ๊ณต์ (URL ํ๋ผ๋ฏธํฐ)
### ๋ฐ์ํ/UX ํ
์คํธ
- [ ] ๋ฐ์คํฌํฑ (1920x1080)
- [ ] ํ๋ธ๋ฆฟ (768x1024)
- [ ] ๋ชจ๋ฐ์ผ (375x667)
- [ ] ํฐ์น ๋๋ฐ์ด์ค ์ซ์ ํจ๋
- [ ] ๋คํฌ๋ชจ๋ ์ ํ
### ๋ณด์ ํ
์คํธ
- [ ] XSS ๋ฐฉ์ง (์ฌ์ฉ์ ์
๋ ฅ ์ด์ค์ผ์ดํ)
- [ ] SQL ์ธ์ ์
๋ฐฉ์ง (prepared statement)
- [ ] ์
๋ ฅ ๊ฒ์ฆ (๋์ด๋/์๋๊ฐ)
### ์ฑ๋ฅ ํ
์คํธ
- [ ] ์ผ์ผ ์ฑ๋ฆฐ์ง ์บ์ฑ ๋์
- [ ] ์๋ ์ ์ฅ ๋๋ฐ์ด์ฑ
- [ ] DB ์ธ๋ฑ์ค ์ ์ฉ ํ์ธ
- [ ] ํ์ด์ง ๋ก๋ ์๋ (3์ด ์ด๋ด)
```
6. README.md ์์ฑ:
```markdown
# ๐งฉ g5se ์ค๋์ ์ค๋์ฟ ํผ์ฆ ์์ฑ๊ธฐ
๋งค์ผ ์๋ก์ด ์ค๋์ฟ ํผ์ฆ์ ํ ์ ์๋ ์น ๊ธฐ๋ฐ ํผ์ฆ ํ๋ซํผ
## ์ฃผ์ ๊ธฐ๋ฅ
- ๋์ด๋๋ณ ํผ์ฆ ์์ฑ (์ฌ์/๋ณดํต/์ด๋ ค์/์ต์คํธ๋ฆผ)
- ์ค์๊ฐ ์
๋ ฅ ๊ฒ์ฆ ๋ฐ ์ค๋ฅ ํ์
- ํํธ ์์คํ
(3ํ ์ ํ)
- ํ์ด ํ์ด๋จธ ๋ฐ ๊ธฐ๋ก ์ ์ฅ
- ์ผ์ผ ์ฑ๋ฆฐ์ง ๋ชจ๋ (์ ์ฒด ์์ํ)
- ๋ฉ๋ชจ ๋ชจ๋ (ํ๋ณด ์ซ์ ๊ธฐ๋ก)
- ์๋ ์ ์ฅ/๋ณต์ (localStorage)
- ํผ์ฆ ๊ณต์ (URL ๋งํฌ)
## ๊ธฐ์ ์คํ
- ํ๋ ์์ํฌ: ๊ทธ๋๋ณด๋5 SE
- DB: SQLite
- ํ๋ก ํธ์๋: Vanilla JavaScript + CSS Grid
- ์๊ณ ๋ฆฌ์ฆ: ๋ฐฑํธ๋ํน (์ค๋์ฟ ์์ฑ)
## ์ค์น ๋ฐฉ๋ฒ
1. g5se ์ค์น (https://github.com/gnuboard/g5se)
2. `app/modules/sudoku/` ํด๋์ ํ์ผ ๋ณต์ฌ
3. `/sudoku` ๊ฒฝ๋ก๋ก ์ ์
## ๋ผ์ด์ผ์ค
MIT License
```
**์ต์ข
ํ์ธ ์ฌํญ:**
- ๋ชจ๋ ๊ธฐ๋ฅ์ด ์ ์ ๋์ํ๋๊ฐ?
- ๋ณด์ ์ทจ์ฝ์ ์ด ์๋๊ฐ?
- ๋ค์ํ ๋๋ฐ์ด์ค์์ ํ
์คํธํ๋๊ฐ?
- ์๋ฌ ํธ๋ค๋ง์ด ์ ์ ํ๊ฐ?
- ์ฝ๋์ ์ฃผ์์ด ์ถฉ๋ถํ๊ฐ?
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ชจ๋ ๊ธฐ๋ฅ์ด ์์ ์ ์ผ๋ก ๋์ํ๋ฉฐ, XSS/SQL ์ธ์ ์ ๋ฑ ๋ณด์ ์ทจ์ฝ์ ์ด ์์ต๋๋ค. ๋ฐ์คํฌํฑ/ํ๋ธ๋ฆฟ/๋ชจ๋ฐ์ผ์์ ๋ฐ์ํ์ผ๋ก ์๋ํ๊ณ , ๋คํฌ๋ชจ๋๋ ์ ์ ์ง์๋ฉ๋๋ค. ์๋ฌ ๋ฐ์ ์ ์ฌ์ฉ์ ์นํ์ ์ธ ๋ฉ์์ง๊ฐ ํ์๋๋ฉฐ, README.md์ ํ๋ก์ ํธ ์ ๋ณด๊ฐ ๋ช ํํ ๊ธฐ์ ๋์ด ์์ต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!