๐ฏ g5se ํ์ดํ ์คํผ๋ ํ ์คํธ - ๋ถ๋น ํ์(WPM) ์ธก์ & ์์ ๊ธฐ๋ก
g5se-typing-speed-test
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
๋ธ๋ผ์ฐ์ ์์ ์ฆ์ ํ์ดํ ์๋๋ฅผ ์ธก์ ํ๊ณ , ์์ ์ ๊ธฐ๋ก์ ์ ์ฅํ๋ฉฐ, ๋ค๋ฅธ ์ฌ์ฉ์๋ค๊ณผ ์์๋ฅผ ๋น๊ตํ ์ ์๋ ํ์ดํ ์คํผ๋ ํ ์คํธ ํ์ด์ง๋ฅผ ๋ง๋ญ๋๋ค. ๊ทธ๋๋ณด๋5 SE์ modern shell์ ํ์ฉํ์ฌ ์ฌ์ดํธ ๋์์ธ๊ณผ ํต์ผ๊ฐ์ ์ ์งํ๋ฉด์, ์์ PHP + SQLite๋ก ๊ฐ๋จํ ์์ ์์คํ ์ ๊ตฌํํฉ๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
-
ํ์ดํ ํ ์คํธ UI
- ์ ์๋ ๋ฌธ์ฅ(ํ๊ธ ๋๋ ์๋ฌธ)์ ์ ํํ ์ ๋ ฅํ๋ ํ ์คํธ
- ์ค์๊ฐ ํ์ด๋จธ (60์ด ์นด์ดํธ๋ค์ด)
- ์ ๋ ฅ ์ค ์ ํ๋ ์ค์๊ฐ ํ์ (๋ง์ ๊ธ์ / ํ๋ฆฐ ๊ธ์)
- ํ ์คํธ ์ข ๋ฃ ์ WPM(๋ถ๋น ํ์), ์ ํ๋(%) ๊ณ์ฐ
-
๊ธฐ๋ก ์ ์ฅ ์์คํ
- ํ ์คํธ ์๋ฃ ํ ๋๋ค์ ์ ๋ ฅํ์ฌ ๊ธฐ๋ก ์ ์ฅ (SQLite DB)
- ์ ์ฅ ํญ๋ชฉ: ๋๋ค์, WPM, ์ ํ๋, ํ ์คํธ ๋ ์ง
- ์ค๋ณต ๋๋ค์ ํ์ฉ (๊ฐ์ ์ฌ๋์ด ์ฌ๋ฌ ๋ฒ ๋์ ๊ฐ๋ฅ)
-
์ค์๊ฐ ์์ํ
- WPM ๊ธฐ์ค ์์ 10๋ช ์์ ํ์
- ์์, ๋๋ค์, WPM, ์ ํ๋, ๋ ์ง ํ์
- ๋ด ์ต๊ณ ๊ธฐ๋ก ํ์ด๋ผ์ดํธ (๋๋ค์ ๋งค์นญ)
๋ณด๋์ค ๊ธฐ๋ฅ (์ ํ)
- ๋์ด๋ ์ ํ (์งง์ ๋ฌธ์ฅ / ๊ธด ๋ฌธ์ฅ / ์๋ฌธ / ํ๊ธ)
- ์คํ ์๊ฐ์ ํผ๋๋ฐฑ (ํ๋ฆฐ ๊ธ์ ๋นจ๊ฐ์ ํ์)
- ํ ์คํธ ์ค ESC ํค๋ก ์ฌ์์
- ๊ฐ์ธ ๊ธฐ๋ก ๊ทธ๋ํ (์ต๊ทผ 5ํ WPM ์ถ์ด)
- ์์ ๊ณต์ ๋ฒํผ ("๋๋ WPM 120! ๋์ ํด๋ณด์ธ์!")
๐จ ๋์์ธ ์๊ตฌ์ฌํญ
- modern shell ์ ์ฉ: ์ฌ์ดํธ ๋ค๋น๊ฒ์ด์ , ํธํฐ, ๋คํฌ๋ชจ๋ ์๋ ์ง์
- ๋์์ธ ํ ํฐ ์ฌ์ฉ:
var(--m-surface),var(--m-text),var(--m-shadow)๋ฑ์ผ๋ก ๋คํฌ๋ชจ๋ ๋์ - ํ์ดํ ์์ญ: ํฐ ํฐํธ(24px), ๊ณ ๋๋น ์์, ์ ๋ ฅ ์ค์ธ ๊ธ์ ํ์ด๋ผ์ดํธ
- ์์ํ: ์นด๋ ํํ, 1~3์๋ ๊ธ/์/๋ ๋ฐฐ์ง ์์ด์ฝ
- ์ ๋๋ฉ์ด์ : ์นด์ดํธ๋ค์ด ์ซ์ ํ์ค ํจ๊ณผ, ๊ฒฐ๊ณผ ํ๋ฉด ํ์ด๋์ธ
๐ ๊ธฐ์ ์คํ
- ํ๋ ์์ํฌ: ๊ทธ๋๋ณด๋5 SE (PHP ๊ธฐ๋ฐ CMS)
- DB: SQLite (๋ชจ๋ ์ ์ฉ ํ์ผ,
G5_DATA_PATH/modules/typing-speed.sqlite) - ํ๋ก ํธ: HTML5 + CSS3 + Vanilla JavaScript
- ๋ผ์ฐํ
: ํด๋ ๊ธฐ๋ฐ ์๋ ๋งคํ (
app/modules/typing-speed/index.phpโ/typing-speed)
๐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL,
wpm INTEGER NOT NULL,
accuracy REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_wpm ON records(wpm DESC);
๐ฏ ํ์ต ๋ชฉํ
- g5se์ ํด๋ ๊ธฐ๋ฐ ๋ผ์ฐํ ์ดํด
- modern shell์ ํ์ฉํ ์ผ๊ด๋ UI ๊ตฌ์ฑ
- SQLite๋ฅผ ํ์ฉํ ๊ฐ๋จํ CRUD ๊ตฌํ
- JavaScript๋ก ํ์ด๋จธ & ์ค์๊ฐ ์ ๋ ฅ ๊ฒ์ฆ ๋ก์ง ์์ฑ
- CSS ๋ณ์(๋์์ธ ํ ํฐ)๋ฅผ ํ์ฉํ ๋คํฌ๋ชจ๋ ๋์
์ ์ฝ ์กฐ๊ฑด
- โข ๊ทธ๋๋ณด๋5 SE ์ค์น ํ๊ฒฝ ํ์ (https://github.com/gnuboard/g5se)
- โข PHP 7.4 ์ด์ + SQLite ํ์ฅ ํ์ฑํ
- โข modern shell ์ ์ฉ ํ์ (์ฌ์ดํธ ๋์์ธ ํต์ผ)
- โข ์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ(npm/CDN) ์ฌ์ฉ ๊ธ์ง - ์์ HTML/CSS/JS๋ง
- โข ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ ์ฌ์ฉ (๋ณ๋ ๋ผ์ฐํธ ๋ฑ๋ก ๋ถํ์)
- โข DB๋ ๋ชจ๋ ์ ์ฉ SQLite ํ์ผ ์ฌ์ฉ (g5se ๋ฉ์ธ DB ๊ฑด๋๋ฆฌ์ง ๋ง๊ฒ)
- โข ๋ชจ๋ ์ฌ์ฉ์ ์ ๋ ฅ์ htmlspecialchars()๋ก ์ด์ค์ผ์ดํ
- โข prepared statement๋ก SQL ์ธ์ ์ ๋ฐฉ์ง
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
ํ์ดํ ์คํผ๋ ํ
์คํธ ํ์ด์ง์ ๊ธฐ๋ณธ ๊ณจ๊ฒฉ์ ๋ง๋ค์ด์ฃผ์ธ์.
**1) ํด๋ & ํ์ผ ์์ฑ**
- `app/modules/typing-speed/index.php` ํ์ผ ์์ฑ
- URL์ ์๋์ผ๋ก `/typing-speed`๊ฐ ๋ฉ๋๋ค (ํด๋ ๊ธฐ๋ฐ ๋ผ์ฐํ
)
**2) modern shell ๊ณจ๊ฒฉ ์ ์ฉ**
ํ์ผ ๋ด์ฉ:
```php
<?php
include_once('./_common.php');
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: 32px; font-weight: 700; margin-bottom: 8px;">โจ๏ธ ํ์ดํ ์คํผ๋ ํ
์คํธ</h1>
<p style="color: var(--m-text-muted); margin-bottom: 32px;">60์ด ์์ ์ผ๋ง๋ ๋น ๋ฅด๊ณ ์ ํํ๊ฒ ์
๋ ฅํ ์ ์๋์?</p>
<div id="app">
<!-- JavaScript๋ก ๋์ ๋ ๋๋ง -->
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
**3) ๊ธฐ๋ณธ CSS ์คํ์ผ**
`<main>` ํ๊ทธ ์์ `<style>` ๋ธ๋ก ์ถ๊ฐ:
- ํ์ดํ ์์ญ ์นด๋: `background: var(--m-surface); box-shadow: var(--m-shadow); border-radius: var(--m-radius-lg); padding: 24px;`
- ํ
์คํธ ์์: `color: var(--m-text);`
- ๋ฒํผ: `background: #3b82f6; color: white; padding: 12px 24px; border-radius: 8px; font-weight: 600; cursor: pointer;`
**4) JavaScript ์ด๊ธฐ ๊ตฌ์กฐ**
`<script>` ๋ธ๋ก ์ถ๊ฐ:
```javascript
const app = document.getElementById('app');
function renderStart() {
app.innerHTML = `
<div class="card">
<h2>์ค๋น๋์
จ๋์?</h2>
<p>์๋ ๋ฒํผ์ ๋๋ฅด๋ฉด 60์ด ํ์ด๋จธ๊ฐ ์์๋ฉ๋๋ค.</p>
<button id="startBtn">์์ํ๊ธฐ</button>
</div>
`;
document.getElementById('startBtn').addEventListener('click', startTest);
}
function startTest() {
app.innerHTML = '<p>ํ
์คํธ ์์!</p>';
}
renderStart();
```
์๋ฃ ํ `/typing-speed`๋ก ์ ์ํ์ฌ "์์ํ๊ธฐ" ๋ฒํผ์ด ๋ณด์ด๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: - `/typing-speed` URL์์ ํ์ด์ง ์ ๊ทผ ๊ฐ๋ฅ - modern shell์ด ์ ์ฉ๋์ด ์ฌ์ดํธ ๋ค๋น๊ฒ์ด์ /ํธํฐ ํ์ - "โจ๏ธ ํ์ดํ ์คํผ๋ ํ ์คํธ" ์ ๋ชฉ๊ณผ ์ค๋ช ํ์ - "์์ํ๊ธฐ" ๋ฒํผ ํด๋ฆญ ์ "ํ ์คํธ ์์!" ๋ฉ์์ง ์ถ๋ ฅ - ๋คํฌ๋ชจ๋ ์ ํ ์ ๋ฐฐ๊ฒฝ/ํ ์คํธ ์์ ์๋ ๋ณ๊ฒฝ
ํ์ดํ ํ
์คํธ ํต์ฌ ๋ก์ง์ ๊ตฌํํด์ฃผ์ธ์.
**1) ํ
์คํธ ๋ฌธ์ฅ ๋ฐ์ดํฐ**
JavaScript ์๋จ์ ์ถ๊ฐ:
```javascript
const sentences = [
'๋น ๋ฅธ ๊ฐ์ ์ฌ์ฐ๊ฐ ๊ฒ์ผ๋ฅธ ๊ฐ๋ฅผ ๋ฐ์ด๋์ต๋๋ค.',
'์ฝ๋ฉ์ ์ฐฝ์๋ ฅ๊ณผ ๋
ผ๋ฆฌ๋ ฅ์ด ๋ง๋๋ ์์ ์
๋๋ค.',
'์ฐ์ต์ ์๋ฒฝ์ ๋ง๋ค์ง ์์ง๋ง ์๊ตฌ์ ์ผ๋ก ๋ง๋ญ๋๋ค.',
'ํ์ดํ ์๋๋ ์์ฐ์ฑ์ ๊ธฐ๋ณธ์
๋๋ค.',
'ํค๋ณด๋๋ ์๊ฐ์ ํ์ค๋ก ๋ฐ๊พธ๋ ๋๊ตฌ์
๋๋ค.'
];
```
**2) ํ
์คํธ ํ๋ฉด ๋ ๋๋ง**
`startTest()` ํจ์ ์์ :
```javascript
let timer = 60;
let timerInterval;
let targetSentence = sentences[Math.floor(Math.random() * sentences.length)];
let userInput = '';
let startTime = Date.now();
function startTest() {
app.innerHTML = `
<div class="card">
<div style="display: flex; justify-content: space-between; margin-bottom: 16px;">
<span style="font-size: 18px; font-weight: 600;">โฑ๏ธ <span id="timer">60</span>์ด</span>
<span style="font-size: 18px; font-weight: 600;">์ ํ๋: <span id="accuracy">100</span>%</span>
</div>
<div id="sentence" style="font-size: 24px; line-height: 1.6; margin-bottom: 16px; color: var(--m-text-muted);"></div>
<textarea id="input" placeholder="์ฌ๊ธฐ์ ์
๋ ฅํ์ธ์..." style="width: 100%; height: 120px; font-size: 18px; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; resize: none;"></textarea>
</div>
`;
renderSentence();
document.getElementById('input').addEventListener('input', handleInput);
document.getElementById('input').focus();
timerInterval = setInterval(() => {
timer--;
document.getElementById('timer').textContent = timer;
if (timer <= 0) {
endTest();
}
}, 1000);
}
```
**3) ์ค์๊ฐ ์
๋ ฅ ๊ฒ์ฆ**
```javascript
function renderSentence() {
const sentenceDiv = document.getElementById('sentence');
let html = '';
for (let i = 0; i < targetSentence.length; i++) {
const char = targetSentence[i];
const userChar = userInput[i];
let color = 'var(--m-text-muted)';
if (userChar !== undefined) {
color = userChar === char ? '#10b981' : '#ef4444';
}
html += `<span style="color: ${color};">${char}</span>`;
}
sentenceDiv.innerHTML = html;
}
function handleInput(e) {
userInput = e.target.value;
renderSentence();
const correct = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
const accuracy = userInput.length > 0 ? Math.round((correct / userInput.length) * 100) : 100;
document.getElementById('accuracy').textContent = accuracy;
if (userInput === targetSentence) {
endTest();
}
}
```
**4) ํ
์คํธ ์ข
๋ฃ**
```javascript
function endTest() {
clearInterval(timerInterval);
const elapsedTime = (Date.now() - startTime) / 1000 / 60; // ๋ถ ๋จ์
const correctChars = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
const wpm = Math.round(correctChars / 5 / elapsedTime); // ๋จ์ด๋น 5๊ธ์ ๊ฐ์
const accuracy = Math.round((correctChars / userInput.length) * 100) || 0;
app.innerHTML = `
<div class="card" style="text-align: center;">
<h2 style="font-size: 28px; margin-bottom: 16px;">๐ ํ
์คํธ ์๋ฃ!</h2>
<div style="font-size: 48px; font-weight: 700; color: #3b82f6; margin-bottom: 8px;">${wpm} WPM</div>
<div style="font-size: 20px; color: var(--m-text-muted); margin-bottom: 24px;">์ ํ๋: ${accuracy}%</div>
<button onclick="location.reload()">๋ค์ ๋์ </button>
</div>
`;
}
```
์๋ฃ ํ ํ
์คํธํด๋ณด์ธ์:
1. ์์ ๋ฒํผ ํด๋ฆญ
2. ์ ์๋ ๋ฌธ์ฅ ์
๋ ฅ ์ค ๋ง์ ๊ธ์๋ ์ด๋ก์, ํ๋ฆฐ ๊ธ์๋ ๋นจ๊ฐ์์ผ๋ก ํ์
3. 60์ด ๊ฒฝ๊ณผ ๋๋ ๋ฌธ์ฅ ์์ฑ ์ WPM๊ณผ ์ ํ๋ ํ์
๊ธฐ๋ ๊ฒฐ๊ณผ: - ๋๋ค ๋ฌธ์ฅ์ด ์ ์๋จ - ์ ๋ ฅ ์ค ์ค์๊ฐ์ผ๋ก ์ ํ๋ ๊ณ์ฐ - ๋ง์ ๊ธ์๋ ์ด๋ก์, ํ๋ฆฐ ๊ธ์๋ ๋นจ๊ฐ์์ผ๋ก ํ์ - 60์ด ํ์ด๋จธ๊ฐ 1์ด์ฉ ๊ฐ์ - ํ ์คํธ ์ข ๋ฃ ์ WPM(๋ถ๋น ํ์)๊ณผ ์ ํ๋(%) ๊ณ์ฐ - "๋ค์ ๋์ " ๋ฒํผ์ผ๋ก ํ์ด์ง ์๋ก๊ณ ์นจ
SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฐ๋ํ์ฌ ๊ธฐ๋ก ์ ์ฅ ๋ฐ ์์ํ ๊ธฐ๋ฅ์ ์ถ๊ฐํด์ฃผ์ธ์.
**1) DB ์ด๊ธฐํ (PHP ์๋จ์ ์ถ๊ฐ)**
`<?php` ๋ธ๋ก ์๋, `require_once` ์ ์:
```php
// ๋ชจ๋ DB ๋๋ ํฐ๋ฆฌ ์์ฑ
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
// SQLite ์ฐ๊ฒฐ
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/typing-speed.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํ
์ด๋ธ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL,
wpm INTEGER NOT NULL,
accuracy REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wpm ON records(wpm DESC)");
// AJAX ์ฒ๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
if ($_POST['action'] === 'save') {
$nickname = trim($_POST['nickname'] ?? '');
$wpm = intval($_POST['wpm'] ?? 0);
$accuracy = floatval($_POST['accuracy'] ?? 0);
if ($nickname && $wpm > 0) {
$stmt = $db->prepare("INSERT INTO records (nickname, wpm, accuracy) VALUES (?, ?, ?)");
$stmt->execute([$nickname, $wpm, $accuracy]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '๋๋ค์๊ณผ WPM์ด ํ์ํฉ๋๋ค.']);
}
exit;
}
if ($_POST['action'] === 'leaderboard') {
$stmt = $db->query("SELECT nickname, wpm, accuracy, created_at FROM records ORDER BY wpm DESC LIMIT 10");
$records = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($records);
exit;
}
}
```
**2) ๊ฒฐ๊ณผ ํ๋ฉด์ ์ ์ฅ ํผ ์ถ๊ฐ**
`endTest()` ํจ์ ์์ :
```javascript
function endTest() {
clearInterval(timerInterval);
const elapsedTime = (Date.now() - startTime) / 1000 / 60;
const correctChars = userInput.split('').filter((c, i) => c === targetSentence[i]).length;
const wpm = Math.round(correctChars / 5 / elapsedTime);
const accuracy = Math.round((correctChars / userInput.length) * 100) || 0;
app.innerHTML = `
<div class="card" style="text-align: center;">
<h2 style="font-size: 28px; margin-bottom: 16px;">๐ ํ
์คํธ ์๋ฃ!</h2>
<div style="font-size: 48px; font-weight: 700; color: #3b82f6; margin-bottom: 8px;">${wpm} WPM</div>
<div style="font-size: 20px; color: var(--m-text-muted); margin-bottom: 24px;">์ ํ๋: ${accuracy}%</div>
<div style="margin-bottom: 16px;">
<input type="text" id="nickname" placeholder="๋๋ค์ ์
๋ ฅ" style="padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 16px; width: 200px;">
</div>
<button id="saveBtn" style="margin-right: 8px;">๊ธฐ๋ก ์ ์ฅ</button>
<button onclick="location.reload()">๋ค์ ๋์ </button>
</div>
<div id="leaderboard" style="margin-top: 32px;"></div>
`;
document.getElementById('saveBtn').addEventListener('click', () => saveRecord(wpm, accuracy));
loadLeaderboard();
}
```
**3) ์ ์ฅ ๋ฐ ์์ํ ๋ก๋**
```javascript
async function saveRecord(wpm, accuracy) {
const nickname = document.getElementById('nickname').value.trim();
if (!nickname) {
alert('๋๋ค์์ ์
๋ ฅํ์ธ์!');
return;
}
const formData = new FormData();
formData.append('action', 'save');
formData.append('nickname', nickname);
formData.append('wpm', wpm);
formData.append('accuracy', accuracy);
const res = await fetch('', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
alert('๊ธฐ๋ก์ด ์ ์ฅ๋์์ต๋๋ค!');
loadLeaderboard();
} else {
alert('์ ์ฅ ์คํจ: ' + (data.error || '์ ์ ์๋ ์ค๋ฅ'));
}
}
async function loadLeaderboard() {
const formData = new FormData();
formData.append('action', 'leaderboard');
const res = await fetch('', { method: 'POST', body: formData });
const records = await res.json();
const leaderboard = document.getElementById('leaderboard');
if (records.length === 0) {
leaderboard.innerHTML = '<p style="text-align: center; color: var(--m-text-muted);">์์ง ๊ธฐ๋ก์ด ์์ต๋๋ค. ์ฒซ ๊ธฐ๋ก์ ๋จ๊ฒจ๋ณด์ธ์!</p>';
return;
}
let html = '<div class="card"><h3 style="margin-bottom: 16px;">๐ ์์ํ</h3><div>';
records.forEach((r, i) => {
const medal = i === 0 ? '๐ฅ' : i === 1 ? '๐ฅ' : i === 2 ? '๐ฅ' : `${i + 1}.`;
html += `
<div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid #e5e7eb;">
<span style="font-weight: 600;">${medal} ${r.nickname}</span>
<span><strong>${r.wpm} WPM</strong> (${r.accuracy}%)</span>
</div>
`;
});
html += '</div></div>';
leaderboard.innerHTML = html;
}
```
์๋ฃ ํ ํ
์คํธ:
1. ํ
์คํธ ์๋ฃ ํ ๋๋ค์ ์
๋ ฅ โ "๊ธฐ๋ก ์ ์ฅ" ํด๋ฆญ
2. ์์ํ์ ๋ด ๊ธฐ๋ก์ด ํ์๋๋์ง ํ์ธ
3. ์ฌ๋ฌ ๋ฒ ํ
์คํธํ์ฌ ์์ 10๋ช
์์๊ฐ WPM ๋ด๋ฆผ์ฐจ์์ผ๋ก ์ ๋ ฌ๋๋์ง ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: - ํ ์คํธ ์๋ฃ ์ ๋๋ค์ ์ ๋ ฅ ํผ ํ์ - "๊ธฐ๋ก ์ ์ฅ" ํด๋ฆญ ์ SQLite DB์ ์ ์ฅ - ์ ์ฅ ์ฑ๊ณต ์ "๊ธฐ๋ก์ด ์ ์ฅ๋์์ต๋๋ค!" ์๋ฆผ - ์์ํ์ ์์ 10๋ช ํ์ (1~3์๋ ๊ธ/์/๋ ๋ฉ๋ฌ ์ด๋ชจ์ง) - ์์ํ์ WPM ๋ด๋ฆผ์ฐจ์ ์ ๋ ฌ - ๊ฐ์ ๋๋ค์์ผ๋ก ์ฌ๋ฌ ๋ฒ ์ ์ฅ ๊ฐ๋ฅ (๊ฐ๊ฐ ๋ณ๋ ๊ธฐ๋ก)
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!