๐ฎ g5se ๋ ํธ๋ก ์์ผ์ด๋ ํ์ด์ค์ฝ์ด ๋ฆฌ๋๋ณด๋ - ์ ์ค์ ๊ฒ์ด๋จธ ๋ช ์์ ์ ๋น
g5se-retro-arcade-leaderboard
๋ฌธ์ ์ค๋ช
๐น๏ธ ํ๋ก์ ํธ ๊ฐ์
80~90๋ ๋ ์ค๋ฝ์ค ๊ฐ์ฑ์ ์ฌํํ ๊ฒ์ ํ์ด์ค์ฝ์ด ๋ฆฌ๋๋ณด๋ ํ๋ซํผ์ ๋๋ค. ์ฌ๋ฌ ๋ ํธ๋ก ๊ฒ์์ ์ต๊ณ ์ ์๋ฅผ ๊ธฐ๋กํ๊ณ , ๊ฒ์ด๋จธ๋ค์ด ์๋ก ๊ฒฝ์ํ๋ฉฐ ์ ์ค์ ์๋ฆฌ๋ฅผ ์ฐจ์งํ๋ ๋ช ์์ ์ ๋น์ ๊ตฌ์ถํฉ๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
-
๊ฒ์ ๋ชฉ๋ก ๊ด๋ฆฌ
- ๋ ํธ๋ก ๊ฒ์ ์นดํ ๊ณ ๋ฆฌ (์ก์ , ์ํ , ํผ์ฆ, ๋ ์ด์ฑ ๋ฑ)
- ๊ฒ์๋ณ ์ธ๋ค์ผ, ์ค๋ช , ์ต๊ณ ๊ธฐ๋ก ๋์ด๋ ์ ๋ณด
- ๊ฒ์๋ณ ์ ์ฉ ๋ฆฌ๋๋ณด๋ ํ์ด์ง
-
ํ์ด์ค์ฝ์ด ๋ฑ๋ก ์์คํ
- ์ ์ ์ ์ถ ํผ (๊ฒ์ ์ ํ, ๋๋ค์, ์ ์, ์คํฌ๋ฆฐ์ท ์ ๋ก๋)
- ์ค๋ณต ์ ์ถ ๋ฐฉ์ง (IP + ์๊ฐ ๊ธฐ๋ฐ ์ฟจ๋ค์ด)
- ๊ด๋ฆฌ์ ์น์ธ ๋๊ธฐ ์ํ (์คํฌ๋ฆฐ์ท ๊ฒ์ฆ)
-
์ค์๊ฐ ๋ฆฌ๋๋ณด๋
- ๊ฒ์๋ณ TOP 10 ๋ญํน (๊ธ/์/๋ ๋ฉ๋ฌ ํ์)
- ์ ์ฒด ๊ฒ์ ํตํฉ ๋ญํน (์ด์ ๊ธฐ๋ฐ)
- ์ฃผ๊ฐ/์๊ฐ/์ญ๋ ๊ธฐ๋ก ํํฐ
- ์ ๋๋ฉ์ด์ ์์ ๋ณ๋ ํจ๊ณผ
-
๊ฒ์ด๋จธ ํ๋กํ
- ๋๋ค์๋ณ ์ ์ ํ์ด์ง
- ๋ณด์ ๋ฉ๋ฌ ์ปฌ๋ ์
- ํ๋ ์ด ํ์คํ ๋ฆฌ ํ์๋ผ์ธ
- ๊ฒ์๋ณ ์ต๊ณ ๊ธฐ๋ก ์์ฝ
-
๊ด๋ฆฌ์ ๊ฒ์ฆ ์์คํ
- ์ ์ถ๋ ์คํฌ๋ฆฐ์ท ๊ฒํ ํ์ด์ง
- ์น์ธ/๊ฑฐ๋ถ ์ฒ๋ฆฌ (์ฌ์ ์ ๋ ฅ)
- ๋ถ์ ๊ธฐ๋ก ์ ๊ณ ์ ์
๋ณด๋์ค ๊ธฐ๋ฅ
- ๐ ์ ์ ์์คํ (์ฒซ 1์ ๋ฌ์ฑ, 3๊ด์, 100๋ง์ ๋ํ ๋ฑ)
- ๐ ๊ฒ์๋ณ ์ ์ ๋ถํฌ ์ฐจํธ
- ๐จ CRT ๋ชจ๋ํฐ ์ค์บ๋ผ์ธ ํจ๊ณผ (CSS ํํฐ)
- ๐ ์ ๊ธฐ๋ก ์๋ฆผ ๋ฐฐ๋ (์ต๊ทผ 24์๊ฐ ๋ด ๊ฐฑ์ ๋ ๊ธฐ๋ก)
- ๐ต 8๋นํธ ๋ฐฐ๊ฒฝ์์ ํ ๊ธ
- ๐ฌ ๊ธฐ๋ก๋ณ ๋๊ธ (์ถํ ๋ฉ์์ง, ๊ณต๋ต ํ ๊ณต์ )
๐ฏ ๊ธฐ์ ์๊ตฌ์ฌํญ
- ๋ฐฑ์๋: g5se (PHP 8.1+) ์์ ๊ตฌํ
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๋ชจ๋ ์ ์ฉ DB ํ์ผ)
- ํ๋ก ํธ: Modern Shell + ์์ JS (CDN ์์กด ์ต์ํ)
- ํ์ผ ์ ๋ก๋: ์คํฌ๋ฆฐ์ท ์ด๋ฏธ์ง ์ ์ฅ (G5_DATA_PATH/modules/arcade-screenshots/)
- ๋ณด์: SQL Injection ๋ฐฉ์ง (prepared statement), XSS ๋ฐฉ์ง (htmlspecialchars)
๐จ ๋์์ธ ์ปจ์
- ๋ค์จ ์ฌ์ธ ์คํ์ผ ํ์ดํฌ๊ทธ๋ํผ
- ๊ทธ๋ฆฌ๋ ๋ฐฐ๊ฒฝ (80๋ ๋ ์ฌ์ด๋ฒํํฌ ๊ฐ์ฑ)
- ํฝ์ ์ํธ ์์ด์ฝ
- ๋คํฌ๋ชจ๋ ๊ธฐ๋ณธ (Modern Shell ํ ํฐ ํ์ฉ)
- ๋ฐ์ํ ๋ ์ด์์ (๋ชจ๋ฐ์ผ ์ต์ ํ)
์ ์ฝ ์กฐ๊ฑด
- โข g5se (PHP) ๊ธฐ๋ฐ โ Laravel/Composer/npm ์ ๋ ์ฌ์ฉ ๊ธ์ง
- โข ํด๋ ์๋ ๋ผ์ฐํ ์ฌ์ฉ: app/modules/arcade/index.php โ /arcade
- โข Modern Shell ํ์ ์ ์ฉ (_head.inc.php + m-shell + _nav.inc.php + m-container + _footer.inc.php)
- โข SQLite ์ ์ฉ DB: G5_DATA_PATH/modules/arcade.sqlite
- โข ์ด๋ฏธ์ง ์ ๋ก๋: G5_DATA_PATH/modules/arcade-screenshots/ ํด๋
- โข prepared statement๋ก SQL Injection ๋ฐฉ์ง
- โข htmlspecialchars()๋ก XSS ๋ฐฉ์ง
- โข ์์ HTML/CSS/JS ๊ตฌํ (์ธ๋ถ CDN ์ต์ํ)
- โข ๋คํฌ๋ชจ๋ ์๋ ๋์ (CSS ๋ณ์ ์ฌ์ฉ)
- โข IP ๊ธฐ๋ฐ ์ค๋ณต ์ ์ถ ๋ฐฉ์ง (1์๊ฐ ์ฟจ๋ค์ด)
- โข ๊ด๋ฆฌ์ ๊ถํ ์ฒดํฌ ($member['mb_level'] >= 10)
- โข ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ ์ฐ์ )
- โข CRT ํจ๊ณผ๋ CSS filter๋ก๋ง ๊ตฌํ (Canvas ๊ธ์ง)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
## Step 1: ํ๋ก์ ํธ ๊ตฌ์กฐ ์์ฑ & ๋ฉ์ธ ํ์ด์ง ๊ณจ๊ฒฉ
๋ค์ ์์
์ ์ํํ์ธ์:
### 1) ํด๋ ๊ตฌ์กฐ ์์ฑ
```bash
mkdir -p app/modules/arcade
mkdir -p data/modules/arcade-screenshots
```
### 2) app/modules/arcade/index.php ์์ฑ
์๋ ์ฝ๋๋ก ๋ฉ์ธ ํ์ด์ง๋ฅผ ๋ง๋์ธ์:
```php
<?php
include_once('./_common.php');
// SQLite DB ์ฐ๊ฒฐ (์ฒซ ์ ๊ทผ ์ ์๋ ์์ฑ)
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/arcade.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํ
์ด๋ธ ์คํค๋ง ์๋ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
thumbnail TEXT,
description TEXT,
difficulty TEXT DEFAULT 'normal',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL,
nickname TEXT NOT NULL,
score INTEGER NOT NULL,
screenshot TEXT,
ip TEXT,
status TEXT DEFAULT 'pending',
admin_note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (game_id) REFERENCES games(id)
);
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game_id, score DESC);
CREATE INDEX IF NOT EXISTS idx_scores_status ON scores(status);
");
// ์ํ ๊ฒ์ ๋ฐ์ดํฐ ์ฝ์
(์ต์ด 1ํ๋ง)
$count = $db->query("SELECT COUNT(*) FROM games")->fetchColumn();
if ($count == 0) {
$stmt = $db->prepare("INSERT INTO games (title, category, description, difficulty) VALUES (?, ?, ?, ?)");
$games = [
['๊ฐค๋ฌ๊ทธ', '์ํ
', '์ธ๊ณ ์นจ๋ต์๋ฅผ ๊ฒฉํํ๋ผ! ํด๋์ ์ข
์คํฌ๋กค ์ํ
', 'hard'],
['ํฉ๋งจ', 'ํผ์ฆ', '๋ฏธ๋ก๋ฅผ ๋๋น๋ฉฐ ์ ์ ๋จน๊ณ ์ ๋ น์ ํผํ๋ผ', 'normal'],
['์คํธ๋ฆฌํธ ํ์ดํฐ II', '๊ฒฉํฌ', '์ธ๊ณ ์ต๊ฐ์ ํ์ดํฐ๋ฅผ ๊ฐ๋ ค๋ผ', 'hard'],
['ํ
ํธ๋ฆฌ์ค', 'ํผ์ฆ', '๋ธ๋ก์ ์์ ๋ผ์ธ์ ์์ฑํ๋ผ', 'normal'],
['์์๋ฐ', '๋ ์ด์ฑ', 'ํ๋ผ๋ฆฌ๋ฅผ ๋ชฐ๊ณ ํด์๋๋ก๋ฅผ ์ง์ฃผํ๋ผ', 'easy']
];
foreach ($games as $g) {
$stmt->execute($g);
}
}
// ์ ์ฒด ๊ฒ์ ๋ชฉ๋ก ์กฐํ
$games = $db->query("SELECT * FROM games ORDER BY category, title")->fetchAll(PDO::FETCH_ASSOC);
// ์ต๊ทผ ์น์ธ๋ ํ์ด์ค์ฝ์ด TOP 5
$recentScores = $db->query("
SELECT s.*, g.title as game_title
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.status = 'approved'
ORDER BY s.created_at DESC
LIMIT 5
")->fetchAll(PDO::FETCH_ASSOC);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<style>
.arcade-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px 20px;
text-align: center;
margin: -32px -20px 32px;
position: relative;
overflow: hidden;
}
.arcade-hero::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.1) 2px,
rgba(0,0,0,0.1) 4px
);
pointer-events: none;
}
.arcade-hero h1 {
font-size: 3rem;
font-weight: 900;
color: #fff;
text-shadow: 0 0 20px rgba(255,255,255,0.5),
0 0 40px rgba(255,0,255,0.3);
margin: 0 0 16px;
position: relative;
}
.arcade-hero p {
color: rgba(255,255,255,0.9);
font-size: 1.1rem;
position: relative;
}
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.game-card {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 20px;
box-shadow: var(--m-shadow);
transition: transform 0.2s;
}
.game-card:hover {
transform: translateY(-4px);
}
.game-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--m-text);
margin-bottom: 8px;
}
.game-category {
display: inline-block;
padding: 4px 12px;
background: var(--m-primary);
color: white;
border-radius: 12px;
font-size: 0.85rem;
margin-bottom: 12px;
}
.recent-scores {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 24px;
box-shadow: var(--m-shadow);
}
.score-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--m-border, rgba(0,0,0,0.1));
}
.score-item:last-child {
border-bottom: none;
}
</style>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="arcade-hero">
<h1>๐น๏ธ ๋ ํธ๋ก ์์ผ์ด๋</h1>
<p>์ ์ค์ ๊ฒ์ด๋จธ๊ฐ ๋์ด ๋ช
์์ ์ ๋น์ ์ด๋ฆ์ ์๊ธฐ์ธ์</p>
</div>
<h2 style="margin-bottom: 24px; color: var(--m-text);">๐ฎ ๊ฒ์ ๋ชฉ๋ก</h2>
<div class="game-grid">
<?php foreach ($games as $game): ?>
<div class="game-card">
<div class="game-title"><?php echo htmlspecialchars($game['title']); ?></div>
<span class="game-category"><?php echo htmlspecialchars($game['category']); ?></span>
<p style="color: var(--m-text-muted); margin: 12px 0;">
<?php echo htmlspecialchars($game['description']); ?>
</p>
<a href="/arcade/game/<?php echo $game['id']; ?>"
style="display: inline-block; padding: 8px 16px; background: var(--m-primary); color: white; border-radius: 8px; text-decoration: none;">
๋ฆฌ๋๋ณด๋ ๋ณด๊ธฐ
</a>
</div>
<?php endforeach; ?>
</div>
<h2 style="margin-bottom: 24px; color: var(--m-text);">๐ ์ต๊ทผ ๋ฑ๋ก๋ ํ์ด์ค์ฝ์ด</h2>
<div class="recent-scores">
<?php if (empty($recentScores)): ?>
<p style="color: var(--m-text-muted); text-align: center;">์์ง ๋ฑ๋ก๋ ๊ธฐ๋ก์ด ์์ต๋๋ค.</p>
<?php else: ?>
<?php foreach ($recentScores as $score): ?>
<div class="score-item">
<div>
<strong style="color: var(--m-text);"><?php echo htmlspecialchars($score['game_title']); ?></strong>
<span style="color: var(--m-text-muted); margin-left: 12px;">
<?php echo htmlspecialchars($score['nickname']); ?>
</span>
</div>
<div style="font-weight: 700; color: var(--m-primary);">
<?php echo number_format($score['score']); ?>์
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
### 3) ์์
์๋ฃ ํ์ธ
๋ธ๋ผ์ฐ์ ์์ `/arcade`๋ก ์ ์ํ์ฌ:
- ๋ค์จ ๊ทธ๋ผ๋ฐ์ด์
ํ์ด๋ก ์น์
- 5๊ฐ ์ํ ๊ฒ์ ์นด๋ ๊ทธ๋ฆฌ๋
- ๋น ์ต๊ทผ ํ์ด์ค์ฝ์ด ์น์
์ด ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ฉ์ธ ํ์ด์ง `/arcade`๊ฐ ์์ฑ๋๊ณ , SQLite DB์ games/scores ํ ์ด๋ธ๊ณผ ์ํ ๊ฒ์ 5๊ฐ๊ฐ ์๋ ์ฝ์ ๋ฉ๋๋ค. Modern Shell์ด ์ ์ฉ๋์ด ์ฌ์ดํธ ๋ค๋น๊ฒ์ด์ ๊ณผ ํธํฐ๊ฐ ํ์๋๋ฉฐ, ๋ ํธ๋ก ์์ผ์ด๋ ๊ฐ์ฑ์ ํ์ด๋ก ์น์ ๊ณผ ๊ฒ์ ์นด๋ ๊ทธ๋ฆฌ๋๊ฐ ๋ ๋๋ง๋ฉ๋๋ค.
## Step 2: ๊ฒ์๋ณ ๋ฆฌ๋๋ณด๋ ํ์ด์ง & ํ์ด์ค์ฝ์ด ์ ์ถ ํผ
### 1) app/modules/arcade/game/[id]/index.php ์์ฑ
๋์ segment๋ฅผ ํ์ฉํ ๊ฒ์๋ณ ํ์ด์ง๋ฅผ ๋ง๋์ธ์:
```php
<?php
include_once('./_common.php');
$game_id = (int)$_GET['id'];
if ($game_id <= 0) {
alert('์๋ชป๋ ์ ๊ทผ์
๋๋ค.');
goto_url('/arcade');
}
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/arcade.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ๊ฒ์ ์ ๋ณด ์กฐํ
$stmt = $db->prepare("SELECT * FROM games WHERE id = ?");
$stmt->execute([$game_id]);
$game = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$game) {
alert('์กด์ฌํ์ง ์๋ ๊ฒ์์
๋๋ค.');
goto_url('/arcade');
}
// TOP 10 ๋ฆฌ๋๋ณด๋ (์น์ธ๋ ๊ธฐ๋ก๋ง)
$stmt = $db->prepare("
SELECT * FROM scores
WHERE game_id = ? AND status = 'approved'
ORDER BY score DESC
LIMIT 10
");
$stmt->execute([$game_id]);
$leaderboard = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ์ ์ ์ ์ถ ์ฒ๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['submit_score'])) {
$nickname = trim($_POST['nickname']);
$score = (int)$_POST['score'];
$ip = $_SERVER['REMOTE_ADDR'];
// ์ ํจ์ฑ ๊ฒ์ฆ
if (empty($nickname) || $score <= 0) {
alert('๋๋ค์๊ณผ ์ ์๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์
๋ ฅํ์ธ์.');
} else {
// IP ๊ธฐ๋ฐ ์ค๋ณต ์ ์ถ ๋ฐฉ์ง (1์๊ฐ ์ฟจ๋ค์ด)
$stmt = $db->prepare("
SELECT COUNT(*) FROM scores
WHERE game_id = ? AND ip = ? AND created_at > datetime('now', '-1 hour')
");
$stmt->execute([$game_id, $ip]);
if ($stmt->fetchColumn() > 0) {
alert('1์๊ฐ์ ํ ๋ฒ๋ง ์ ์ถํ ์ ์์ต๋๋ค.');
} else {
// ์คํฌ๋ฆฐ์ท ์
๋ก๋ ์ฒ๋ฆฌ
$screenshot = null;
if (!empty($_FILES['screenshot']['tmp_name'])) {
$upload_dir = G5_DATA_PATH.'/modules/arcade-screenshots';
@mkdir($upload_dir, 0707, true);
$ext = pathinfo($_FILES['screenshot']['name'], PATHINFO_EXTENSION);
$filename = uniqid('screenshot_').'.'.$ext;
if (move_uploaded_file($_FILES['screenshot']['tmp_name'], $upload_dir.'/'.$filename)) {
$screenshot = $filename;
}
}
// DB ์ฝ์
$stmt = $db->prepare("
INSERT INTO scores (game_id, nickname, score, screenshot, ip)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([$game_id, $nickname, $score, $screenshot, $ip]);
alert('ํ์ด์ค์ฝ์ด๊ฐ ๋ฑ๋ก๋์์ต๋๋ค. ๊ด๋ฆฌ์ ์น์ธ ํ ๋ฆฌ๋๋ณด๋์ ๋ฐ์๋ฉ๋๋ค.');
goto_url('/arcade/game/'.$game_id);
}
}
}
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<style>
.game-header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
padding: 40px 20px;
border-radius: var(--m-radius-lg);
margin-bottom: 32px;
color: white;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 32px;
}
.leaderboard-table th {
background: var(--m-primary);
color: white;
padding: 12px;
text-align: left;
}
.leaderboard-table td {
padding: 12px;
border-bottom: 1px solid var(--m-border, rgba(0,0,0,0.1));
color: var(--m-text);
}
.rank-medal {
font-size: 1.5rem;
margin-right: 8px;
}
.submit-form {
background: var(--m-surface);
padding: 24px;
border-radius: var(--m-radius-lg);
box-shadow: var(--m-shadow);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--m-text);
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--m-border, #ddd);
border-radius: 8px;
background: var(--m-surface);
color: var(--m-text);
}
.submit-btn {
background: var(--m-primary);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
width: 100%;
}
</style>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="game-header">
<h1 style="margin: 0 0 8px;"><?php echo htmlspecialchars($game['title']); ?></h1>
<p style="margin: 0; opacity: 0.9;"><?php echo htmlspecialchars($game['description']); ?></p>
</div>
<h2 style="margin-bottom: 16px; color: var(--m-text);">๐ ๋ฆฌ๋๋ณด๋ TOP 10</h2>
<table class="leaderboard-table">
<thead>
<tr>
<th>์์</th>
<th>๋๋ค์</th>
<th>์ ์</th>
<th>๋ฑ๋ก์ผ</th>
</tr>
</thead>
<tbody>
<?php if (empty($leaderboard)): ?>
<tr><td colspan="4" style="text-align: center; color: var(--m-text-muted);">์์ง ๋ฑ๋ก๋ ๊ธฐ๋ก์ด ์์ต๋๋ค.</td></tr>
<?php else: ?>
<?php foreach ($leaderboard as $idx => $row): ?>
<tr>
<td>
<?php
$medals = ['๐ฅ', '๐ฅ', '๐ฅ'];
echo $idx < 3 ? '<span class="rank-medal">'.$medals[$idx].'</span>' : ($idx + 1).'์';
?>
</td>
<td><?php echo htmlspecialchars($row['nickname']); ?></td>
<td style="font-weight: 700; color: var(--m-primary);"><?php echo number_format($row['score']); ?>์ </td>
<td style="color: var(--m-text-muted);"><?php echo date('Y-m-d', strtotime($row['created_at'])); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<h2 style="margin-bottom: 16px; color: var(--m-text);">๐ ํ์ด์ค์ฝ์ด ๋ฑ๋ก</h2>
<form method="post" enctype="multipart/form-data" class="submit-form">
<div class="form-group">
<label>๋๋ค์</label>
<input type="text" name="nickname" required maxlength="20" placeholder="๊ฒ์ด๋จธ ํ๊ทธ๋ฅผ ์
๋ ฅํ์ธ์">
</div>
<div class="form-group">
<label>์ ์</label>
<input type="number" name="score" required min="1" placeholder="ํ๋ํ ์ ์">
</div>
<div class="form-group">
<label>์คํฌ๋ฆฐ์ท (์ ํ)</label>
<input type="file" name="screenshot" accept="image/*">
<small style="color: var(--m-text-muted);">๊ด๋ฆฌ์ ๊ฒ์ฆ์ ์ํด ๊ฒ์ ํ๋ฉด ์บก์ฒ๋ฅผ ์
๋ก๋ํ์ธ์</small>
</div>
<button type="submit" name="submit_score" class="submit-btn">๐ ํ์ด์ค์ฝ์ด ๋ฑ๋ก</button>
</form>
<div style="margin-top: 24px; text-align: center;">
<a href="/arcade" style="color: var(--m-primary);">โ ๊ฒ์ ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
### 2) ์์
์๋ฃ ํ์ธ
- `/arcade/game/1` ์ ์ ์ ๊ฐค๋ฌ๊ทธ ๋ฆฌ๋๋ณด๋ ํ์
- ํ์ด์ค์ฝ์ด ์ ์ถ ํผ์์ ๋๋ค์/์ ์/์คํฌ๋ฆฐ์ท ์
๋ ฅ ๊ฐ๋ฅ
- ์ ์ถ ํ "๊ด๋ฆฌ์ ์น์ธ ๋๊ธฐ" ๋ฉ์์ง ํ์
- 1์๊ฐ ์ด๋ด ์ฌ์ ์ถ ์ ์ฟจ๋ค์ด ๊ฒฝ๊ณ
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋์ ๋ผ์ฐํ ์ผ๋ก ๊ฒ์๋ณ ํ์ด์ง๊ฐ ์์ฑ๋๋ฉฐ, ๋ฆฌ๋๋ณด๋ ํ ์ด๋ธ๊ณผ ํ์ด์ค์ฝ์ด ์ ์ถ ํผ์ด ํ์๋ฉ๋๋ค. ์คํฌ๋ฆฐ์ท ์ ๋ก๋๊ฐ ์ฒ๋ฆฌ๋๊ณ , IP ๊ธฐ๋ฐ ์ค๋ณต ์ ์ถ ๋ฐฉ์ง ๋ก์ง์ด ์๋ํฉ๋๋ค. ์ ์ถ๋ ์ ์๋ pending ์ํ๋ก DB์ ์ ์ฅ๋ฉ๋๋ค.
## Step 3: ๊ด๋ฆฌ์ ๊ฒ์ฆ ์์คํ
& ์น์ธ ๋์๋ณด๋
### 1) app/modules/arcade/admin/index.php ์์ฑ
๊ด๋ฆฌ์ ์ ์ฉ ๊ฒ์ฆ ํ์ด์ง๋ฅผ ๋ง๋์ธ์:
```php
<?php
include_once('./_common.php');
// ๊ด๋ฆฌ์ ๊ถํ ์ฒดํฌ
if (!$is_member || $member['mb_level'] < 10) {
alert('๊ด๋ฆฌ์๋ง ์ ๊ทผํ ์ ์์ต๋๋ค.');
goto_url('/arcade');
}
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/arcade.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ์น์ธ/๊ฑฐ๋ถ ์ฒ๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$score_id = (int)$_POST['score_id'];
$action = $_POST['action'];
$note = trim($_POST['admin_note'] ?? '');
if (in_array($action, ['approved', 'rejected'])) {
$stmt = $db->prepare("UPDATE scores SET status = ?, admin_note = ? WHERE id = ?");
$stmt->execute([$action, $note, $score_id]);
alert($action === 'approved' ? '์น์ธ๋์์ต๋๋ค.' : '๊ฑฐ๋ถ๋์์ต๋๋ค.');
goto_url('/arcade/admin');
}
}
// ๋๊ธฐ ์ค์ธ ์ ์ ๋ชฉ๋ก
$pendingScores = $db->query("
SELECT s.*, g.title as game_title
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.status = 'pending'
ORDER BY s.created_at DESC
")->fetchAll(PDO::FETCH_ASSOC);
// ์ต๊ทผ ์ฒ๋ฆฌ๋ ๊ธฐ๋ก
$recentProcessed = $db->query("
SELECT s.*, g.title as game_title
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.status IN ('approved', 'rejected')
ORDER BY s.created_at DESC
LIMIT 20
")->fetchAll(PDO::FETCH_ASSOC);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<style>
.admin-header {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
padding: 40px 20px;
border-radius: var(--m-radius-lg);
margin-bottom: 32px;
color: white;
}
.pending-card {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--m-shadow);
}
.screenshot-preview {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
margin: 12px 0;
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 16px;
}
.approve-btn {
flex: 1;
padding: 10px;
background: #10b981;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.reject-btn {
flex: 1;
padding: 10px;
background: #ef4444;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.status-approved { background: #d1fae5; color: #065f46; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.status-pending { background: #fef3c7; color: #92400e; }
</style>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="admin-header">
<h1 style="margin: 0 0 8px;">๐ก๏ธ ๊ด๋ฆฌ์ ๊ฒ์ฆ ๋์๋ณด๋</h1>
<p style="margin: 0; opacity: 0.9;">ํ์ด์ค์ฝ์ด ์น์ธ ๋ฐ ๋ถ์ ๊ธฐ๋ก ๊ด๋ฆฌ</p>
</div>
<h2 style="margin-bottom: 16px; color: var(--m-text);">โณ ์น์ธ ๋๊ธฐ ์ค (<?php echo count($pendingScores); ?>๊ฑด)</h2>
<?php if (empty($pendingScores)): ?>
<p style="color: var(--m-text-muted); text-align: center; padding: 40px 0;">๋๊ธฐ ์ค์ธ ๊ธฐ๋ก์ด ์์ต๋๋ค.</p>
<?php else: ?>
<?php foreach ($pendingScores as $score): ?>
<div class="pending-card">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<div>
<h3 style="margin: 0 0 4px; color: var(--m-text);"><?php echo htmlspecialchars($score['game_title']); ?></h3>
<p style="margin: 0; color: var(--m-text-muted);">
๋๋ค์: <strong><?php echo htmlspecialchars($score['nickname']); ?></strong> |
์ ์: <strong style="color: var(--m-primary);"><?php echo number_format($score['score']); ?>์ </strong>
</p>
</div>
<span class="status-badge status-pending">๋๊ธฐ์ค</span>
</div>
<?php if ($score['screenshot']): ?>
<img src="/data/modules/arcade-screenshots/<?php echo htmlspecialchars($score['screenshot']); ?>"
class="screenshot-preview" alt="์คํฌ๋ฆฐ์ท">
<?php else: ?>
<p style="color: var(--m-text-muted); font-style: italic;">์คํฌ๋ฆฐ์ท ์์</p>
<?php endif; ?>
<form method="post" style="margin-top: 16px;">
<input type="hidden" name="score_id" value="<?php echo $score['id']; ?>">
<input type="text" name="admin_note" placeholder="๊ด๋ฆฌ์ ๋ฉ๋ชจ (์ ํ)"
style="width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid var(--m-border, #ddd); border-radius: 8px; background: var(--m-surface); color: var(--m-text);">
<div class="action-buttons">
<button type="submit" name="action" value="approved" class="approve-btn">โ
์น์ธ</button>
<button type="submit" name="action" value="rejected" class="reject-btn">โ ๊ฑฐ๋ถ</button>
</div>
</form>
</div>
<?php endforeach; ?>
<?php endif; ?>
<h2 style="margin: 48px 0 16px; color: var(--m-text);">๐ ์ต๊ทผ ์ฒ๋ฆฌ ๋ด์ญ</h2>
<table class="leaderboard-table">
<thead>
<tr>
<th>๊ฒ์</th>
<th>๋๋ค์</th>
<th>์ ์</th>
<th>์ํ</th>
<th>์ฒ๋ฆฌ์ผ</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentProcessed as $row): ?>
<tr>
<td><?php echo htmlspecialchars($row['game_title']); ?></td>
<td><?php echo htmlspecialchars($row['nickname']); ?></td>
<td><?php echo number_format($row['score']); ?>์ </td>
<td>
<span class="status-badge status-<?php echo $row['status']; ?>">
<?php echo $row['status'] === 'approved' ? '์น์ธ' : '๊ฑฐ๋ถ'; ?>
</span>
</td>
<td style="color: var(--m-text-muted);"><?php echo date('Y-m-d H:i', strtotime($row['created_at'])); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div style="margin-top: 24px; text-align: center;">
<a href="/arcade" style="color: var(--m-primary);">โ ๋ฉ์ธ์ผ๋ก ๋์๊ฐ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
### 2) ์์
์๋ฃ ํ์ธ
- ๊ด๋ฆฌ์ ๊ณ์ ์ผ๋ก `/arcade/admin` ์ ์
- ๋๊ธฐ ์ค์ธ ์ ์ ์นด๋์ ์คํฌ๋ฆฐ์ท ๋ฏธ๋ฆฌ๋ณด๊ธฐ
- ์น์ธ/๊ฑฐ๋ถ ๋ฒํผ ํด๋ฆญ ์ ์ํ ๋ณ๊ฒฝ
- ์ต๊ทผ ์ฒ๋ฆฌ ๋ด์ญ ํ
์ด๋ธ์ ํ์คํ ๋ฆฌ ํ์
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๋ฆฌ์ ์ ์ฉ ๊ฒ์ฆ ๋์๋ณด๋๊ฐ ์์ฑ๋๋ฉฐ, pending ์ํ์ ์ ์ ๋ชฉ๋ก์ด ์นด๋ ํํ๋ก ํ์๋ฉ๋๋ค. ์คํฌ๋ฆฐ์ท์ด ์์ ๊ฒฝ์ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๊ฐ ๋ ๋๋ง๋๊ณ , ์น์ธ/๊ฑฐ๋ถ ๋ฒํผ์ผ๋ก ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค. ์ฒ๋ฆฌ๋ ๊ธฐ๋ก์ ํ๋จ ํ ์ด๋ธ์ ํ์คํ ๋ฆฌ๋ก ๋จ์ต๋๋ค.
## Step 4: ๊ฒ์ด๋จธ ํ๋กํ ํ์ด์ง & ์ ์ ํต๊ณ
### 1) app/modules/arcade/profile/[nickname]/index.php ์์ฑ
๋๋ค์๋ณ ํ๋กํ ํ์ด์ง๋ฅผ ๋ง๋์ธ์:
```php
<?php
include_once('./_common.php');
$nickname = trim($_GET['nickname'] ?? '');
if (empty($nickname)) {
alert('์๋ชป๋ ์ ๊ทผ์
๋๋ค.');
goto_url('/arcade');
}
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/arcade.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํด๋น ๋๋ค์์ ์ ์ฒด ์น์ธ๋ ๊ธฐ๋ก
$stmt = $db->prepare("
SELECT s.*, g.title as game_title, g.category
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.nickname = ? AND s.status = 'approved'
ORDER BY s.created_at DESC
");
$stmt->execute([$nickname]);
$records = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($records)) {
alert('ํด๋น ๊ฒ์ด๋จธ์ ๊ธฐ๋ก์ด ์์ต๋๋ค.');
goto_url('/arcade');
}
// ํต๊ณ ๊ณ์ฐ
$totalScore = array_sum(array_column($records, 'score'));
$totalGames = count(array_unique(array_column($records, 'game_id')));
$avgScore = $totalGames > 0 ? round($totalScore / $totalGames) : 0;
// ๋ฉ๋ฌ ์ง๊ณ
$medals = ['gold' => 0, 'silver' => 0, 'bronze' => 0];
foreach ($records as $record) {
$stmt = $db->prepare("
SELECT COUNT(*) as rank FROM scores
WHERE game_id = ? AND status = 'approved' AND score > ?
");
$stmt->execute([$record['game_id'], $record['score']]);
$rank = $stmt->fetchColumn() + 1;
if ($rank === 1) $medals['gold']++;
elseif ($rank === 2) $medals['silver']++;
elseif ($rank === 3) $medals['bronze']++;
}
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<style>
.profile-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
border-radius: var(--m-radius-lg);
margin-bottom: 32px;
color: white;
text-align: center;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin: 0 auto 16px;
}
.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);
text-align: center;
box-shadow: var(--m-shadow);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--m-primary);
margin-bottom: 8px;
}
.stat-label {
color: var(--m-text-muted);
font-size: 0.9rem;
}
.record-timeline {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 24px;
box-shadow: var(--m-shadow);
}
.timeline-item {
padding: 16px 0;
border-bottom: 1px solid var(--m-border, rgba(0,0,0,0.1));
}
.timeline-item:last-child {
border-bottom: none;
}
</style>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="profile-header">
<div class="profile-avatar">๐ฎ</div>
<h1 style="margin: 0 0 8px;"><?php echo htmlspecialchars($nickname); ?></h1>
<p style="margin: 0; opacity: 0.9;">๋ ํธ๋ก ๊ฒ์ด๋จธ</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value"><?php echo number_format($totalScore); ?></div>
<div class="stat-label">์ด ๋์ ์ ์</div>
</div>
<div class="stat-card">
<div class="stat-value"><?php echo $totalGames; ?>๊ฐ</div>
<div class="stat-label">ํ๋ ์ดํ ๊ฒ์</div>
</div>
<div class="stat-card">
<div class="stat-value"><?php echo number_format($avgScore); ?></div>
<div class="stat-label">ํ๊ท ์ ์</div>
</div>
<div class="stat-card">
<div class="stat-value">
๐ฅ<?php echo $medals['gold']; ?>
๐ฅ<?php echo $medals['silver']; ?>
๐ฅ<?php echo $medals['bronze']; ?>
</div>
<div class="stat-label">ํ๋ ๋ฉ๋ฌ</div>
</div>
</div>
<h2 style="margin-bottom: 16px; color: var(--m-text);">๐ ํ๋ ์ด ํ์คํ ๋ฆฌ</h2>
<div class="record-timeline">
<?php foreach ($records as $record): ?>
<div class="timeline-item">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="color: var(--m-text); font-size: 1.1rem;">
<?php echo htmlspecialchars($record['game_title']); ?>
</strong>
<span style="margin-left: 12px; padding: 2px 8px; background: var(--m-primary); color: white; border-radius: 8px; font-size: 0.8rem;">
<?php echo htmlspecialchars($record['category']); ?>
</span>
</div>
<div style="text-align: right;">
<div style="font-weight: 700; color: var(--m-primary); font-size: 1.2rem;">
<?php echo number_format($record['score']); ?>์
</div>
<div style="color: var(--m-text-muted); font-size: 0.85rem;">
<?php echo date('Y-m-d', strtotime($record['created_at'])); ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div style="margin-top: 24px; text-align: center;">
<a href="/arcade" style="color: var(--m-primary);">โ ๋ฉ์ธ์ผ๋ก ๋์๊ฐ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
### 2) ๊ฒ์๋ณ ๋ฆฌ๋๋ณด๋์ ํ๋กํ ๋งํฌ ์ถ๊ฐ
`app/modules/arcade/game/[id]/index.php`์ ๋ฆฌ๋๋ณด๋ ํ
์ด๋ธ ๋ถ๋ถ์ ์์ :
```php
<td>
<a href="/arcade/profile/<?php echo urlencode($row['nickname']); ?>"
style="color: var(--m-primary); text-decoration: none; font-weight: 600;">
<?php echo htmlspecialchars($row['nickname']); ?>
</a>
</td>
```
### 3) ์์
์๋ฃ ํ์ธ
- ๋ฆฌ๋๋ณด๋์์ ๋๋ค์ ํด๋ฆญ ์ ํ๋กํ ํ์ด์ง ์ด๋
- ์ด ์ ์, ํ๋ ์ด ๊ฒ์ ์, ํ๊ท ์ ์ ํต๊ณ ํ์
- ๋ฉ๋ฌ ์ง๊ณ (1์=๊ธ, 2์=์, 3์=๋)
- ํ๋ ์ด ํ์คํ ๋ฆฌ ํ์๋ผ์ธ ๋ ๋๋ง
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋๋ค์๋ณ ํ๋กํ ํ์ด์ง๊ฐ ์์ฑ๋๋ฉฐ, ํด๋น ๊ฒ์ด๋จธ์ ์ ์ฒด ์น์ธ๋ ๊ธฐ๋ก์ ๊ธฐ๋ฐ์ผ๋ก ํต๊ณ๊ฐ ๊ณ์ฐ๋ฉ๋๋ค. ๋ฉ๋ฌ ์ง๊ณ ๋ก์ง์ด ๊ฐ ๊ฒ์๋ณ ์์๋ฅผ ์ค์๊ฐ ๊ณ์ฐํ์ฌ ํ์ํ๊ณ , ํ๋ ์ด ํ์คํ ๋ฆฌ๊ฐ ํ์๋ผ์ธ ํํ๋ก ๋ ๋๋ง๋ฉ๋๋ค.
## Step 5: ์ ์ฒด ๋ญํน ํตํฉ ํ์ด์ง & ํํฐ ์์คํ
### 1) app/modules/arcade/rankings/index.php ์์ฑ
์ ์ฒด ๊ฒ์ด๋จธ ํตํฉ ๋ญํน์ ๋ณด์ฌ์ฃผ๋ ํ์ด์ง๋ฅผ ๋ง๋์ธ์:
```php
<?php
include_once('./_common.php');
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/arcade.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํํฐ ํ๋ผ๋ฏธํฐ
$period = $_GET['period'] ?? 'all';
$category = $_GET['category'] ?? 'all';
// ๊ธฐ๊ฐ ํํฐ SQL
$periodSql = '';
if ($period === 'week') {
$periodSql = "AND s.created_at > datetime('now', '-7 days')";
} elseif ($period === 'month') {
$periodSql = "AND s.created_at > datetime('now', '-30 days')";
}
// ์นดํ
๊ณ ๋ฆฌ ํํฐ SQL
$categorySql = '';
if ($category !== 'all') {
$categorySql = "AND g.category = ".$db->quote($category);
}
// ๋๋ค์๋ณ ์ด์ ์ง๊ณ
$rankings = $db->query("
SELECT
s.nickname,
SUM(s.score) as total_score,
COUNT(DISTINCT s.game_id) as games_played,
MAX(s.created_at) as last_played
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.status = 'approved' {$periodSql} {$categorySql}
GROUP BY s.nickname
ORDER BY total_score DESC
LIMIT 50
")->fetchAll(PDO::FETCH_ASSOC);
// ์นดํ
๊ณ ๋ฆฌ ๋ชฉ๋ก
$categories = $db->query("SELECT DISTINCT category FROM games ORDER BY category")->fetchAll(PDO::FETCH_COLUMN);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<style>
.filter-bar {
background: var(--m-surface);
padding: 20px;
border-radius: var(--m-radius-lg);
margin-bottom: 24px;
display: flex;
gap: 16px;
flex-wrap: wrap;
box-shadow: var(--m-shadow);
}
.filter-group {
display: flex;
gap: 8px;
align-items: center;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid var(--m-border, #ddd);
background: var(--m-surface);
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: var(--m-primary);
}
.filter-btn.active {
background: var(--m-primary);
color: white;
border-color: var(--m-primary);
}
.ranking-card {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 16px 20px;
margin-bottom: 12px;
box-shadow: var(--m-shadow);
display: flex;
align-items: center;
gap: 20px;
}
.rank-number {
font-size: 2rem;
font-weight: 900;
color: var(--m-text-muted);
min-width: 60px;
text-align: center;
}
.rank-number.top3 {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.player-info {
flex: 1;
}
.player-name {
font-size: 1.3rem;
font-weight: 700;
color: var(--m-text);
margin-bottom: 4px;
}
.player-stats {
color: var(--m-text-muted);
font-size: 0.9rem;
}
.total-score {
font-size: 1.5rem;
font-weight: 700;
color: var(--m-primary);
text-align: right;
}
</style>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<h1 style="margin-bottom: 24px; color: var(--m-text);">๐ ์ ์ฒด ๋ญํน</h1>
<div class="filter-bar">
<div class="filter-group">
<span style="color: var(--m-text); font-weight: 600;">๊ธฐ๊ฐ:</span>
<a href="?period=all&category=<?php echo $category; ?>"
class="filter-btn <?php echo $period === 'all' ? 'active' : ''; ?>">์ ์ฒด</a>
<a href="?period=week&category=<?php echo $category; ?>"
class="filter-btn <?php echo $period === 'week' ? 'active' : ''; ?>">์ฃผ๊ฐ</a>
<a href="?period=month&category=<?php echo $category; ?>"
class="filter-btn <?php echo $period === 'month' ? 'active' : ''; ?>">์๊ฐ</a>
</div>
<div class="filter-group">
<span style="color: var(--m-text); font-weight: 600;">์นดํ
๊ณ ๋ฆฌ:</span>
<a href="?period=<?php echo $period; ?>&category=all"
class="filter-btn <?php echo $category === 'all' ? 'active' : ''; ?>">์ ์ฒด</a>
<?php foreach ($categories as $cat): ?>
<a href="?period=<?php echo $period; ?>&category=<?php echo urlencode($cat); ?>"
class="filter-btn <?php echo $category === $cat ? 'active' : ''; ?>">
<?php echo htmlspecialchars($cat); ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php if (empty($rankings)): ?>
<p style="text-align: center; color: var(--m-text-muted); padding: 40px 0;">๋ญํน ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.</p>
<?php else: ?>
<?php foreach ($rankings as $idx => $player): ?>
<div class="ranking-card">
<div class="rank-number <?php echo $idx < 3 ? 'top3' : ''; ?>">
<?php
if ($idx === 0) echo '๐ฅ';
elseif ($idx === 1) echo '๐ฅ';
elseif ($idx === 2) echo '๐ฅ';
else echo ($idx + 1);
?>
</div>
<div class="player-info">
<div class="player-name">
<a href="/arcade/profile/<?php echo urlencode($player['nickname']); ?>"
style="color: var(--m-text); text-decoration: none;">
<?php echo htmlspecialchars($player['nickname']); ?>
</a>
</div>
<div class="player-stats">
<?php echo $player['games_played']; ?>๊ฐ ๊ฒ์ ํ๋ ์ด |
์ต๊ทผ ํ๋: <?php echo date('Y-m-d', strtotime($player['last_played'])); ?>
</div>
</div>
<div class="total-score">
<?php echo number_format($player['total_score']); ?>์
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<div style="margin-top: 32px; text-align: center;">
<a href="/arcade" style="color: var(--m-primary); font-weight: 600;">โ ๋ฉ์ธ์ผ๋ก ๋์๊ฐ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
### 2) ๋ฉ์ธ ํ์ด์ง์ ๋ญํน ๋งํฌ ์ถ๊ฐ
`app/modules/arcade/index.php`์ ํ์ด๋ก ์น์
์๋์ ์ถ๊ฐ:
```php
<div style="text-align: center; margin-bottom: 32px;">
<a href="/arcade/rankings"
style="display: inline-block; padding: 12px 24px; background: var(--m-primary); color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">
๐ ์ ์ฒด ๋ญํน ๋ณด๊ธฐ
</a>
</div>
```
### 3) ์์
์๋ฃ ํ์ธ
- `/arcade/rankings` ์ ์ ์ ์ ์ฒด ๊ฒ์ด๋จธ ์ด์ ๋ญํน ํ์
- ๊ธฐ๊ฐ ํํฐ (์ ์ฒด/์ฃผ๊ฐ/์๊ฐ) ๋์ ํ์ธ
- ์นดํ
๊ณ ๋ฆฌ ํํฐ (์ ์ฒด/์ํ
/ํผ์ฆ/๊ฒฉํฌ/๋ ์ด์ฑ) ๋์ ํ์ธ
- TOP 3 ๋ฉ๋ฌ ํ์ ๋ฐ ๊ทธ๋ผ๋ฐ์ด์
ํจ๊ณผ
- ๋๋ค์ ํด๋ฆญ ์ ํ๋กํ ํ์ด์ง ์ด๋
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ ์ฒด ๋ญํน ํ์ด์ง๊ฐ ์์ฑ๋๋ฉฐ, ๋๋ค์๋ณ ์ด์ ์ ์ง๊ณํ์ฌ ์์๋ฅผ ํ์ํฉ๋๋ค. ๊ธฐ๊ฐ ๋ฐ ์นดํ ๊ณ ๋ฆฌ ํํฐ๊ฐ URL ํ๋ผ๋ฏธํฐ๋ก ๋์ํ๊ณ , SQL ์ฟผ๋ฆฌ์ ๋์ ์ผ๋ก ๋ฐ์๋ฉ๋๋ค. TOP 3๋ ๋ฉ๋ฌ ์ด๋ชจ์ง์ ๊ทธ๋ผ๋ฐ์ด์ ํ ์คํธ๋ก ๊ฐ์กฐ ํ์๋ฉ๋๋ค.
## Step 6: ์ ๊ธฐ๋ก ์๋ฆผ ๋ฐฐ๋ & ์
์ ์์คํ
### 1) ์ ๊ธฐ๋ก ์๋ฆผ ๋ฐฐ๋ ์ถ๊ฐ
`app/modules/arcade/index.php`์ ๊ฒ์ ๋ชฉ๋ก ์์ ๋ค์ ์ฝ๋๋ฅผ ์ถ๊ฐ:
```php
// ์ต๊ทผ 24์๊ฐ ๋ด ๊ฐฑ์ ๋ ์ ๊ธฐ๋ก ์กฐํ
$newRecords = $db->query("
WITH RankedScores AS (
SELECT
s.*,
g.title as game_title,
ROW_NUMBER() OVER (PARTITION BY s.game_id ORDER BY s.score DESC) as rank
FROM scores s
JOIN games g ON s.game_id = g.id
WHERE s.status = 'approved'
)
SELECT * FROM RankedScores
WHERE rank = 1 AND created_at > datetime('now', '-24 hours')
ORDER BY created_at DESC
LIMIT 3
")->fetchAll(PDO::FETCH_ASSOC);
?>
<!-- ํ์ด๋ก ์น์
๋ฐ๋ก ์๋์ ์ถ๊ฐ -->
<?php if (!empty($newRecords)): ?>
<div style="background: linear-gradient(135deg, #ffeaa7 0%, #fdcb6e 100%); padding: 20px; border-radius: var(--m-radius-lg); margin-bottom: 32px; box-shadow: var(--m-shadow);">
<h3 style="margin: 0 0 12px; color: #2d3436; display: flex; align-items: center; gap: 8px;">
๐ฅ ์ต๊ทผ 24์๊ฐ ์ ๊ธฐ๋ก!
</h3>
<?php foreach ($newRecords as $record): ?>
<div style="background: rgba(255,255,255,0.9); padding: 12px 16px; border-radius: 8px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="color: #2d3436;"><?php echo htmlspecialchars($record['game_title']); ?></strong>
<span style="color: #636e72; margin-left: 12px;">
<?php echo htmlspecialchars($record['nickname']); ?>๋์ด 1์ ๋ฌ์ฑ!
</span>
</div>
<span style="font-weight: 700; color: #d63031; font-size: 1.1rem;">
<?php echo number_format($record['score']); ?>์
</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
```
### 2) ์
์ ์์คํ
๊ตฌํ
`app/modules/arcade/profile/[nickname]/index.php`์ ์
์ ๊ณ์ฐ ๋ก์ง ์ถ๊ฐ:
```php
// ๊ธฐ์กด ํต๊ณ ๊ณ์ฐ ์๋์ ์ถ๊ฐ
// ์
์ ์ฒดํฌ
$achievements = [];
// ์ฒซ 1์ ๋ฌ์ฑ
if ($medals['gold'] >= 1) {
$achievements[] = [
'icon' => '๐',
'title' => '์ฒซ ์น๋ฆฌ',
'description' => '์ฒซ 1์ ๊ธฐ๋ก ๋ฌ์ฑ',
'rarity' => 'common'
];
}
// 3๊ด์
if ($medals['gold'] >= 3) {
$achievements[] = [
'icon' => '๐',
'title' => '3๊ด์',
'description' => '3๊ฐ ๊ฒ์์์ 1์ ๋ฌ์ฑ',
'rarity' => 'rare'
];
}
// 100๋ง์ ๋ํ
if ($totalScore >= 1000000) {
$achievements[] = [
'icon' => '๐',
'title' => '๋ฐ๋ฆฌ์ธ ๊ฒ์ด๋จธ',
'description' => '์ด ๋์ ์ ์ 100๋ง์ ๋ํ',
'rarity' => 'epic'
];
}
// ์ฌ๋ผ์ด๋ (5๊ฐ ์ด์ ๊ฒ์ ํ๋ ์ด)
if ($totalGames >= 5) {
$achievements[] = [
'icon' => '๐ฎ',
'title' => '์ฌ๋ผ์ด๋',
'description' => '5๊ฐ ์ด์์ ๊ฒ์ ํ๋ ์ด',
'rarity' => 'common'
];
}
// ํผํํธ ๊ฒ์ด๋จธ (๋ชจ๋ ๊ฒ์ 1์)
$allGamesCount = $db->query("SELECT COUNT(*) FROM games")->fetchColumn();
if ($medals['gold'] >= $allGamesCount && $allGamesCount > 0) {
$achievements[] = [
'icon' => 'โญ',
'title' => 'ํผํํธ ๊ฒ์ด๋จธ',
'description' => '๋ชจ๋ ๊ฒ์์์ 1์ ๋ฌ์ฑ',
'rarity' => 'legendary'
];
}
```
ํต๊ณ ๊ทธ๋ฆฌ๋ ์๋์ ์
์ ์น์
์ถ๊ฐ:
```php
<?php if (!empty($achievements)): ?>
<h2 style="margin-bottom: 16px; color: var(--m-text);">๐
ํ๋ ์
์ </h2>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-bottom: 32px;">
<?php foreach ($achievements as $ach): ?>
<div style="background: var(--m-surface); padding: 16px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); text-align: center;
border: 2px solid <?php
echo $ach['rarity'] === 'legendary' ? '#ffd700' :
($ach['rarity'] === 'epic' ? '#a855f7' :
($ach['rarity'] === 'rare' ? '#3b82f6' : '#94a3b8'));
?>;">
<div style="font-size: 3rem; margin-bottom: 8px;"><?php echo $ach['icon']; ?></div>
<div style="font-weight: 700; color: var(--m-text); margin-bottom: 4px;"><?php echo $ach['title']; ?></div>
<div style="color: var(--m-text-muted); font-size: 0.9rem;"><?php echo $ach['description']; ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
```
### 3) ์์
์๋ฃ ํ์ธ
- ๋ฉ์ธ ํ์ด์ง์ ์ต๊ทผ 24์๊ฐ ์ ๊ธฐ๋ก ๋ฐฐ๋ ํ์
- ํ๋กํ ํ์ด์ง์ ์
์ ์นด๋ ๋ ๋๋ง
- ์
์ ๋ฑ๊ธ๋ณ ํ
๋๋ฆฌ ์์ (์ ์ค=๊ธ์, ์ํฝ=๋ณด๋ผ, ๋ ์ด=ํ๋, ์ผ๋ฐ=ํ์)
- ์กฐ๊ฑด ์ถฉ์กฑ ์ ์
์ ์๋ ํด์
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ฉ์ธ ํ์ด์ง์ ์ต๊ทผ 24์๊ฐ ๋ด ๊ฐฑ์ ๋ 1์ ๊ธฐ๋ก์ ๋ณด์ฌ์ฃผ๋ ์๋ฆผ ๋ฐฐ๋๊ฐ ์ถ๊ฐ๋ฉ๋๋ค. ํ๋กํ ํ์ด์ง์ ์ ์ ์์คํ ์ด ๊ตฌํ๋์ด, ํน์ ์กฐ๊ฑด(์ฒซ 1์, 3๊ด์, 100๋ง์ , ์ฌ๋ผ์ด๋, ํผํํธ ๊ฒ์ด๋จธ)์ ์ถฉ์กฑํ๋ฉด ์ ์ ์นด๋๊ฐ ์๋์ผ๋ก ํ์๋ฉ๋๋ค.
## Step 7: CRT ํจ๊ณผ & ๋ ํธ๋ก UI ํด๋ฆฌ์ฑ
### 1) CRT ์ค์บ๋ผ์ธ ํจ๊ณผ ์ถ๊ฐ
๋ชจ๋ ํ์ด์ง์ `<style>` ๋ธ๋ก์ ๋ค์ CSS๋ฅผ ์ถ๊ฐ:
```css
/* CRT ํจ๊ณผ ํ ๊ธ */
.crt-mode {
position: relative;
}
.crt-mode::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.1) 2px,
rgba(0, 0, 0, 0.1) 4px
);
pointer-events: none;
z-index: 9999;
animation: scanline 8s linear infinite;
}
.crt-mode::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none;
z-index: 9998;
}
@keyframes scanline {
0% { transform: translateY(0); }
100% { transform: translateY(100vh); }
}
/* ํฝ์
ํฐํธ ์ ์ฉ */
.pixel-text {
font-family: 'Courier New', monospace;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
/* ๋ค์จ ๊ธ๋ก์ฐ ํจ๊ณผ */
.neon-glow {
text-shadow:
0 0 10px currentColor,
0 0 20px currentColor,
0 0 30px currentColor,
0 0 40px currentColor;
animation: neon-flicker 3s infinite alternate;
}
@keyframes neon-flicker {
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% {
opacity: 1;
}
20%, 24%, 55% {
opacity: 0.8;
}
}
```
### 2) CRT ๋ชจ๋ ํ ๊ธ ๋ฒํผ ์ถ๊ฐ
๋ชจ๋ ํ์ด์ง์ `<main>` ์์ ๋ถ๋ถ์ ์ถ๊ฐ:
```php
<div style="position: fixed; bottom: 20px; right: 20px; z-index: 1000;">
<button onclick="toggleCRT()"
style="background: var(--m-primary); color: white; border: none; padding: 12px 20px; border-radius: 50px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-weight: 600;">
๐บ CRT ๋ชจ๋
</button>
</div>
<script>
function toggleCRT() {
document.body.classList.toggle('crt-mode');
const isEnabled = document.body.classList.contains('crt-mode');
localStorage.setItem('crtMode', isEnabled ? '1' : '0');
}
// ํ์ด์ง ๋ก๋ ์ ์ ์ฅ๋ ์ค์ ๋ณต์
if (localStorage.getItem('crtMode') === '1') {
document.body.classList.add('crt-mode');
}
</script>
```
### 3) ํ์ด๋ก ์น์
์ ๋ค์จ ํจ๊ณผ ์ ์ฉ
`app/modules/arcade/index.php`์ ํ์ด๋ก h1 ํ๊ทธ๋ฅผ ์์ :
```php
<h1 class="pixel-text neon-glow" style="font-size: 3rem; font-weight: 900; color: #fff; margin: 0 0 16px; position: relative;">
๐น๏ธ RETRO ARCADE
</h1>
```
### 4) ๊ฒ์ ์นด๋์ ํธ๋ฒ ํจ๊ณผ ๊ฐํ
`.game-card` ์คํ์ผ์ ์ถ๊ฐ:
```css
.game-card {
background: var(--m-surface);
border-radius: var(--m-radius-lg);
padding: 20px;
box-shadow: var(--m-shadow);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.game-card::before {
content: '';
position: absolute;
top: -50%; left: -50%;
width: 200%; height: 200%;
background: radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s;
}
.game-card:hover::before {
opacity: 1;
}
.game-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.3);
}
```
### 5) ๋ฆฌ๋๋ณด๋ ํ
์ด๋ธ ์ ๋๋ฉ์ด์
`.leaderboard-table tbody tr`์ ์ถ๊ฐ:
```css
.leaderboard-table tbody tr {
animation: fadeInUp 0.5s ease-out backwards;
}
.leaderboard-table tbody tr:nth-child(1) { animation-delay: 0.1s; }
.leaderboard-table tbody tr:nth-child(2) { animation-delay: 0.2s; }
.leaderboard-table tbody tr:nth-child(3) { animation-delay: 0.3s; }
.leaderboard-table tbody tr:nth-child(4) { animation-delay: 0.4s; }
.leaderboard-table tbody tr:nth-child(5) { animation-delay: 0.5s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
```
### 6) ์์
์๋ฃ ํ์ธ
- ์ฐ์ธก ํ๋จ CRT ๋ชจ๋ ํ ๊ธ ๋ฒํผ ํด๋ฆญ ์ ์ค์บ๋ผ์ธ ํจ๊ณผ ํ์ฑํ
- ํ์ด๋ก ์น์
์ ๋ชฉ์ ๋ค์จ ๊ธ๋ก์ฐ ์ ๋๋ฉ์ด์
- ๊ฒ์ ์นด๋ ํธ๋ฒ ์ ๋ถ๋๋ฌ์ด ํ๋ + ๊ทธ๋ฆผ์ ํจ๊ณผ
- ๋ฆฌ๋๋ณด๋ ํ
์ด๋ธ ํ์ด ์์ฐจ์ ์ผ๋ก ํ์ด๋์ธ
- localStorage๋ก CRT ๋ชจ๋ ์ค์ ์ ์ง
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ ํธ๋ก ์์ผ์ด๋ ๊ฐ์ฑ์ ๊ทน๋ํํ๋ ์๊ฐ ํจ๊ณผ๊ฐ ์ถ๊ฐ๋ฉ๋๋ค. CRT ๋ชจ๋ ํ ๊ธ ์ ๋ธ๋ผ์ฐ์ ์ ์ฒด์ ์ค์บ๋ผ์ธ๊ณผ ๋น๋คํ ํจ๊ณผ๊ฐ ์ ์ฉ๋๋ฉฐ, ๋ค์จ ๊ธ๋ก์ฐ ์ ๋๋ฉ์ด์ ์ด ํ์ด๋ก ์น์ ์ ์๋๊ฐ์ ๋ํฉ๋๋ค. ๊ฒ์ ์นด๋์ ๋ฆฌ๋๋ณด๋์ ์ธํฐ๋์ ์ ๋๋ฉ์ด์ ์ด ์ฌ์ฉ์ ๊ฒฝํ์ ํฅ์์ํต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!