Add visitor tracking and combat selection features

This commit is contained in:
Horoli 2026-05-22 13:22:26 +09:00
parent 104bf4fe48
commit 1509b0c5dd
20 changed files with 1049 additions and 1220 deletions

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules/
dist/
.vite/
config.json
package-lock.json

View File

@ -4,25 +4,52 @@
### [Core Engine]
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
- 앱 로드 시 `trackVisitor()`를 호출해 방문자 체크 API와 연동합니다.
- **`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`: 선택 실루엣의 캐릭터 이격 거리, 두께, 투명도.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
### [Server/API - server/]
- **`server/index.js`**:
- Fastify 서버 진입점입니다.
- 개발 모드에서는 `@fastify/middie`로 Vite 미들웨어를 붙이고, `/api/*` 요청은 Vite SPA fallback이 가로채지 않도록 Fastify 라우트로 통과시킵니다.
- 운영 모드(`npm start`)에서는 `@fastify/static`으로 `dist/`를 서빙하고, HTML 요청은 `index.html`로 fallback합니다.
- **`server/config.js`**:
- `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다.
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
- **`server/visitors.js`**:
- `POST /api/visitors/check`: `arena_visitor_id` `HttpOnly` 쿠키를 확인하고 없으면 UUID를 발급합니다.
- MongoDB `visitors` 컬렉션에 `_id = visitorId`로 upsert해 방문자 1명당 1개 문서를 유지합니다.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수를 반환합니다.
### [Game Logic - src/game/]
- **`ArenaScene.js`**:
- `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다.
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
- `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 선택 상태를 설정하고 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다.
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
- **`matchSetup.js`**:
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
- **`combat.js`**:
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다.
- `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다.
- `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`에 표시합니다.
## 2. 주요 로직 구현 세부 사항
@ -38,7 +65,34 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
### 캐릭터 선택 실루엣
선택 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 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, 위치를 원본 캐릭터와 동기화합니다.
이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 선택 윤곽을 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 윤곽이 같은 배율로 따라갑니다.
### 유니크 방문자 체크
브라우저가 직접 MongoDB에 연결하지 않고, Fastify API가 MongoDB 커넥션 풀을 유지합니다.
1. 프론트엔드가 앱 로드 시 `POST /api/visitors/check`를 호출합니다.
2. 서버가 `arena_visitor_id` 쿠키를 검사합니다.
3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다.
4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다.
5. `countDocuments()`로 전체 유니크 방문자 수를 계산해 반환합니다.
방문자 체크는 인증 기능이 아니며, 브라우저/쿠키 단위의 단순 유니크 카운트입니다.
## 3. 개발 및 유지보수 규칙
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
- **공격력 조정**: 기본 피해량은 `src/constants.js``ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 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로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다.

View File

@ -4,14 +4,23 @@
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수를 기록하는 간단한 방문자 통계 API를 제공합니다.
## 2. 프로젝트 전체 구조 (Directory Tree)
```text
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite)
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB)
├── config.json # 로컬 서버/MongoDB 설정 (git ignore)
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서)
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
├── todo.md # 작업 내역 및 잔여 이슈 관리
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
├── public/ # 정적 리소스 (게임 에셋)
│ └── assets/
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
@ -19,20 +28,21 @@
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
└── src/ # 소스 코드 root
├── main.js # Phaser 게임 인스턴스 생성 및 초기화
├── constants.js # 전역 물리/UI 상수 통합 관리 (줌, 카메라 속도 등)
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션)
├── game/ # 게임 로직 모듈
│ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어)
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
│ ├── combat.js # 전투 AI 및 피격 판정 로직
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리
│ ├── fighterAssets.js# 스프라이트 시트 로드 및 애니메이션 생성
│ ├── fighterFactory.js# 캐릭터 객체 및 HUD 생성
│ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 선택 실루엣 생성
│ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD 및 선택 윤곽 동기화
│ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정)
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산
└── ui/
└── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어
├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어
└── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신
```
## 3. 핵심 기능
@ -43,16 +53,30 @@
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
- **역동적인 전투 연출**:
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
- 치명타(Critical) 발생 시 화면 흔들림 효과 및 대미지 가중치 적용.
- `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다.
- 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다.
- **캐릭터 선택 관전**: 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 원본 스프라이트 알파 마스크를 바탕으로 1px 공백을 둔 노란 실루엣 윤곽이 표시됩니다.
- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다.
- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다.
## 4. 기술 사양
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
- **Build Tool**: Vite 7.1.12
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
- **Database**: MongoDB 7.x Node Driver
- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용)
## 5. 관련 문서
## 5. 서버/API 설정
- 개발/운영 서버는 `npm run dev` 또는 `npm start`로 실행하며 기본 포트는 `config.json``SERVER_PORT` 값인 `9736`입니다.
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
- 기본 API:
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
## 6. 관련 문서
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독)
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리

13
config.json.sample Normal file
View File

@ -0,0 +1,13 @@
{
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": 9736,
"MONGODB_HOST": "172.16.0.7",
"MONGODB_PORT": 27017,
"MONGODB_DB": "arena",
"MONGODB_USER": "",
"MONGODB_PASS": "",
"MONGODB_VISITOR_COLLECTION": "visitors",
"MONGODB_MAX_POOL_SIZE": 10,
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
"COOKIE_SECURE": false
}

View File

@ -11,6 +11,7 @@
<div class="entry-copy">
<p class="eyebrow">Arena Picker</p>
<h1>팀 전투 뽑기</h1>
<p id="visitor-count" class="visitor-count" aria-live="polite">방문자 확인 중</p>
</div>
<form id="fighter-form" autocomplete="off">
<fieldset>

1161
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,19 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "node server/index.js",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"start": "node server/index.js --production"
},
"dependencies": {
"@fastify/middie": "^9.3.2",
"@fastify/static": "^9.1.3",
"fastify": "^5.8.5",
"mongodb": "^7.2.0",
"phaser": "^3.90.0"
},
"devDependencies": {
"vite": "^7.1.12"
}
}

129
server/config.js Normal file
View File

@ -0,0 +1,129 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_CONFIG = {
SERVER_HOST: "0.0.0.0",
SERVER_PORT: 9736,
MONGODB_HOST: "",
MONGODB_PORT: 27017,
MONGODB_DB: "arena",
MONGODB_USER: "",
MONGODB_PASS: "",
MONGODB_URI: "",
MONGODB_VISITOR_COLLECTION: "visitors",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
COOKIE_SECURE: false,
};
let config;
export function getConfig() {
if (!config) {
config = loadConfig();
}
return config;
}
export function loadConfig(filePath = path.resolve(process.cwd(), "config.json")) {
const rawConfig = fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, "utf8"))
: {};
return normalizeConfig(rawConfig);
}
export function hasMongoConfig() {
const appConfig = getConfig();
return Boolean(appConfig.MONGODB_URI || appConfig.MONGODB_HOST);
}
export function getMongoUri() {
const appConfig = getConfig();
if (appConfig.MONGODB_URI) {
return appConfig.MONGODB_URI;
}
if (!appConfig.MONGODB_HOST) {
throw new Error("MongoDB configuration is required for visitor tracking.");
}
const host = appConfig.MONGODB_HOST;
const port = Number(appConfig.MONGODB_PORT || DEFAULT_CONFIG.MONGODB_PORT);
const credentials = mongoCredentials(appConfig);
return `mongodb://${credentials}${host}:${port}`;
}
function normalizeConfig(rawConfig) {
const server = rawConfig.server || {};
const mongodb = rawConfig.mongodb || {};
return {
SERVER_HOST: stringValue(rawConfig.SERVER_HOST, server.host, DEFAULT_CONFIG.SERVER_HOST),
SERVER_PORT: numberValue(rawConfig.SERVER_PORT, server.port, DEFAULT_CONFIG.SERVER_PORT),
MONGODB_HOST: stringValue(rawConfig.MONGODB_HOST, mongodb.host, DEFAULT_CONFIG.MONGODB_HOST),
MONGODB_PORT: numberValue(rawConfig.MONGODB_PORT, mongodb.port, DEFAULT_CONFIG.MONGODB_PORT),
MONGODB_DB: stringValue(rawConfig.MONGODB_DB, mongodb.db, DEFAULT_CONFIG.MONGODB_DB),
MONGODB_USER: stringValue(rawConfig.MONGODB_USER, mongodb.user, DEFAULT_CONFIG.MONGODB_USER),
MONGODB_PASS: stringValue(rawConfig.MONGODB_PASS, mongodb.pass, DEFAULT_CONFIG.MONGODB_PASS),
MONGODB_URI: stringValue(rawConfig.MONGODB_URI, mongodb.uri, DEFAULT_CONFIG.MONGODB_URI),
MONGODB_VISITOR_COLLECTION: stringValue(
rawConfig.MONGODB_VISITOR_COLLECTION,
mongodb.visitorCollection,
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
),
MONGODB_MAX_POOL_SIZE: numberValue(
rawConfig.MONGODB_MAX_POOL_SIZE,
mongodb.maxPoolSize,
DEFAULT_CONFIG.MONGODB_MAX_POOL_SIZE,
),
MONGODB_SERVER_SELECTION_TIMEOUT_MS: numberValue(
rawConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
mongodb.serverSelectionTimeoutMs,
DEFAULT_CONFIG.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
),
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
};
}
function mongoCredentials(appConfig) {
if (!appConfig.MONGODB_USER) {
return "";
}
const user = encodeURIComponent(appConfig.MONGODB_USER);
const pass = encodeURIComponent(appConfig.MONGODB_PASS || "");
return `${user}:${pass}@`;
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string" && candidate.length > 0);
return value ?? "";
}
function numberValue(...values) {
const value = values.find((candidate) => {
const numericValue = Number(candidate);
return Number.isFinite(numericValue) && numericValue > 0;
});
return Number(value);
}
function booleanValue(...values) {
const value = values.find((candidate) => typeof candidate === "boolean" || candidate === "true" || candidate === "false");
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return Boolean(value);
}

52
server/db.js Normal file
View File

@ -0,0 +1,52 @@
import { MongoClient } from "mongodb";
import { getConfig, getMongoUri, hasMongoConfig } from "./config.js";
const DEFAULT_DB_NAME = "arena_picker";
let mongoClient;
let mongoClientPromise;
export { hasMongoConfig };
export async function getMongoClient() {
if (mongoClient) {
return mongoClient;
}
if (!mongoClientPromise) {
const appConfig = getConfig();
const client = new MongoClient(getMongoUri(), {
maxPoolSize: appConfig.MONGODB_MAX_POOL_SIZE,
serverSelectionTimeoutMS: appConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
});
mongoClientPromise = client
.connect()
.then((connectedClient) => {
mongoClient = connectedClient;
return connectedClient;
})
.catch((error) => {
mongoClientPromise = undefined;
throw error;
});
}
return mongoClientPromise;
}
export async function getDb() {
const client = await getMongoClient();
return client.db(getConfig().MONGODB_DB || DEFAULT_DB_NAME);
}
export async function closeMongoConnection() {
if (!mongoClient && !mongoClientPromise) {
return;
}
const client = mongoClient || (await mongoClientPromise);
mongoClient = undefined;
mongoClientPromise = undefined;
await client.close();
}

112
server/index.js Normal file
View File

@ -0,0 +1,112 @@
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 { visitorRoutes } from "./visitors.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distPath = path.join(root, "dist");
const isProduction = process.env.NODE_ENV === "production" || process.argv.includes("--production");
const appConfig = getConfig();
const port = appConfig.SERVER_PORT;
const host = appConfig.SERVER_HOST;
const app = Fastify({
bodyLimit: 16 * 1024,
});
app.addContentTypeParser("*", { parseAs: "string" }, (request, body, done) => {
done(null, body);
});
app.get("/api/health", async () => {
return {
ok: true,
dbConfigured: hasMongoConfig(),
};
});
await app.register(visitorRoutes, { prefix: "/api/visitors" });
if (isProduction) {
await app.register(fastifyStatic, {
root: distPath,
prefix: "/",
});
app.setNotFoundHandler((request, reply) => {
const acceptsHtml = String(request.headers.accept || "").includes("text/html");
if (request.method !== "GET" || request.url.startsWith("/api/") || !acceptsHtml) {
reply.code(404).send({ error: "not_found" });
return;
}
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();
return;
}
vite.middlewares(request, response, next);
});
}
app.setErrorHandler((error, request, reply) => {
const isMissingMongoConfig = error.message.includes("MongoDB configuration");
const status = isMissingMongoConfig ? 503 : 500;
console.error(error);
reply.code(status).send({
error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error",
message: isProduction ? "Visitor tracking is unavailable." : error.message,
});
});
await app.listen({ port, host });
console.log(`Arena Picker listening on http://localhost:${port}`);
if (hasMongoConfig()) {
getMongoClient()
.then(() => {
console.log("MongoDB connection pool is ready.");
})
.catch((error) => {
console.error("MongoDB connection failed. Visitor API will retry on request.", error);
});
}
["SIGINT", "SIGTERM"].forEach((signal) => {
process.on(signal, () => {
shutdown(signal);
});
});
function shutdown(signal) {
console.log(`${signal} received. Closing server.`);
app.close()
.then(closeMongoConnection)
.finally(() => {
process.exit(0);
});
}

117
server/visitors.js Normal file
View File

@ -0,0 +1,117 @@
import { randomUUID } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
const COOKIE_NAME = "arena_visitor_id";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
const DEFAULT_COLLECTION_NAME = "visitors";
const USER_AGENT_LIMIT = 500;
let indexesReady;
export async function visitorRoutes(fastify) {
fastify.post("/check", async (request, reply) => {
return recordVisitor(request, reply);
});
fastify.get("/stats", async () => {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
return {
uniqueVisitors: await collection.countDocuments(),
};
});
}
async function recordVisitor(request, reply) {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
let visitorId = readCookie(request, COOKIE_NAME);
const hadValidCookie = isValidVisitorId(visitorId);
if (!hadValidCookie) {
visitorId = randomUUID();
}
const now = new Date();
const userAgent = String(request.headers["user-agent"] || "").slice(0, USER_AGENT_LIMIT);
const result = await collection.updateOne(
{ _id: visitorId },
{
$setOnInsert: {
_id: visitorId,
firstSeenAt: now,
firstUserAgent: userAgent,
},
$set: {
lastSeenAt: now,
lastUserAgent: userAgent,
},
$inc: {
visits: 1,
},
},
{ upsert: true },
);
if (!hadValidCookie || result.upsertedCount > 0) {
writeVisitorCookie(reply, visitorId);
}
return {
isNewVisitor: result.upsertedCount > 0,
uniqueVisitors: await collection.countDocuments(),
checkedAt: now.toISOString(),
};
}
async function getVisitorCollection() {
const db = await getDb();
return db.collection(getConfig().MONGODB_VISITOR_COLLECTION || DEFAULT_COLLECTION_NAME);
}
async function ensureVisitorIndexes(collection) {
if (!indexesReady) {
indexesReady = collection.createIndex({ lastSeenAt: -1 });
}
return indexesReady;
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}
function writeVisitorCookie(reply, visitorId) {
const secureFlag = getConfig().COOKIE_SECURE ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}

View File

@ -1,57 +1,143 @@
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
export const GRID_SIZE = 50;
// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다.
export const TILE_SIZE = 64;
// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다.
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
export const ATTACK_RANGE = 84;
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
export const ATTACK_COOLDOWN = 840;
// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다.
export const ATTACK_DAMAGE_MIN = 14;
// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다.
export const ATTACK_DAMAGE_MAX = 24;
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
export const DEFAULT_TEAM_SIZE = 5;
// 캐릭터 스프라이트의 기본 화면 배율입니다.
export const FIGHTER_SCALE = 3;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
export const FIGHTER_FRAME_WIDTH = 100;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
export const FIGHTER_FRAME_HEIGHT = 100;
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
export const FIGHTER_HITBOX_WIDTH = 22;
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
export const FIGHTER_HITBOX_HEIGHT = 20;
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
export const FIGHTER_HITBOX_OFFSET_X = 39;
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
export const FIGHTER_HITBOX_OFFSET_Y = 40;
// 캐릭터의 기본 최대 체력입니다.
export const FIGHTER_MAX_HP = 100;
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
export const KILL_GROWTH_MULTIPLIER = 1.25;
// 처치 성장 연출 tween 지속 시간(ms)입니다.
export const KILL_GROWTH_TWEEN_DURATION = 180;
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
export const MAX_TEAM_SIZE = 100;
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
export const MELEE_CRITICAL_CHANCE = 0.05;
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
export const MOVE_SPEED = 148;
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
export const PROJECTILE_LIFETIME = 1800;
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
export const PROJECTILE_SPEED = 420;
// 원거리 캐릭터의 기본 치명타 확률입니다.
export const RANGED_CRITICAL_CHANCE = 0;
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
export const MELEE_HIT_DELAY = 260;
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
export const PROJECTILE_FIRE_DELAY = 360;
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
export const PROJECTILE_BODY_OFFSET = 4;
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
export const PROJECTILE_HIT_PADDING = 20;
// 투사체 충돌 원형 바디의 반지름입니다.
export const PROJECTILE_HIT_RADIUS = 12;
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
export const PROJECTILE_SPAWN_DISTANCE = 1;
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
export const SPELL_CAST_DELAY = 340;
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
export const SPELL_HIT_DELAY = 160;
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
export const CAMERA_MIN_ZOOM = 1;
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
export const CAMERA_MAX_ZOOM = 3;
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
export const CAMERA_ZOOM_STEP = 0.1;
// 미니맵 카메라가 보일 때의 투명도입니다.
export const MINIMAP_ALPHA = 0.8;
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
// 미니맵의 고정 픽셀 크기입니다.
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다.
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다.
export const MINIMAP_VIEW_FRAME_STROKE = 10;
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
export const SPECTATOR_CAMERA_LERP = 0.1;
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 10;
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
export const SPECTATOR_LATE_FIGHT_ZOOM = 2;
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
// 선택 실루엣의 빨간색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
// 선택 실루엣의 초록색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
// 선택 실루엣의 파란색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
export const NICKNAME_LENGTH = 18;
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
export const FIGHTER_ANIMATION_OPTIONS = {
// 기본 공격 애니메이션 속도입니다.
attack: { frameRate: 15, repeat: 0 },
// 보조 공격 애니메이션 속도입니다.
attack02: { frameRate: 15, repeat: 0 },
// 강공격/치명타용 공격 애니메이션 속도입니다.
attack03: { frameRate: 15, repeat: 0 },
// 방어 애니메이션 속도입니다.
block: { frameRate: 13, repeat: 0 },
// 사망 애니메이션 속도입니다.
death: { frameRate: 11, repeat: 0 },
// 회복 애니메이션 속도입니다.
heal: { frameRate: 13, repeat: 0 },
// 피격 애니메이션 속도입니다.
hurt: { frameRate: 13, repeat: 0 },
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
idle: { frameRate: 7, repeat: -1 },
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
walk: { frameRate: 10, repeat: -1 },
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
walk02: { frameRate: 10, repeat: -1 },
};
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
export const TEAM_COLORS = [
"#da6a48",
"#5fb4d9",

View File

@ -9,6 +9,7 @@ import {
MINIMAP_VIEWPORT_SIZE,
MINIMAP_VIEW_FRAME_OUTLINE,
MINIMAP_VIEW_FRAME_STROKE,
SELECTED_FIGHTER_CAMERA_ZOOM,
SPECTATOR_CAMERA_LERP,
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
SPECTATOR_FINAL_FIGHT_ZOOM,
@ -45,6 +46,7 @@ export class ArenaScene extends Phaser.Scene {
}
};
this.observedCombat = [];
this.selectedFighter = null;
this.teams = [];
}
@ -70,7 +72,6 @@ export class ArenaScene extends Phaser.Scene {
this.cameras.main.ignore(this.minimapViewportFrame);
this.updateMinimapViewportFrame();
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
// 마우스 휠로 줌 조절
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
const newZoom = Phaser.Math.Clamp(
@ -83,6 +84,19 @@ export class ArenaScene extends Phaser.Scene {
// 확대 시 미니맵 표시
});
this.input.on("gameobjectdown", (pointer, gameObject) => {
if (this.fighters.includes(gameObject)) {
this.selectFighter(gameObject);
}
});
this.input.on("pointerdown", (pointer, gameObjects = []) => {
if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject))) {
this.clearSelectedFighter();
}
});
this.input.keyboard?.on("keydown-ESC", () => {
this.clearSelectedFighter();
});
this.ready = true;
this.startMatch(this.getInitialMatchConfig());
@ -104,6 +118,7 @@ export class ArenaScene extends Phaser.Scene {
this.matchId += 1;
this.matchOver = false;
this.observedCombat = [];
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
clearCombatObjects(this);
@ -122,6 +137,20 @@ export class ArenaScene extends Phaser.Scene {
update(time) {
this.fighters.forEach(syncFighterHud);
if (!this.matchOver) {
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
this.updateScoreboard();
this.finishMatch();
});
});
}
if (this.focusSelectedFighter()) {
this.updateMinimapViewportFrame();
return;
}
if (this.matchOver) {
this.updateMinimapViewportFrame();
return;
@ -150,16 +179,56 @@ update(time) {
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
}
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
this.updateScoreboard();
this.finishMatch();
});
});
this.updateMinimapViewportFrame();
}
selectFighter(fighter) {
if (!isLivingFighter(fighter)) {
return;
}
if (this.selectedFighter === fighter) {
this.clearSelectedFighter();
return;
}
this.clearSelectedFighter();
this.selectedFighter = fighter;
fighter.isSelected = true;
this.observedCombat = [];
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, SELECTED_FIGHTER_CAMERA_ZOOM));
this.centerCameraOnFighter(fighter);
syncFighterHud(fighter);
}
clearSelectedFighter() {
if (this.selectedFighter) {
this.selectedFighter.isSelected = false;
syncFighterHud(this.selectedFighter);
}
this.selectedFighter = null;
}
focusSelectedFighter() {
if (!this.selectedFighter) {
return false;
}
if (!isLivingFighter(this.selectedFighter)) {
this.clearSelectedFighter();
return false;
}
this.centerCameraOnFighter(this.selectedFighter);
return true;
}
centerCameraOnFighter(fighter) {
const target = fighter.body?.center ?? fighter;
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
}
setMainCameraZoom(zoom) {
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);

View File

@ -1,8 +1,14 @@
import Phaser from "phaser";
import {
ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN,
ATTACK_RANGE,
FIGHTER_MAX_HP,
FIGHTER_SCALE,
KILL_HEALTH_RECOVERY_RATIO,
KILL_GROWTH_MULTIPLIER,
KILL_GROWTH_TWEEN_DURATION,
MELEE_HIT_DELAY,
MELEE_CRITICAL_CHANCE,
MOVE_SPEED,
@ -41,7 +47,7 @@ export function updateFighter(scene, fighter, time, onWinner) {
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier());
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * fighterMovementSpeedMultiplier(fighter));
playIfNeeded(fighter, "walk");
return;
}
@ -66,10 +72,11 @@ export function clearCombatObjects(scene) {
function beginAttack(scene, attacker, defender, time, onWinner) {
const attack = createAttackProfile(attacker);
attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN);
attacker.nextAttackAt =
time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
attacker.isLocked = true;
scene.observeCombat?.(attacker, defender);
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
switch (getCombatType(attacker)) {
case "projectile":
@ -86,7 +93,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY), () => {
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
applyHit(scene, attacker, defender, onWinner, matchId, {
instantKill: attack.isCritical,
});
@ -96,7 +103,7 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
function queueProjectile(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY), () => {
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
@ -108,7 +115,7 @@ function queueProjectile(scene, attacker, defender, onWinner) {
function queueInstantSpell(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY), () => {
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
@ -144,7 +151,8 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
projectile,
defenderHitPoint.x,
defenderHitPoint.y,
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
fighterAttackSpeedMultiplier(attacker),
);
trackCombatObject(scene, projectile);
@ -209,9 +217,12 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
disposeCombatObject(scene, effect);
});
scene.time.delayedCall(scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY), () => {
scene.time.delayedCall(
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
() => {
applyHit(scene, attacker, defender, onWinner, matchId);
});
},
);
}
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
@ -219,7 +230,9 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
return;
}
defender.hp = instantKill ? 0 : Math.max(0, defender.hp - Phaser.Math.Between(14, 24));
defender.hp = instantKill
? 0
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
@ -336,9 +349,36 @@ function killFighter(defender, winner, onWinner) {
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
applyKillReward(winner);
onWinner(winner);
}
function applyKillReward(winner) {
winner.killCount = (winner.killCount ?? 0) + 1;
const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount;
winner.killRewardMultiplier = rewardMultiplier;
winner.hp = recoveredHealth(winner);
const nextScaleX = (winner.baseScaleX ?? FIGHTER_SCALE) * rewardMultiplier;
const nextScaleY = (winner.baseScaleY ?? FIGHTER_SCALE) * rewardMultiplier;
winner.scene.tweens.add({
targets: winner,
scaleX: nextScaleX,
scaleY: nextScaleY,
duration: KILL_GROWTH_TWEEN_DURATION,
ease: "Back.Out",
});
}
function recoveredHealth(fighter) {
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
return Math.min(maxHp, fighter.hp + recovery);
}
function findNearestEnemy(fighters, fighter) {
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
@ -381,8 +421,16 @@ function playAnimation(fighter, action, timeScale = 1) {
fighter.play(fighterAnimationKey(fighter.skin, action), true);
}
function scaledAttackDelay(duration) {
return duration / getAttackSpeedMultiplier();
function scaledAttackDelay(duration, fighter) {
return duration / fighterAttackSpeedMultiplier(fighter);
}
function fighterAttackSpeedMultiplier(fighter) {
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
}
function fighterMovementSpeedMultiplier(fighter) {
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
}
function trackCombatObject(scene, object) {

View File

@ -1,4 +1,16 @@
import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js";
import {
FIGHTER_ANIMATION_OPTIONS,
FIGHTER_FRAME_HEIGHT,
FIGHTER_FRAME_WIDTH,
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";
const SOURCE_ALPHA_THRESHOLD = 8;
export function fighterSheetKey(skin, action) {
return `${skin.key}-${action}`;
@ -8,6 +20,14 @@ export function fighterAnimationKey(skin, action) {
return `${fighterSheetKey(skin, action)}-anim`;
}
export function fighterOutlineSheetKey(skin, action) {
return `${fighterSheetKey(skin, action)}-outline`;
}
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
return `${sheetKey}-outline`;
}
export function fighterAttackEffectKey(skin) {
return `${skin.key}-attack-effect`;
}
@ -26,7 +46,7 @@ export function preloadFighterSheets(scene, skins) {
scene.load.spritesheet(
fighterSheetKey(skin, action),
`${skin.assetRoot}/${animation.file}`,
{ frameWidth: 100, frameHeight: 100 },
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
);
});
@ -39,10 +59,7 @@ export function createFighterAnimations(scene, skins) {
Object.entries(skin.animations).forEach(([action, animation]) => {
const key = fighterAnimationKey(skin, action);
if (scene.anims.exists(key)) {
return;
}
if (!scene.anims.exists(key)) {
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
scene.anims.create({
@ -54,6 +71,9 @@ export function createFighterAnimations(scene, skins) {
frameRate,
repeat,
});
}
createFighterOutlineSheet(scene, skin, action, animation.frames);
});
createAttackEffectAnimation(scene, skin);
@ -72,7 +92,7 @@ function preloadCombatAssets(scene, skin) {
scene.load.spritesheet(
fighterAttackEffectKey(skin),
`${skin.assetRoot}/${attackEffect.file}`,
{ frameWidth: 100, frameHeight: 100 },
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
);
}
}
@ -100,3 +120,114 @@ function createAttackEffectAnimation(scene, skin) {
repeat: 0,
});
}
function createFighterOutlineSheet(scene, skin, action, frameCount) {
const key = fighterOutlineSheetKey(skin, action);
if (scene.textures.exists(key)) {
return;
}
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
const sourceImage = sourceTexture?.getSourceImage?.();
if (!sourceImage) {
return;
}
const sheetWidth = FIGHTER_FRAME_WIDTH * frameCount;
const sheetHeight = FIGHTER_FRAME_HEIGHT;
const sourceCanvas = document.createElement("canvas");
sourceCanvas.width = sheetWidth;
sourceCanvas.height = sheetHeight;
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
sourceContext.drawImage(sourceImage, 0, 0);
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
const outlineCanvas = document.createElement("canvas");
outlineCanvas.width = sheetWidth;
outlineCanvas.height = sheetHeight;
const outlineContext = outlineCanvas.getContext("2d");
const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight);
const outlineData = outlineImage.data;
const gapMask = new Uint8Array(sheetWidth * sheetHeight);
const outerMask = new Uint8Array(sheetWidth * sheetHeight);
const outlineAlpha = Math.round(SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameLeft = frameIndex * FIGHTER_FRAME_WIDTH;
for (let y = 0; y < FIGHTER_FRAME_HEIGHT; y += 1) {
for (let x = 0; x < FIGHTER_FRAME_WIDTH; x += 1) {
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
continue;
}
markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y);
}
}
}
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
outlineContext.putImageData(outlineImage, 0, 0);
scene.textures.addSpriteSheet(key, outlineCanvas, {
frameWidth: FIGHTER_FRAME_WIDTH,
frameHeight: FIGHTER_FRAME_HEIGHT,
});
}
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH;
for (
let offsetY = -outerRadius;
offsetY <= outerRadius;
offsetY += 1
) {
const targetY = sourceY + offsetY;
if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) {
continue;
}
for (
let offsetX = -outerRadius;
offsetX <= outerRadius;
offsetX += 1
) {
const targetX = sourceX + offsetX;
if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) {
continue;
}
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
outerMask[maskIndex] = 1;
if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) {
gapMask[maskIndex] = 1;
}
}
}
}
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
continue;
}
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 + 3] = outlineAlpha;
}
}

View File

@ -1,6 +1,21 @@
import Phaser from "phaser";
import { FIGHTER_SCALE } from "../constants.js";
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
import {
FIGHTER_FRAME_HEIGHT,
FIGHTER_FRAME_WIDTH,
FIGHTER_HITBOX_HEIGHT,
FIGHTER_HITBOX_OFFSET_X,
FIGHTER_HITBOX_OFFSET_Y,
FIGHTER_HITBOX_WIDTH,
FIGHTER_MAX_HP,
FIGHTER_SCALE,
} from "../constants.js";
import {
fighterAnimationKey,
fighterOutlineSheetKeyFromSheetKey,
fighterSheetKey,
} from "./fighterAssets.js";
const NAME_LABEL_BOTTOM_GAP = 14;
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
@ -8,11 +23,26 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
fighter.setDepth(2);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
fighter.body.setSize(22, 20);
fighter.body.setOffset(39, 60);
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
fighter.body.setOffset(FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_Y);
fighter.setInteractive(
new Phaser.Geom.Rectangle(
FIGHTER_HITBOX_OFFSET_X,
FIGHTER_HITBOX_OFFSET_Y,
FIGHTER_HITBOX_WIDTH,
FIGHTER_HITBOX_HEIGHT,
),
Phaser.Geom.Rectangle.Contains,
);
fighter.input.cursor = "pointer";
fighter.selectionOutline = scene.add
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
.setDepth(1.9)
.setVisible(false);
fighter.nameLabel = scene.add
.text(x, y - 68, name, {
.text(x, y, name, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
@ -20,7 +50,7 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
stroke: team.color,
strokeThickness: 4,
})
.setOrigin(0.5)
.setOrigin(0.5, 0)
.setDepth(4);
fighter.healthBack = scene.add
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
@ -33,7 +63,13 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
fighter.skin = skin;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.hp = 100;
fighter.baseScaleX = FIGHTER_SCALE;
fighter.baseScaleY = FIGHTER_SCALE;
fighter.isSelected = false;
fighter.killCount = 0;
fighter.killRewardMultiplier = 1;
fighter.maxHp = FIGHTER_MAX_HP;
fighter.hp = fighter.maxHp;
fighter.nextAttackAt = 0;
fighter.isLocked = false;
fighter.isDead = false;
@ -50,21 +86,56 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
});
attachHudCleanup(fighter);
syncFighterHud(fighter);
return fighter;
}
export function syncFighterHud(fighter) {
fighter.nameLabel.setPosition(fighter.x, fighter.y - 68);
fighter.healthBack.setPosition(fighter.x, fighter.y - 44);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - 44);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / 100));
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
const healthOffset = 44 * scaleRatio;
const hitbox = fighter.body;
const nameX = hitbox.x + hitbox.width / 2;
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
fighter.nameLabel.setPosition(nameX, nameY);
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);
}
function syncSelectionOutline(fighter) {
const outline = fighter.selectionOutline;
if (!outline) {
return;
}
const isVisible = Boolean(fighter.isSelected && !fighter.isDead);
outline.setVisible(isVisible);
if (!isVisible) {
return;
}
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
if (fighter.scene.textures.exists(outlineTextureKey)) {
outline.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);
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
fighter.selectionOutline.destroy();
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();

View File

@ -2,6 +2,7 @@ import Phaser from "phaser";
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();
@ -32,4 +33,22 @@ const game = new Phaser.Game({
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
const visitorCountNode = document.querySelector("#visitor-count");
trackVisitor({
onUpdate({ uniqueVisitors }) {
if (visitorCountNode) {
visitorCountNode.textContent = `방문자 ${uniqueVisitors.toLocaleString("ko-KR")}`;
}
},
onError(error) {
console.warn(error);
if (visitorCountNode) {
visitorCountNode.textContent = "";
visitorCountNode.hidden = true;
}
},
});
window.arenaGame = game;

View File

@ -65,6 +65,18 @@ h1 {
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;
}
form {
display: grid;
gap: 16px;

23
src/ui/visitorCounter.js Normal file
View File

@ -0,0 +1,23 @@
export async function trackVisitor({ onUpdate, onError } = {}) {
try {
const response = await fetch("/api/visitors/check", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) {
throw new Error(`Visitor check failed: ${response.status}`);
}
const visitorStats = await response.json();
onUpdate?.(visitorStats);
return visitorStats;
} catch (error) {
onError?.(error);
return null;
}
}

24
todo.md
View File

@ -13,3 +13,27 @@
- **조치 사항**:
- `src/game/ArenaScene.js``finishMatch` 로직을 개선하여 생존 팀이 1개일 때 해당 닉네임 승리 표시.
- 생존자가 없을 경우 "무승부!"가 표시되도록 예외 처리 추가.
4. MongoDB 기반 유니크 방문자 체크 API 추가 (완료)
- **조치 사항**:
- Fastify 서버를 추가하고 `config.json` 기반 서버/MongoDB 설정을 도입.
- MongoDB 커넥션 풀을 재사용하는 `server/db.js` 구성.
- `POST /api/visitors/check`, `GET /api/visitors/stats`, `GET /api/health` API 추가.
- 프론트엔드 로드 시 방문자 체크 API를 호출하고 유니크 방문자 수를 표시.
5. 처치 보상 및 공격력 튜닝 상수화 (완료)
- **조치 사항**:
- 적 처치 시 현재 체력 기준 30% 회복, 누적 성장 배율에 따른 크기/공격속도/이동속도 증가를 적용.
- 기존 `combat.js`에 하드코딩되어 있던 `14~24` 피해량을 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX` 상수로 분리.
- 주요 전투/카메라/UI 상수마다 조정 대상이 무엇인지 주석 추가.
6. 캐릭터 히트박스 기준 이름표 및 선택 관전 기능 (완료)
- **조치 사항**:
- 캐릭터 이름표를 스프라이트 중심이 아니라 실제 히트박스 하단에 고정.
- 캐릭터 클릭 시 선택 상태를 설정하고 카메라를 해당 캐릭터 히트박스 중심에 고정.
- 선택 표시는 사각형 대신 원본 alpha 마스크 기반 노란 실루엣으로 생성.
- 실루엣은 캐릭터 바로 옆 1px을 비우고 그 바깥 1px에만 표시해 자글자글한 느낌을 줄임.
7. 패키지 락 파일 제외 (완료)
- **조치 사항**:
- `package-lock.json`을 git 추적에서 제외하고 `.gitignore`에 추가.