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 조립
|
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
|
||||||
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
||||||
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
|
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
|
||||||
|
│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API
|
||||||
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||||
├── public/ # 정적 리소스 (게임 에셋)
|
├── public/ # 정적 리소스 (게임 에셋)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
||||||
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
||||||
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
||||||
|
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
|
||||||
├── deathStats.js # 사망 통계 API 호출 래퍼
|
├── deathStats.js # 사망 통계 API 호출 래퍼
|
||||||
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
||||||
```
|
```
|
||||||
|
|
@ -89,6 +91,7 @@
|
||||||
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
||||||
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
||||||
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
||||||
|
- `GET /api/about`: 서버 시작 시 캐시한 개발자정보와 개인정보처리방침 Markdown 조회.
|
||||||
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
||||||
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@
|
||||||
"MONGODB_USER": "",
|
"MONGODB_USER": "",
|
||||||
"MONGODB_PASS": "",
|
"MONGODB_PASS": "",
|
||||||
"MONGODB_VISITOR_COLLECTION": "visitors",
|
"MONGODB_VISITOR_COLLECTION": "visitors",
|
||||||
|
"MONGODB_ABOUT_COLLECTION": "about_content",
|
||||||
"MONGODB_DAILY_DEATH_COLLECTION": "daily_death_stats",
|
"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_MAX_POOL_SIZE": 10,
|
||||||
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
|
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
|
||||||
"DEATH_STATS_TIME_ZONE": "Asia/Seoul",
|
"DEATH_STATS_TIME_ZONE": "Asia/Seoul",
|
||||||
|
"ANALYTICS_TIME_ZONE": "Asia/Seoul",
|
||||||
|
"DAILY_ACTIVITY_RETENTION_DAYS": 60,
|
||||||
"COOKIE_SECURE": false
|
"COOKIE_SECURE": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
### UI 컴포넌트 (`src/ui/`)
|
### UI 컴포넌트 (`src/ui/`)
|
||||||
- **`matchForm.js`**: 설정 폼 제어 및 `localStorage` 설정 유지.
|
- **`matchForm.js`**: 설정 폼 제어 및 `localStorage` 설정 유지.
|
||||||
|
- **`aboutDialog.js`**: About 버튼/다이얼로그, 개발자정보, 개인정보처리방침 Markdown 표시.
|
||||||
- **`arenaScoreboard.js`**: 좌측 HUD 레일의 팀 badge 업데이트 및 관전 시점 전환.
|
- **`arenaScoreboard.js`**: 좌측 HUD 레일의 팀 badge 업데이트 및 관전 시점 전환.
|
||||||
- **`arenaKillLog.js`**: 좌측 하단 킬로그 표시 및 관리.
|
- **`arenaKillLog.js`**: 좌측 하단 킬로그 표시 및 관리.
|
||||||
- **`battleDeathNotice.js`**: 상단 사망 통계 공지 UI.
|
- **`battleDeathNotice.js`**: 상단 사망 통계 공지 UI.
|
||||||
|
|
@ -23,8 +24,18 @@
|
||||||
### 전투 화면 레이아웃 (HUD)
|
### 전투 화면 레이아웃 (HUD)
|
||||||
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
|
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
|
||||||
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
|
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
|
||||||
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 무승부는 더 차분한 톤을 사용합니다.
|
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 방문자 카운터 안전 여백을 남겨 하단 카운터와 충돌하지 않게 합니다.
|
||||||
|
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
|
||||||
|
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
|
||||||
|
|
||||||
## 3. UI 개발 규칙
|
## 3. UI 개발 규칙
|
||||||
- **DOM 접근 최소화**: 성능 최적화를 위해 필요한 시점에만 최소한으로 DOM을 업데이트합니다.
|
- **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, 쿠키 보안 옵션을 정규화합니다.
|
- `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다.
|
||||||
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
|
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
|
||||||
- 전투 사망 통계 컬렉션(`MONGODB_DAILY_DEATH_COLLECTION`)과 집계 기준 타임존(`DEATH_STATS_TIME_ZONE`) 기본값을 제공합니다.
|
- 전투 사망 통계 컬렉션(`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`**:
|
- **`server/db.js`**:
|
||||||
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
|
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
|
||||||
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
|
- 종료 시 `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`**:
|
- **`server/deathStats.js`**:
|
||||||
- `GET /api/death-stats/today`: `DEATH_STATS_TIME_ZONE` 기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.
|
- `GET /api/death-stats/today`: `DEATH_STATS_TIME_ZONE` 기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.
|
||||||
- `POST /api/death-stats/today`: 전투 종료 시 전달된 `deathsBySpecies`를 오늘 일자별 누적 문서의 `deathsBySpecies`, `totalDeaths`, `battles`에 바로 더합니다.
|
- `POST /api/death-stats/today`: 전투 종료 시 전달된 `deathsBySpecies`를 오늘 일자별 누적 문서의 `deathsBySpecies`, `totalDeaths`, `battles`에 바로 더합니다.
|
||||||
|
|
@ -31,6 +44,16 @@
|
||||||
3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다.
|
3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다.
|
||||||
4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다.
|
4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다.
|
||||||
5. `countDocuments()`로 전체 유니크 방문자 수를 계산해 반환합니다.
|
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. 설정 규칙
|
## 3. 설정 규칙
|
||||||
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
|
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
|
||||||
- **MongoDB 연결**: 접속 정보는 `config.json`의 `MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB` 등으로 관리합니다.
|
- **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 미들웨어보다 우선순위를 가집니다.
|
- **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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Arena Picker</title>
|
<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>
|
<style>
|
||||||
html.app-booting,
|
html.app-booting,
|
||||||
html.app-booting body {
|
html.app-booting body {
|
||||||
|
|
@ -63,9 +64,21 @@
|
||||||
<span data-status-text>옵션 대기 중</span>
|
<span data-status-text>옵션 대기 중</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="visitor-count" class="visitor-count" aria-live="polite">
|
<div class="arena-meta">
|
||||||
방문자 확인 중
|
<p id="visitor-count" class="visitor-count" aria-live="polite">
|
||||||
</p>
|
방문자 확인 중
|
||||||
|
</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>
|
||||||
|
|
||||||
<section class="intro-stage" aria-label="Arena 시작 화면">
|
<section class="intro-stage" aria-label="Arena 시작 화면">
|
||||||
|
|
@ -207,6 +220,96 @@ Player 10</textarea
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</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>
|
</main>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</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_PASS: "",
|
||||||
MONGODB_URI: "",
|
MONGODB_URI: "",
|
||||||
MONGODB_VISITOR_COLLECTION: "visitors",
|
MONGODB_VISITOR_COLLECTION: "visitors",
|
||||||
|
MONGODB_ABOUT_COLLECTION: "about_content",
|
||||||
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
|
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_MAX_POOL_SIZE: 10,
|
||||||
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
|
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
|
||||||
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
|
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
|
||||||
|
ANALYTICS_TIME_ZONE: "Asia/Seoul",
|
||||||
|
DAILY_ACTIVITY_RETENTION_DAYS: 60,
|
||||||
COOKIE_SECURE: false,
|
COOKIE_SECURE: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -77,11 +82,26 @@ function normalizeConfig(rawConfig) {
|
||||||
mongodb.visitorCollection,
|
mongodb.visitorCollection,
|
||||||
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
|
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(
|
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
|
||||||
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
|
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
|
||||||
mongodb.dailyDeathCollection,
|
mongodb.dailyDeathCollection,
|
||||||
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
|
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(
|
MONGODB_MAX_POOL_SIZE: numberValue(
|
||||||
rawConfig.MONGODB_MAX_POOL_SIZE,
|
rawConfig.MONGODB_MAX_POOL_SIZE,
|
||||||
mongodb.maxPoolSize,
|
mongodb.maxPoolSize,
|
||||||
|
|
@ -98,6 +118,17 @@ function normalizeConfig(rawConfig) {
|
||||||
mongodb.deathStatsTimeZone,
|
mongodb.deathStatsTimeZone,
|
||||||
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
|
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),
|
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 { fileURLToPath } from "node:url";
|
||||||
import { getConfig } from "./config.js";
|
import { getConfig } from "./config.js";
|
||||||
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.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 { deathStatsRoutes } from "./deathStats.js";
|
||||||
import { visitorRoutes } from "./visitors.js";
|
import { visitorRoutes } from "./visitors.js";
|
||||||
|
|
||||||
|
|
@ -45,12 +47,19 @@ if (!isProduction) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.register(visitorRoutes, { prefix: "/api/visitors" });
|
await app.register(visitorRoutes, { prefix: "/api/visitors" });
|
||||||
|
await app.register(aboutRoutes, { prefix: "/api" });
|
||||||
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
|
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
|
||||||
|
await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" });
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: distPath,
|
root: distPath,
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
|
cacheControl: true,
|
||||||
|
maxAge: 3600000 * 24 * 7, // 7일간 캐시 유지
|
||||||
|
immutable: true,
|
||||||
|
lastModified: true,
|
||||||
|
etag: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler((request, reply) => {
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
|
@ -108,11 +117,17 @@ console.log(`Arena Picker listening on http://localhost:${port}`);
|
||||||
|
|
||||||
if (hasMongoConfig()) {
|
if (hasMongoConfig()) {
|
||||||
getMongoClient()
|
getMongoClient()
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
console.log("MongoDB connection pool is ready.");
|
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) => {
|
.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 { randomUUID } from "node:crypto";
|
||||||
import { getConfig } from "./config.js";
|
import { getConfig } from "./config.js";
|
||||||
import { getDb } from "./db.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 DEFAULT_COLLECTION_NAME = "visitors";
|
||||||
const USER_AGENT_LIMIT = 500;
|
const USER_AGENT_LIMIT = 500;
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ async function recordVisitor(request, reply) {
|
||||||
const collection = await getVisitorCollection();
|
const collection = await getVisitorCollection();
|
||||||
await ensureVisitorIndexes(collection);
|
await ensureVisitorIndexes(collection);
|
||||||
|
|
||||||
let visitorId = readCookie(request, COOKIE_NAME);
|
let visitorId = readVisitorCookie(request);
|
||||||
const hadValidCookie = isValidVisitorId(visitorId);
|
const hadValidCookie = isValidVisitorId(visitorId);
|
||||||
|
|
||||||
if (!hadValidCookie) {
|
if (!hadValidCookie) {
|
||||||
|
|
@ -57,7 +57,13 @@ async function recordVisitor(request, reply) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hadValidCookie || result.upsertedCount > 0) {
|
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 {
|
return {
|
||||||
|
|
@ -80,38 +86,3 @@ async function ensureVisitorIndexes(collection) {
|
||||||
return indexesReady;
|
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 { fighterManifest } from "../fighter/fighterManifest.js";
|
||||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||||
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
||||||
|
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
|
||||||
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
|
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
|
||||||
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
|
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -68,13 +69,14 @@ import {
|
||||||
} from "../../ui/battleDeathNotice.js";
|
} from "../../ui/battleDeathNotice.js";
|
||||||
|
|
||||||
export class ArenaScene extends Phaser.Scene {
|
export class ArenaScene extends Phaser.Scene {
|
||||||
constructor({ getInitialMatchConfig, setStatus }) {
|
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
|
||||||
super("arena");
|
super("arena");
|
||||||
this.fighters = [];
|
this.fighters = [];
|
||||||
this.getInitialMatchConfig = getInitialMatchConfig;
|
this.getInitialMatchConfig = getInitialMatchConfig;
|
||||||
this.matchId = 0;
|
this.matchId = 0;
|
||||||
this.matchOver = false;
|
this.matchOver = false;
|
||||||
this.matchPaused = false;
|
this.matchPaused = false;
|
||||||
|
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
|
||||||
this.presentationMode = true;
|
this.presentationMode = true;
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
|
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
|
||||||
|
|
@ -197,6 +199,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
|
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
|
trackMatchStart();
|
||||||
this.setStatus(matchStatusText(this.teams));
|
this.setStatus(matchStatusText(this.teams));
|
||||||
} else {
|
} else {
|
||||||
this.focusPresentationCombat();
|
this.focusPresentationCombat();
|
||||||
|
|
@ -906,6 +909,10 @@ update(time) {
|
||||||
}
|
}
|
||||||
|
|
||||||
finishMatch() {
|
finishMatch() {
|
||||||
|
if (this.matchOver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
|
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
|
||||||
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
|
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
|
||||||
|
|
||||||
|
|
@ -934,6 +941,7 @@ update(time) {
|
||||||
|
|
||||||
this.clearBattleNotice();
|
this.clearBattleNotice();
|
||||||
this.persistDailyDeathStats();
|
this.persistDailyDeathStats();
|
||||||
|
trackMatchFinish();
|
||||||
|
|
||||||
if (livingTeams.size === 1) {
|
if (livingTeams.size === 1) {
|
||||||
const winningTeamId = Array.from(livingTeams)[0];
|
const winningTeamId = Array.from(livingTeams)[0];
|
||||||
|
|
@ -942,5 +950,7 @@ update(time) {
|
||||||
} else {
|
} else {
|
||||||
this.setStatus("무승부!");
|
this.setStatus("무승부!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onMatchEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
src/main.js
39
src/main.js
|
|
@ -6,9 +6,11 @@ import {
|
||||||
PRESENTATION_TEAM_SIZE,
|
PRESENTATION_TEAM_SIZE,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { createMatchForm } from "./ui/matchForm.js";
|
import { createMatchForm } from "./ui/matchForm.js";
|
||||||
|
import { createAboutDialog } from "./ui/aboutDialog.js";
|
||||||
import { trackVisitor } from "./ui/visitorCounter.js";
|
import { trackVisitor } from "./ui/visitorCounter.js";
|
||||||
|
|
||||||
const matchForm = createMatchForm();
|
const matchForm = createMatchForm();
|
||||||
|
const aboutDialog = createAboutDialog();
|
||||||
const appNode = document.querySelector("#app");
|
const appNode = document.querySelector("#app");
|
||||||
const startButton = document.querySelector("#start-button");
|
const startButton = document.querySelector("#start-button");
|
||||||
const drawer = document.querySelector("#fighter-entry");
|
const drawer = document.querySelector("#fighter-entry");
|
||||||
|
|
@ -18,6 +20,7 @@ const drawerToggleButton = document.querySelector("#drawer-toggle");
|
||||||
const playerNamesInput = document.querySelector("#player-names");
|
const playerNamesInput = document.querySelector("#player-names");
|
||||||
const pauseButton = document.querySelector("#pause-button");
|
const pauseButton = document.querySelector("#pause-button");
|
||||||
const restartButton = document.querySelector("#restart-button");
|
const restartButton = document.querySelector("#restart-button");
|
||||||
|
const MOBILE_MATCH_MEDIA_QUERY = "(max-width: 960px)";
|
||||||
|
|
||||||
function isMatchLive() {
|
function isMatchLive() {
|
||||||
return appNode?.classList.contains("match-live") ?? false;
|
return appNode?.classList.contains("match-live") ?? false;
|
||||||
|
|
@ -26,6 +29,7 @@ function isMatchLive() {
|
||||||
function openOptionsDrawer({ focus = true } = {}) {
|
function openOptionsDrawer({ focus = true } = {}) {
|
||||||
appNode?.classList.add("options-open");
|
appNode?.classList.add("options-open");
|
||||||
setDrawerCollapsed(false);
|
setDrawerCollapsed(false);
|
||||||
|
resetDrawerScroll();
|
||||||
drawer?.setAttribute("aria-hidden", "false");
|
drawer?.setAttribute("aria-hidden", "false");
|
||||||
startButton?.setAttribute("aria-expanded", "true");
|
startButton?.setAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
|
|
@ -53,8 +57,15 @@ function startConfiguredMatch(matchConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appNode?.classList.remove("match-ended");
|
||||||
appNode?.classList.add("match-live");
|
appNode?.classList.add("match-live");
|
||||||
openOptionsDrawer({ focus: false });
|
|
||||||
|
if (shouldCompactOptionsDrawer()) {
|
||||||
|
setDrawerCollapsed(true);
|
||||||
|
} else {
|
||||||
|
openOptionsDrawer({ focus: false });
|
||||||
|
}
|
||||||
|
|
||||||
arenaScene.startMatch(matchConfig);
|
arenaScene.startMatch(matchConfig);
|
||||||
syncPauseButton();
|
syncPauseButton();
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +82,11 @@ function setDrawerCollapsed(collapsed) {
|
||||||
|
|
||||||
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
|
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
|
||||||
drawer?.setAttribute("aria-hidden", "false");
|
drawer?.setAttribute("aria-hidden", "false");
|
||||||
|
|
||||||
|
if (!nextCollapsed) {
|
||||||
|
resetDrawerScroll();
|
||||||
|
}
|
||||||
|
|
||||||
syncDrawerToggleButton();
|
syncDrawerToggleButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +111,22 @@ function syncPauseButton() {
|
||||||
pauseButton.setAttribute("aria-pressed", String(isPaused));
|
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() {
|
function revealAppWhenStylesAreReady() {
|
||||||
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
|
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
|
|
@ -127,12 +159,17 @@ restartButton?.addEventListener("click", () => {
|
||||||
});
|
});
|
||||||
window.addEventListener("keydown", (event) => {
|
window.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (aboutDialog?.isOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
closeOptionsDrawer();
|
closeOptionsDrawer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const arenaScene = new ArenaScene({
|
const arenaScene = new ArenaScene({
|
||||||
getInitialMatchConfig: getPresentationMatchConfig,
|
getInitialMatchConfig: getPresentationMatchConfig,
|
||||||
|
onMatchEnd: handleMatchEnd,
|
||||||
setStatus: matchForm.setStatus,
|
setStatus: matchForm.setStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
500
src/styles.css
500
src/styles.css
|
|
@ -273,28 +273,47 @@ textarea:focus-visible {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visitor-count {
|
.arena-meta {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: clamp(10px, 2vw, 18px);
|
right: clamp(10px, 2vw, 18px);
|
||||||
bottom: clamp(10px, 2vw, 18px);
|
bottom: clamp(10px, 2vw, 18px);
|
||||||
z-index: 5;
|
z-index: 10;
|
||||||
min-height: 26px;
|
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;
|
margin: 0;
|
||||||
border: 1px solid rgb(238 185 73 / 0.18);
|
border: 1px solid rgb(238 185 73 / 0.22);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 5px 9px;
|
padding: 5px 12px;
|
||||||
background: rgb(8 10 7 / 0.58);
|
background: rgb(8 10 7 / 0.68);
|
||||||
color: #e7c879;
|
color: #e7c879;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.2;
|
line-height: 1;
|
||||||
opacity: 0;
|
text-decoration: none;
|
||||||
pointer-events: none;
|
backdrop-filter: blur(10px);
|
||||||
transform: translateY(8px);
|
pointer-events: auto;
|
||||||
transition:
|
transition:
|
||||||
opacity 220ms ease,
|
background 180ms ease,
|
||||||
transform 220ms ease;
|
border-color 180ms ease,
|
||||||
backdrop-filter: blur(8px);
|
transform 180ms ease,
|
||||||
|
opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-count {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#app.match-live .visitor-count {
|
#app.match-live .visitor-count {
|
||||||
|
|
@ -302,6 +321,19 @@ textarea:focus-visible {
|
||||||
transform: translateY(0);
|
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,
|
.start-button,
|
||||||
form button[type="submit"],
|
form button[type="submit"],
|
||||||
.pause-button,
|
.pause-button,
|
||||||
|
|
@ -373,6 +405,10 @@ form button[type="submit"]:hover,
|
||||||
color: #120f08;
|
color: #120f08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app.match-ended .pause-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-scrim {
|
.drawer-scrim {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -549,6 +585,200 @@ h2 {
|
||||||
background: rgb(255 246 216 / 0.14);
|
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 {
|
form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
@ -749,8 +979,8 @@ input[type="range"] {
|
||||||
|
|
||||||
.team-score.is-focused {
|
.team-score.is-focused {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px rgb(255 244 209 / 0.92),
|
inset 0 0 0 2px rgb(255 244 209 / 0.92),
|
||||||
0 0 24px rgb(227 178 79 / 0.34);
|
0 0 18px rgb(227 178 79 / 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score:disabled {
|
.team-score:disabled {
|
||||||
|
|
@ -1028,7 +1258,17 @@ input[type="range"] {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgb(4 6 4 / 0.2);
|
background: rgb(4 6 4 / 0.2);
|
||||||
isolation: isolate;
|
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 {
|
.victory-celebration::before {
|
||||||
|
|
@ -1151,7 +1391,7 @@ input[type="range"] {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.victory-celebration.is-draw .victory-banner {
|
||||||
|
|
@ -1374,7 +1614,7 @@ input[type="range"] {
|
||||||
|
|
||||||
@keyframes victory-message-pulse {
|
@keyframes victory-message-pulse {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0.72;
|
||||||
transform: scale(0.88);
|
transform: scale(0.88);
|
||||||
}
|
}
|
||||||
58% {
|
58% {
|
||||||
|
|
@ -1405,6 +1645,13 @@ input[type="range"] {
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
--arena-gap: 0px;
|
--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-left: 10px;
|
||||||
--score-panel-width: calc(100vw - 20px);
|
--score-panel-width: calc(100vw - 20px);
|
||||||
--score-rail-width: 0px;
|
--score-rail-width: 0px;
|
||||||
|
|
@ -1415,8 +1662,8 @@ input[type="range"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
#app.match-live #game {
|
#app.match-live #game {
|
||||||
width: min(100vw, calc(100svh - var(--score-band-height)));
|
width: var(--mobile-game-size);
|
||||||
height: min(100vw, calc(100svh - var(--score-band-height)));
|
height: var(--mobile-game-size);
|
||||||
margin-top: var(--score-band-height);
|
margin-top: var(--score-band-height);
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1434,6 +1681,101 @@ input[type="range"] {
|
||||||
padding: 22px;
|
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 {
|
.battle-preview {
|
||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
}
|
}
|
||||||
|
|
@ -1454,22 +1796,62 @@ input[type="range"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard {
|
.scoreboard {
|
||||||
|
align-items: flex-start;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: var(--score-panel-left);
|
left: var(--score-panel-left);
|
||||||
width: var(--score-panel-width);
|
width: var(--score-panel-width);
|
||||||
max-height: calc(var(--score-band-height) - 20px);
|
max-height: calc(var(--score-band-height) - 12px);
|
||||||
padding: 7px;
|
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 {
|
.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 {
|
.team-score {
|
||||||
width: 90px;
|
width: auto;
|
||||||
min-height: 54px;
|
min-height: 0;
|
||||||
padding: 7px 8px 6px;
|
height: 48px;
|
||||||
font-size: 0.72rem;
|
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 {
|
.battle-notice {
|
||||||
|
|
@ -1487,21 +1869,77 @@ input[type="range"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.kill-log {
|
.kill-log {
|
||||||
bottom: 10px;
|
top: var(--mobile-kill-log-top);
|
||||||
|
bottom: auto;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
width: calc(100vw - 20px);
|
width: calc(100vw - 20px);
|
||||||
max-height: 25vh;
|
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
|
||||||
padding: 8px;
|
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 {
|
.match-status {
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
width: calc(100vw - 20px);
|
width: calc(100vw - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.visitor-count {
|
.arena-meta {
|
||||||
bottom: calc(10px + env(safe-area-inset-bottom));
|
|
||||||
right: 10px;
|
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;
|
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_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||||
const VICTORY_CONFETTI_COUNT = 40;
|
const VICTORY_CONFETTI_COUNT = 40;
|
||||||
|
const VICTORY_CELEBRATION_EXIT_MS = 260;
|
||||||
|
const VICTORY_CELEBRATION_VISIBLE_MS = 5200;
|
||||||
const VICTORY_FANFARE_NOTES = [
|
const VICTORY_FANFARE_NOTES = [
|
||||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
{ 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 };
|
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
|
||||||
|
|
||||||
let victoryAudioContext = null;
|
let victoryAudioContext = null;
|
||||||
|
let victoryDismissTimer = null;
|
||||||
|
let victoryRemoveTimer = null;
|
||||||
|
|
||||||
export function removeVictoryCelebration() {
|
export function removeVictoryCelebration() {
|
||||||
|
clearVictoryCelebrationTimers();
|
||||||
document.querySelector(".victory-celebration")?.remove();
|
document.querySelector(".victory-celebration")?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,13 +77,45 @@ export function createVictoryCelebration(message) {
|
||||||
|
|
||||||
banner.appendChild(messageNode);
|
banner.appendChild(messageNode);
|
||||||
celebration.append(rays, confetti, banner);
|
celebration.append(rays, confetti, banner);
|
||||||
|
celebration.addEventListener("click", () => {
|
||||||
|
dismissVictoryCelebration(celebration);
|
||||||
|
});
|
||||||
celebrationHost.appendChild(celebration);
|
celebrationHost.appendChild(celebration);
|
||||||
|
scheduleVictoryCelebrationDismiss(celebration);
|
||||||
|
|
||||||
if (isVictory) {
|
if (isVictory) {
|
||||||
playVictoryFanfare();
|
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() {
|
export function primeVictoryFanfareAudio() {
|
||||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||||
|
|
||||||
|
|
|
||||||
37
todo.md
37
todo.md
|
|
@ -134,3 +134,40 @@
|
||||||
- `src/game` 폴더 내의 파일들을 역할별 하위 폴더(`arena/`, `combat/`, `fighter/`, `match/`)로 분류하여 재배치.
|
- `src/game` 폴더 내의 파일들을 역할별 하위 폴더(`arena/`, `combat/`, `fighter/`, `match/`)로 분류하여 재배치.
|
||||||
- 모든 `import` 경로를 새로운 계층 구조에 맞춰 업데이트하고 빌드 안정성을 확보.
|
- 모든 `import` 경로를 새로운 계층 구조에 맞춰 업데이트하고 빌드 안정성을 확보.
|
||||||
- `ArenaScene.js`는 이제 각 모듈을 조율하는 오케스트레이션 역할에 집중하도록 경량화됨.
|
- `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