fix: mobile team card width and modularize CSS with documentation

This commit is contained in:
Horoli 2026-05-24 19:15:31 +09:00
parent 43383bb833
commit 36fd25731a
11 changed files with 2117 additions and 1978 deletions

View File

@ -29,6 +29,10 @@
│ └── visitors.js # 유니크 방문자 체크 및 통계 API │ └── visitors.js # 유니크 방문자 체크 및 통계 API
├── public/ # 정적 리소스 (게임 에셋) ├── public/ # 정적 리소스 (게임 에셋)
│ └── assets/ │ └── assets/
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
│ │ ├── heal/ # 처치 회복 연출
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋 │ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략) │ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함 │ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
@ -39,19 +43,21 @@
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성) ├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
│ ├── arena/ # 아레나 및 씬 관리 │ ├── arena/ # 아레나 및 씬 관리
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율) │ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
│ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링 │ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직 │ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
│ ├── combat/ # 전투 시스템 │ ├── combat/ # 전투 시스템
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진 │ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리 │ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
│ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과 │ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
│ ├── fighter/ # 캐릭터 및 에셋 │ ├── fighter/ # 캐릭터 및 에셋
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성 │ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화 │ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의 │ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직 │ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
│ └── match/ # 매치 및 진행 │ └── match/ # 매치 및 진행
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (역/랜덤) │ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (스타팅 영역/랜덤)
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화) │ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
└── ui/ # UI 컴포넌트 및 API 연동 └── ui/ # UI 컴포넌트 및 API 연동
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈 ├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
@ -71,9 +77,10 @@
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙. - **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세. - **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인. - **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 연출. - **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 및 월드 이펙트 연출.
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의. - **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI. - **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
## 4. 기술 사양 ## 4. 기술 사양

View File

@ -18,14 +18,15 @@
### 매치 설정 및 스폰 배치 ### 매치 설정 및 스폰 배치
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다. - **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다. - **스타팅 지점 배치**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다. - **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD) ### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. - **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. - **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다. - **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다. - **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄로 배치됩니다. 이때 데스크톱의 고정 가로폭 상속을 방지(`grid-template-columns: none`)하여 모든 팀 카드가 균일한 가로폭을 유지하도록 하며, 4개 이후 팀도 스크롤을 통해 확인할 수 있습니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다. - **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다. - **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.

25
context/style.md Normal file
View File

@ -0,0 +1,25 @@
# Context: Style & Design
## 1. CSS 모듈 구조 (src/styles/)
이 프로젝트는 거대한 단일 CSS 파일을 지양하고, 기능별로 분리된 모듈형 CSS 구조를 채택하고 있습니다. `src/styles.css`는 각 모듈을 통합하는 엔트리 포인트 역할을 합니다.
- **`base.css`**: 전역 변수(`:root`), 리셋 스타일, 레이아웃의 뼈대(`#app`, `#game`, `.arena-shell`)를 정의합니다.
- **`intro.css`**: 대기 화면, 로고 애니메이션, 전투 프리뷰 연출 스타일을 담당합니다.
- **`game-ui.css`**: 스코어보드(팀 badge), 킬로그, 상단 전투 안내바, 승리/무승부 축하 레이어 등 실제 게임 진행 중 노출되는 모든 HUD 요소를 관리합니다.
- **`overlay.css`**: 설정 드로어(전투 옵션 폼), About 다이얼로그 및 공통 폼 컨트롤 스타일을 정의합니다.
- **`animations.css`**: 프로젝트 전역에서 재사용되는 `@keyframes`와 애니메이션 관련 유틸리티 클래스를 포함합니다.
- **`mobile.css`**: `960px` 이하 해상도를 위한 미디어 쿼리 오버라이드 스타일을 통합 관리합니다. 모바일 전용 레이아웃 조정 및 터치 최적화 스타일이 포함됩니다.
## 2. 디자인 시스템 및 변수
- **색상 체계**: 어두운 배경(`#080a07`)과 금색/주황색 계열의 포인트 컬러(`rgb(238 185 73)`)를 사용하여 판타지 아레나 분위기를 연출합니다.
- **반응형 대응**: `clamp()`, `min()`, `calc()` 등 현대적인 CSS 함수를 적극 활용하여 다양한 화면 크기에서도 유연하게 대응합니다.
- **가독성**: 텍스트 섀도우와 반투명 배경(`backdrop-filter`)을 활용해 복잡한 전투 화면 위에서도 UI 요소의 시인성을 확보합니다.
## 3. 스타일 수정 가이드
- **전역 상수 변경**: 색상이나 기본 여백 등은 `base.css``:root` 변수를 먼저 확인하십시오.
- **컴포넌트 스타일 수정**: 수정하려는 UI 요소가 속한 카테고리에 맞는 파일을 열어 작업하십시오. (예: 킬로그 수정 -> `game-ui.css`)
- **모바일 레이아웃 수정**: 데스크톱 스타일을 수정한 후에는 `mobile.css`에서 해당 요소가 모바일에서 어떻게 보이는지 반드시 확인하고 필요한 경우 오버라이드하십시오.
- **애니메이션 추가**: 새로운 키프레임은 `animations.css`에 추가하여 중앙 집중식으로 관리합니다.

File diff suppressed because it is too large Load Diff

158
src/styles/animations.css Normal file
View File

@ -0,0 +1,158 @@
@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: translateY(18px) scale(0.78);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes victory-banner-sheen {
from {
opacity: 0;
transform: translateX(0) skewX(-18deg);
}
18% {
opacity: 1;
}
to {
opacity: 0;
transform: translateX(560%) skewX(-18deg);
}
}
@keyframes victory-confetti-burst {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
}
12% {
opacity: 1;
}
74% {
opacity: 1;
}
100% {
opacity: 0;
transform:
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
scale(1);
}
}
@keyframes victory-glow {
from {
opacity: 0;
transform: scale(0.58);
}
35% {
opacity: 1;
}
to {
opacity: 0.8;
transform: scale(1);
}
}
@keyframes victory-rays-in {
from {
transform: scale(0.56);
}
to {
transform: scale(1);
}
}
@keyframes victory-rays-turn {
to {
rotate: 360deg;
}
}
@keyframes victory-message-pulse {
from {
opacity: 0.72;
transform: scale(0.88);
}
58% {
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.victory-banner,
.victory-banner::before,
.victory-banner-message,
.victory-celebration::before,
.victory-confetti-piece,
.victory-rays {
animation-duration: 1ms;
animation-iteration-count: 1;
}
}

129
src/styles/base.css Normal file
View File

@ -0,0 +1,129 @@
:root {
color-scheme: dark;
font-family:
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
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,
input,
textarea {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgb(238 185 73 / 0.46);
outline-offset: 3px;
}
#app {
--arena-gap: 18px;
--score-band-height: 134px;
--score-panel-left: 14px;
--score-panel-width: 260px;
--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;
overflow: hidden;
background:
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;
}

601
src/styles/game-ui.css Normal file
View File

@ -0,0 +1,601 @@
.scoreboard {
position: fixed;
top: clamp(14px, 3vw, 28px);
left: var(--score-panel-left);
z-index: 3;
display: flex;
justify-content: flex-start;
width: var(--score-panel-width);
max-height: calc(100vh - 420px);
min-height: 64px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
padding: 8px;
border: 1px solid rgb(238 185 73 / 0.18);
border-radius: 8px;
background: rgb(4 6 4 / 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-18px);
transition:
opacity 420ms ease,
transform 420ms ease;
backdrop-filter: blur(10px);
}
.scoreboard::-webkit-scrollbar {
width: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .scoreboard {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.score-side {
display: grid;
grid-template-columns: repeat(2, 114px);
gap: 6px;
width: 100%;
}
.score-side.right {
display: none;
}
.team-score {
display: grid;
grid-template-rows: 1fr 1px auto;
gap: 6px;
width: 114px;
min-height: 72px;
overflow: hidden;
border-radius: 6px;
padding: 8px 9px;
color: #fff;
font-size: 0.8rem;
font-weight: 900;
text-align: left;
text-shadow: 1px 1px 2px #000;
transition:
filter 160ms ease,
transform 160ms ease;
}
.team-score:hover {
filter: brightness(1.16);
transform: translateY(-1px);
}
.team-score.is-focused {
box-shadow:
inset 0 0 0 2px rgb(255 244 209 / 0.92),
0 0 18px rgb(227 178 79 / 0.26);
}
.team-score:disabled {
cursor: default;
filter: grayscale(0.6) brightness(0.68);
}
.team-score:disabled:hover {
transform: none;
}
.team-score-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
line-height: 1.15;
}
.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-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
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);
}
.kill-log::-webkit-scrollbar {
width: 4px;
}
.kill-log::-webkit-scrollbar-track {
background: transparent;
}
.kill-log::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .kill-log.has-entries {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.kill-log-list {
display: flex;
flex-direction: column;
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 {
position: relative;
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: -24px -16px;
background-repeat: no-repeat;
background-size: auto 86px;
image-rendering: pixelated;
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
}
.kill-log-fighter.victim .kill-log-avatar::before,
.kill-log-fighter.victim .kill-log-avatar::after {
content: "";
position: absolute;
top: 7px;
right: 1px;
width: 14px;
height: 2px;
border: 1px solid rgb(255 216 212 / 0.22);
border-radius: 999px;
background: #f24a42;
box-shadow:
0 0 0 1px rgb(48 4 3 / 0.7),
0 0 5px rgb(227 54 46 / 0.6);
transform-origin: center;
}
.kill-log-fighter.victim .kill-log-avatar::before {
transform: rotate(45deg);
}
.kill-log-fighter.victim .kill-log-avatar::after {
transform: rotate(-45deg);
}
.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%;
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;
}
.kill-log-weapon::before {
transform: translate(-50%, -50%) rotate(42deg);
}
.kill-log-weapon::after {
transform: translate(-50%, -50%) rotate(-42deg);
}
.victory-celebration {
position: fixed;
z-index: 9;
display: grid;
overflow: hidden;
place-items: center;
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition:
opacity 220ms ease,
transform 220ms ease;
}
.victory-celebration.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.victory-celebration::before {
content: "";
position: absolute;
z-index: -1;
width: min(122vmin, 1240px);
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
animation: victory-glow 1.8s ease-out both;
}
.victory-celebration.is-draw::before {
background:
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
}
.victory-rays {
position: absolute;
z-index: 0;
width: min(112vmin, 1120px);
aspect-ratio: 1;
border-radius: 50%;
background: repeating-conic-gradient(
from -4deg,
rgb(255 233 166 / 0.18) 0 8deg,
transparent 8deg 18deg
);
opacity: 0.54;
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
}
.victory-celebration.is-draw .victory-rays {
opacity: 0.22;
}
.victory-confetti {
position: absolute;
z-index: 1;
inset: 0;
}
.victory-confetti-piece {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: clamp(6px, 0.8vw, 11px);
height: clamp(10px, 1.2vw, 18px);
border-radius: 8px;
background: var(--confetti-color);
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
}
.victory-confetti-piece:nth-child(3n) {
width: clamp(10px, 1vw, 15px);
height: clamp(6px, 0.72vw, 10px);
border-radius: 2px;
}
.victory-banner {
position: relative;
z-index: 2;
display: grid;
width: min(calc(100vw - 36px), 760px);
min-height: clamp(108px, 18vw, 170px);
overflow: hidden;
place-items: center;
border: 2px solid #f1c45d;
border-radius: 8px;
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
background:
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
rgb(4 6 4 / 0.9);
color: #fff7df;
font-size: clamp(1.65rem, 5vw, 3rem);
font-weight: 950;
letter-spacing: 0;
line-height: 1.12;
text-align: center;
text-wrap: balance;
text-shadow:
0 2px 0 rgb(55 36 8 / 0.56),
0 0 24px rgb(255 226 153 / 0.28);
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.2) inset,
0 0 42px rgb(227 178 79 / 0.44),
0 24px 90px rgb(0 0 0 / 0.58);
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
backdrop-filter: blur(6px);
}
.victory-banner::before {
content: "";
position: absolute;
inset: -40% auto -40% -36%;
width: 28%;
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
transform: skewX(-18deg);
animation: victory-banner-sheen 1s 0.28s ease-out both;
}
.victory-banner::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgb(255 225 151 / 0.24);
border-radius: 5px;
}
.victory-banner-message {
position: relative;
z-index: 1;
display: block;
max-width: 100%;
overflow-wrap: anywhere;
animation: victory-message-pulse 720ms 80ms ease-out both;
}
.victory-celebration.is-draw .victory-banner {
border-color: #d8c28d;
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.14) inset,
0 0 28px rgb(227 178 79 / 0.24),
0 24px 90px rgb(0 0 0 / 0.52);
}
#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: fixed;
bottom: clamp(14px, 3vw, 26px);
left: 50%;
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: 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);
}
#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;
}

225
src/styles/intro.css Normal file
View File

@ -0,0 +1,225 @@
.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 {
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;
}
.arena-logo .small-text {
font-size: 0.7em;
margin-top: 0.05em;
}
.arena-logo span {
display: block;
}
.arena-meta {
position: fixed;
right: clamp(10px, 2vw, 18px);
bottom: clamp(10px, 2vw, 18px);
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
pointer-events: none;
transition: opacity 220ms ease;
}
.visitor-count,
.about-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 999px;
padding: 5px 12px;
background: rgb(8 10 7 / 0.68);
color: #e7c879;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
text-decoration: none;
backdrop-filter: blur(10px);
pointer-events: auto;
transition:
background 180ms ease,
border-color 180ms ease,
transform 180ms ease,
opacity 220ms ease;
}
.visitor-count {
opacity: 0;
transform: translateY(8px);
}
#app.match-live .visitor-count {
opacity: 0.86;
transform: translateY(0);
}
.about-button {
min-width: 72px;
color: #ffe8b4;
font-weight: 900;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
}
.about-button:hover {
border-color: rgb(238 185 73 / 0.42);
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
.start-button {
min-width: 180px;
padding: 0 30px;
text-transform: uppercase;
}
#app.options-open:not(.match-live) .start-button {
pointer-events: none;
visibility: hidden;
}

308
src/styles/mobile.css Normal file
View File

@ -0,0 +1,308 @@
@media (max-width: 960px) {
body {
overflow: hidden;
}
#app {
--arena-gap: 0px;
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
--score-panel-width: calc(100vw - 20px);
--score-rail-width: 0px;
}
#app.match-live .arena-shell {
place-items: start center;
}
#app.match-live #game {
width: var(--mobile-game-size);
height: var(--mobile-game-size);
margin-top: var(--score-band-height);
margin-left: 0;
}
.intro-stage {
padding: 20px;
}
.arena-logo {
font-size: clamp(3.8rem, 22vw, 7rem);
}
.fighter-entry {
width: 100vw;
padding: 22px;
}
#app.match-live .fighter-entry {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
gap: 10px;
padding: 12px;
}
#app.match-live.drawer-collapsed .fighter-entry {
top: calc(22px + env(safe-area-inset-top));
right: 10px;
left: auto;
width: auto;
}
#app.match-live .fighter-entry h2 {
font-size: clamp(1.45rem, 7vw, 1.8rem);
}
#app.match-live .fighter-entry textarea {
height: 112px;
min-height: 112px;
resize: none;
}
#app.match-live .fighter-entry fieldset {
gap: 7px;
padding: 9px;
}
#app.match-live .fighter-entry form {
gap: 10px;
}
#app.match-live .entry-copy {
gap: 4px;
}
#app.match-live .eyebrow {
font-size: 0.68rem;
}
#app.match-live label,
#app.match-live .spawn-placement-label {
font-size: 0.82rem;
}
#app.match-live input:not([type="range"]):not([type="radio"]),
#app.match-live textarea {
min-height: 40px;
padding-inline: 10px;
}
#app.match-live textarea {
padding-block: 9px;
}
#app.match-live .team-size-number {
width: 64px;
min-width: 64px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
font-size: 0.76rem;
}
#app.match-live .match-actions {
gap: 8px;
}
#app.match-live .match-actions button {
min-height: 44px;
}
#app.match-live .drawer-toggle {
min-width: 116px;
}
#app.match-live.drawer-collapsed .drawer-toggle {
width: var(--mobile-options-button-width);
min-width: var(--mobile-options-button-width);
padding-inline: 6px;
font-size: 0;
}
#app.match-live.drawer-collapsed .drawer-toggle::before {
content: "옵션";
font-size: 0.78rem;
line-height: 1;
}
.battle-preview {
opacity: 0.62;
}
.preview-knight {
left: -8vw;
top: 52vh;
}
.preview-orc {
right: -9vw;
top: 49vh;
}
.preview-wizard {
left: 48vw;
top: 21vh;
}
.scoreboard {
align-items: flex-start;
top: 10px;
left: var(--score-panel-left);
width: var(--score-panel-width);
max-height: calc(var(--score-band-height) - 12px);
overflow-x: auto;
overflow-y: hidden;
padding: 9px min(148px, 38vw) 9px 9px;
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
scrollbar-width: thin;
touch-action: pan-x;
}
#app.match-live.drawer-collapsed .scoreboard {
width: calc(
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
);
padding-right: 9px;
}
.score-side {
display: grid;
grid-template-columns: none;
grid-auto-columns: var(--mobile-team-card-width);
grid-auto-flow: column;
grid-template-rows: repeat(2, 48px);
gap: 5px 5px;
}
.scoreboard::-webkit-scrollbar {
height: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(238 185 73 / 0.38);
}
.team-score {
width: auto;
min-height: 48px;
height: 48px;
gap: 3px;
padding: 5px 6px;
font-size: 0.66rem;
grid-template-rows: 1fr 1px auto;
align-content: center;
}
.team-score-count {
font-size: 0.74rem;
}
.team-score.is-focused {
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
}
.battle-notice {
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 {
top: var(--mobile-kill-log-top);
bottom: auto;
left: 10px;
width: calc(100vw - 20px);
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
padding: 8px;
}
#app.match-live .victory-celebration {
padding:
var(--score-band-height)
14px
min(30svh, 230px);
}
.victory-banner {
width: min(calc(100vw - 48px), 520px);
min-height: 92px;
padding: 1rem 1.1rem;
font-size: clamp(1.35rem, 7vw, 2rem);
}
.match-status {
bottom: 10px;
width: calc(100vw - 20px);
}
.arena-meta {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
gap: 8px;
opacity: 1;
pointer-events: auto;
}
.visitor-count {
font-size: 0.68rem;
}
.about-button {
min-width: 68px;
min-height: 26px;
font-size: 0.68rem;
}
.about-backdrop {
align-items: end;
padding: 12px;
}
.about-dialog {
width: 100%;
max-height: calc(100svh - 24px);
}
.about-header {
padding: 18px 18px 12px;
}
.about-tabs {
padding: 0 18px 12px;
}
.about-panel {
padding: 16px 18px 22px;
}
.about-field-row {
grid-template-columns: 78px minmax(0, 1fr);
min-height: 48px;
gap: 10px;
}
}

566
src/styles/overlay.css Normal file
View File

@ -0,0 +1,566 @@
.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: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;
}
#app.match-ended .pause-button {
display: none;
}
.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: 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: 8px;
}
.eyebrow {
margin: 0;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h2 {
margin: 0;
color: #fff3d2;
font-size: clamp(1.7rem, 4vw, 2.5rem);
line-height: 1.05;
letter-spacing: 0;
}
.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);
}
.about-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: clamp(16px, 4vw, 34px);
background: rgb(3 5 4 / 0.66);
backdrop-filter: blur(8px);
}
.about-backdrop[hidden] {
display: none;
}
.about-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(560px, calc(100vw - 32px));
max-height: min(760px, calc(100svh - 32px));
overflow: hidden;
border: 1px solid rgb(239 199 103 / 0.28);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
#11140f;
box-shadow:
0 24px 100px rgb(0 0 0 / 0.62),
inset 0 1px 0 rgb(255 255 255 / 0.06);
outline: none;
}
.about-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
}
.about-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.about-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 0 clamp(20px, 4vw, 30px) 14px;
border-bottom: 1px solid rgb(238 185 73 / 0.16);
}
.about-tab {
min-width: 0;
min-height: 42px;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px 10px;
background: rgb(255 246 216 / 0.06);
color: #ead8ad;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.2;
}
.about-tab[aria-selected="true"] {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.about-panel {
min-height: 0;
overflow: auto;
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
}
.about-fields {
display: grid;
gap: 0;
margin: 0;
}
.about-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 50px;
border-bottom: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row:first-child {
border-top: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row dt {
color: #e3b24f;
font-size: 0.78rem;
font-weight: 950;
text-transform: uppercase;
}
.about-field-row dd {
min-width: 0;
margin: 0;
color: #fff7df;
font-weight: 800;
overflow-wrap: anywhere;
}
.about-field-row a,
.about-markdown a {
color: #85dcc7;
text-decoration-color: rgb(133 220 199 / 0.42);
text-underline-offset: 3px;
}
.about-markdown {
display: grid;
gap: 12px;
color: #ead8ad;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.56;
}
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
margin: 0;
}
.about-markdown h3,
.about-markdown h4,
.about-markdown h5,
.about-markdown h6 {
color: #fff3d2;
font-size: 1rem;
line-height: 1.3;
}
.about-markdown blockquote {
border-left: 3px solid rgb(238 185 73 / 0.36);
padding: 4px 0 4px 16px;
color: #c4b693;
font-style: italic;
}
.about-markdown hr {
margin: 8px 0;
border: 0;
border-top: 1px solid rgb(238 185 73 / 0.16);
}
.about-markdown code {
border: 1px solid rgb(238 185 73 / 0.14);
border-radius: 4px;
padding: 2px 5px;
background: rgb(255 246 216 / 0.08);
color: #f1c761;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.about-markdown li {
line-height: 1.5;
}
.about-markdown li strong {
color: #fff3d2;
}
.about-markdown ul {
display: grid;
gap: 8px;
padding-left: 1.2rem;
}
.about-empty {
color: #bfae83;
}
form {
display: grid;
gap: 16px;
}
.match-actions {
display: grid;
gap: 10px;
}
fieldset {
display: grid;
gap: 10px;
min-width: 0;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
padding: 14px;
background: rgb(5 7 5 / 0.26);
}
legend {
padding: 0 6px;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.team-size-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.team-size-number {
width: 88px;
min-width: 88px;
padding-inline: 10px;
text-align: center;
font-weight: 900;
}
label {
color: #ead8ad;
font-size: 0.92rem;
}
input:not([type="range"]):not([type="radio"]),
textarea {
min-height: 48px;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 0 14px;
background: #232719;
color: #fff7df;
outline: none;
}
textarea {
min-height: 258px;
resize: vertical;
padding-block: 12px;
line-height: 1.45;
}
input[type="range"] {
width: 100%;
accent-color: #e3b24f;
}
.spawn-placement-field {
display: grid;
gap: 8px;
}
.spawn-placement-label {
color: #ead8ad;
font-size: 0.92rem;
}
.spawn-placement-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
min-width: 0;
border: 1px solid rgb(238 185 73 / 0.2);
border-radius: 8px;
padding: 4px;
background: #1d2116;
}
.spawn-placement-option {
position: relative;
min-width: 0;
}
.spawn-placement-option input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.spawn-placement-option span {
display: grid;
min-height: 44px;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px;
color: #ead8ad;
text-align: center;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.25;
cursor: pointer;
}
.spawn-placement-option input:checked + span {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.spawn-placement-option input:focus-visible + span {
outline: 2px solid #f1c761;
outline-offset: -2px;
}

86
todo.md
View File

@ -109,12 +109,12 @@
18. 치명타 적중 표기 추가 (완료) 18. 치명타 적중 표기 추가 (완료)
- **조치 사항**: - **조치 사항**:
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리. - 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강. - 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
19. 리스폰 배치 설정 구분 추가 (완료) 19. 리스폰 배치 설정 구분 추가 (완료)
- **조치 사항**: - **조치 사항**:
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가. - 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현. - `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지. - 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
20. 팀당 인원 직접 입력 동기화 (완료) 20. 팀당 인원 직접 입력 동기화 (완료)
@ -178,4 +178,86 @@
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거. - 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화. - 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
28. 전투 역할별 기본 스탯 프로필 분리 (완료)
- **조치 사항**:
- `src/constants.js``FIGHTER_TYPE_STATS.melee/ranged/magic` 프로필을 추가해 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타, 공격 발동 지연을 역할별로 조정할 수 있도록 변경.
- `src/game/fighter/fighterStats.js`를 추가해 투사체 캐릭터는 원거리, 즉발 주문 캐릭터는 마법, 나머지는 근접 프로필로 판별하고 개별 스킨 오버라이드를 병합.
- 캐릭터 생성과 전투 엔진이 해석된 프로필을 사용하도록 연결해 역할별 체력, 이동 및 공격 수치가 실제 전투에 적용되도록 변경.
29. 킬로그 캐릭터 아이콘 가시성 개선 (완료)
- **조치 사항**:
- `100x100` idle 프레임에 포함된 투명 여백까지 축소되던 킬로그 아이콘 배경 표시 방식을 보정.
- 아이콘 박스 크기와 행 레이아웃은 유지하면서 캐릭터 실루엣이 있는 중앙 하단 영역을 확대 표시하도록 배경 크기와 위치를 조정.
30. 팀별 스타팅 영역 앵커 및 전장 표시 추가 (완료)
- **조치 사항**:
- `스타팅 지점 배치`에서 전장 스폰 가능 그리드 중 팀별 중심 셀을 무작위로 선택하고, 중심 주변 2칸을 포함하는 `5 x 5` 영역을 팀별 스폰 구역으로 사용하도록 변경.
- 겹치지 않는 후보가 남아 있는 동안에는 선택된 스타팅 영역끼리 중첩되지 않는 랜덤 중심을 우선 사용해 전투 시작 즉시 팀이 섞이는 상황을 줄임.
- 팀별로 무작위 배정된 스타팅 영역 데이터를 실제 스폰 좌표와 공유해 표시 영역 밖에서 시작하지 않도록 구성.
- `arenaRenderer.js`에 팀 색상의 매우 옅은 채움 및 외곽선 오버레이를 추가하고, 랜덤 배치에서는 오버레이가 표시되지 않도록 연결.
31. 사망 시점 팀 badge 클릭 입력 유실 수정 (완료)
- **조치 사항**:
- 사망 발생 때마다 `arenaScoreboard.js`가 팀 badge 버튼 전체를 재생성해 클릭 중인 DOM이 제거되던 문제를 수정.
- 팀 구성이 바뀌지 않는 전투 중 갱신에서는 기존 버튼 DOM을 유지하고 생존 인원, 선택 강조, 비활성 상태만 업데이트하도록 변경.
- 사망 처리와 팀 badge 클릭이 같은 시점에 겹쳐도 생존 캐릭터 관전 시점 선택이 정상 전달되도록 보강.
32. 주기적 월드 이펙트 메테오 및 냉각지대 추가 (완료)
- **조치 사항**:
- `public/assets/effects/world_Effect.png`를 7프레임 공용 스프라이트시트로 로드하고, 실제 전투 시작 후 8초마다 무작위 생존자 위치에 메테오 또는 냉각지대를 무작위 발동하도록 `worldEffects.js`를 추가.
- 메테오는 낙하 경고 후 대상 위치 기준 `5 x 5` 영역에 환경 피해를 적용하고, 환경 사망이 처치 보상 없이 사망 통계와 승패 판정에 반영되도록 전투 피해 처리를 확장.
- 냉각지대는 냉기 착탄 연출과 지속 구역을 표시하며, 구역 안에 있는 캐릭터의 공격속도와 이동속도를 함께 감속하도록 연결.
- 발동 간격, 범위, 피해량, 냉각 지속시간과 감속 배율을 `src/constants.js``WORLD_EFFECT_*` 상수로 분리하고, 새 경기/종료/일시정지 생명주기에 맞춰 정리되도록 구성.
33. 월드 메테오 대각선 낙하 및 냉기 전용 시트 적용 (완료)
- **조치 사항**:
- 대상 위치가 전장 좌측 반면(2, 3사분면)이면 좌상단에서 우하단, 우측 반면(1, 4사분면)이면 우상단에서 좌하단으로 낙하하도록 궤적, 좌우 반전, `45`도 회전을 적용.
- `WORLD_EFFECT_VISUAL_SCALE``WORLD_EFFECT_FALL_TRAVEL_TILES`를 추가해 피해 판정 `5 x 5`는 유지하면서 스프라이트를 전역 마법처럼 크게 보이도록 확장.
- 화염 메테오는 `public/assets/effects/world_Effect.png`, 냉기 메테오는 새 `public/assets/effects/world_Effect_2.png`를 각각 독립된 7프레임 애니메이션으로 로드하도록 변경.
34. 냉기 메테오 착탄 피해 옵션 추가 (완료)
- **조치 사항**:
- `WORLD_EFFECT_FROST_DAMAGE`를 추가해 냉기 메테오 피해를 화염 메테오와 독립적으로 조절할 수 있도록 변경.
- 냉기 메테오 착탄 시 `5 x 5` 영역 피해를 먼저 처리하고, 전투가 종료되지 않은 경우 기존 냉각지대 감속 효과를 이어서 생성하도록 연결.
35. 스타팅 영역 표시 시간 제한 추가 (완료)
- **조치 사항**:
- 팀별 스타팅 영역 오버레이가 `스타팅 지점 배치` 매치 시작 후 5초 동안만 표시되고 이후 자동으로 사라지도록 연결.
- 숨김 예약을 Phaser 씬 타이머로 관리하여 일시정지 시간은 표시 지속 시간에 포함되지 않고, 새 매치 시작 시 이전 숨김 타이머가 남지 않도록 정리.
36. 최종 2팀 자동 관전 및 메테오 착탄 화면 흔들림 전환 (완료)
- **조치 사항**:
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
- 화염 메테오가 착탄할 때 화면 흔들림을 적용하고, 냉각지대 착탄과 피해 계산에서는 흔들림을 분리.
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
- **조치 사항**:
- 후반 자동 관전 조건이 성립하기 전 화염 또는 냉기 메테오가 낙하하면 착탄 위치를 확대 추적하고 착탄 연출 종료 후 기존 카메라 위치와 줌을 복원.
- 캐릭터 수동 선택과 후반/최종 자동 관전은 메테오 임시 시점보다 우선하도록 카메라 상태를 정리.
- `src/constants.js``CAMERA.METEOR_FOCUS_ENABLED` 플래그로 메테오 임시 포커싱을 코드에서 켜고 끌 수 있도록 구성.
38. 냉기 메테오 동결 기절 및 실루엣 효과 추가 (완료)
- **조치 사항**:
- 냉기 메테오 착탄 피해에 생존한 전투원은 `2초` 동안 이동과 새 공격이 정지되는 `isFrostStunned` 상태가 되도록 연결.
- 동결 중 캐릭터 본체와 팀 실루엣 마커를 함께 얼음색으로 틴트하고, 시간이 끝나거나 매치가 정리되면 본체 원본 색상과 팀 색상으로 복원.
- `WORLD_EFFECT.FROST_STUN_DURATION``WORLD_EFFECT.FROST_STUN_TINT`를 추가해 동결 지속시간과 표시 색상을 조절 가능하게 구성.
39. 모바일 세로모드 팀 카드 가로폭 불균형 수정 (완료)
- **조치 사항**:
- 모바일 미디어 쿼리에서 `.score-side`가 데스크톱의 `grid-template-columns: repeat(2, 114px)`를 상속받아 1~4번 팀 카드만 길게 표시되던 현상을 수정.
- `grid-template-columns: none`을 추가하여 모든 팀 카드가 `grid-auto-columns`에 설정된 일정한 가로폭을 가지도록 보정.
40. CSS 파일 기능별 모듈화 (완료)
- **조치 사항**:
- 거대했던 `src/styles.css`(약 2,000라인)를 기능별로 6개의 파일(`base`, `intro`, `game-ui`, `overlay`, `animations`, `mobile`)로 분리.
- `src/styles/` 폴더를 생성하여 모듈화된 CSS 파일들을 관리.
- `src/styles.css`는 이제 `@import`를 통해 각 모듈을 통합하는 엔트리 포인트 역할만 수행.
- 코드 가독성과 유지보수 편의성을 대폭 향상.
41. 스타일 관련 컨텍스트 문서 추가 및 라우팅 업데이트 (완료)
- **조치 사항**:
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.