diff --git a/CONTEXT.md b/CONTEXT.md
index edfbd06..95756a7 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -5,11 +5,14 @@
### [Core Engine]
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
- 앱 로드 시 `trackVisitor()`를 호출해 방문자 체크 API와 연동합니다.
+ - `Start` 버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시 `#app`에 `match-live` 상태 클래스를 부여합니다.
+ - 전투 중 drawer 접기/펼치기(`drawer-collapsed`), 재시작 버튼, 일시정지 버튼 상태(`match-paused`)를 DOM 클래스와 `ArenaScene` 상태에 동기화합니다.
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- - `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`: 처치 후 회복량과 크기/공격속도/이동속도 성장 배율.
- - `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 선택 실루엣의 캐릭터 이격 거리, 두께, 투명도.
+ - `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
+ - `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
+ - `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
@@ -22,9 +25,14 @@
- **`server/config.js`**:
- `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다.
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
+ - 전투 사망 통계 컬렉션(`MONGODB_DAILY_DEATH_COLLECTION`)과 집계 기준 타임존(`DEATH_STATS_TIME_ZONE`) 기본값을 제공합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
+- **`server/deathStats.js`**:
+ - `GET /api/death-stats/today`: `DEATH_STATS_TIME_ZONE` 기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.
+ - `POST /api/death-stats/today`: 전투 종료 시 전달된 `deathsBySpecies`를 오늘 일자별 누적 문서의 `deathsBySpecies`, `totalDeaths`, `battles`에 바로 더합니다.
+ - 집계 대상 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`로 제한합니다.
- **`server/visitors.js`**:
- `POST /api/visitors/check`: `arena_visitor_id` `HttpOnly` 쿠키를 확인하고 없으면 UUID를 발급합니다.
- MongoDB `visitors` 컬렉션에 `_id = visitorId`로 upsert해 방문자 1명당 1개 문서를 유지합니다.
@@ -32,24 +40,34 @@
### [Game Logic - src/game/]
- **`ArenaScene.js`**:
- - `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다.
+ - `update()`: 매 프레임 생존 팀을 체크하고 좌측 HUD 레일의 팀 badge를 갱신합니다.
+ - 최초 로드 시 `presentationMode`로 프리뷰 전투를 조용히 실행하고, 실제 전투 시작 전까지 가까운 교전 지점을 배경처럼 보여줍니다.
+ - `createFighterPlans()`: 매치 시작 시 배정된 스킨의 `traits.spawnMultiplier`를 반영해 실제 생성할 전투원 목록을 확장합니다.
+ - `spawnSplitFighters()`: Slime 사망 분열처럼 전투 중 새 전투원을 생성해야 하는 특성을 처리합니다.
+ - `recordDeath()`, `persistDailyDeathStats()`: 실제 전투에서만 사망 캐릭터의 `skin.species`를 종족별로 집계하고, 전투 종료 시 오늘 일자별 서버 사망 통계에 더합니다.
+ - `scheduleBattleNotice()`, `showBattleDeathNotice()`: 실제 전투가 5초 이상 지속되면 오늘 누적 사망 통계와 현재 전투 사망 수를 합산해 상단 안내바에 표시합니다. 안내는 2초 표시 후 10초 대기하는 주기로 반복됩니다.
+ - `setPaused()`, `togglePause()`: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개합니다. 프리뷰 전투(`presentationMode`)와 종료된 전투는 pause 대상에서 제외합니다.
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
- - `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 선택 상태를 설정하고 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다.
+ - `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. 팀 색상 마커는 선택 상태와 별개로 상시 표시됩니다.
+ - `selectRandomTeamFighter()`: 좌측 팀 badge 클릭 시 해당 팀의 생존 전투원 중 무작위 1명을 골라 카메라를 고정합니다.
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
- **`matchSetup.js`**:
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
- **`combat.js`**:
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다.
- - `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다.
+ - `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
+ - `clampFighterInsideArena()`: 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다.
+ - `maybeSplitFighter()`: 사망한 캐릭터의 `traits.splitOnDeath` 특성을 확인하고 분열 생성을 요청합니다.
- `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
### [Assets & UI]
-- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 선택용 노란 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠해 선택 윤곽이 캐릭터에 붙어 보이지 않도록 합니다.
-- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 선택 실루엣 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다.
-- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다.
-- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다.
-- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 `#visitor-count`에 표시합니다.
+- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 흰색 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠한 뒤, `fighterFactory.js`에서 팀 색상으로 tint 처리합니다.
+- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 팀 색상 마커 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. 생성 옵션의 `hp`, `maxHp`, `canSplitOnDeath`를 통해 개별 전투원의 체력과 분열 가능 여부를 지정할 수 있습니다.
+- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. 각 스킨은 사망 통계용 `species`를 가지며, `combat.type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. `stats`와 `traits`로 캐릭터별 기본 체력과 특수 규칙을 정의합니다.
+- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달하고, 검증/결과 상태 메시지를 DOM에 반영할 수 있는 setter를 제공합니다. 실제 전투 중 하단 안내바는 숨기고, 처치 내역은 `ArenaScene`의 좌측 하단 킬로그가 담당합니다.
+- **`deathStats.js`**: `GET /api/death-stats/today`, `POST /api/death-stats/today`를 호출하는 프론트엔드 API 래퍼입니다.
+- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 전투 화면 우측 하단의 `#visitor-count` 배지에 표시합니다.
## 2. 주요 로직 구현 세부 사항
@@ -65,15 +83,42 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
-### 캐릭터 선택 실루엣
-선택 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다.
+### 전투 진입 UI와 HUD 레이아웃
+초기 화면은 Phaser 프리뷰 전투와 CSS 스프라이트 프리뷰를 낮은 투명도의 배경으로 깔고, 중앙에 `Arena` 로고와 `Start` 버튼을 둡니다.
+
+1. `Start` 클릭 시 `#app.options-open`이 설정되고 우측 옵션 drawer가 열립니다.
+2. `전투 시작` 제출 시 `#app.match-live`가 설정되며 실제 전투가 시작됩니다. drawer는 닫히지 않고 우측 compact 패널로 유지됩니다.
+3. 전투 중 drawer 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다.
+4. drawer 안의 `재시작` 버튼은 현재 입력값으로 새 매치를 시작합니다.
+5. drawer 안의 `일시정지` 버튼은 `ArenaScene.togglePause()`를 호출하고, 정지 중에는 버튼 문구를 `계속`으로 바꾸며 `#app.match-paused`를 설정합니다.
+6. 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수 `--score-panel-width`, `--score-rail-width`로 계산되며 미니맵과 겹치지 않습니다.
+7. badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다.
+8. 전투 중 하단 `match-status` 안내바는 숨깁니다. 처치가 발생하면 좌측 하단 `kill-log`가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명, `manifest.key`를 표시합니다. 중앙에는 칼 아이콘과 `처치 >` 방향 텍스트를 둬 왼쪽 캐릭터가 오른쪽 캐릭터를 처치했다는 관계를 명확히 보여줍니다.
+9. 실제 전투가 5초 이상 지속되면 `#battle-notice` 상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다.
+10. 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은 `#visitor-count` 배지로 표시합니다.
+
+### 상시 팀 색상 실루엣
+팀 색상 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다.
1. `fighterAssets.js`가 로드된 원본 스프라이트시트의 alpha 데이터를 캔버스에서 읽습니다.
2. 원본 alpha 픽셀 주변 `SELECTED_FIGHTER_OUTLINE_GAP` 범위는 공백 마스크로 남깁니다.
-3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 노란색 outline을 칠합니다.
-4. `fighterFactory.js`가 선택된 캐릭터 뒤에 outline sprite를 배치하고, 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다.
+3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 흰색 outline을 칠합니다.
+4. `fighterFactory.js`가 생존 캐릭터 뒤에 `teamMarker` sprite를 배치하고, 팀 색상으로 tint 처리합니다.
+5. `syncFighterHud()`가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다.
-이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 선택 윤곽을 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 윤곽이 같은 배율로 따라갑니다.
+이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다.
+
+### 캐릭터별 특성: Slime
+Slime은 `fighterManifest.js`의 `stats`와 `traits`로 특수 규칙을 선언합니다.
+
+- `stats.maxHp: 1`: 처음 배정되어 스폰되는 Slime과 분열로 생성되는 Slime 모두 최대 체력이 1입니다.
+- `traits.spawnMultiplier: 10`: Slime 스킨이 배정된 기본 스폰 슬롯 1개를 실제 Slime 10마리로 확장합니다.
+- `traits.splitOnDeath.chance: 0.5`: Slime이 사망할 때 50% 확률로 분열합니다.
+- `traits.splitOnDeath.count: 2`: 분열 성공 시 같은 팀의 Slime 2마리를 사망 위치 근처에 생성합니다.
+- `traits.splitOnDeath.childMaxHp: 1`: 분열체도 최대 체력 1로 생성됩니다.
+- `traits.splitOnDeath.childCanSplit: false`: 분열체는 다시 분열하지 않습니다.
+
+처치 보상은 최대 체력을 증가시키지 않습니다. `applyKillReward()`는 현재 체력을 `maxHp` 상한 안에서 회복하고, 처치 누적 배율로 크기/공격속도/이동속도만 증가시킵니다. 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한되므로 Slime이 적을 처치해도 최대 체력은 1로 유지되고, 장기전에서 캐릭터 scale/속도가 무한정 커지지 않습니다.
### 유니크 방문자 체크
브라우저가 직접 MongoDB에 연결하지 않고, Fastify API가 MongoDB 커넥션 풀을 유지합니다.
@@ -86,13 +131,25 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
방문자 체크는 인증 기능이 아니며, 브라우저/쿠키 단위의 단순 유니크 카운트입니다.
+### 전투 사망 통계
+프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
+
+1. `fighterManifest.js`의 모든 스킨은 `species`를 가집니다. 사용 가능한 값은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`입니다.
+2. `combat.js`의 처치 흐름이 `ArenaScene.recordKill()`을 호출하면, `ArenaScene`은 피처치자의 `skin.species`를 현재 전투의 `battleDeathCounts`에 누적합니다.
+3. 전투가 5초 이상 이어지면 `GET /api/death-stats/today`로 가져온 오늘 집계와 현재 전투의 사망 수를 합산해 `#battle-notice`에 표시합니다.
+4. 전투 종료 시 `POST /api/death-stats/today`로 `deathsBySpecies`만 보냅니다.
+5. 서버는 별도의 매치별 문서를 만들지 않고, `daily_death_stats` 컬렉션에서 오늘 일자의 `battles`, `totalDeaths`, `deathsBySpecies.*` 값을 `$inc`로 갱신합니다.
+
## 3. 개발 및 유지보수 규칙
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
+- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species`를 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
+- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 기본 피해량은 `src/constants.js`의 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
-- **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
+- **캐릭터별 스탯/특성 추가**: 기본 체력은 `fighterManifest.js`의 `stats.maxHp`에 정의하고, 스폰 배율이나 사망 분열 같은 특성은 `traits`에 선언합니다. 전투 중 새 전투원을 만들 필요가 있는 특성은 `ArenaScene.js`의 생성 헬퍼를 통해 `this.fighters`와 `team.size`를 함께 갱신해야 합니다.
+- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. `config.json`은 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
- **기본 포트**: `SERVER_PORT` 기본값은 `9736`입니다.
- **MongoDB 연결**: DB 접속 정보는 `config.json`의 `MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB`, 선택적 `MONGODB_USER`, `MONGODB_PASS`로 관리합니다.
-- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당합니다. 개발 모드에서 Vite 미들웨어가 API 요청을 SPA HTML로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다.
+- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당합니다. 개발 모드에서 Vite 미들웨어가 API 요청을 SPA HTML로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다. 전투 사망 통계 API는 MongoDB 설정이 없으면 방문자 통계와 마찬가지로 503 응답을 반환합니다.
diff --git a/agent.md b/agent.md
index a4b9b25..1c572bf 100644
--- a/agent.md
+++ b/agent.md
@@ -1,10 +1,14 @@
# Agent: Arena Picker
+## 0. 필수
+
+- 작업이 완료되면 작업에 관련된 모든 문서를 업데이트한다
+
## 1. 프로젝트 정의
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
-서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수를 기록하는 간단한 방문자 통계 API를 제공합니다.
+서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수와 전투 사망 통계를 기록하는 간단한 통계 API를 제공합니다.
## 2. 프로젝트 전체 구조 (Directory Tree)
@@ -20,6 +24,7 @@
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
+│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
├── public/ # 정적 리소스 (게임 에셋)
│ └── assets/
@@ -27,37 +32,41 @@
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
└── src/ # 소스 코드 root
- ├── main.js # Phaser 게임 인스턴스 생성 및 초기화
+ ├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
- ├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션)
+ ├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바)
├── game/ # 게임 로직 모듈
- │ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어)
+ │ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, pause, 사망 통계, 좌측 HUD badge/킬로그 제어)
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
│ ├── combat.js # 전투 AI 및 피격 판정 로직
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리
- │ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 선택 실루엣 생성
- │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD 및 선택 윤곽 동기화
- │ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정)
+ │ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 팀 색상 실루엣 마스크 생성
+ │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력 및 팀 색상 마커 동기화
+ │ ├── fighterManifest.js# 캐릭터 스킨/종족/전투/스탯/특성 데이터 정의 (20종 캐릭터 상세 설정)
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산
└── ui/
├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어
+ ├── deathStats.js # 전투 사망 통계 API 호출
└── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신
```
## 3. 핵심 기능
-- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다.
+- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. 캐릭터 특성에 따라 실제 출전 수가 달라질 수 있으며, Slime은 배정된 기본 스폰 슬롯마다 10마리로 확장됩니다.
+- **전투 진입 및 제어 UI**: 최초 접속 화면은 투명 전투 프리뷰를 배경으로 `Arena` 로고와 `Start` 버튼을 표시합니다. `Start`를 누르면 우측 옵션 drawer가 열리고, 전투 시작 후에도 drawer는 compact 패널로 유지됩니다. 패널 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 전장 시야를 확보할 수 있으며, 패널 안에서 `재시작`과 `일시정지/계속`을 제어합니다.
- **지능형 카메라 시스템**:
- **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다.
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
- **역동적인 전투 연출**:
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
- `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다.
- - 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다.
-- **캐릭터 선택 관전**: 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 원본 스프라이트 알파 마스크를 바탕으로 1px 공백을 둔 노란 실루엣 윤곽이 표시됩니다.
-- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다.
-- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다.
+ - 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다. 누적 보상은 `KILL_GROWTH_MAX_MULTIPLIER` 상한으로 제한해 캐릭터가 필드/히트박스를 벗어나 전투가 끝나지 않는 상황을 방지합니다.
+ - 캐릭터별 종족(`human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`)과 스탯/특성을 `fighterManifest.js`에서 정의할 수 있습니다. Slime은 최대 체력 1이며, 사망 시 50% 확률로 최대 체력 1인 Slime 2마리로 분열합니다. 분열체는 다시 분열하지 않습니다.
+- **상시 팀 색상 표시 및 선택 관전**: 생존 캐릭터는 항상 팀 색상 실루엣 마커를 표시합니다. 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. 좌측 팀 badge를 클릭하면 해당 팀의 생존 캐릭터 중 무작위 1명으로 시점이 고정됩니다.
+- **실시간 경기 중계 UI**: 팀 badge는 경기장 밖 좌측 HUD 레일에 고정되어 미니맵을 가리지 않습니다. 각 badge는 팀명, 구분선, 현재 생존 인원 순서로 표시되며, 클릭 가능한 관전 진입점으로 동작합니다. 전투 중 하단 안내바는 숨기고, 좌측 하단 킬로그에 처치자와 피처치자를 좌우로 나눠 작은 캐릭터 이미지, 팀명, `manifest.key`, 칼 아이콘, `처치 >` 방향 표시를 목록으로 보여줍니다. 실제 전투가 5초 이상 지속되면 작은 상단 안내바에 오늘의 종족별 사망 집계를 2초 표시하고 10초 쉬는 주기로 재치 있게 보여줍니다. 승리 시 대형 배너로 결과를 알립니다.
+- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다. 방문자 수는 메인 화면이 아니라 실제 전투 화면의 우측 하단에 작은 배지로 표시합니다.
+- **전투 사망 통계**: 실제 전투에서 사망한 캐릭터를 종족별로 누적하고, 전투 종료 시 `POST /api/death-stats/today`로 오늘 일자별 집계 문서에 카운트를 더합니다. 별도의 매치별 사망 통계 문서는 저장하지 않습니다.
## 4. 기술 사양
@@ -75,6 +84,8 @@
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
+ - `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
+ - `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
## 6. 관련 문서
diff --git a/config.json.sample b/config.json.sample
index 63c51e3..9b7d0a4 100644
--- a/config.json.sample
+++ b/config.json.sample
@@ -7,7 +7,9 @@
"MONGODB_USER": "",
"MONGODB_PASS": "",
"MONGODB_VISITOR_COLLECTION": "visitors",
+ "MONGODB_DAILY_DEATH_COLLECTION": "daily_death_stats",
"MONGODB_MAX_POOL_SIZE": 10,
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
+ "DEATH_STATS_TIME_ZONE": "Asia/Seoul",
"COOKIE_SECURE": false
}
diff --git a/index.html b/index.html
index e8f4ff7..5b65f68 100644
--- a/index.html
+++ b/index.html
@@ -1,17 +1,94 @@
-
+
Arena Picker
+
+
-
-
-
-
Arena Picker
-
팀 전투 뽑기
-
방문자 확인 중
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 옵션 대기 중
+ 옵션 대기 중
+
+
+ 방문자 확인 중
+
+
+
+
+
Arena
+
+ Start
+
+
+
+
+
+
+
- 전투 시작
+
+ 전투 시작
+ 재시작
+ 일시정지
+
-
-
+
diff --git a/server/config.js b/server/config.js
index b21b3a2..96c0d53 100644
--- a/server/config.js
+++ b/server/config.js
@@ -11,8 +11,10 @@ const DEFAULT_CONFIG = {
MONGODB_PASS: "",
MONGODB_URI: "",
MONGODB_VISITOR_COLLECTION: "visitors",
+ MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
+ DEATH_STATS_TIME_ZONE: "Asia/Seoul",
COOKIE_SECURE: false,
};
@@ -47,7 +49,7 @@ export function getMongoUri() {
}
if (!appConfig.MONGODB_HOST) {
- throw new Error("MongoDB configuration is required for visitor tracking.");
+ throw new Error("MongoDB configuration is required for arena tracking.");
}
const host = appConfig.MONGODB_HOST;
@@ -75,6 +77,11 @@ function normalizeConfig(rawConfig) {
mongodb.visitorCollection,
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
),
+ MONGODB_DAILY_DEATH_COLLECTION: stringValue(
+ rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
+ mongodb.dailyDeathCollection,
+ DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
+ ),
MONGODB_MAX_POOL_SIZE: numberValue(
rawConfig.MONGODB_MAX_POOL_SIZE,
mongodb.maxPoolSize,
@@ -85,6 +92,12 @@ function normalizeConfig(rawConfig) {
mongodb.serverSelectionTimeoutMs,
DEFAULT_CONFIG.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
),
+ DEATH_STATS_TIME_ZONE: stringValue(
+ rawConfig.DEATH_STATS_TIME_ZONE,
+ rawConfig.TIME_ZONE,
+ mongodb.deathStatsTimeZone,
+ DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
+ ),
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
};
}
diff --git a/server/deathStats.js b/server/deathStats.js
new file mode 100644
index 0000000..f6ec148
--- /dev/null
+++ b/server/deathStats.js
@@ -0,0 +1,156 @@
+import { getConfig } from "./config.js";
+import { getDb } from "./db.js";
+
+const DEFAULT_DAILY_COLLECTION_NAME = "daily_death_stats";
+const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
+
+let dailyIndexesReady;
+
+export async function deathStatsRoutes(fastify) {
+ fastify.get("/today", async () => {
+ return getTodayDeathStats();
+ });
+
+ fastify.post("/today", async (request) => {
+ const payload = parseJsonBody(request.body);
+ const deathsBySpecies = normalizeDeathCounts(payload.deathsBySpecies);
+ const totalDeaths = totalCount(deathsBySpecies);
+ const now = new Date();
+ const date = dayKey(now);
+ const collection = await getDailyCollection();
+
+ await ensureDailyDeathStatsIndex(collection);
+
+ if (totalDeaths === 0) {
+ const today = await collection.findOne({ _id: date });
+
+ return {
+ saved: false,
+ today: formatDailyStats(today, date),
+ };
+ }
+
+ await collection.updateOne(
+ { _id: date },
+ {
+ $setOnInsert: {
+ _id: date,
+ date,
+ createdAt: now,
+ },
+ $set: {
+ updatedAt: now,
+ },
+ $inc: {
+ battles: 1,
+ totalDeaths,
+ ...speciesIncrements(deathsBySpecies),
+ },
+ },
+ { upsert: true },
+ );
+
+ const today = await collection.findOne({ _id: date });
+
+ return {
+ saved: true,
+ today: formatDailyStats(today, date),
+ };
+ });
+}
+
+async function getTodayDeathStats() {
+ const collection = await getDailyCollection();
+ await ensureDailyDeathStatsIndex(collection);
+
+ const date = dayKey(new Date());
+ const today = await collection.findOne({ _id: date });
+
+ return formatDailyStats(today, date);
+}
+
+async function getDailyCollection() {
+ const db = await getDb();
+ return db.collection(
+ getConfig().MONGODB_DAILY_DEATH_COLLECTION || DEFAULT_DAILY_COLLECTION_NAME,
+ );
+}
+
+async function ensureDailyDeathStatsIndex(collection) {
+ if (!dailyIndexesReady) {
+ dailyIndexesReady = collection.createIndex({ updatedAt: -1 });
+ }
+
+ return dailyIndexesReady;
+}
+
+function parseJsonBody(body) {
+ if (!body) {
+ return {};
+ }
+
+ if (typeof body === "object") {
+ return body;
+ }
+
+ try {
+ return JSON.parse(body);
+ } catch {
+ return {};
+ }
+}
+
+function normalizeDeathCounts(value = {}) {
+ return SPECIES_KEYS.reduce((counts, species) => {
+ counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
+ return counts;
+ }, {});
+}
+
+function speciesIncrements(deathsBySpecies) {
+ return SPECIES_KEYS.reduce((increments, species) => {
+ increments[`deathsBySpecies.${species}`] = deathsBySpecies[species] ?? 0;
+ return increments;
+ }, {});
+}
+
+function formatDailyStats(document, date) {
+ const deathsBySpecies = normalizeDeathCounts(document?.deathsBySpecies);
+
+ return {
+ date,
+ battles: Math.max(0, Math.round(Number(document?.battles) || 0)),
+ deathsBySpecies,
+ totalDeaths: Math.max(
+ totalCount(deathsBySpecies),
+ Math.round(Number(document?.totalDeaths) || 0),
+ ),
+ updatedAt: document?.updatedAt?.toISOString?.() ?? null,
+ };
+}
+
+function totalCount(deathsBySpecies) {
+ return SPECIES_KEYS.reduce((sum, species) => sum + (deathsBySpecies[species] ?? 0), 0);
+}
+
+function dayKey(date) {
+ const timeZone = getConfig().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);
+ }
+}
diff --git a/server/index.js b/server/index.js
index bbd2e59..d46df00 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,10 +1,10 @@
-import fastifyMiddie from "@fastify/middie";
import fastifyStatic from "@fastify/static";
import Fastify from "fastify";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getConfig } from "./config.js";
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
+import { deathStatsRoutes } from "./deathStats.js";
import { visitorRoutes } from "./visitors.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -13,6 +13,7 @@ const isProduction = process.env.NODE_ENV === "production" || process.argv.inclu
const appConfig = getConfig();
const port = appConfig.SERVER_PORT;
const host = appConfig.SERVER_HOST;
+let viteDevServer;
const app = Fastify({
bodyLimit: 16 * 1024,
@@ -29,7 +30,22 @@ app.get("/api/health", async () => {
};
});
+if (!isProduction) {
+ const { createServer: createViteServer } = await import("vite");
+ viteDevServer = await createViteServer({
+ root,
+ server: {
+ middlewareMode: true,
+ hmr: {
+ server: app.server,
+ },
+ },
+ appType: "spa",
+ });
+}
+
await app.register(visitorRoutes, { prefix: "/api/visitors" });
+await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
if (isProduction) {
await app.register(fastifyStatic, {
@@ -48,27 +64,31 @@ if (isProduction) {
reply.sendFile("index.html");
});
} else {
- await app.register(fastifyMiddie);
-
- const { createServer: createViteServer } = await import("vite");
- const vite = await createViteServer({
- root,
- server: {
- middlewareMode: true,
- hmr: {
- server: app.server,
- },
- },
- appType: "spa",
- });
-
- app.use((request, response, next) => {
- if (request.url?.startsWith("/api/")) {
- next();
+ app.setNotFoundHandler((request, reply) => {
+ if (request.url.startsWith("/api/")) {
+ reply.code(404).send({ error: "not_found" });
return;
}
- vite.middlewares(request, response, next);
+ reply.hijack();
+ viteDevServer.middlewares(request.raw, reply.raw, (error) => {
+ if (error) {
+ viteDevServer.ssrFixStacktrace(error);
+ console.error(error);
+
+ if (!reply.raw.headersSent) {
+ reply.raw.statusCode = 500;
+ reply.raw.end(error.stack);
+ }
+
+ return;
+ }
+
+ if (!reply.raw.headersSent && !reply.raw.writableEnded) {
+ reply.raw.statusCode = 404;
+ reply.raw.end("Not found");
+ }
+ });
});
}
@@ -79,7 +99,7 @@ app.setErrorHandler((error, request, reply) => {
console.error(error);
reply.code(status).send({
error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error",
- message: isProduction ? "Visitor tracking is unavailable." : error.message,
+ message: isProduction ? "Arena tracking is unavailable." : error.message,
});
});
diff --git a/src/constants.js b/src/constants.js
index 63a2764..63e5e4c 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -39,6 +39,8 @@ export const KILL_HEAL_EFFECT_FRAMES = 4;
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
export const KILL_GROWTH_MULTIPLIER = 1.25;
+// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
+export const KILL_GROWTH_MAX_MULTIPLIER = 5;
// 처치 성장 연출 tween 지속 시간(ms)입니다.
export const KILL_GROWTH_TWEEN_DURATION = 180;
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
@@ -152,3 +154,50 @@ export const TEAM_COLORS = [
"#63c5a6",
"#d98755",
];
+
+const TEAM_COLOR_GOLDEN_ANGLE = 137.508;
+const TEAM_COLOR_HUE_OFFSET = 12;
+const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
+const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
+
+export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
+ const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
+ const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
+
+ if (safeTeamCount <= TEAM_COLORS.length) {
+ return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
+ }
+
+ const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
+ const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
+ const lightness =
+ TEAM_COLOR_LIGHTNESSES[
+ Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length
+ ];
+
+ return hslToHex(hue, saturation, lightness);
+}
+
+function hslToHex(hue, saturation, lightness) {
+ const normalizedSaturation = saturation / 100;
+ const normalizedLightness = lightness / 100;
+ const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
+ const huePrime = hue / 60;
+ const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
+ const match = normalizedLightness - chroma / 2;
+ const [red, green, blue] = huePrime < 1
+ ? [chroma, x, 0]
+ : huePrime < 2
+ ? [x, chroma, 0]
+ : huePrime < 3
+ ? [0, chroma, x]
+ : huePrime < 4
+ ? [0, x, chroma]
+ : huePrime < 5
+ ? [x, 0, chroma]
+ : [chroma, 0, x];
+
+ return `#${[red, green, blue]
+ .map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0"))
+ .join("")}`;
+}
diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js
index bb9ef2d..453198e 100644
--- a/src/game/ArenaScene.js
+++ b/src/game/ArenaScene.js
@@ -23,6 +23,7 @@ import { createFighter, syncFighterHud } from "./fighterFactory.js";
import { fighterManifest } from "./fighterManifest.js";
import { pickFighters } from "./fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
+import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
@@ -31,23 +32,35 @@ export class ArenaScene extends Phaser.Scene {
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
+ this.matchPaused = false;
+ this.presentationMode = true;
this.ready = false;
+ this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
this.setStatus = (message) => {
- // 기존 배너 제거
+ this.updateStatus(message);
+
const oldBanner = document.querySelector(".victory-banner");
if (oldBanner) oldBanner.remove();
- // 승리 또는 무승부 메시지인 경우 전용 배너 생성
if (message.includes("승리") || message.includes("무승부")) {
const banner = document.createElement("div");
banner.className = "victory-banner";
banner.textContent = message;
- document.querySelector(".arena-shell").appendChild(banner);
+ document.querySelector(".arena-shell")?.appendChild(banner);
}
};
this.observedCombat = [];
this.selectedFighter = null;
this.teams = [];
+ this.killLogNode = null;
+ this.killLogListNode = null;
+ this.battleNoticeHideTimer = null;
+ this.battleNoticeNode = null;
+ this.battleNoticeSequence = 0;
+ this.battleNoticeTimer = null;
+ this.battleDeathCounts = createDeathCounts();
+ this.deathStatsBaseline = createDeathCounts();
+ this.deathStatsSaved = false;
}
preload() {
@@ -99,10 +112,10 @@ export class ArenaScene extends Phaser.Scene {
});
this.ready = true;
- this.startMatch(this.getInitialMatchConfig());
+ this.startMatch(this.getInitialMatchConfig(), { silent: true });
}
- startMatch({ names = [], teamSize } = {}) {
+ startMatch({ names = [], teamSize } = {}, { silent = false } = {}) {
if (!this.ready) {
return;
}
@@ -114,29 +127,257 @@ export class ArenaScene extends Phaser.Scene {
const matchSetup = createMatchSetup(names, teamSize);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
+ const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins);
+ syncTeamSizes(matchSetup.teams, fighterPlans);
this.matchId += 1;
this.matchOver = false;
+ this.setPaused(false, { silent: true });
+ this.presentationMode = silent;
+ this.resetMatchDeathStats({ silent });
this.observedCombat = [];
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.destroy());
+ this.resetKillLog();
this.teams = matchSetup.teams;
- this.fighters = matchSetup.fighters.map((fighterSetup, index) =>
- createFighter(this, {
- ...fighterSetup,
- skin: matchSkins[index],
- }),
+ this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
+
+ if (!silent) {
+ this.setStatus(matchStatusText(this.teams));
+ } else {
+ this.focusPresentationCombat();
+ }
+
+ this.updateScoreboard();
+ }
+
+ spawnSplitFighters(source, splitOnDeath) {
+ const count = Math.max(0, Math.round(splitOnDeath.count ?? 0));
+ const childMaxHp = Math.max(1, Math.round(splitOnDeath.childMaxHp ?? 1));
+
+ if (count === 0) {
+ return [];
+ }
+
+ const children = Array.from({ length: count }, (_, index) => {
+ const position = clusterSpawnPosition(source, index, count);
+
+ return createFighter(this, {
+ canSplitOnDeath: Boolean(splitOnDeath.childCanSplit),
+ faceLeft: source.flipX,
+ hp: childMaxHp,
+ maxHp: childMaxHp,
+ name: source.name,
+ skin: source.skin,
+ team: source.team,
+ teamIndex: source.teamIndex,
+ x: position.x,
+ y: position.y,
+ });
+ });
+
+ this.fighters.push(...children);
+
+ const team = this.teams.find((candidate) => candidate.id === source.team.id);
+ if (team) {
+ team.size += children.length;
+ }
+
+ return children;
+ }
+
+ resetKillLog() {
+ const { logNode, listNode } = this.getKillLogNodes();
+
+ if (listNode) {
+ listNode.replaceChildren();
+ }
+
+ logNode?.classList.remove("has-entries");
+ logNode?.setAttribute("aria-hidden", "true");
+ }
+
+ resetMatchDeathStats({ silent = false } = {}) {
+ this.clearBattleNotice();
+ this.battleDeathCounts = createDeathCounts();
+ this.battleNoticeSequence = 0;
+ this.deathStatsBaseline = createDeathCounts();
+ this.deathStatsSaved = false;
+
+ if (!silent) {
+ this.loadTodayDeathStats();
+ this.scheduleBattleNotice();
+ }
+ }
+
+ loadTodayDeathStats() {
+ const activeMatchId = this.matchId;
+
+ fetchTodayDeathStats()
+ .then((stats) => {
+ if (this.matchId !== activeMatchId || this.presentationMode) {
+ return;
+ }
+
+ this.deathStatsBaseline = normalizeDeathCounts(stats?.deathsBySpecies);
+ })
+ .catch((error) => {
+ console.warn(error);
+ });
+ }
+
+ scheduleBattleNotice(delayMs = BATTLE_NOTICE_DELAY_MS) {
+ this.battleNoticeTimer?.remove(false);
+ this.battleNoticeTimer = this.time.delayedCall(delayMs, () => {
+ this.battleNoticeTimer = null;
+
+ if (!this.matchOver && !this.presentationMode) {
+ this.showBattleDeathNotice();
+ }
+ });
+ }
+
+ clearBattleNotice() {
+ this.battleNoticeTimer?.remove(false);
+ this.battleNoticeHideTimer?.remove(false);
+ this.battleNoticeTimer = null;
+ this.battleNoticeHideTimer = null;
+
+ const noticeNode = this.getBattleNoticeNode();
+
+ noticeNode?.classList.remove("is-visible");
+ noticeNode?.setAttribute("aria-hidden", "true");
+ }
+
+ recordDeath(fighter) {
+ if (this.presentationMode) {
+ return;
+ }
+
+ const species = normalizeSpecies(fighter?.skin?.species);
+ this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1;
+ }
+
+ showBattleDeathNotice() {
+ const noticeNode = this.getBattleNoticeNode();
+
+ if (!noticeNode) {
+ return;
+ }
+
+ noticeNode.textContent = createDeathNoticeMessage(
+ addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
+ this.matchId + this.battleNoticeSequence,
+ );
+ this.battleNoticeSequence += 1;
+ noticeNode.classList.add("is-visible");
+ noticeNode.setAttribute("aria-hidden", "false");
+
+ this.battleNoticeHideTimer?.remove(false);
+ this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
+ this.battleNoticeHideTimer = null;
+ noticeNode.classList.remove("is-visible");
+ noticeNode.setAttribute("aria-hidden", "true");
+
+ if (!this.matchOver && !this.presentationMode) {
+ this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
+ }
+ });
+ }
+
+ getBattleNoticeNode() {
+ this.battleNoticeNode ??= document.getElementById("battle-notice");
+ return this.battleNoticeNode;
+ }
+
+ persistDailyDeathStats() {
+ if (this.deathStatsSaved || this.presentationMode) {
+ return;
+ }
+
+ this.deathStatsSaved = true;
+
+ addTodayDeathStats({
+ deathsBySpecies: { ...this.battleDeathCounts },
+ })
+ .then((result) => {
+ this.deathStatsBaseline = normalizeDeathCounts(result?.today?.deathsBySpecies);
+ })
+ .catch((error) => {
+ console.warn(error);
+ });
+ }
+
+ recordKill(winner, defender) {
+ if (this.presentationMode) {
+ return;
+ }
+
+ this.recordDeath(defender);
+
+ const { logNode, listNode } = this.getKillLogNodes();
+
+ if (!logNode || !listNode) {
+ return;
+ }
+
+ const item = document.createElement("li");
+ const killer = killLogFighterParts(winner);
+ const victim = killLogFighterParts(defender);
+ const action = document.createElement("span");
+ const weapon = document.createElement("span");
+ const actionText = document.createElement("span");
+
+ item.className = "kill-log-item";
+ item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
+ item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
+ item.setAttribute(
+ "aria-label",
+ `${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
);
- this.setStatus(matchStatusText(this.teams));
- this.updateScoreboard();
+ action.className = "kill-log-action";
+ weapon.className = "kill-log-weapon";
+ weapon.setAttribute("aria-hidden", "true");
+ actionText.className = "kill-log-action-text";
+ actionText.textContent = "처치 >";
+ action.append(weapon, actionText);
+
+ item.append(
+ createKillLogFighterNode(killer, "killer"),
+ action,
+ createKillLogFighterNode(victim, "victim"),
+ );
+ listNode.append(item);
+
+ while (listNode.children.length > KILL_LOG_LIMIT) {
+ listNode.firstElementChild?.remove();
+ }
+
+ logNode.classList.add("has-entries");
+ logNode.setAttribute("aria-hidden", "false");
+ }
+
+ getKillLogNodes() {
+ this.killLogNode ??= document.getElementById("kill-log");
+ this.killLogListNode ??= document.getElementById("kill-log-list");
+
+ return {
+ logNode: this.killLogNode,
+ listNode: this.killLogListNode,
+ };
}
update(time) {
this.fighters.forEach(syncFighterHud);
+ if (this.matchPaused) {
+ this.updateMinimapViewportFrame();
+ return;
+ }
+
if (!this.matchOver) {
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
@@ -146,6 +387,13 @@ update(time) {
});
}
+ if (this.presentationMode) {
+ this.followPresentationCombat();
+ this.minimapCamera?.setAlpha(0);
+ this.updateMinimapViewportFrame();
+ return;
+ }
+
if (this.focusSelectedFighter()) {
this.updateMinimapViewportFrame();
return;
@@ -229,6 +477,109 @@ update(time) {
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
}
+ isMatchPaused() {
+ return this.matchPaused;
+ }
+
+ togglePause() {
+ return this.setPaused(!this.matchPaused);
+ }
+
+ setPaused(paused, { silent = false } = {}) {
+ const nextPaused = Boolean(paused) && this.ready && !this.matchOver && !this.presentationMode;
+
+ if (this.matchPaused === nextPaused) {
+ return this.matchPaused;
+ }
+
+ this.matchPaused = nextPaused;
+
+ if (nextPaused) {
+ this.physics.pause();
+ this.time.paused = true;
+ this.tweens.pauseAll?.();
+ } else {
+ this.physics.resume();
+ this.time.paused = false;
+ this.tweens.resumeAll?.();
+ }
+
+ this.setSceneAnimationsPaused(nextPaused);
+
+ if (!silent && !this.presentationMode) {
+ this.setStatus(nextPaused ? "일시정지" : matchStatusText(this.teams));
+ }
+
+ return this.matchPaused;
+ }
+
+ setSceneAnimationsPaused(paused) {
+ const animatedObjects = [
+ ...this.fighters,
+ ...(this.combatObjects ? Array.from(this.combatObjects) : []),
+ ];
+
+ animatedObjects.forEach((object) => {
+ if (!object?.anims) {
+ return;
+ }
+
+ if (paused) {
+ object.anims.pause();
+ } else {
+ object.anims.resume();
+ }
+ });
+ }
+
+ selectRandomTeamFighter(teamId) {
+ if (this.matchOver) {
+ return;
+ }
+
+ const candidates = this.fighters.filter(
+ (fighter) => isLivingFighter(fighter) && fighter.team.id === teamId,
+ );
+
+ if (candidates.length === 0) {
+ return;
+ }
+
+ const fighter = candidates[Phaser.Math.Between(0, candidates.length - 1)];
+ this.selectFighter(fighter);
+ this.setStatus(`${fighter.team.label} 시점: ${fighter.fighterName ?? fighter.name}`);
+ this.updateScoreboard();
+ }
+
+ focusPresentationCombat() {
+ this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
+ this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
+
+ const combatCenter = this.getObservedCombatCenter();
+ if (combatCenter) {
+ this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
+ }
+
+ this.minimapCamera?.setAlpha(0);
+ this.updateMinimapViewportFrame();
+ }
+
+ followPresentationCombat() {
+ if (this.cameras.main.zoom !== SPECTATOR_LATE_FIGHT_ZOOM) {
+ this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
+ }
+
+ const combatCenter = this.getObservedCombatCenter();
+ if (!combatCenter) {
+ return;
+ }
+
+ this.cameras.main.scrollX +=
+ (Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
+ this.cameras.main.scrollY +=
+ (Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
+ }
+
setMainCameraZoom(zoom) {
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
@@ -315,22 +666,41 @@ update(time) {
scoreLeft.innerHTML = "";
scoreRight.innerHTML = "";
- this.teams.forEach((team, index) => {
+ this.teams.forEach((team) => {
const aliveCount = this.fighters.filter(
(f) => f.team.id === team.id && !f.isDead
).length;
- const teamEl = document.createElement("div");
+ const teamEl = document.createElement("button");
teamEl.className = "team-score";
- teamEl.style.backgroundColor = `${team.color}44`; // 44 is alpha for 26%
+ teamEl.type = "button";
+ teamEl.disabled = aliveCount === 0;
+ teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
+ teamEl.style.setProperty("--team-color", team.color);
+ teamEl.style.backgroundColor = `${team.color}33`;
teamEl.style.borderLeft = `4px solid ${team.color}`;
- teamEl.innerHTML = `${team.label} ${aliveCount} `;
- if (index % 2 === 0) {
- scoreLeft.appendChild(teamEl);
- } else {
- scoreRight.appendChild(teamEl);
+ if (this.selectedFighter?.team.id === team.id) {
+ teamEl.classList.add("is-focused");
}
+
+ const labelEl = document.createElement("span");
+ labelEl.className = "team-score-name";
+ labelEl.textContent = team.label;
+
+ const ruleEl = document.createElement("span");
+ ruleEl.className = "team-score-rule";
+
+ const countEl = document.createElement("span");
+ countEl.className = "team-score-count";
+ countEl.textContent = `${aliveCount}명`;
+
+ teamEl.addEventListener("click", () => {
+ this.selectRandomTeamFighter(team.id);
+ });
+
+ teamEl.append(labelEl, ruleEl, countEl);
+ scoreLeft.appendChild(teamEl);
});
}
@@ -350,6 +720,19 @@ update(time) {
}
});
+ if (this.presentationMode) {
+ const finishedMatchId = this.matchId;
+ this.time.delayedCall(1200, () => {
+ if (this.presentationMode && this.matchId === finishedMatchId) {
+ this.startMatch(this.getInitialMatchConfig(), { silent: true });
+ }
+ });
+ return;
+ }
+
+ this.clearBattleNotice();
+ this.persistDailyDeathStats();
+
if (livingTeams.size === 1) {
const winningTeamId = Array.from(livingTeams)[0];
const winningTeam = this.teams.find((team) => team.id === winningTeamId);
@@ -360,6 +743,170 @@ update(time) {
}
}
+const KILL_LOG_LIMIT = 8;
+const BATTLE_NOTICE_DELAY_MS = 5000;
+const BATTLE_NOTICE_VISIBLE_MS = 2000;
+const BATTLE_NOTICE_INTERVAL_MS = 10000;
+const SPAWN_CLUSTER_MARGIN = 48;
+const SPAWN_CLUSTER_STEP = 28;
+const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
+const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
+const SPECIES_LABELS = {
+ bear: "곰",
+ human: "인간",
+ orc: "오크",
+ skeleton: "해골",
+ slime: "슬라임",
+ wolf: "늑대",
+};
+const SPECIES_SUBJECT_PARTICLES = {
+ bear: "이",
+ human: "이",
+ orc: "가",
+ skeleton: "이",
+ slime: "이",
+ wolf: "가",
+};
+const DEATH_NOTICE_TEMPLATES = [
+ "오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
+ "{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
+ "오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
+ "{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
+];
+
+function createDeathCounts() {
+ return SPECIES_KEYS.reduce((counts, species) => {
+ counts[species] = 0;
+ return counts;
+ }, {});
+}
+
+function normalizeDeathCounts(value = {}) {
+ return SPECIES_KEYS.reduce((counts, species) => {
+ counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
+ return counts;
+ }, {});
+}
+
+function addDeathCounts(baseCounts, matchCounts) {
+ return SPECIES_KEYS.reduce((counts, species) => {
+ counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
+ return counts;
+ }, {});
+}
+
+function normalizeSpecies(value) {
+ return SPECIES_KEYS.includes(value) ? value : "human";
+}
+
+function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
+ const topSpecies = SPECIES_KEYS
+ .map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
+ .sort((left, right) => right.count - left.count)[0];
+
+ if (!topSpecies || topSpecies.count === 0) {
+ return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
+ }
+
+ const template = DEATH_NOTICE_TEMPLATES[
+ (topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
+ ];
+
+ return template
+ .replace("{species}", SPECIES_LABELS[topSpecies.species])
+ .replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
+ .replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
+}
+
+function createKillLogFighterNode(fighterParts, role) {
+ const container = document.createElement("span");
+ const avatar = document.createElement("span");
+ const copy = document.createElement("span");
+ const team = document.createElement("span");
+ const member = document.createElement("span");
+
+ container.className = `kill-log-fighter ${role}`;
+ avatar.className = "kill-log-avatar";
+ if (fighterParts.avatarUrl) {
+ avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
+ }
+ avatar.setAttribute("aria-hidden", "true");
+ copy.className = "kill-log-copy";
+ team.className = "kill-log-team";
+ team.textContent = fighterParts.teamLabel;
+ member.className = "kill-log-member";
+ member.textContent = fighterParts.memberLabel;
+
+ copy.append(team, member);
+ container.append(avatar, copy);
+
+ return container;
+}
+
+function killLogFighterParts(fighter) {
+ return {
+ teamLabel: fighter?.team?.label ?? "Unknown",
+ memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
+ avatarUrl: fighterSkinIdleUrl(fighter?.skin),
+ };
+}
+
+function fighterSkinIdleUrl(skin) {
+ const idleFile = skin?.animations?.idle?.file;
+
+ if (!skin?.assetRoot || !idleFile) {
+ return "";
+ }
+
+ return `${skin.assetRoot}/${idleFile}`;
+}
+
+function createFighterPlans(fighterSetups, skins) {
+ return fighterSetups.flatMap((fighterSetup, index) => {
+ const skin = skins[index];
+ const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1));
+
+ return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
+ const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
+
+ return {
+ ...fighterSetup,
+ skin,
+ x: position.x,
+ y: position.y,
+ };
+ });
+ });
+}
+
+function clusterSpawnPosition(origin, index, count) {
+ if (count <= 1 || index === 0) {
+ return {
+ x: origin.x,
+ y: origin.y,
+ };
+ }
+
+ const ring = Math.ceil(index / 6);
+ const radius = SPAWN_CLUSTER_STEP * ring;
+ const angle = index * GOLDEN_ANGLE;
+
+ return {
+ x: clampInsideArena(origin.x + Math.cos(angle) * radius),
+ y: clampInsideArena(origin.y + Math.sin(angle) * radius),
+ };
+}
+
+function clampInsideArena(value) {
+ return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
+}
+
+function syncTeamSizes(teams, fighterPlans) {
+ teams.forEach((team) => {
+ team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
+ });
+}
+
function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
diff --git a/src/game/combat.js b/src/game/combat.js
index 916a6d6..849441a 100644
--- a/src/game/combat.js
+++ b/src/game/combat.js
@@ -1,5 +1,6 @@
import Phaser from "phaser";
import {
+ ARENA_SIZE,
ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN,
@@ -7,6 +8,7 @@ import {
FIGHTER_MAX_HP,
FIGHTER_SCALE,
KILL_HEALTH_RECOVERY_RATIO,
+ KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER,
KILL_GROWTH_TWEEN_DURATION,
MELEE_HIT_DELAY,
@@ -351,14 +353,37 @@ function killFighter(defender, winner, onWinner) {
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
+ winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
+ maybeSplitFighter(defender);
onWinner(winner);
}
+function maybeSplitFighter(fighter) {
+ const splitOnDeath = fighter.canSplitOnDeath === false
+ ? null
+ : fighter.skin.traits?.splitOnDeath;
+
+ if (!splitOnDeath || typeof fighter.scene.spawnSplitFighters !== "function") {
+ return;
+ }
+
+ const chance = Phaser.Math.Clamp(Number(splitOnDeath.chance ?? 1), 0, 1);
+
+ if (Math.random() >= chance) {
+ return;
+ }
+
+ fighter.scene.spawnSplitFighters(fighter, splitOnDeath);
+}
+
function applyKillReward(winner) {
winner.killCount = (winner.killCount ?? 0) + 1;
- const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount;
+ const rewardMultiplier = Math.min(
+ KILL_GROWTH_MAX_MULTIPLIER,
+ KILL_GROWTH_MULTIPLIER ** winner.killCount,
+ );
const previousHp = winner.hp;
const nextHp = recoveredHealth(winner);
winner.killRewardMultiplier = rewardMultiplier;
@@ -377,9 +402,31 @@ function applyKillReward(winner) {
scaleY: nextScaleY,
duration: KILL_GROWTH_TWEEN_DURATION,
ease: "Back.Out",
+ onUpdate: () => clampFighterInsideArena(winner),
+ onComplete: () => clampFighterInsideArena(winner),
});
}
+function clampFighterInsideArena(fighter) {
+ if (!fighter?.active || !fighter.body) {
+ return;
+ }
+
+ const halfWidth = Math.min(
+ ARENA_SIZE / 2,
+ Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
+ );
+ const halfHeight = Math.min(
+ ARENA_SIZE / 2,
+ Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
+ );
+ const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA_SIZE - halfWidth);
+ const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA_SIZE - halfHeight);
+
+ fighter.setPosition(x, y);
+ fighter.body.updateFromGameObject?.();
+}
+
function recoveredHealth(fighter) {
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
diff --git a/src/game/fighterAssets.js b/src/game/fighterAssets.js
index 42be910..45d56fc 100644
--- a/src/game/fighterAssets.js
+++ b/src/game/fighterAssets.js
@@ -5,10 +5,7 @@ import {
KILL_HEAL_EFFECT_FRAME_RATE,
KILL_HEAL_EFFECT_FRAMES,
SELECTED_FIGHTER_OUTLINE_ALPHA,
- SELECTED_FIGHTER_OUTLINE_BLUE,
SELECTED_FIGHTER_OUTLINE_GAP,
- SELECTED_FIGHTER_OUTLINE_GREEN,
- SELECTED_FIGHTER_OUTLINE_RED,
SELECTED_FIGHTER_OUTLINE_WIDTH,
} from "../constants.js";
@@ -261,9 +258,9 @@ function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
const outlineIndex = maskIndex * 4;
- outlineData[outlineIndex] = SELECTED_FIGHTER_OUTLINE_RED;
- outlineData[outlineIndex + 1] = SELECTED_FIGHTER_OUTLINE_GREEN;
- outlineData[outlineIndex + 2] = SELECTED_FIGHTER_OUTLINE_BLUE;
+ outlineData[outlineIndex] = 255;
+ outlineData[outlineIndex + 1] = 255;
+ outlineData[outlineIndex + 2] = 255;
outlineData[outlineIndex + 3] = outlineAlpha;
}
}
diff --git a/src/game/fighterFactory.js b/src/game/fighterFactory.js
index 30574d5..2ba94c6 100644
--- a/src/game/fighterFactory.js
+++ b/src/game/fighterFactory.js
@@ -17,9 +17,21 @@ import {
const NAME_LABEL_BOTTOM_GAP = 14;
-export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
+export function createFighter(
+ scene,
+ { canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
+) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
+ const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
+ const displayName = name || team.label;
+ const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? skin.stats?.maxHp ?? FIGHTER_MAX_HP));
+ const resolvedHp = Math.min(
+ resolvedMaxHp,
+ Math.max(1, Math.round(hp ?? resolvedMaxHp)),
+ );
+
fighter.setScale(FIGHTER_SCALE);
+ fighter.setName(displayName);
fighter.setDepth(2);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
@@ -35,14 +47,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
Phaser.Geom.Rectangle.Contains,
);
fighter.input.cursor = "pointer";
- fighter.selectionOutline = scene.add
+
+ fighter.teamMarker = scene.add
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
+ .setTint(teamColor)
+ .setAlpha(0.8)
.setDepth(1.9)
- .setVisible(false);
+ .setVisible(true);
fighter.nameLabel = scene.add
- .text(x, y, name, {
+ .text(x, y, displayName, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
@@ -61,15 +76,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
.setDepth(5);
fighter.skin = skin;
+ fighter.fighterName = displayName;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.baseScaleX = FIGHTER_SCALE;
fighter.baseScaleY = FIGHTER_SCALE;
+ fighter.canSplitOnDeath = canSplitOnDeath;
fighter.isSelected = false;
fighter.killCount = 0;
fighter.killRewardMultiplier = 1;
- fighter.maxHp = FIGHTER_MAX_HP;
- fighter.hp = fighter.maxHp;
+ fighter.maxHp = resolvedMaxHp;
+ fighter.hp = resolvedHp;
fighter.nextAttackAt = 0;
fighter.isLocked = false;
fighter.isDead = false;
@@ -102,18 +119,18 @@ export function syncFighterHud(fighter) {
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
- syncSelectionOutline(fighter);
+ syncTeamMarker(fighter);
}
-function syncSelectionOutline(fighter) {
- const outline = fighter.selectionOutline;
+function syncTeamMarker(fighter) {
+ const marker = fighter.teamMarker;
- if (!outline) {
+ if (!marker) {
return;
}
- const isVisible = Boolean(fighter.isSelected && !fighter.isDead);
- outline.setVisible(isVisible);
+ const isVisible = Boolean(fighter.active && !fighter.isDead);
+ marker.setVisible(isVisible);
if (!isVisible) {
return;
@@ -122,20 +139,20 @@ function syncSelectionOutline(fighter) {
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
if (fighter.scene.textures.exists(outlineTextureKey)) {
- outline.setTexture(outlineTextureKey, fighter.frame.name);
+ marker.setTexture(outlineTextureKey, fighter.frame.name);
}
- outline.setPosition(fighter.x, fighter.y);
- outline.setScale(fighter.scaleX, fighter.scaleY);
- outline.setFlipX(fighter.flipX);
- outline.setDepth(fighter.depth - 0.1);
+ marker.setPosition(fighter.x, fighter.y);
+ marker.setScale(fighter.scaleX, fighter.scaleY);
+ marker.setFlipX(fighter.flipX);
+ marker.setDepth(fighter.depth - 0.1);
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
- fighter.selectionOutline.destroy();
+ fighter.teamMarker.destroy();
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();
diff --git a/src/game/fighterManifest.js b/src/game/fighterManifest.js
index ad20cd2..2d0d3b2 100644
--- a/src/game/fighterManifest.js
+++ b/src/game/fighterManifest.js
@@ -4,6 +4,7 @@ export const fighterManifest = [
{
key: "knight",
label: "Knight",
+ species: "human",
assetRoot: "assets/characters/knight",
animations: {
idle: animation("Knight-Idle.png", 6),
@@ -19,6 +20,7 @@ export const fighterManifest = [
{
key: "orc",
label: "Orc",
+ species: "orc",
assetRoot: "assets/characters/orc",
animations: {
idle: animation("Orc-Idle.png", 6),
@@ -32,6 +34,7 @@ export const fighterManifest = [
{
key: "archer",
label: "Archer",
+ species: "human",
assetRoot: "assets/characters/archer",
combat: {
projectile: {
@@ -51,6 +54,7 @@ export const fighterManifest = [
{
key: "armored-axeman",
label: "Armored Axeman",
+ species: "human",
assetRoot: "assets/characters/armored-axeman",
animations: {
idle: animation("Armored Axeman-Idle.png", 6),
@@ -65,6 +69,7 @@ export const fighterManifest = [
{
key: "armored-orc",
label: "Armored Orc",
+ species: "orc",
assetRoot: "assets/characters/armored-orc",
animations: {
idle: animation("Armored Orc-Idle.png", 6),
@@ -80,6 +85,7 @@ export const fighterManifest = [
{
key: "armored-skeleton",
label: "Armored Skeleton",
+ species: "skeleton",
assetRoot: "assets/characters/armored-skeleton",
animations: {
idle: animation("Armored Skeleton-Idle.png", 6),
@@ -93,6 +99,7 @@ export const fighterManifest = [
{
key: "elite-orc",
label: "Elite Orc",
+ species: "orc",
assetRoot: "assets/characters/elite-orc",
animations: {
idle: animation("Elite Orc-Idle.png", 6),
@@ -107,6 +114,7 @@ export const fighterManifest = [
{
key: "greatsword-skeleton",
label: "Greatsword Skeleton",
+ species: "skeleton",
assetRoot: "assets/characters/greatsword-skeleton",
animations: {
idle: animation("Greatsword Skeleton-Idle.png", 6),
@@ -121,6 +129,7 @@ export const fighterManifest = [
{
key: "knight-templar",
label: "Knight Templar",
+ species: "human",
assetRoot: "assets/characters/knight-templar",
animations: {
idle: animation("Knight Templar-Idle.png", 6),
@@ -137,6 +146,7 @@ export const fighterManifest = [
{
key: "lancer",
label: "Lancer",
+ species: "human",
assetRoot: "assets/characters/lancer",
animations: {
idle: animation("Lancer-Idle.png", 6),
@@ -152,6 +162,7 @@ export const fighterManifest = [
{
key: "orc-rider",
label: "Orc rider",
+ species: "orc",
assetRoot: "assets/characters/orc-rider",
animations: {
idle: animation("Orc rider-Idle.png", 6),
@@ -167,6 +178,7 @@ export const fighterManifest = [
{
key: "priest",
label: "Priest",
+ species: "human",
assetRoot: "assets/characters/priest",
combat: {
attackEffect: {
@@ -187,6 +199,7 @@ export const fighterManifest = [
{
key: "skeleton",
label: "Skeleton",
+ species: "skeleton",
assetRoot: "assets/characters/skeleton",
animations: {
idle: animation("Skeleton-Idle.png", 6),
@@ -201,6 +214,7 @@ export const fighterManifest = [
{
key: "skeleton-archer",
label: "Skeleton Archer",
+ species: "skeleton",
assetRoot: "assets/characters/skeleton-archer",
combat: {
projectile: {
@@ -219,7 +233,20 @@ export const fighterManifest = [
{
key: "slime",
label: "Slime",
+ species: "slime",
assetRoot: "assets/characters/slime",
+ stats: {
+ maxHp: 1,
+ },
+ traits: {
+ spawnMultiplier: 10,
+ splitOnDeath: {
+ chance: 0.5,
+ count: 2,
+ childMaxHp: 1,
+ childCanSplit: false,
+ },
+ },
animations: {
idle: animation("Slime-Idle.png", 6),
walk: animation("Slime-Walk.png", 6),
@@ -232,6 +259,7 @@ export const fighterManifest = [
{
key: "soldier-close",
label: "Soldier Close",
+ species: "human",
assetRoot: "assets/characters/soldier",
combat: {
type: "melee",
@@ -248,6 +276,7 @@ export const fighterManifest = [
{
key: "soldier-range",
label: "Soldier Range",
+ species: "human",
assetRoot: "assets/characters/soldier",
combat: {
projectile: {
@@ -266,6 +295,7 @@ export const fighterManifest = [
{
key: "swordsman",
label: "Swordsman",
+ species: "human",
assetRoot: "assets/characters/swordsman",
animations: {
idle: animation("Swordsman-Idle.png", 6),
@@ -280,6 +310,7 @@ export const fighterManifest = [
{
key: "werebear",
label: "Werebear",
+ species: "bear",
assetRoot: "assets/characters/werebear",
animations: {
idle: animation("Werebear-Idle.png", 6),
@@ -294,6 +325,7 @@ export const fighterManifest = [
{
key: "werewolf",
label: "Werewolf",
+ species: "wolf",
assetRoot: "assets/characters/werewolf",
animations: {
idle: animation("Werewolf-Idle.png", 6),
@@ -307,6 +339,7 @@ export const fighterManifest = [
{
key: "wizard",
label: "Wizard",
+ species: "human",
assetRoot: "assets/characters/wizard",
combat: {
attackEffect: {
diff --git a/src/game/matchSetup.js b/src/game/matchSetup.js
index 72c71cc..f2d5e2d 100644
--- a/src/game/matchSetup.js
+++ b/src/game/matchSetup.js
@@ -2,15 +2,15 @@ import {
ARENA_SIZE,
DEFAULT_TEAM_SIZE,
GRID_SIZE,
+ getTeamColor,
MAX_TEAM_SIZE,
- TEAM_COLORS,
TILE_SIZE,
} from "../constants.js";
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({
- color: TEAM_COLORS[index % TEAM_COLORS.length],
+ color: getTeamColor(index, names.length),
id: `team-${index + 1}`,
label: name,
size: teamSize,
@@ -39,13 +39,19 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
}
export function matchStatusText(teams) {
- const teamSizes = teams.map((team) => team.size).join(", ");
- return `${teams.length}팀 전투: ${teamSizes}`;
+ const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
+ const teamSizes = new Set(teams.map((team) => team.size));
+ const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}명` : "팀별 가변 인원";
+ const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
+
+ return `${teams.length}팀 | ${teamSizeText} | 총 ${totalFighters}명 출전 | ${labels}`;
}
function createTeams(playerCount, teamSize) {
- return Array.from({ length: Math.ceil(playerCount / teamSize) }, (_, index) => ({
- color: TEAM_COLORS[index % TEAM_COLORS.length],
+ const teamCount = Math.ceil(playerCount / teamSize);
+
+ return Array.from({ length: teamCount }, (_, index) => ({
+ color: getTeamColor(index, teamCount),
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
diff --git a/src/main.js b/src/main.js
index e33b072..2ac0fe0 100644
--- a/src/main.js
+++ b/src/main.js
@@ -3,9 +3,123 @@ import { ArenaScene } from "./game/ArenaScene.js";
import { ARENA_SIZE } from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js";
import { trackVisitor } from "./ui/visitorCounter.js";
-import "./styles.css";
const matchForm = createMatchForm();
+const appNode = document.querySelector("#app");
+const startButton = document.querySelector("#start-button");
+const drawer = document.querySelector("#fighter-entry");
+const drawerCloseButton = document.querySelector("#drawer-close");
+const drawerScrim = document.querySelector("#drawer-scrim");
+const drawerToggleButton = document.querySelector("#drawer-toggle");
+const playerNamesInput = document.querySelector("#player-names");
+const pauseButton = document.querySelector("#pause-button");
+const restartButton = document.querySelector("#restart-button");
+
+function isMatchLive() {
+ return appNode?.classList.contains("match-live") ?? false;
+}
+
+function openOptionsDrawer({ focus = true } = {}) {
+ appNode?.classList.add("options-open");
+ setDrawerCollapsed(false);
+ drawer?.setAttribute("aria-hidden", "false");
+ startButton?.setAttribute("aria-expanded", "true");
+
+ if (focus) {
+ window.setTimeout(() => playerNamesInput?.focus(), 220);
+ }
+}
+
+function closeOptionsDrawer() {
+ if (isMatchLive()) {
+ setDrawerCollapsed(true);
+ return;
+ }
+
+ appNode?.classList.remove("options-open");
+ appNode?.classList.remove("drawer-collapsed");
+ drawer?.setAttribute("aria-hidden", "true");
+ startButton?.setAttribute("aria-expanded", "false");
+ syncDrawerToggleButton();
+}
+
+function startConfiguredMatch(matchConfig) {
+ if (matchConfig.names.length < 2) {
+ matchForm.setStatus("참가자 닉네임을 2명 이상 입력하세요");
+ return;
+ }
+
+ appNode?.classList.add("match-live");
+ openOptionsDrawer({ focus: false });
+ arenaScene.startMatch(matchConfig);
+ syncPauseButton();
+}
+
+function setDrawerCollapsed(collapsed) {
+ const nextCollapsed = Boolean(collapsed) && isMatchLive();
+
+ appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
+ drawer?.setAttribute("aria-hidden", "false");
+ syncDrawerToggleButton();
+}
+
+function syncDrawerToggleButton() {
+ if (!drawerToggleButton) {
+ return;
+ }
+
+ const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
+ drawerToggleButton.textContent = isCollapsed ? "옵션 펼치기" : "옵션 접기";
+ drawerToggleButton.setAttribute("aria-expanded", String(!isCollapsed));
+}
+
+function syncPauseButton() {
+ if (!pauseButton) {
+ return;
+ }
+
+ const isPaused = arenaScene.isMatchPaused();
+ appNode?.classList.toggle("match-paused", isPaused);
+ pauseButton.textContent = isPaused ? "계속" : "일시정지";
+ pauseButton.setAttribute("aria-pressed", String(isPaused));
+}
+
+function revealAppWhenStylesAreReady() {
+ const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
+ const reveal = () => {
+ window.requestAnimationFrame(() => {
+ document.documentElement.classList.remove("app-booting");
+ });
+ };
+
+ if (!stylesheet || stylesheet.sheet) {
+ reveal();
+ return;
+ }
+
+ stylesheet.addEventListener("load", reveal, { once: true });
+}
+
+startButton?.addEventListener("click", openOptionsDrawer);
+drawerCloseButton?.addEventListener("click", closeOptionsDrawer);
+drawerScrim?.addEventListener("click", closeOptionsDrawer);
+drawerToggleButton?.addEventListener("click", () => {
+ const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
+ setDrawerCollapsed(!isCollapsed);
+});
+pauseButton?.addEventListener("click", () => {
+ arenaScene.togglePause();
+ syncPauseButton();
+});
+restartButton?.addEventListener("click", () => {
+ startConfiguredMatch(matchForm.readMatchConfig());
+});
+window.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ closeOptionsDrawer();
+ }
+});
+
const arenaScene = new ArenaScene({
getInitialMatchConfig: matchForm.readMatchConfig,
setStatus: matchForm.setStatus,
@@ -31,7 +145,11 @@ const game = new Phaser.Game({
scene: arenaScene,
});
-matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
+revealAppWhenStylesAreReady();
+
+matchForm.onSubmit((matchConfig) => {
+ startConfiguredMatch(matchConfig);
+});
const visitorCountNode = document.querySelector("#visitor-count");
diff --git a/src/styles.css b/src/styles.css
index 90ce6ea..5749f91 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -3,18 +3,25 @@
font-family:
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
- background: #141612;
- color: #f6f1dd;
+ background: #080a07;
+ color: #fff5db;
}
* {
box-sizing: border-box;
}
+html {
+ min-width: 320px;
+ min-height: 100%;
+ background: #080a07;
+}
+
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
+ overflow: hidden;
}
button,
@@ -28,53 +35,514 @@ button {
cursor: pointer;
}
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+ outline: 3px solid rgb(238 185 73 / 0.46);
+ outline-offset: 3px;
+}
+
#app {
- display: grid;
+ --arena-gap: 18px;
+ --score-band-height: 134px;
+ --score-panel-left: 14px;
+ --score-panel-width: 224px;
+ --score-rail-width: calc(var(--score-panel-left) + var(--score-panel-width));
+ --drawer-width: min(430px, 100vw);
+ --drawer-live-width: min(340px, calc(100vw - 48px));
+ position: relative;
min-height: 100vh;
- grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+ overflow: hidden;
background:
- linear-gradient(135deg, rgb(112 53 29 / 0.16), transparent 30%),
- linear-gradient(180deg, #171912, #0d0f0c);
+ linear-gradient(180deg, rgb(8 10 7 / 0.18), rgb(3 5 4 / 0.84)),
+ #080a07;
+}
+
+#app.match-live {
+ --drawer-width: var(--drawer-live-width);
+}
+
+.arena-shell {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: #090b08;
+}
+
+.arena-shell::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ background:
+ radial-gradient(circle at 50% 50%, rgb(255 211 122 / 0.06), transparent 42%),
+ linear-gradient(90deg, rgb(3 5 4 / 0.48), rgb(3 5 4 / 0.08) 45%, rgb(3 5 4 / 0.48)),
+ linear-gradient(180deg, rgb(3 5 4 / 0.08), rgb(3 5 4 / 0.5));
+ pointer-events: none;
+ transition:
+ background 520ms ease,
+ opacity 520ms ease;
+}
+
+#app.match-live .arena-shell::before {
+ opacity: 0.24;
+}
+
+#app.match-live .arena-shell {
+ place-items: center;
+}
+
+#game {
+ position: relative;
+ z-index: 0;
+ width: max(100vw, 100vh);
+ height: max(100vw, 100vh);
+ overflow: hidden;
+ opacity: 0.68;
+ filter: saturate(1) contrast(1.08) brightness(1.08);
+ transform: scale(1.04);
+ transform-origin: center;
+ transition:
+ width 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
+ height 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
+ opacity 520ms ease,
+ filter 520ms ease,
+ transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+#app.match-live #game {
+ width: min(100vw, 100vh);
+ height: min(100vw, 100vh);
+ margin-left: 0;
+ opacity: 1;
+ filter: none;
+ transform: scale(1);
+}
+
+#game canvas {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+ image-rendering: pixelated;
+}
+
+.battle-preview {
+ position: fixed;
+ inset: 0;
+ z-index: 2;
+ overflow: hidden;
+ opacity: 0.84;
+ pointer-events: none;
+ transition:
+ opacity 420ms ease,
+ transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+#app.match-live .battle-preview {
+ opacity: 0;
+ transform: scale(1.08);
+}
+
+.preview-fighter {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background-repeat: no-repeat;
+ background-size: auto 100px;
+ image-rendering: pixelated;
+ transform-origin: center;
+ filter:
+ drop-shadow(0 18px 22px rgb(0 0 0 / 0.68))
+ saturate(1.14)
+ brightness(1.12);
+ animation:
+ preview-attack var(--sprite-speed) steps(var(--sprite-steps)) infinite,
+ preview-breathe 1800ms ease-in-out infinite;
+}
+
+.preview-knight {
+ --sprite-end: -600px;
+ --sprite-scale: 5.2;
+ --sprite-speed: 840ms;
+ --sprite-steps: 6;
+ left: 10vw;
+ top: 48vh;
+ background-image: url("/assets/characters/knight/Knight-Attack01.png");
+ transform: scale(var(--sprite-scale));
+}
+
+.preview-orc {
+ --sprite-end: -500px;
+ --sprite-scale: 5.35;
+ --sprite-speed: 760ms;
+ --sprite-steps: 5;
+ right: 9vw;
+ top: 46vh;
+ background-image: url("/assets/characters/orc/Orc-Attack01.png");
+ transform: scaleX(-1) scale(var(--sprite-scale));
+}
+
+.preview-wizard {
+ --sprite-end: -500px;
+ --sprite-scale: 4.35;
+ --sprite-speed: 980ms;
+ --sprite-steps: 5;
+ left: 56vw;
+ top: 24vh;
+ background-image: url("/assets/characters/wizard/Wizard-Attack01.png");
+ opacity: 0.58;
+ transform: scaleX(-1) scale(var(--sprite-scale));
+}
+
+.preview-strike {
+ position: absolute;
+ width: 160px;
+ height: 5px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, transparent, rgb(255 229 156 / 0.86), transparent);
+ box-shadow: 0 0 24px rgb(227 89 59 / 0.5);
+ opacity: 0;
+ transform-origin: center;
+ animation: preview-strike 980ms ease-in-out infinite;
+}
+
+.preview-strike-a {
+ left: 38vw;
+ top: 54vh;
+ transform: rotate(-18deg);
+}
+
+.preview-strike-b {
+ right: 31vw;
+ top: 42vh;
+ transform: rotate(22deg);
+ animation-delay: 260ms;
+}
+
+.intro-stage {
+ position: fixed;
+ inset: 0;
+ z-index: 5;
+ display: grid;
+ place-items: center;
+ padding: clamp(24px, 5vw, 56px);
+ pointer-events: none;
+ transition:
+ opacity 420ms ease,
+ transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+#app.match-live .intro-stage {
+ opacity: 0;
+ transform: scale(0.96);
+}
+
+#app.match-live .intro-content {
+ pointer-events: none;
+}
+
+.intro-content {
+ display: grid;
+ justify-items: center;
+ gap: 22px;
+ text-align: center;
+ pointer-events: auto;
+ animation: intro-rise 760ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ transition:
+ transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1),
+ opacity 360ms ease;
+}
+
+#app.options-open:not(.match-live) .intro-content {
+ transform: translateX(-14vw) scale(0.92);
+ opacity: 0.72;
+}
+
+.arena-logo {
+ margin: 0;
+ color: #fff4d1;
+ font-size: clamp(4rem, 16vw, 11rem);
+ font-weight: 950;
+ letter-spacing: 0;
+ line-height: 0.9;
+ text-shadow:
+ 0 2px 0 #ad4d37,
+ 0 14px 42px rgb(0 0 0 / 0.72),
+ 0 0 40px rgb(230 173 71 / 0.28);
+ text-transform: uppercase;
+}
+
+.visitor-count {
+ position: fixed;
+ right: clamp(10px, 2vw, 18px);
+ bottom: clamp(10px, 2vw, 18px);
+ z-index: 5;
+ min-height: 26px;
+ margin: 0;
+ border: 1px solid rgb(238 185 73 / 0.18);
+ border-radius: 999px;
+ padding: 5px 9px;
+ background: rgb(8 10 7 / 0.58);
+ color: #e7c879;
+ font-size: 0.72rem;
+ font-weight: 800;
+ line-height: 1.2;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(8px);
+ transition:
+ opacity 220ms ease,
+ transform 220ms ease;
+ backdrop-filter: blur(8px);
+}
+
+#app.match-live .visitor-count {
+ opacity: 0.86;
+ transform: translateY(0);
+}
+
+.start-button,
+form button[type="submit"],
+.pause-button,
+.restart-button {
+ min-height: 52px;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #e56443, #b93c2f);
+ color: #fff7df;
+ font-weight: 900;
+ box-shadow:
+ 0 18px 44px rgb(0 0 0 / 0.36),
+ inset 0 1px 0 rgb(255 255 255 / 0.2);
+ transition:
+ background 180ms ease,
+ transform 180ms ease,
+ box-shadow 180ms ease;
+}
+
+.start-button {
+ min-width: 180px;
+ padding: 0 30px;
+ text-transform: uppercase;
+}
+
+.start-button:hover,
+form button[type="submit"]:hover,
+.pause-button:hover,
+.restart-button:hover {
+ background: linear-gradient(180deg, #f0754f, #c84636);
+ transform: translateY(-1px);
+ box-shadow:
+ 0 22px 52px rgb(0 0 0 / 0.42),
+ inset 0 1px 0 rgb(255 255 255 / 0.24);
+}
+
+.pause-button,
+.restart-button {
+ display: none;
+ border: 1px solid rgb(238 185 73 / 0.3);
+ background: rgb(255 246 216 / 0.08);
+ color: #ffe8b4;
+}
+
+.pause-button:hover,
+.restart-button:hover {
+ background: rgb(255 246 216 / 0.14);
+}
+
+#app.match-live .pause-button,
+#app.match-live .restart-button {
+ display: block;
+}
+
+#app.match-live .match-actions {
+ grid-template-columns: 1fr 1fr;
+}
+
+#app.match-live .match-actions button[type="submit"] {
+ grid-column: 1 / -1;
+}
+
+#app.match-paused .pause-button {
+ background: linear-gradient(180deg, #e3b24f, #9a6c24);
+ color: #120f08;
+}
+
+.drawer-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 6;
+ background: rgb(4 5 4 / 0.42);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 320ms ease;
+}
+
+#app.options-open .drawer-scrim {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+#app.match-live .drawer-scrim {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.drawer-toggle {
+ display: none;
+ min-height: 40px;
+ border: 1px solid rgb(238 185 73 / 0.28);
+ border-radius: 8px;
+ padding: 0 12px;
+ background: rgb(12 15 11 / 0.84);
+ color: #ffe8b4;
+ font-size: 0.82rem;
+ font-weight: 900;
+ box-shadow: 0 16px 38px rgb(0 0 0 / 0.36);
+ transition:
+ background 180ms ease,
+ transform 180ms ease;
+ backdrop-filter: blur(10px);
+}
+
+.drawer-toggle:hover {
+ background: rgb(255 246 216 / 0.14);
+ transform: translateY(-1px);
+}
+
+#app.match-live .drawer-toggle {
+ display: block;
}
.fighter-entry {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 7;
display: grid;
- align-content: center;
- gap: 28px;
- padding: clamp(24px, 5vw, 48px);
- border-right: 1px solid rgb(230 207 134 / 0.14);
+ align-content: start;
+ gap: 24px;
+ width: var(--drawer-width);
+ height: 100vh;
+ overflow-y: auto;
+ border-left: 1px solid rgb(239 199 103 / 0.22);
+ padding: clamp(22px, 4vw, 34px);
+ background:
+ linear-gradient(180deg, rgb(29 33 22 / 0.94), rgb(13 16 12 / 0.96)),
+ #11140f;
+ box-shadow: -28px 0 80px rgb(0 0 0 / 0.52);
+ transform: translateX(104%);
+ transition:
+ opacity 260ms ease,
+ transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ backdrop-filter: blur(16px);
+}
+
+#app.options-open .fighter-entry {
+ transform: translateX(0);
+}
+
+#app.match-live .fighter-entry {
+ top: 24px;
+ right: 24px;
+ height: auto;
+ max-height: calc(100vh - 48px);
+ gap: 16px;
+ border: 1px solid rgb(239 199 103 / 0.22);
+ border-radius: 8px;
+ padding: 20px;
+ transform: translateX(0);
+}
+
+#app.match-live.drawer-collapsed .fighter-entry {
+ width: auto;
+ min-width: 0;
+ overflow: visible;
+ border-color: transparent;
+ padding: 0;
+ background: transparent;
+ box-shadow: none;
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateX(0);
+}
+
+#app.match-live .drawer-close {
+ display: none;
+}
+
+#app.match-live .fighter-entry h2 {
+ font-size: 2rem;
+}
+
+#app.match-live .fighter-entry textarea {
+ min-height: 190px;
+}
+
+#app.match-live .fighter-entry fieldset {
+ padding: 12px;
+}
+
+#app.match-live.drawer-collapsed .entry-copy,
+#app.match-live.drawer-collapsed .fighter-entry form {
+ display: none;
+}
+
+#app.match-live.drawer-collapsed .drawer-header {
+ justify-content: end;
+}
+
+.drawer-header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.drawer-header-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
.entry-copy {
display: grid;
- gap: 10px;
+ gap: 8px;
}
.eyebrow {
margin: 0;
- color: #d6a94a;
- font-size: 0.82rem;
- font-weight: 800;
+ color: #e3b24f;
+ font-size: 0.78rem;
+ font-weight: 900;
+ letter-spacing: 0;
text-transform: uppercase;
}
-h1 {
+h2 {
margin: 0;
- font-size: clamp(1.8rem, 4vw, 3rem);
+ color: #fff3d2;
+ font-size: clamp(1.7rem, 4vw, 2.5rem);
line-height: 1.05;
letter-spacing: 0;
}
-.visitor-count {
- width: fit-content;
- margin: 0;
- border: 1px solid rgb(230 207 134 / 0.18);
- border-radius: 6px;
- padding: 6px 10px;
- background: rgb(29 32 23 / 0.74);
- color: #f1d892;
- font-size: 0.88rem;
- font-weight: 800;
+.drawer-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;
+}
+
+.drawer-close:hover {
+ background: rgb(255 246 216 / 0.14);
}
form {
@@ -82,21 +550,28 @@ form {
gap: 16px;
}
+.match-actions {
+ display: grid;
+ gap: 10px;
+}
+
fieldset {
display: grid;
gap: 10px;
min-width: 0;
margin: 0;
- border: 1px solid rgb(230 207 134 / 0.18);
+ border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
- padding: 12px;
+ padding: 14px;
+ background: rgb(5 7 5 / 0.26);
}
legend {
padding: 0 6px;
- color: #d6a94a;
- font-size: 0.82rem;
- font-weight: 800;
+ color: #e3b24f;
+ font-size: 0.78rem;
+ font-weight: 900;
+ letter-spacing: 0;
text-transform: uppercase;
}
@@ -109,33 +584,33 @@ legend {
output {
min-width: 88px;
- border: 1px solid rgb(230 207 134 / 0.18);
- border-radius: 6px;
+ border: 1px solid rgb(238 185 73 / 0.2);
+ border-radius: 8px;
padding: 8px 10px;
- background: #1d2017;
+ background: #1d2116;
color: #fff7df;
text-align: center;
- font-weight: 800;
+ font-weight: 900;
}
label {
- color: #e6d7ac;
+ color: #ead8ad;
font-size: 0.92rem;
}
input:not([type="range"]),
textarea {
min-height: 48px;
- border: 1px solid rgb(230 207 134 / 0.24);
- border-radius: 6px;
+ border: 1px solid rgb(238 185 73 / 0.28);
+ border-radius: 8px;
padding: 0 14px;
- background: #26291d;
+ background: #232719;
color: #fff7df;
outline: none;
}
textarea {
- min-height: 232px;
+ min-height: 258px;
resize: vertical;
padding-block: 12px;
line-height: 1.45;
@@ -143,157 +618,595 @@ textarea {
input[type="range"] {
width: 100%;
- accent-color: #d6a94a;
-}
-
-input:focus,
-textarea:focus {
- border-color: #d6a94a;
- box-shadow: 0 0 0 3px rgb(214 169 74 / 0.18);
-}
-
-button {
- min-height: 50px;
- border-radius: 6px;
- background: #c84f34;
- color: #fff1da;
- font-weight: 800;
-}
-
-button:hover {
- background: #dd6245;
-}
-
-.arena-shell {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1rem;
- min-height: 100vh;
- overflow: hidden;
- padding: clamp(16px, 3vw, 36px);
+ accent-color: #e3b24f;
}
.scoreboard {
- width: 100%;
+ position: fixed;
+ top: clamp(14px, 3vw, 28px);
+ left: var(--score-panel-left);
+ z-index: 3;
display: flex;
- justify-content: space-between;
- padding: 0.5rem 1rem;
- background: rgba(0, 0, 0, 0.5);
+ justify-content: flex-start;
+ width: var(--score-panel-width);
+ max-height: calc(100vh - 96px);
+ min-height: 64px;
+ overflow: hidden;
+ padding: 8px;
+ border: 1px solid rgb(238 185 73 / 0.18);
border-radius: 8px;
- min-height: 40px;
+ background: rgb(4 6 4 / 0.5);
+ opacity: 0;
pointer-events: none;
- z-index: 10;
+ transform: translateY(-18px);
+ transition:
+ opacity 420ms ease,
+ transform 420ms ease;
+ backdrop-filter: blur(10px);
+}
+
+#app.match-live .scoreboard {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0);
}
.score-side {
display: flex;
flex-wrap: wrap;
- gap: 0.5rem;
- width: 48%;
+ gap: 6px;
+ width: max-content;
}
.score-side.right {
- justify-content: flex-end;
+ display: none;
}
.team-score {
- padding: 4px 10px;
- border-radius: 4px;
- font-size: 0.85rem;
- font-weight: bold;
+ display: grid;
+ grid-template-rows: auto 1px auto;
+ gap: 5px;
+ width: 100px;
+ min-height: 54px;
+ overflow: hidden;
+ border-radius: 6px;
+ padding: 7px 9px 6px;
color: #fff;
+ font-size: 0.8rem;
+ font-weight: 900;
+ text-align: left;
text-shadow: 1px 1px 2px #000;
- display: flex;
- gap: 6px;
- background: rgba(255, 255, 255, 0.1);
+ transition:
+ filter 160ms ease,
+ transform 160ms ease;
}
-.victory-banner {
+.team-score:hover {
+ filter: brightness(1.16);
+ transform: translateY(-1px);
+}
+
+.team-score.is-focused {
+ box-shadow:
+ 0 0 0 2px rgb(255 244 209 / 0.92),
+ 0 0 24px rgb(227 178 79 / 0.34);
+}
+
+.team-score:disabled {
+ cursor: default;
+ filter: grayscale(0.6) brightness(0.68);
+}
+
+.team-score:disabled:hover {
+ transform: none;
+}
+
+.team-score-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.team-score-rule {
+ width: 100%;
+ background: var(--team-color);
+ opacity: 0.9;
+}
+
+.team-score-count {
+ justify-self: end;
+ color: #fff2c8;
+ font-size: 0.86rem;
+}
+
+.battle-notice {
+ position: fixed;
+ top: clamp(12px, 2vw, 20px);
+ left: 50%;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: min(420px, 72vmin, calc(100vw - 64px));
+ min-height: 38px;
+ border: 1px solid rgb(238 185 73 / 0.26);
+ border-radius: 8px;
+ padding: 8px 14px;
+ background: rgb(8 10 7 / 0.68);
+ color: #ffe8b4;
+ font-size: 0.8rem;
+ font-weight: 900;
+ line-height: 1.35;
+ text-align: center;
+ text-shadow: 1px 1px 2px #000;
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, -10px);
+ transition:
+ opacity 260ms ease,
+ transform 260ms ease;
+ backdrop-filter: blur(10px);
+}
+
+#app.match-live .battle-notice.is-visible {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+@media (min-width: 961px) {
+ #app.match-live .battle-notice {
+ right: auto;
+ left: 50%;
+ width: min(420px, 72vmin, calc(100vw - var(--drawer-width) - var(--score-rail-width) - 56px));
+ transform: translate(-50%, -10px);
+ }
+
+ #app.match-live .battle-notice.is-visible {
+ transform: translate(-50%, 0);
+ }
+
+ #app.match-live.drawer-collapsed .battle-notice {
+ right: auto;
+ width: min(420px, 72vmin, calc(100vw - 64px));
+ }
+}
+
+.kill-log {
+ position: fixed;
+ bottom: clamp(14px, 3vw, 26px);
+ left: var(--score-panel-left);
+ z-index: 4;
+ width: min(370px, calc(100vw - 32px));
+ max-height: min(34vh, 292px);
+ overflow: hidden;
+ border: 1px solid rgb(238 185 73 / 0.2);
+ border-radius: 8px;
+ padding: 10px;
+ background: rgb(4 6 4 / 0.58);
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(16px);
+ transition:
+ opacity 260ms ease,
+ transform 260ms ease;
+ backdrop-filter: blur(10px);
+}
+
+#app.match-live .kill-log.has-entries {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.kill-log-list {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 6px;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.kill-log-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-height: 54px;
+ border: 1px solid rgb(255 244 209 / 0.12);
+ border-radius: 6px;
+ padding: 7px 9px;
+ background: rgb(8 10 7 / 0.74);
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
+ animation: kill-log-entry 180ms ease both;
+}
+
+.kill-log-fighter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.kill-log-fighter.killer {
+ border-left: 3px solid var(--killer-color);
+ padding-left: 6px;
+}
+
+.kill-log-fighter.victim {
+ flex-direction: row-reverse;
+ border-right: 3px solid var(--victim-color);
+ padding-right: 6px;
+ justify-content: end;
+ text-align: right;
+}
+
+.kill-log-avatar {
+ flex: 0 0 auto;
+ width: 36px;
+ height: 36px;
+ border: 1px solid rgb(255 244 209 / 0.16);
+ border-radius: 6px;
+ background-color: rgb(255 246 216 / 0.08);
+ background-position: left center;
+ background-repeat: no-repeat;
+ background-size: auto 36px;
+ image-rendering: pixelated;
+ box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
+}
+
+.kill-log-copy {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.kill-log-team,
+.kill-log-member {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.kill-log-team {
+ min-width: 0;
+ color: #fff7df;
+ font-size: 0.78rem;
+ font-weight: 900;
+ text-shadow: 1px 1px 2px #000;
+}
+
+.kill-log-member {
+ flex: 0 0 auto;
+ color: #ead8ad;
+ font-size: 0.72rem;
+ font-weight: 800;
+}
+
+.kill-log-action {
+ display: grid;
+ justify-items: center;
+ gap: 2px;
+ min-width: 0;
+}
+
+.kill-log-action-text {
+ color: #ffdc93;
+ font-size: 0.68rem;
+ font-weight: 950;
+ line-height: 1;
+}
+
+.kill-log-weapon {
+ position: relative;
+ display: block;
+ width: 28px;
+ height: 28px;
+ place-self: center;
+ border: 1px solid rgb(238 185 73 / 0.28);
+ border-radius: 999px;
+ background: rgb(255 246 216 / 0.08);
+ box-shadow: 0 0 16px rgb(227 89 59 / 0.16);
+}
+
+.kill-log-weapon::before,
+.kill-log-weapon::after {
+ content: "";
position: absolute;
top: 50%;
left: 50%;
- transform: translate(-50%, -50%);
- background: rgba(0, 0, 0, 0.85);
- padding: 1.5rem 3rem;
- border: 2px solid #d6a94a;
- border-radius: 12px;
- color: #fff7df;
- font-size: 2rem;
- font-weight: 900;
- text-align: center;
- z-index: 100;
- box-shadow: 0 0 30px rgba(214, 169, 74, 0.4);
- backdrop-filter: blur(4px);
- animation: banner-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ width: 18px;
+ height: 3px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #ffe8b4 0 70%, #b93c2f 70% 100%);
+ box-shadow: 0 0 8px rgb(255 226 166 / 0.3);
+ transform-origin: center;
}
-@keyframes banner-in {
- from { transform: translate(-50%, -60%) scale(0.8); opacity: 0; }
- to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
+.kill-log-weapon::before {
+ transform: translate(-50%, -50%) rotate(42deg);
}
-#game {
- width: min(100%, calc(100vh - 72px), 1080px);
- aspect-ratio: 1;
- overflow: hidden;
- border: 1px solid rgb(245 219 136 / 0.22);
+.kill-log-weapon::after {
+ transform: translate(-50%, -50%) rotate(-42deg);
+}
+
+.victory-banner {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 9;
+ max-width: min(92vw, 720px);
+ border: 2px solid #e3b24f;
border-radius: 8px;
- background: #242617;
- box-shadow:
- 0 24px 80px rgb(0 0 0 / 0.45),
- inset 0 0 0 1px rgb(255 244 205 / 0.06);
+ padding: 1.3rem 2.4rem;
+ background: rgb(4 6 4 / 0.88);
+ color: #fff7df;
+ font-size: clamp(1.45rem, 5vw, 2.4rem);
+ font-weight: 950;
+ text-align: center;
+ box-shadow: 0 0 34px rgb(227 178 79 / 0.34);
+ transform: translate(-50%, -50%);
+ animation: banner-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
+ backdrop-filter: blur(6px);
}
-#game canvas {
- display: block;
- width: 100%;
- height: 100%;
- image-rendering: pixelated;
+#app.match-paused .arena-shell::after {
+ content: "일시정지";
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 6;
+ border: 1px solid rgb(238 185 73 / 0.34);
+ border-radius: 8px;
+ padding: 14px 26px;
+ background: rgb(5 7 5 / 0.76);
+ color: #ffe8b4;
+ font-size: clamp(1.3rem, 4vw, 2rem);
+ font-weight: 950;
+ transform: translate(-50%, -50%);
+ box-shadow: 0 18px 60px rgb(0 0 0 / 0.46);
+ backdrop-filter: blur(8px);
}
.match-status {
- position: absolute;
- top: clamp(24px, 4vw, 48px);
+ position: fixed;
+ bottom: clamp(14px, 3vw, 26px);
left: 50%;
- min-width: min(78vw, 340px);
- transform: translateX(-50%);
- border: 1px solid rgb(252 224 147 / 0.22);
+ z-index: 4;
+ width: min(980px, calc(100vw - 32px));
+ min-height: 48px;
+ overflow: hidden;
+ border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
- padding: 12px 16px;
- background: rgb(18 19 13 / 0.82);
- color: #f8e8b5;
- text-align: center;
- font-weight: 800;
- backdrop-filter: blur(8px);
+ padding: 13px 0;
+ background: rgb(8 10 7 / 0.74);
+ color: #ffe2a6;
+ font-weight: 900;
+ opacity: 0;
pointer-events: none;
+ transform: translate(-50%, calc(100% + 28px));
+ transition:
+ opacity 420ms ease,
+ transform 420ms ease;
+ backdrop-filter: blur(10px);
}
-@media (max-width: 820px) {
+#app.status-active:not(.match-live) .match-status {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+#app.match-live .match-status {
+ display: none;
+}
+
+@media (min-width: 961px) {
+ #app.match-live .match-status {
+ left: calc((100vw - var(--drawer-width)) / 2);
+ width: min(760px, calc(100vw - var(--drawer-width) - 32px));
+ }
+
+ #app.match-live.drawer-collapsed .match-status {
+ left: 50%;
+ width: min(980px, calc(100vw - 32px));
+ }
+}
+
+.status-track {
+ display: flex;
+ width: max-content;
+ min-width: 200%;
+ gap: 64px;
+ animation: status-marquee 22s linear infinite;
+}
+
+.status-track span {
+ flex: 0 0 auto;
+ min-width: calc(50vw - 32px);
+ padding-left: 28px;
+ white-space: nowrap;
+}
+
+@keyframes intro-rise {
+ from {
+ opacity: 0;
+ transform: translateY(22px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes preview-attack {
+ to {
+ background-position-x: var(--sprite-end);
+ }
+}
+
+@keyframes preview-breathe {
+ 0%,
+ 100% {
+ margin-top: 0;
+ }
+ 50% {
+ margin-top: -8px;
+ }
+}
+
+@keyframes preview-strike {
+ 0%,
+ 58%,
+ 100% {
+ opacity: 0;
+ }
+ 64%,
+ 76% {
+ opacity: 0.86;
+ }
+}
+
+@keyframes status-marquee {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+@keyframes kill-log-entry {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes banner-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -56%) scale(0.86);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+@media (max-width: 960px) {
+ body {
+ overflow: hidden;
+ }
+
#app {
- grid-template-columns: 1fr;
+ --arena-gap: 0px;
+ --score-panel-left: 10px;
+ --score-panel-width: calc(100vw - 20px);
+ --score-rail-width: 0px;
+ }
+
+ #app.match-live .arena-shell {
+ place-items: start center;
+ }
+
+ #app.match-live #game {
+ width: min(100vw, calc(100svh - var(--score-band-height)));
+ height: min(100vw, calc(100svh - var(--score-band-height)));
+ margin-top: var(--score-band-height);
+ margin-left: 0;
+ }
+
+ .intro-stage {
+ padding: 20px;
+ }
+
+ #app.options-open:not(.match-live) .intro-content {
+ transform: translateY(-16vh) scale(0.86);
+ }
+
+ .arena-logo {
+ font-size: clamp(3.8rem, 22vw, 7rem);
}
.fighter-entry {
- align-content: start;
- gap: 18px;
- padding-bottom: 18px;
- border-right: 0;
- border-bottom: 1px solid rgb(230 207 134 / 0.14);
+ width: 100vw;
+ padding: 22px;
}
- .arena-shell {
- align-content: start;
- min-height: auto;
+ .battle-preview {
+ opacity: 0.62;
}
- #game {
- width: min(100%, calc(100svh - 360px));
- min-width: min(100%, 320px);
+ .preview-knight {
+ left: -8vw;
+ top: 52vh;
+ }
+
+ .preview-orc {
+ right: -9vw;
+ top: 49vh;
+ }
+
+ .preview-wizard {
+ left: 48vw;
+ top: 21vh;
+ }
+
+ .scoreboard {
+ top: 10px;
+ left: var(--score-panel-left);
+ width: var(--score-panel-width);
+ max-height: calc(var(--score-band-height) - 20px);
+ padding: 7px;
+ }
+
+ .score-side {
+ gap: 5px;
+ }
+
+ .team-score {
+ width: 90px;
+ min-height: 54px;
+ padding: 7px 8px 6px;
+ font-size: 0.72rem;
+ }
+
+ .battle-notice {
+ top: calc(var(--score-band-height) + 8px);
+ right: 24px;
+ left: 24px;
+ width: auto;
+ padding-inline: 12px;
+ font-size: 0.76rem;
+ transform: translateY(-10px);
+ }
+
+ #app.match-live .battle-notice.is-visible {
+ transform: translateY(0);
+ }
+
+ .kill-log {
+ bottom: 10px;
+ left: 10px;
+ width: calc(100vw - 20px);
+ max-height: 25vh;
+ padding: 8px;
}
.match-status {
- top: 28px;
+ bottom: 10px;
+ width: calc(100vw - 20px);
+ }
+
+ .visitor-count {
+ bottom: calc(10px + env(safe-area-inset-bottom));
+ right: 10px;
+ font-size: 0.68rem;
}
}
diff --git a/src/ui/deathStats.js b/src/ui/deathStats.js
new file mode 100644
index 0000000..1024dac
--- /dev/null
+++ b/src/ui/deathStats.js
@@ -0,0 +1,30 @@
+export async function fetchTodayDeathStats() {
+ const response = await fetch("/api/death-stats/today", {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Death stats fetch failed: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+export async function addTodayDeathStats(deathStats) {
+ const response = await fetch("/api/death-stats/today", {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(deathStats),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Death stats update failed: ${response.status}`);
+ }
+
+ return response.json();
+}
diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js
index 321744a..3d24307 100644
--- a/src/ui/matchForm.js
+++ b/src/ui/matchForm.js
@@ -3,7 +3,9 @@ import { NICKNAME_LENGTH } from "../constants.js";
export function createMatchForm() {
const form = getElement("#fighter-form");
const namesInput = getElement("#player-names");
+ const appNode = document.querySelector("#app");
const statusNode = document.querySelector("#match-status");
+ const statusTextNodes = document.querySelectorAll("[data-status-text]");
const teamSizeInput = getElement("#team-size");
const teamSizeOutput = getElement("#team-size-value");
@@ -27,8 +29,15 @@ export function createMatchForm() {
readMatchConfig,
setStatus(message) {
if (statusNode) {
- statusNode.textContent = message;
+ statusNode.setAttribute("aria-hidden", "false");
+ statusNode.title = message;
}
+
+ statusTextNodes.forEach((node) => {
+ node.textContent = message;
+ });
+
+ appNode?.classList.add("status-active");
},
};
}
diff --git a/todo.md b/todo.md
index d9a1d5d..510a400 100644
--- a/todo.md
+++ b/todo.md
@@ -31,9 +31,54 @@
- **조치 사항**:
- 캐릭터 이름표를 스프라이트 중심이 아니라 실제 히트박스 하단에 고정.
- 캐릭터 클릭 시 선택 상태를 설정하고 카메라를 해당 캐릭터 히트박스 중심에 고정.
- - 선택 표시는 사각형 대신 원본 alpha 마스크 기반 노란 실루엣으로 생성.
- - 실루엣은 캐릭터 바로 옆 1px을 비우고 그 바깥 1px에만 표시해 자글자글한 느낌을 줄임.
+ - 초기 선택 표시는 사각형 대신 원본 alpha 마스크 기반 실루엣으로 생성.
+ - 이후 선택 전용 실루엣은 상시 팀 색상 마커로 전환되어, 생존 캐릭터마다 팀 색상을 계속 보여줌.
7. 패키지 락 파일 제외 (완료)
- **조치 사항**:
- `package-lock.json`을 git 추적에서 제외하고 `.gitignore`에 추가.
+
+8. Slime 캐릭터 특성 추가 (완료)
+- **조치 사항**:
+ - `src/game/fighterManifest.js`에 Slime 전용 `stats.maxHp: 1`과 `traits`를 추가.
+ - Slime으로 배정된 기본 스폰 슬롯 1개가 실제 Slime 10마리로 확장되도록 `spawnMultiplier` 처리 추가.
+ - Slime 사망 시 50% 확률로 최대 체력 1인 Slime 2마리를 생성하도록 분열 특성 추가.
+ - 분열체는 `childCanSplit: false`로 다시 분열하지 않도록 제한.
+ - 처치 보상은 최대 체력을 증가시키지 않으므로 Slime이 적을 처치해도 최대 체력 1이 유지됨.
+
+9. 전투 진입 UI, 좌측 HUD badge, 좌측 하단 킬로그 개선 (완료)
+- **조치 사항**:
+ - 최초 접속 화면에 투명 전투 프리뷰, `Arena` 로고, `Start` 버튼을 배치.
+ - `Start` 클릭 시 우측 옵션 drawer가 열리고, 전투 시작 시 실제 경기 화면으로 전환.
+ - 팀 badge를 상단 좌/우 분할에서 경기장 밖 좌측 HUD 레일로 이동.
+ - badge를 팀명, 팀 색상 구분선, 생존 인원 형식으로 표기.
+ - 좌측 HUD 레일 폭과 경기장 시작 위치를 분리 계산해 badge가 미니맵과 경기장 캔버스를 가리지 않도록 조정.
+ - 전투 시작 후 하단 안내바는 숨기고, 좌측 하단에 처치자/피처치자 이미지와 `manifest.key`를 포함한 목록형 킬로그를 표시.
+
+10. 전투 중 옵션 drawer 유지, 접기/펼치기, 재시작, 일시정지 추가 (완료)
+- **조치 사항**:
+ - 전투 시작 후 우측 drawer를 compact 패널로 유지하고, 전투 화면 중앙 정렬을 유지.
+ - `전투 설정` 헤더 우측 상단에 `옵션 접기/옵션 펼치기` 버튼을 배치.
+ - 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남기고 패널 내용은 숨겨 전투 화면을 가리지 않도록 조정.
+ - `재시작` 버튼으로 현재 입력값 기준 새 전투를 즉시 시작하도록 연결.
+ - `일시정지/계속` 버튼으로 Phaser 물리, 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개하도록 구현.
+
+11. 팀 badge 기반 무작위 시점 고정 (완료)
+- **조치 사항**:
+ - 좌측 팀 badge를 클릭 가능한 버튼으로 변경.
+ - 클릭한 팀의 생존 캐릭터 중 무작위 1명을 선택해 기존 캐릭터 선택 카메라 고정 로직에 연결.
+ - 선택된 팀 badge에 강조 스타일을 적용하고, 전투 중 하단 안내바가 노출되지 않도록 상태 표시를 정리.
+
+12. 처치 성장 scale 상한 및 arena 내부 보정 (완료)
+- **조치 사항**:
+ - `KILL_GROWTH_MAX_MULTIPLIER`를 추가해 처치 누적 보상으로 인한 크기/공격속도/이동속도 증가에 상한을 적용.
+ - 처치 성장 tween 중/완료 시 캐릭터 위치를 arena 안쪽으로 보정해 히트박스가 전장 밖으로 나가는 문제를 방지.
+
+13. 종족별 사망 통계 저장 및 상단 안내바 추가 (완료)
+- **조치 사항**:
+ - `src/game/fighterManifest.js`의 모든 캐릭터에 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나의 `species` 값을 부여.
+ - 실제 전투에서 사망한 캐릭터를 `skin.species` 기준으로 집계하고, 프리뷰 전투는 통계에서 제외.
+ - `server/deathStats.js`와 `/api/death-stats/today` API를 추가해 별도 매치별 문서 없이 오늘 일자별 집계에 사망 카운트를 누적.
+ - 전투가 5초 이상 지속되면 상단 `#battle-notice`에 오늘 종족별 사망 집계를 2초 표시/10초 대기 주기의 재치 있는 안내 문구로 표시.
+ - 상단 안내바 폭을 전투 화면 안쪽에 어울리도록 줄이고, 방문자 수는 메인 화면 대신 전투 화면 우측 하단 작은 배지로 이동.
+ - `config.json.sample`에 사망 통계 컬렉션명과 집계 타임존 설정 예시를 추가.