feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화

- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침)
- Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원)
- 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일
- 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지)
- 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가
- 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선
- 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
Horoli 2026-05-23 11:32:01 +09:00
parent e4f542d487
commit 3e5c079e68
18 changed files with 1811 additions and 79 deletions

View File

@ -25,6 +25,7 @@
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
├── public/ # 정적 리소스 (게임 에셋)
│ └── assets/
@ -58,6 +59,7 @@
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
├── deathStats.js # 사망 통계 API 호출 래퍼
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
```
@ -89,6 +91,7 @@
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
- `GET /api/about`: 서버 시작 시 캐시한 개발자정보와 개인정보처리방침 Markdown 조회.
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.

View File

@ -7,9 +7,14 @@
"MONGODB_USER": "",
"MONGODB_PASS": "",
"MONGODB_VISITOR_COLLECTION": "visitors",
"MONGODB_ABOUT_COLLECTION": "about_content",
"MONGODB_DAILY_DEATH_COLLECTION": "daily_death_stats",
"MONGODB_DAILY_METRICS_COLLECTION": "daily_metrics",
"MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION": "daily_visitor_activity",
"MONGODB_MAX_POOL_SIZE": 10,
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
"DEATH_STATS_TIME_ZONE": "Asia/Seoul",
"ANALYTICS_TIME_ZONE": "Asia/Seoul",
"DAILY_ACTIVITY_RETENTION_DAYS": 60,
"COOKIE_SECURE": false
}

View File

@ -8,6 +8,7 @@
### UI 컴포넌트 (`src/ui/`)
- **`matchForm.js`**: 설정 폼 제어 및 `localStorage` 설정 유지.
- **`aboutDialog.js`**: About 버튼/다이얼로그, 개발자정보, 개인정보처리방침 Markdown 표시.
- **`arenaScoreboard.js`**: 좌측 HUD 레일의 팀 badge 업데이트 및 관전 시점 전환.
- **`arenaKillLog.js`**: 좌측 하단 킬로그 표시 및 관리.
- **`battleDeathNotice.js`**: 상단 사망 통계 공지 UI.
@ -23,8 +24,18 @@
### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 무승부는 더 차분한 톤을 사용합니다.
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 방문자 카운터 안전 여백을 남겨 하단 카운터와 충돌하지 않게 합니다.
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
## 3. UI 개발 규칙
- **DOM 접근 최소화**: 성능 최적화를 위해 필요한 시점에만 최소한으로 DOM을 업데이트합니다.
- **반응형 상태**: `#app`의 클래스(`match-live`, `options-open`, `drawer-collapsed`, `match-paused`)를 통해 전반적인 UI 상태를 제어합니다.
- **반응형 상태**: `#app`의 클래스(`match-live`, `options-open`, `drawer-collapsed`, `match-paused`, `match-ended`)를 통해 전반적인 UI 상태를 제어합니다.
## 4. About 다이얼로그
- **`src/ui/aboutDialog.js`**:
- 메인 대기 화면과 전투 화면에서 공통으로 노출되는 `#about-button`을 제어합니다.
- About 다이얼로그는 기본으로 개발자정보 탭을 보여주고, 개인정보처리방침 탭에서 DB에 저장된 Markdown 원문을 안전한 DOM 노드로 렌더링합니다.
- 브라우저는 `GET /api/about` 읽기 전용 API로 서버 시작 시 캐시된 About 콘텐츠를 가져오며, API 실패 시 개발자 기본값과 빈 정책 안내 문구로 폴백합니다.
- 전투 화면 모바일 레이아웃에서는 About 버튼과 방문자 카운터가 하단에서 겹치지 않도록 kill log 여백 계산에 포함합니다.

View File

@ -10,9 +10,22 @@
- `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다.
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
- 전투 사망 통계 컬렉션(`MONGODB_DAILY_DEATH_COLLECTION`)과 집계 기준 타임존(`DEATH_STATS_TIME_ZONE`) 기본값을 제공합니다.
- About 콘텐츠 컬렉션은 `MONGODB_ABOUT_COLLECTION`으로 조정하며 기본값은 `about_content`입니다.
- **`server/visitorCookie.js`**:
- `arena_visitor_id` 쿠키 읽기/쓰기와 UUID 형식 검증을 담당합니다.
- 방문자 쿠키 처리를 방문자 API와 일일 지표 API가 공유하도록 분리합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
- **`server/about.js`**:
- About 개발자정보와 개인정보처리방침 Markdown을 DB 기본 문서로 시드하고, 서버 메모리에 캐시합니다.
- **`server/dailyMetrics.js`**:
- `GET /api/daily-metrics/today`: `ANALYTICS_TIME_ZONE` 기준 오늘의 운영 지표를 반환합니다.
- `POST /api/daily-metrics/match-started`: 사용자가 실제 전투를 시작했을 때 `totalMatchStarts`를 누적합니다.
- `POST /api/daily-metrics/match-finished`: 실제 전투가 끝났을 때 `totalMatchFinishes`를 누적합니다.
- `POST /api/daily-metrics/donation-clicked`: 후원 버튼 클릭 수를 누적하기 위한 예약 API입니다.
- 날짜별 합산 컬렉션(`daily_metrics`)과 날짜+방문자 해시 기준 임시 카운터 컬렉션(`daily_visitor_activity`)을 사용합니다.
- 임시 카운터는 `expireAt` TTL 인덱스로 기본 60일 뒤 자동 삭제됩니다.
- **`server/deathStats.js`**:
- `GET /api/death-stats/today`: `DEATH_STATS_TIME_ZONE` 기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.
- `POST /api/death-stats/today`: 전투 종료 시 전달된 `deathsBySpecies`를 오늘 일자별 누적 문서의 `deathsBySpecies`, `totalDeaths`, `battles`에 바로 더합니다.
@ -31,6 +44,16 @@
3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다.
4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다.
5. `countDocuments()`로 전체 유니크 방문자 수를 계산해 반환합니다.
6. 같은 요청에서 일일 지표의 `totalVisits`를 1 증가시키고, 해당 날짜에 처음 확인된 방문자면 `uniqueVisitors`도 1 증가시킵니다.
### 일일 운영 지표
수익화 판단에 필요한 최소 지표만 저장하며, 입력 닉네임이나 매치 상세 로그는 저장하지 않습니다.
1. 앱 로드 시 기존 `POST /api/visitors/check` 흐름에서 `daily_metrics.totalVisits``daily_metrics.uniqueVisitors`를 갱신합니다.
2. 사용자가 직접 시작한 전투만 `POST /api/daily-metrics/match-started``totalMatchStarts`에 누적합니다.
3. 프리뷰 전투는 제외하고, 실제 전투가 승리/무승부로 끝난 경우만 `POST /api/daily-metrics/match-finished``totalMatchFinishes`에 누적합니다.
4. `daily_visitor_activity`는 날짜와 방문자 UUID를 함께 해시한 `_id`를 사용해 당일 방문자별 `visits`, `matchStarts`, `matchFinishes`, `donationClicks`만 임시 저장합니다.
5. 방문자의 당일 `matchStarts`가 1에서 2로 넘어가는 순간에만 `daily_metrics.visitorsWithTwoOrMoreMatches`를 1 증가시킵니다.
6. `daily_visitor_activity``DAILY_ACTIVITY_RETENTION_DAYS` 설정값에 따라 TTL로 자동 삭제하고, 장기 보관 대상은 날짜별 합산 문서인 `daily_metrics`입니다.
### 전투 사망 통계
프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
@ -43,4 +66,13 @@
## 3. 설정 규칙
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
- **MongoDB 연결**: 접속 정보는 `config.json``MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB` 등으로 관리합니다.
- **일일 지표 설정**: `MONGODB_DAILY_METRICS_COLLECTION`, `MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION`, `ANALYTICS_TIME_ZONE`, `DAILY_ACTIVITY_RETENTION_DAYS`로 집계 컬렉션과 임시 카운터 보관 기간을 조정합니다.
- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당하며, 개발 모드에서 Vite 미들웨어보다 우선순위를 가집니다.
## 4. About 콘텐츠
- **`server/about.js`**:
- 서버 시작 시 `MONGODB_ABOUT_COLLECTION` 컬렉션(기본값 `about_content`)에 `developer-info`, `privacy-policy` 기본 문서를 upsert합니다.
- 개발자 정보 기본값은 `alias: horoli`, `email: sunha321@gmail.com`, `github: https://github.com/Horoli`입니다.
- 개인정보처리방침은 `privacy-policy.markdown` 문자열 필드에 Markdown 원문으로 저장합니다. 기본값은 빈 문자열이며, 운영자가 DB에서 직접 작성/수정합니다.
- 서버가 MongoDB 연결에 성공하면 About 콘텐츠를 메모리에 캐시합니다. 브라우저 표시를 위해 `GET /api/about` 읽기 전용 API만 제공하며, 수정 API는 만들지 않습니다.

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arena Picker</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>">
<style>
html.app-booting,
html.app-booting body {
@ -63,9 +64,21 @@
<span data-status-text>옵션 대기 중</span>
</div>
</div>
<div class="arena-meta">
<p id="visitor-count" class="visitor-count" aria-live="polite">
방문자 확인 중
</p>
<button
id="about-button"
class="about-button"
type="button"
aria-haspopup="dialog"
aria-controls="about-dialog"
aria-expanded="false"
>
About
</button>
</div>
</section>
<section class="intro-stage" aria-label="Arena 시작 화면">
@ -207,6 +220,96 @@ Player 10</textarea
</div>
</form>
</aside>
<div id="about-dialog" class="about-backdrop" hidden>
<section
class="about-dialog"
data-about-dialog
role="dialog"
aria-modal="true"
aria-labelledby="about-dialog-title"
tabindex="-1"
>
<header class="about-header">
<div>
<p class="eyebrow">About</p>
<h2 id="about-dialog-title">개발자정보</h2>
</div>
<button
class="about-close"
data-about-close
type="button"
aria-label="About 닫기"
>
X
</button>
</header>
<div class="about-tabs" role="tablist" aria-label="About 메뉴">
<button
id="about-tab-developer"
class="about-tab"
data-about-tab="developer"
type="button"
role="tab"
aria-selected="true"
aria-controls="about-panel-developer"
>
개발자정보
</button>
<button
id="about-tab-privacy"
class="about-tab"
data-about-tab="privacy"
type="button"
role="tab"
aria-selected="false"
aria-controls="about-panel-privacy"
tabindex="-1"
>
개인정보처리방침
</button>
</div>
<section
id="about-panel-developer"
class="about-panel"
data-about-panel="developer"
role="tabpanel"
aria-labelledby="about-tab-developer"
>
<dl class="about-fields">
<div class="about-field-row">
<dt>alias</dt>
<dd data-about-field="alias">horoli</dd>
</div>
<div class="about-field-row">
<dt>email</dt>
<dd data-about-field="email">sunha321@gmail.com</dd>
</div>
<div class="about-field-row">
<dt>github</dt>
<dd data-about-field="github">
<a href="https://github.com/Horoli" target="_blank" rel="noreferrer">
https://github.com/Horoli
</a>
</dd>
</div>
</dl>
</section>
<section
id="about-panel-privacy"
class="about-panel"
data-about-panel="privacy"
role="tabpanel"
aria-labelledby="about-tab-privacy"
hidden
>
<div id="privacy-policy-content" class="about-markdown"></div>
</section>
</section>
</div>
</main>
<script type="module" src="/src/main.js"></script>
</body>

179
server/about.js Normal file
View File

@ -0,0 +1,179 @@
import { getConfig } from "./config.js";
import { getDb, hasMongoConfig } from "./db.js";
const DEFAULT_ABOUT_COLLECTION_NAME = "about_content";
const DEVELOPER_INFO_ID = "developer-info";
const PRIVACY_POLICY_ID = "privacy-policy";
const DEFAULT_DEVELOPER_INFO = {
alias: "horoli",
email: "sunha321@gmail.com",
github: "https://github.com/Horoli",
};
const DEFAULT_PRIVACY_POLICY_MARKDOWN = `
### 개인정보처리방침 (초안)
**Arena Picker** 이용자의 개인정보를 최소한으로 수집하며, 투명하게 관리하기 위해 노력합니다.
#### 1. 수집하는 개인정보 항목 방법
서비스는 별도의 회원가입 없이 이용 가능하며, 서비스 운영 지표 측정을 위해 아래와 같은 정보를 수집합니다.
- **수집 항목**: 방문자 식별값 (브라우저 쿠키를 기반으로 생성된 암호화된 UUID 해시), 방문 일시, 서비스 이용 기록 (전투 시작/종료, 버튼 클릭 )
- **수집 방법**: 서비스 접속 자동으로 생성 서버로 전송
#### 2. 개인정보의 수집 이용 목적
수집된 정보는 오직 서비스 품질 개선 통계 분석을 위해서만 활용됩니다.
- 중복되지 않는 일일 방문자 측정
- 서비스 이용 통계 (전투 횟수, 선호 캐릭터 ) 분석
- 서비스 안정성 확인 버그 진단
#### 3. 개인정보의 보유 이용 기간
- 수집된 활동 로그 통계 데이터는 수집일로부터 **60** 보관 복구 불가능한 방법으로 파기됩니다.
#### 4. 개인정보의 제3자 제공
서비스는 이용자의 개인정보를 외부에 제공하거나 공유하지 않습니다.
#### 5. 이용자의 권리
이용자는 브라우저의 쿠키를 삭제함으로써 언제든지 식별 정보를 초기화할 있습니다.
**공고일자**: 2024 5 23
**시행일자**: 2024 5 23
`;
let aboutCache;
let aboutIndexesReady;
let aboutWarmupPromise;
export async function aboutRoutes(fastify) {
fastify.get("/about", async () => getAboutContent());
fastify.get("/about/", async () => getAboutContent());
}
export async function warmAboutContent() {
if (!hasMongoConfig()) {
aboutCache = formatAboutContent();
return aboutCache;
}
if (!aboutWarmupPromise) {
aboutWarmupPromise = loadAboutContent()
.then((content) => {
aboutCache = content;
return content;
})
.finally(() => {
aboutWarmupPromise = undefined;
});
}
return aboutWarmupPromise;
}
async function getAboutContent() {
if (aboutCache) {
return aboutCache;
}
return warmAboutContent();
}
async function loadAboutContent() {
const collection = await getAboutCollection();
await ensureAboutDefaults(collection);
const [developerInfo, privacyPolicy] = await Promise.all([
collection.findOne({ _id: DEVELOPER_INFO_ID }),
collection.findOne({ _id: PRIVACY_POLICY_ID }),
]);
return formatAboutContent(developerInfo, privacyPolicy);
}
async function getAboutCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_ABOUT_COLLECTION || DEFAULT_ABOUT_COLLECTION_NAME,
);
await ensureAboutIndexes(collection);
return collection;
}
async function ensureAboutIndexes(collection) {
if (!aboutIndexesReady) {
aboutIndexesReady = collection.createIndex({ type: 1 });
}
return aboutIndexesReady;
}
async function ensureAboutDefaults(collection) {
const now = new Date();
await collection.bulkWrite(
[
{
updateOne: {
filter: { _id: DEVELOPER_INFO_ID },
update: {
$setOnInsert: {
_id: DEVELOPER_INFO_ID,
type: "developerInfo",
...DEFAULT_DEVELOPER_INFO,
createdAt: now,
},
},
upsert: true,
},
},
{
updateOne: {
filter: { _id: PRIVACY_POLICY_ID },
update: {
$set: {
markdown: DEFAULT_PRIVACY_POLICY_MARKDOWN,
updatedAt: now,
},
$setOnInsert: {
_id: PRIVACY_POLICY_ID,
type: "privacyPolicy",
createdAt: now,
},
},
upsert: true,
},
},
],
{ ordered: false },
);
}
function formatAboutContent(developerInfo = {}, privacyPolicy = {}) {
return {
developer: normalizeDeveloperInfo(developerInfo),
privacyPolicy: {
markdown: stringValue(
privacyPolicy?.markdown,
DEFAULT_PRIVACY_POLICY_MARKDOWN,
),
updatedAt: dateString(privacyPolicy?.updatedAt || privacyPolicy?.createdAt),
},
};
}
function normalizeDeveloperInfo(document = {}) {
return {
alias: stringValue(document?.alias, DEFAULT_DEVELOPER_INFO.alias),
email: stringValue(document?.email, DEFAULT_DEVELOPER_INFO.email),
github: stringValue(document?.github, DEFAULT_DEVELOPER_INFO.github),
};
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string");
return value?.trim() ?? "";
}
function dateString(value) {
return value?.toISOString?.() ?? null;
}

View File

@ -11,10 +11,15 @@ const DEFAULT_CONFIG = {
MONGODB_PASS: "",
MONGODB_URI: "",
MONGODB_VISITOR_COLLECTION: "visitors",
MONGODB_ABOUT_COLLECTION: "about_content",
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
MONGODB_DAILY_METRICS_COLLECTION: "daily_metrics",
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: "daily_visitor_activity",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
ANALYTICS_TIME_ZONE: "Asia/Seoul",
DAILY_ACTIVITY_RETENTION_DAYS: 60,
COOKIE_SECURE: false,
};
@ -77,11 +82,26 @@ function normalizeConfig(rawConfig) {
mongodb.visitorCollection,
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
),
MONGODB_ABOUT_COLLECTION: stringValue(
rawConfig.MONGODB_ABOUT_COLLECTION,
mongodb.aboutCollection,
DEFAULT_CONFIG.MONGODB_ABOUT_COLLECTION,
),
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
mongodb.dailyDeathCollection,
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
),
MONGODB_DAILY_METRICS_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_METRICS_COLLECTION,
mongodb.dailyMetricsCollection,
DEFAULT_CONFIG.MONGODB_DAILY_METRICS_COLLECTION,
),
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
mongodb.dailyVisitorActivityCollection,
DEFAULT_CONFIG.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
),
MONGODB_MAX_POOL_SIZE: numberValue(
rawConfig.MONGODB_MAX_POOL_SIZE,
mongodb.maxPoolSize,
@ -98,6 +118,17 @@ function normalizeConfig(rawConfig) {
mongodb.deathStatsTimeZone,
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
),
ANALYTICS_TIME_ZONE: stringValue(
rawConfig.ANALYTICS_TIME_ZONE,
rawConfig.TIME_ZONE,
mongodb.analyticsTimeZone,
DEFAULT_CONFIG.ANALYTICS_TIME_ZONE,
),
DAILY_ACTIVITY_RETENTION_DAYS: numberValue(
rawConfig.DAILY_ACTIVITY_RETENTION_DAYS,
mongodb.dailyActivityRetentionDays,
DEFAULT_CONFIG.DAILY_ACTIVITY_RETENTION_DAYS,
),
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
};
}

357
server/dailyMetrics.js Normal file
View File

@ -0,0 +1,357 @@
import { createHash } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { readVisitorCookie, isValidVisitorId } from "./visitorCookie.js";
const DEFAULT_DAILY_METRICS_COLLECTION_NAME = "daily_metrics";
const DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME = "daily_visitor_activity";
const DEFAULT_ACTIVITY_RETENTION_DAYS = 60;
const METRIC_FIELDS = [
"uniqueVisitors",
"totalVisits",
"totalMatchStarts",
"totalMatchFinishes",
"visitorsWithTwoOrMoreMatches",
"donationClicks",
];
const EVENT_CONFIG = {
"match-started": {
metricField: "totalMatchStarts",
activityField: "matchStarts",
},
"match-finished": {
metricField: "totalMatchFinishes",
activityField: "matchFinishes",
},
"donation-clicked": {
metricField: "donationClicks",
activityField: "donationClicks",
},
};
let metricsIndexesReady;
let activityIndexesReady;
export async function dailyMetricsRoutes(fastify) {
fastify.get("/today", async () => {
return getTodayDailyMetrics();
});
fastify.post("/match-started", async (request) => {
return recordDailyMetricEvent("match-started", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/match-finished", async (request) => {
return recordDailyMetricEvent("match-finished", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/donation-clicked", async (request) => {
return recordDailyMetricEvent("donation-clicked", {
visitorId: readVisitorCookie(request),
});
});
}
export async function recordDailyVisit(visitorId, { now = new Date() } = {}) {
const date = dayKey(now);
let uniqueVisitors = 0;
if (isValidVisitorId(visitorId)) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
const uniqueResult = await activityCollection.updateOne(
{
_id: activityId,
dailyUniqueCounted: { $ne: true },
},
{
$set: {
dailyUniqueCounted: true,
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
if (uniqueResult.modifiedCount > 0) {
uniqueVisitors = 1;
} else {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
}
}
return updateDailyMetrics(date, now, {
totalVisits: 1,
uniqueVisitors,
});
}
export async function recordDailyMetricEvent(eventType, { visitorId, now = new Date() } = {}) {
const eventConfig = EVENT_CONFIG[eventType];
if (!eventConfig) {
throw new Error(`Unknown daily metric event: ${eventType}`);
}
const date = dayKey(now);
const increments = {
[eventConfig.metricField]: 1,
};
if (isValidVisitorId(visitorId)) {
const countedSecondMatch = await recordDailyVisitorEvent(
date,
visitorId,
eventConfig.activityField,
now,
);
if (countedSecondMatch) {
increments.visitorsWithTwoOrMoreMatches = 1;
}
}
return updateDailyMetrics(date, now, increments);
}
async function recordDailyVisitorEvent(date, visitorId, activityField, now) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
if (activityField !== "matchStarts") {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
[activityField]: 1,
},
},
);
return false;
}
const secondMatchResult = await activityCollection.updateOne(
{
_id: activityId,
matchStarts: 1,
},
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
if (secondMatchResult.modifiedCount > 0) {
return true;
}
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
return false;
}
async function ensureDailyVisitorActivity(collection, date, visitorId, now) {
const visitorHash = dailyVisitorHash(date, visitorId);
const activityId = `${date}:${visitorHash}`;
await collection.updateOne(
{ _id: activityId },
{
$setOnInsert: {
_id: activityId,
date,
visitorHash,
dailyUniqueCounted: false,
visits: 0,
matchStarts: 0,
matchFinishes: 0,
donationClicks: 0,
firstSeenAt: now,
expireAt: retentionDate(now),
},
},
{ upsert: true },
);
return activityId;
}
async function updateDailyMetrics(date, now, increments) {
const collection = await getDailyMetricsCollection();
const normalizedIncrements = normalizeIncrements(increments);
await collection.updateOne(
{ _id: date },
{
$setOnInsert: {
_id: date,
date,
createdAt: now,
},
$set: {
updatedAt: now,
},
$inc: normalizedIncrements,
},
{ upsert: true },
);
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getTodayDailyMetrics(now = new Date()) {
const date = dayKey(now);
const collection = await getDailyMetricsCollection();
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getDailyMetricsCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_METRICS_COLLECTION || DEFAULT_DAILY_METRICS_COLLECTION_NAME,
);
await ensureDailyMetricsIndexes(collection);
return collection;
}
async function getDailyVisitorActivityCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION
|| DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME,
);
await ensureDailyVisitorActivityIndexes(collection);
return collection;
}
async function ensureDailyMetricsIndexes(collection) {
if (!metricsIndexesReady) {
metricsIndexesReady = collection.createIndex({ updatedAt: -1 });
}
return metricsIndexesReady;
}
async function ensureDailyVisitorActivityIndexes(collection) {
if (!activityIndexesReady) {
activityIndexesReady = Promise.all([
collection.createIndex({ date: 1 }),
collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }),
]);
}
return activityIndexesReady;
}
function normalizeIncrements(increments) {
return Object.entries(increments).reduce((result, [field, value]) => {
const numericValue = Math.max(0, Math.round(Number(value) || 0));
if (METRIC_FIELDS.includes(field) && numericValue > 0) {
result[field] = numericValue;
}
return result;
}, {});
}
function formatDailyMetrics(document, date) {
return {
date,
uniqueVisitors: metricNumber(document?.uniqueVisitors),
totalVisits: metricNumber(document?.totalVisits),
totalMatchStarts: metricNumber(document?.totalMatchStarts),
totalMatchFinishes: metricNumber(document?.totalMatchFinishes),
visitorsWithTwoOrMoreMatches: metricNumber(document?.visitorsWithTwoOrMoreMatches),
donationClicks: metricNumber(document?.donationClicks),
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
};
}
function metricNumber(value) {
return Math.max(0, Math.round(Number(value) || 0));
}
function dailyVisitorHash(date, visitorId) {
return createHash("sha256")
.update(`${date}:${visitorId}`)
.digest("hex")
.slice(0, 32);
}
function retentionDate(now) {
const retentionDays = getConfig().DAILY_ACTIVITY_RETENTION_DAYS || DEFAULT_ACTIVITY_RETENTION_DAYS;
return new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000);
}
function dayKey(date) {
const appConfig = getConfig();
const timeZone = appConfig.ANALYTICS_TIME_ZONE || appConfig.DEATH_STATS_TIME_ZONE || "Asia/Seoul";
try {
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
})
.formatToParts(date)
.reduce((result, part) => {
result[part.type] = part.value;
return result;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
} catch {
return date.toISOString().slice(0, 10);
}
}

View File

@ -4,6 +4,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { getConfig } from "./config.js";
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
import { aboutRoutes, warmAboutContent } from "./about.js";
import { dailyMetricsRoutes } from "./dailyMetrics.js";
import { deathStatsRoutes } from "./deathStats.js";
import { visitorRoutes } from "./visitors.js";
@ -45,12 +47,19 @@ if (!isProduction) {
}
await app.register(visitorRoutes, { prefix: "/api/visitors" });
await app.register(aboutRoutes, { prefix: "/api" });
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" });
if (isProduction) {
await app.register(fastifyStatic, {
root: distPath,
prefix: "/",
cacheControl: true,
maxAge: 3600000 * 24 * 7, // 7일간 캐시 유지
immutable: true,
lastModified: true,
etag: true,
});
app.setNotFoundHandler((request, reply) => {
@ -108,11 +117,17 @@ console.log(`Arena Picker listening on http://localhost:${port}`);
if (hasMongoConfig()) {
getMongoClient()
.then(() => {
.then(async () => {
console.log("MongoDB connection pool is ready.");
try {
await warmAboutContent();
console.log("About content cache is ready.");
} catch (error) {
console.error("About content cache warmup failed. API route will retry on request.", error);
}
})
.catch((error) => {
console.error("MongoDB connection failed. Visitor API will retry on request.", error);
console.error("MongoDB connection failed. API routes will retry on request.", error);
});
}

42
server/visitorCookie.js Normal file
View File

@ -0,0 +1,42 @@
export const VISITOR_COOKIE_NAME = "arena_visitor_id";
export const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
export function readVisitorCookie(request) {
return readCookie(request, VISITOR_COOKIE_NAME);
}
export function writeVisitorCookie(reply, visitorId, { secure = false } = {}) {
const secureFlag = secure ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${VISITOR_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
export function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}

View File

@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { recordDailyVisit } from "./dailyMetrics.js";
import { isValidVisitorId, readVisitorCookie, writeVisitorCookie } from "./visitorCookie.js";
const COOKIE_NAME = "arena_visitor_id";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
const DEFAULT_COLLECTION_NAME = "visitors";
const USER_AGENT_LIMIT = 500;
@ -28,7 +28,7 @@ async function recordVisitor(request, reply) {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
let visitorId = readCookie(request, COOKIE_NAME);
let visitorId = readVisitorCookie(request);
const hadValidCookie = isValidVisitorId(visitorId);
if (!hadValidCookie) {
@ -57,7 +57,13 @@ async function recordVisitor(request, reply) {
);
if (!hadValidCookie || result.upsertedCount > 0) {
writeVisitorCookie(reply, visitorId);
writeVisitorCookie(reply, visitorId, { secure: getConfig().COOKIE_SECURE });
}
try {
await recordDailyVisit(visitorId, { now });
} catch (error) {
request.log.warn({ err: error }, "Daily visit metrics update failed.");
}
return {
@ -80,38 +86,3 @@ async function ensureVisitorIndexes(collection) {
return indexesReady;
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}
function writeVisitorCookie(reply, visitorId) {
const secureFlag = getConfig().COOKIE_SECURE ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}

View File

@ -30,6 +30,7 @@ import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "../fighter/fighterSelection.js";
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
import {
@ -68,13 +69,14 @@ import {
} from "../../ui/battleDeathNotice.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
super("arena");
this.fighters = [];
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
this.matchPaused = false;
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
this.presentationMode = true;
this.ready = false;
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
@ -197,6 +199,7 @@ export class ArenaScene extends Phaser.Scene {
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
if (!silent) {
trackMatchStart();
this.setStatus(matchStatusText(this.teams));
} else {
this.focusPresentationCombat();
@ -906,6 +909,10 @@ update(time) {
}
finishMatch() {
if (this.matchOver) {
return;
}
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
@ -934,6 +941,7 @@ update(time) {
this.clearBattleNotice();
this.persistDailyDeathStats();
trackMatchFinish();
if (livingTeams.size === 1) {
const winningTeamId = Array.from(livingTeams)[0];
@ -942,5 +950,7 @@ update(time) {
} else {
this.setStatus("무승부!");
}
this.onMatchEnd();
}
}

View File

@ -6,9 +6,11 @@ import {
PRESENTATION_TEAM_SIZE,
} from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js";
import { createAboutDialog } from "./ui/aboutDialog.js";
import { trackVisitor } from "./ui/visitorCounter.js";
const matchForm = createMatchForm();
const aboutDialog = createAboutDialog();
const appNode = document.querySelector("#app");
const startButton = document.querySelector("#start-button");
const drawer = document.querySelector("#fighter-entry");
@ -18,6 +20,7 @@ const drawerToggleButton = document.querySelector("#drawer-toggle");
const playerNamesInput = document.querySelector("#player-names");
const pauseButton = document.querySelector("#pause-button");
const restartButton = document.querySelector("#restart-button");
const MOBILE_MATCH_MEDIA_QUERY = "(max-width: 960px)";
function isMatchLive() {
return appNode?.classList.contains("match-live") ?? false;
@ -26,6 +29,7 @@ function isMatchLive() {
function openOptionsDrawer({ focus = true } = {}) {
appNode?.classList.add("options-open");
setDrawerCollapsed(false);
resetDrawerScroll();
drawer?.setAttribute("aria-hidden", "false");
startButton?.setAttribute("aria-expanded", "true");
@ -53,8 +57,15 @@ function startConfiguredMatch(matchConfig) {
return;
}
appNode?.classList.remove("match-ended");
appNode?.classList.add("match-live");
if (shouldCompactOptionsDrawer()) {
setDrawerCollapsed(true);
} else {
openOptionsDrawer({ focus: false });
}
arenaScene.startMatch(matchConfig);
syncPauseButton();
}
@ -71,6 +82,11 @@ function setDrawerCollapsed(collapsed) {
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
drawer?.setAttribute("aria-hidden", "false");
if (!nextCollapsed) {
resetDrawerScroll();
}
syncDrawerToggleButton();
}
@ -95,6 +111,22 @@ function syncPauseButton() {
pauseButton.setAttribute("aria-pressed", String(isPaused));
}
function shouldCompactOptionsDrawer() {
return window.matchMedia?.(MOBILE_MATCH_MEDIA_QUERY).matches ?? window.innerWidth <= 960;
}
function resetDrawerScroll() {
if (drawer) {
drawer.scrollTop = 0;
}
}
function handleMatchEnd() {
appNode?.classList.add("match-ended");
setDrawerCollapsed(true);
syncPauseButton();
}
function revealAppWhenStylesAreReady() {
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
const reveal = () => {
@ -127,12 +159,17 @@ restartButton?.addEventListener("click", () => {
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (aboutDialog?.isOpen()) {
return;
}
closeOptionsDrawer();
}
});
const arenaScene = new ArenaScene({
getInitialMatchConfig: getPresentationMatchConfig,
onMatchEnd: handleMatchEnd,
setStatus: matchForm.setStatus,
});

View File

@ -273,28 +273,47 @@ textarea:focus-visible {
text-transform: uppercase;
}
.visitor-count {
.arena-meta {
position: fixed;
right: clamp(10px, 2vw, 18px);
bottom: clamp(10px, 2vw, 18px);
z-index: 5;
min-height: 26px;
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
pointer-events: none;
transition: opacity 220ms ease;
}
.visitor-count,
.about-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.18);
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 999px;
padding: 5px 9px;
background: rgb(8 10 7 / 0.58);
padding: 5px 12px;
background: rgb(8 10 7 / 0.68);
color: #e7c879;
font-size: 0.72rem;
font-weight: 800;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
line-height: 1;
text-decoration: none;
backdrop-filter: blur(10px);
pointer-events: auto;
transition:
opacity 220ms ease,
transform 220ms ease;
backdrop-filter: blur(8px);
background 180ms ease,
border-color 180ms ease,
transform 180ms ease,
opacity 220ms ease;
}
.visitor-count {
opacity: 0;
transform: translateY(8px);
}
#app.match-live .visitor-count {
@ -302,6 +321,19 @@ textarea:focus-visible {
transform: translateY(0);
}
.about-button {
min-width: 72px;
color: #ffe8b4;
font-weight: 900;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
}
.about-button:hover {
border-color: rgb(238 185 73 / 0.42);
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
.start-button,
form button[type="submit"],
.pause-button,
@ -373,6 +405,10 @@ form button[type="submit"]:hover,
color: #120f08;
}
#app.match-ended .pause-button {
display: none;
}
.drawer-scrim {
position: fixed;
inset: 0;
@ -549,6 +585,200 @@ h2 {
background: rgb(255 246 216 / 0.14);
}
.about-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: clamp(16px, 4vw, 34px);
background: rgb(3 5 4 / 0.66);
backdrop-filter: blur(8px);
}
.about-backdrop[hidden] {
display: none;
}
.about-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(560px, calc(100vw - 32px));
max-height: min(760px, calc(100svh - 32px));
overflow: hidden;
border: 1px solid rgb(239 199 103 / 0.28);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
#11140f;
box-shadow:
0 24px 100px rgb(0 0 0 / 0.62),
inset 0 1px 0 rgb(255 255 255 / 0.06);
outline: none;
}
.about-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
}
.about-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.about-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 0 clamp(20px, 4vw, 30px) 14px;
border-bottom: 1px solid rgb(238 185 73 / 0.16);
}
.about-tab {
min-width: 0;
min-height: 42px;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px 10px;
background: rgb(255 246 216 / 0.06);
color: #ead8ad;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.2;
}
.about-tab[aria-selected="true"] {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.about-panel {
min-height: 0;
overflow: auto;
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
}
.about-fields {
display: grid;
gap: 0;
margin: 0;
}
.about-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 50px;
border-bottom: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row:first-child {
border-top: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row dt {
color: #e3b24f;
font-size: 0.78rem;
font-weight: 950;
text-transform: uppercase;
}
.about-field-row dd {
min-width: 0;
margin: 0;
color: #fff7df;
font-weight: 800;
overflow-wrap: anywhere;
}
.about-field-row a,
.about-markdown a {
color: #85dcc7;
text-decoration-color: rgb(133 220 199 / 0.42);
text-underline-offset: 3px;
}
.about-markdown {
display: grid;
gap: 12px;
color: #ead8ad;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.56;
}
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
margin: 0;
}
.about-markdown h3,
.about-markdown h4,
.about-markdown h5,
.about-markdown h6 {
color: #fff3d2;
font-size: 1rem;
line-height: 1.3;
}
.about-markdown blockquote {
border-left: 3px solid rgb(238 185 73 / 0.36);
padding: 4px 0 4px 16px;
color: #c4b693;
font-style: italic;
}
.about-markdown hr {
margin: 8px 0;
border: 0;
border-top: 1px solid rgb(238 185 73 / 0.16);
}
.about-markdown code {
border: 1px solid rgb(238 185 73 / 0.14);
border-radius: 4px;
padding: 2px 5px;
background: rgb(255 246 216 / 0.08);
color: #f1c761;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.about-markdown li {
line-height: 1.5;
}
.about-markdown li strong {
color: #fff3d2;
}
.about-markdown ul {
display: grid;
gap: 8px;
padding-left: 1.2rem;
}
.about-empty {
color: #bfae83;
}
form {
display: grid;
gap: 16px;
@ -749,8 +979,8 @@ input[type="range"] {
.team-score.is-focused {
box-shadow:
0 0 0 2px rgb(255 244 209 / 0.92),
0 0 24px rgb(227 178 79 / 0.34);
inset 0 0 0 2px rgb(255 244 209 / 0.92),
0 0 18px rgb(227 178 79 / 0.26);
}
.team-score:disabled {
@ -1028,7 +1258,17 @@ input[type="range"] {
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
pointer-events: none;
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition:
opacity 220ms ease,
transform 220ms ease;
}
.victory-celebration.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.victory-celebration::before {
@ -1151,7 +1391,7 @@ input[type="range"] {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
animation: victory-message-pulse 1.1s 0.2s ease-out both;
animation: victory-message-pulse 720ms 80ms ease-out both;
}
.victory-celebration.is-draw .victory-banner {
@ -1374,7 +1614,7 @@ input[type="range"] {
@keyframes victory-message-pulse {
from {
opacity: 0;
opacity: 0.72;
transform: scale(0.88);
}
58% {
@ -1405,6 +1645,13 @@ input[type="range"] {
#app {
--arena-gap: 0px;
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
--score-panel-width: calc(100vw - 20px);
--score-rail-width: 0px;
@ -1415,8 +1662,8 @@ input[type="range"] {
}
#app.match-live #game {
width: min(100vw, calc(100svh - var(--score-band-height)));
height: min(100vw, calc(100svh - var(--score-band-height)));
width: var(--mobile-game-size);
height: var(--mobile-game-size);
margin-top: var(--score-band-height);
margin-left: 0;
}
@ -1434,6 +1681,101 @@ input[type="range"] {
padding: 22px;
}
#app.match-live .fighter-entry {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
gap: 10px;
padding: 12px;
}
#app.match-live.drawer-collapsed .fighter-entry {
top: calc(22px + env(safe-area-inset-top));
right: 10px;
left: auto;
width: auto;
}
#app.match-live .fighter-entry h2 {
font-size: clamp(1.45rem, 7vw, 1.8rem);
}
#app.match-live .fighter-entry textarea {
height: 112px;
min-height: 112px;
resize: none;
}
#app.match-live .fighter-entry fieldset {
gap: 7px;
padding: 9px;
}
#app.match-live .fighter-entry form {
gap: 10px;
}
#app.match-live .entry-copy {
gap: 4px;
}
#app.match-live .eyebrow {
font-size: 0.68rem;
}
#app.match-live label,
#app.match-live .spawn-placement-label {
font-size: 0.82rem;
}
#app.match-live input:not([type="range"]):not([type="radio"]),
#app.match-live textarea {
min-height: 40px;
padding-inline: 10px;
}
#app.match-live textarea {
padding-block: 9px;
}
#app.match-live .team-size-number {
width: 64px;
min-width: 64px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
font-size: 0.76rem;
}
#app.match-live .match-actions {
gap: 8px;
}
#app.match-live .match-actions button {
min-height: 44px;
}
#app.match-live .drawer-toggle {
min-width: 116px;
}
#app.match-live.drawer-collapsed .drawer-toggle {
width: var(--mobile-options-button-width);
min-width: var(--mobile-options-button-width);
padding-inline: 6px;
font-size: 0;
}
#app.match-live.drawer-collapsed .drawer-toggle::before {
content: "옵션";
font-size: 0.78rem;
line-height: 1;
}
.battle-preview {
opacity: 0.62;
}
@ -1454,22 +1796,62 @@ input[type="range"] {
}
.scoreboard {
align-items: flex-start;
top: 10px;
left: var(--score-panel-left);
width: var(--score-panel-width);
max-height: calc(var(--score-band-height) - 20px);
padding: 7px;
max-height: calc(var(--score-band-height) - 12px);
overflow-x: auto;
overflow-y: hidden;
padding: 9px min(148px, 38vw) 9px 9px;
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
scrollbar-width: thin;
touch-action: pan-x;
}
#app.match-live.drawer-collapsed .scoreboard {
width: calc(
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
);
padding-right: 9px;
}
.score-side {
gap: 5px;
display: grid;
grid-auto-columns: var(--mobile-team-card-width);
grid-auto-flow: column;
grid-template-rows: repeat(2, 48px);
gap: 5px 5px;
}
.scoreboard::-webkit-scrollbar {
height: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(238 185 73 / 0.38);
}
.team-score {
width: 90px;
min-height: 54px;
padding: 7px 8px 6px;
font-size: 0.72rem;
width: auto;
min-height: 0;
height: 48px;
gap: 3px;
padding: 5px 6px;
font-size: 0.66rem;
}
.team-score-count {
font-size: 0.74rem;
}
.team-score.is-focused {
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
}
.battle-notice {
@ -1487,21 +1869,77 @@ input[type="range"] {
}
.kill-log {
bottom: 10px;
top: var(--mobile-kill-log-top);
bottom: auto;
left: 10px;
width: calc(100vw - 20px);
max-height: 25vh;
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
padding: 8px;
}
#app.match-live .victory-celebration {
padding:
var(--score-band-height)
14px
min(30svh, 230px);
}
.victory-banner {
width: min(calc(100vw - 48px), 520px);
min-height: 92px;
padding: 1rem 1.1rem;
font-size: clamp(1.35rem, 7vw, 2rem);
}
.match-status {
bottom: 10px;
width: calc(100vw - 20px);
}
.visitor-count {
bottom: calc(10px + env(safe-area-inset-bottom));
.arena-meta {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
gap: 8px;
opacity: 1;
pointer-events: auto;
}
.visitor-count {
font-size: 0.68rem;
}
.about-button {
min-width: 68px;
min-height: 26px;
font-size: 0.68rem;
}
.about-backdrop {
align-items: end;
padding: 12px;
}
.about-dialog {
width: 100%;
max-height: calc(100svh - 24px);
}
.about-header {
padding: 18px 18px 12px;
}
.about-tabs {
padding: 0 18px 12px;
}
.about-panel {
padding: 16px 18px 22px;
}
.about-field-row {
grid-template-columns: 78px minmax(0, 1fr);
min-height: 48px;
gap: 10px;
}
}

379
src/ui/aboutDialog.js Normal file
View File

@ -0,0 +1,379 @@
const ABOUT_ENDPOINT = "/api/about";
const DEFAULT_ABOUT_CONTENT = {
developer: {
alias: "horoli",
email: "sunha321@gmail.com",
github: "https://github.com/Horoli",
},
privacyPolicy: {
markdown: "",
},
};
export function createAboutDialog() {
const openButton = document.querySelector("#about-button");
const backdrop = document.querySelector("#about-dialog");
const dialog = backdrop?.querySelector("[data-about-dialog]");
const closeButton = backdrop?.querySelector("[data-about-close]");
const tabs = [...(backdrop?.querySelectorAll("[data-about-tab]") ?? [])];
const panels = [...(backdrop?.querySelectorAll("[data-about-panel]") ?? [])];
const privacyContent = backdrop?.querySelector("#privacy-policy-content");
if (!openButton || !backdrop || !dialog || !closeButton || tabs.length === 0) {
return null;
}
let lastFocusedElement = null;
let loadPromise = null;
let loaded = false;
renderAboutContent(DEFAULT_ABOUT_CONTENT);
openButton.addEventListener("click", () => {
openDialog();
});
closeButton.addEventListener("click", () => {
closeDialog();
});
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) {
closeDialog();
}
});
dialog.addEventListener("keydown", trapFocus);
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !isOpen()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
closeDialog();
},
true,
);
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
selectTab(tab.dataset.aboutTab, { focus: true });
});
});
function openDialog() {
lastFocusedElement = document.activeElement;
backdrop.hidden = false;
document.body.classList.add("about-dialog-open");
openButton.setAttribute("aria-expanded", "true");
selectTab("developer");
window.requestAnimationFrame(() => {
dialog.focus();
});
loadAboutContent();
}
function closeDialog() {
backdrop.hidden = true;
document.body.classList.remove("about-dialog-open");
openButton.setAttribute("aria-expanded", "false");
if (lastFocusedElement instanceof HTMLElement) {
lastFocusedElement.focus();
}
}
function isOpen() {
return !backdrop.hidden;
}
function selectTab(tabName, { focus = false } = {}) {
const nextTabName = tabName || "developer";
tabs.forEach((tab) => {
const selected = tab.dataset.aboutTab === nextTabName;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
if (selected && focus) {
tab.focus();
}
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.aboutPanel !== nextTabName;
});
}
async function loadAboutContent() {
if (loaded) {
return;
}
if (!loadPromise) {
loadPromise = fetchAboutContent()
.then((content) => {
renderAboutContent(content);
loaded = true;
})
.catch((error) => {
console.warn(error);
renderMarkdown(
privacyContent,
"",
"개인정보처리방침을 불러오지 못했습니다.",
);
})
.finally(() => {
loadPromise = null;
});
}
await loadPromise;
}
function renderAboutContent(content) {
const normalizedContent = normalizeAboutContent(content);
setField("alias", normalizedContent.developer.alias);
setField("email", normalizedContent.developer.email);
setLinkField("github", normalizedContent.developer.github);
renderMarkdown(privacyContent, normalizedContent.privacyPolicy.markdown);
}
return {
close: closeDialog,
isOpen,
open: openDialog,
};
}
async function fetchAboutContent() {
const response = await fetch(ABOUT_ENDPOINT, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`About content fetch failed: ${response.status}`);
}
return response.json();
}
function setField(fieldName, value) {
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
if (field) {
field.textContent = value || "-";
}
}
function setLinkField(fieldName, value) {
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
if (!field) {
return;
}
field.textContent = "";
if (!value) {
field.textContent = "-";
return;
}
const link = document.createElement("a");
link.href = value;
link.rel = "noreferrer";
link.target = "_blank";
link.textContent = value;
field.appendChild(link);
}
function normalizeAboutContent(content = {}) {
return {
developer: {
alias: stringValue(content?.developer?.alias, DEFAULT_ABOUT_CONTENT.developer.alias),
email: stringValue(content?.developer?.email, DEFAULT_ABOUT_CONTENT.developer.email),
github: stringValue(content?.developer?.github, DEFAULT_ABOUT_CONTENT.developer.github),
},
privacyPolicy: {
markdown: stringValue(
content?.privacyPolicy?.markdown,
DEFAULT_ABOUT_CONTENT.privacyPolicy.markdown,
),
},
};
}
function renderMarkdown(container, markdown, emptyMessage = "개인정보처리방침이 아직 작성되지 않았습니다.") {
if (!container) {
return;
}
container.textContent = "";
const text = String(markdown || "").trim();
if (!text) {
const message = document.createElement("p");
message.className = "about-empty";
message.textContent = emptyMessage;
container.appendChild(message);
return;
}
const lines = text.replace(/\r\n?/g, "\n").split("\n");
let paragraphLines = [];
let list = null;
let blockquote = null;
const flushParagraph = () => {
if (paragraphLines.length === 0) {
return;
}
const paragraph = document.createElement("p");
appendInlineMarkdown(paragraph, paragraphLines.join(" "));
(blockquote || container).appendChild(paragraph);
paragraphLines = [];
};
const closeList = () => {
list = null;
};
const closeBlockquote = () => {
blockquote = null;
};
lines.forEach((line) => {
const trimmed = line.trim();
// Horizontal Rule
if (/^(?:---|[*]{3}|_{3})$/.test(trimmed)) {
flushParagraph();
closeList();
closeBlockquote();
container.appendChild(document.createElement("hr"));
return;
}
// Headings
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushParagraph();
closeList();
closeBlockquote();
const level = Math.min(heading[1].length + 2, 6);
const headingNode = document.createElement(`h${level}`);
appendInlineMarkdown(headingNode, heading[2]);
container.appendChild(headingNode);
return;
}
// Blockquote
const bqMatch = /^>\s?(.*)$/.exec(line);
if (bqMatch) {
flushParagraph();
closeList();
if (!blockquote) {
blockquote = document.createElement("blockquote");
container.appendChild(blockquote);
}
const content = bqMatch[1].trim();
if (content) {
const p = document.createElement("p");
appendInlineMarkdown(p, content);
blockquote.appendChild(p);
}
return;
}
// List Item
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
if (listItem) {
flushParagraph();
closeBlockquote();
if (!list) {
list = document.createElement("ul");
container.appendChild(list);
}
const item = document.createElement("li");
appendInlineMarkdown(item, listItem[1]);
list.appendChild(item);
return;
}
if (!trimmed) {
flushParagraph();
closeList();
closeBlockquote();
} else {
paragraphLines.push(trimmed);
}
});
flushParagraph();
}
function appendInlineMarkdown(parent, text) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = escaped
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)]+)\)/g,
'<a href="$2" rel="noreferrer" target="_blank">$1</a>',
);
parent.innerHTML = html;
}
function trapFocus(event) {
if (event.key !== "Tab") {
return;
}
const focusableElements = [
...event.currentTarget.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
].filter((element) => element.offsetParent !== null);
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string");
return value?.trim() ?? "";
}

45
src/ui/dailyMetrics.js Normal file
View File

@ -0,0 +1,45 @@
const DAILY_METRIC_ENDPOINTS = {
donationClicked: "/api/daily-metrics/donation-clicked",
matchFinished: "/api/daily-metrics/match-finished",
matchStarted: "/api/daily-metrics/match-started",
};
export function trackMatchStart() {
return postDailyMetric("matchStarted");
}
export function trackMatchFinish() {
return postDailyMetric("matchFinished");
}
export function trackDonationClick() {
return postDailyMetric("donationClicked");
}
async function postDailyMetric(type) {
const endpoint = DAILY_METRIC_ENDPOINTS[type];
if (!endpoint) {
return null;
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) {
throw new Error(`Daily metric update failed: ${response.status}`);
}
return response.json();
} catch (error) {
console.warn(error);
return null;
}
}

View File

@ -1,5 +1,7 @@
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
const VICTORY_CONFETTI_COUNT = 40;
const VICTORY_CELEBRATION_EXIT_MS = 260;
const VICTORY_CELEBRATION_VISIBLE_MS = 5200;
const VICTORY_FANFARE_NOTES = [
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
@ -34,8 +36,11 @@ export function createVictoryConfettiPiece(index) {
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
let victoryAudioContext = null;
let victoryDismissTimer = null;
let victoryRemoveTimer = null;
export function removeVictoryCelebration() {
clearVictoryCelebrationTimers();
document.querySelector(".victory-celebration")?.remove();
}
@ -72,13 +77,45 @@ export function createVictoryCelebration(message) {
banner.appendChild(messageNode);
celebration.append(rays, confetti, banner);
celebration.addEventListener("click", () => {
dismissVictoryCelebration(celebration);
});
celebrationHost.appendChild(celebration);
scheduleVictoryCelebrationDismiss(celebration);
if (isVictory) {
playVictoryFanfare();
}
}
function clearVictoryCelebrationTimers() {
window.clearTimeout(victoryDismissTimer);
window.clearTimeout(victoryRemoveTimer);
victoryDismissTimer = null;
victoryRemoveTimer = null;
}
function scheduleVictoryCelebrationDismiss(celebration) {
clearVictoryCelebrationTimers();
victoryDismissTimer = window.setTimeout(() => {
dismissVictoryCelebration(celebration);
}, VICTORY_CELEBRATION_VISIBLE_MS);
}
function dismissVictoryCelebration(celebration) {
if (!celebration?.isConnected || celebration.classList.contains("is-leaving")) {
return;
}
clearVictoryCelebrationTimers();
celebration.classList.add("is-leaving");
victoryRemoveTimer = window.setTimeout(() => {
celebration.remove();
victoryRemoveTimer = null;
}, VICTORY_CELEBRATION_EXIT_MS);
}
export function primeVictoryFanfareAudio() {
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;

37
todo.md
View File

@ -134,3 +134,40 @@
- `src/game` 폴더 내의 파일들을 역할별 하위 폴더(`arena/`, `combat/`, `fighter/`, `match/`)로 분류하여 재배치.
- 모든 `import` 경로를 새로운 계층 구조에 맞춰 업데이트하고 빌드 안정성을 확보.
- `ArenaScene.js`는 이제 각 모듈을 조율하는 오케스트레이션 역할에 집중하도록 경량화됨.
23. 일일 운영 지표 집계 추가 (완료)
- **조치 사항**:
- `server/dailyMetrics.js``/api/daily-metrics/today`, `/match-started`, `/match-finished`, `/donation-clicked` API를 추가.
- 날짜별 합산 문서에는 `uniqueVisitors`, `totalVisits`, `totalMatchStarts`, `totalMatchFinishes`, `visitorsWithTwoOrMoreMatches`, `donationClicks`만 저장.
- 날짜+방문자 UUID 해시 기준의 `daily_visitor_activity` 임시 카운터로 당일 2회 이상 매치 시작 방문자 수를 계산.
- 임시 카운터에는 TTL 인덱스를 적용하고, 기본 보관 기간을 `DAILY_ACTIVITY_RETENTION_DAYS` 60일로 설정.
- 프리뷰 전투는 제외하고 사용자가 시작한 실제 전투만 매치 시작/종료 지표에 반영.
24. 모바일 전투 화면 구성 및 종료 팝업 대응 (완료)
- **조치 사항**:
- 모바일에서 실제 전투 시작 시 옵션 drawer를 자동으로 접어 상단 HUD와 전투 화면을 먼저 보여주도록 변경.
- 전투 중 옵션을 다시 펼쳐도 패널이 좌우 화면 밖으로 밀리지 않도록 모바일 live drawer 위치와 크기 규칙을 보정.
- 모바일 킬 로그를 정사각형 전투 캔버스 바로 아래에 배치해 큰 빈 구간이 생기지 않도록 조정.
- 전투 종료 시 옵션 drawer를 접고 `match-ended` 상태를 부여해 승리/무승부 연출이 설정 폼 위에 겹치지 않게 처리.
- 승리 연출은 읽을 수 있는 시간 동안 표시한 뒤 자동으로 사라지며, 결과 텍스트가 더 빠르게 선명하게 보이도록 애니메이션을 조정.
- 모바일 접힘 상태의 옵션 버튼을 더 작게 표시하고 상단 팀 HUD를 두 줄 4열 레이아웃으로 바꿔 4개 이후 팀도 잘리지 않게 조정.
- 모바일 킬로그 최대 높이 계산에 방문자 카운터 안전 여백을 포함해 하단 방문자 카운터와 겹치지 않도록 보정.
- 모바일 팀 카드의 선택 표시를 내부 테두리로 바꿔 카드 외곽선이 부모 영역에서 잘려 보이지 않게 수정.
- 모바일 전투 중 옵션 drawer를 압축하고 닉네임 입력 높이를 고정해 전투 시작/재시작/일시정지 버튼이 한 화면에 보이도록 조정.
- 승리/무승부 연출 레이어를 클릭하면 즉시 닫히도록 처리.
25. About 다이얼로그 및 개인정보처리방침 보관 컬렉션 추가 (완료)
- **조치 사항**:
- `server/about.js`를 추가해 `about_content` 컬렉션에 `developer-info`, `privacy-policy` 기본 문서를 서버 시작 시 upsert하고 메모리에 캐시.
- 개발자정보 기본값을 `alias: horoli`, `email: sunha321@gmail.com`, `github: https://github.com/Horoli`로 설정.
- 개인정보처리방침은 DB의 `privacy-policy.markdown` 문자열 필드에 Markdown 원문으로 저장하고, 클라이언트에서는 안전한 DOM 노드로 렌더링.
- 대기 화면과 전투 화면에 공통 About 버튼을 추가하고 개발자정보/개인정보처리방침 탭 다이얼로그를 연결.
- 수정 API는 만들지 않고, 브라우저 표시용 `GET /api/about` 읽기 전용 API만 추가.
26. 전투 화면 'About' 버튼 위치 최적화 (완료)
- **조치 사항**:
- `index.html`에서 `about-button``visitor-count``arena-meta` 컨테이너로 통합하여 `arena-shell` 내부로 배치.
- `styles.css`에서 `arena-meta`에 flex 레이아웃을 적용하여 전투 중 방문자 수 표시와 About 버튼이 나란히 배치되도록 수정.
- 전투 중 드로어(옵션 패널)가 열릴 때 `arena-meta` 전체가 드로어 왼쪽으로 자연스럽게 이동하도록 반응형 스타일 적용.
- 모바일 환경에서도 두 요소가 겹치지 않고 하단 여백을 공유하며 적절히 배치되도록 미디어 쿼리 보정.