feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화
- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침) - Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원) - 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일 - 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지) - 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가 - 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선 - 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
parent
e4f542d487
commit
3e5c079e68
3
agent.md
3
agent.md
|
|
@ -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`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 여백 계산에 포함합니다.
|
||||
|
|
|
|||
|
|
@ -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는 만들지 않습니다.
|
||||
|
|
|
|||
109
index.html
109
index.html
|
|
@ -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>
|
||||
<p id="visitor-count" class="visitor-count" aria-live="polite">
|
||||
방문자 확인 중
|
||||
</p>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/main.js
39
src/main.js
|
|
@ -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");
|
||||
openOptionsDrawer({ focus: false });
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
|
|||
500
src/styles.css
500
src/styles.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
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() ?? "";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
37
todo.md
|
|
@ -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` 전체가 드로어 왼쪽으로 자연스럽게 이동하도록 반응형 스타일 적용.
|
||||
- 모바일 환경에서도 두 요소가 겹치지 않고 하단 여백을 공유하며 적절히 배치되도록 미디어 쿼리 보정.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue