22 KiB
Context: Arena Picker 개발 가이드
1. 모듈별 상세 역할
[Core Engine]
src/main.js: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며,ArenaScene을 인스턴스화합니다.- 앱 로드 시
trackVisitor()를 호출해 방문자 체크 API와 연동합니다. Start버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시#app에match-live상태 클래스를 부여합니다.- 전투 중 drawer 접기/펼치기(
drawer-collapsed), 재시작 버튼, 일시정지 버튼 상태(match-paused)를 DOM 클래스와ArenaScene상태에 동기화합니다.
- 앱 로드 시
src/constants.js: 게임 내 모든 튜닝 수치를 관리합니다.ATTACK_DAMAGE_MIN,ATTACK_DAMAGE_MAX: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.FIGHTER_HITBOX_*: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.KILL_HEALTH_RECOVERY_RATIO,KILL_GROWTH_MULTIPLIER,KILL_GROWTH_MAX_MULTIPLIER: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.SELECTED_FIGHTER_OUTLINE_GAP,SELECTED_FIGHTER_OUTLINE_WIDTH,SELECTED_FIGHTER_OUTLINE_ALPHA: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.TEAM_COLORS,getTeamColor(): 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.SPECTATOR_CAMERA_LERP: 카메라 추적의 부드러움 정도.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,SPECTATOR_RANDOM_FOCUS_INTERVAL,FINAL_COMBAT_SLOW_MOTION_*: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 on/off, 배율과 속도 램프 시간.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를 조립합니다.- 전투 사망 통계 컬렉션(
MONGODB_DAILY_DEATH_COLLECTION)과 집계 기준 타임존(DEATH_STATS_TIME_ZONE) 기본값을 제공합니다.
server/db.js:MongoClient를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.- 종료 시
closeMongoConnection()으로 커넥션을 닫습니다.
server/deathStats.js:GET /api/death-stats/today:DEATH_STATS_TIME_ZONE기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.POST /api/death-stats/today: 전투 종료 시 전달된deathsBySpecies를 오늘 일자별 누적 문서의deathsBySpecies,totalDeaths,battles에 바로 더합니다.- 집계 대상 종족은
human,orc,skeleton,slime,wolf,bear로 제한합니다.
server/visitors.js:POST /api/visitors/check:arena_visitor_idHttpOnly쿠키를 확인하고 없으면 UUID를 발급합니다.- MongoDB
visitors컬렉션에_id = visitorId로 upsert해 방문자 1명당 1개 문서를 유지합니다. GET /api/visitors/stats: 전체 유니크 방문자 수를 반환합니다.
[Game Logic - src/game/]
ArenaScene.js:update(): 매 프레임 생존 팀을 체크하고 좌측 HUD 레일의 팀 badge를 갱신합니다.- 최초 로드 시
presentationMode로 프리뷰 전투를 조용히 실행하고, 실제 전투 시작 전까지 가까운 교전 지점을 배경처럼 보여줍니다. 프리뷰 전투는 로컬 저장 옵션과 분리된 10팀 x 팀당 5명 설정으로 시작합니다. createFighterPlans(): 실제 매치 시작 시 배정된 스킨의traits.spawnMultiplier를 반영해 전투원 목록을 확장합니다. 프리뷰 전투는 10팀 x 팀당 5명 고정 규모를 지키도록 이 확장을 건너뜁니다.spawnSplitFighters(): Slime 사망 분열처럼 전투 중 새 전투원을 생성해야 하는 특성을 처리합니다.recordDeath(),persistDailyDeathStats(): 실제 전투에서만 사망 캐릭터의skin.species를 종족별로 집계하고, 전투 종료 시 오늘 일자별 서버 사망 통계에 더합니다.scheduleBattleNotice(),showBattleDeathNotice(): 실제 전투가 5초 이상 지속되면 오늘 누적 사망 통계와 현재 전투 사망 수를 합산해 상단 안내바에 표시합니다. 안내는 2초 표시 후 10초 대기하는 주기로 반복됩니다.setStatus(),createVictoryCelebration(): 종료 상태가 승리면 중앙 금빛 배너, 광선, 컨페티와 Web Audio 기반 짧은 팡파르를 띄웁니다. 실제 매치 시작에서 오디오 컨텍스트를 미리 깨워 자동 재생 제한을 완화하고, 오디오가 막혀도 CSS 축하 레이어는 그대로 표시합니다. 무승부는 팡파르와 컨페티 없이 절제된 종료 배너를 사용합니다.setPaused(),togglePause(): 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개합니다. 프리뷰 전투(presentationMode)와 종료된 전투는 pause 대상에서 제외합니다.observeCombat(): 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.getSpectatorState(),getSpectatorCameraTarget(): 생존 4명 이하에서는 생존 캐릭터를 무작위로 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 적은 팀을 포커싱합니다.triggerFinalCombatSlowMotion():FINAL_COMBAT_SLOW_MOTION_ENABLED가 켜진 최종교전에서 공격 모션이 시작될 때만 전역 time scale을 낮추고 진입/유지/복귀 속도 램프로 슬로우모션을 연출합니다. Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.selectFighter(),focusSelectedFighter(): 캐릭터 클릭 시 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. 팀 색상 마커는 선택 상태와 별개로 상시 표시됩니다.selectRandomTeamFighter(): 좌측 팀 badge 클릭 시 해당 팀의 생존 전투원 중 무작위 1명을 골라 카메라를 고정합니다.updateMinimapViewportFrame(): 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
matchSetup.js:- 입력된 닉네임을 순회하여
team객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다. - 기본
완전 랜덤 배치는 전장 전체 스폰 슬롯을 섞고,스타팅 지점 배치는 참가자 수에 맞춰 전장을 구역으로 나눈 뒤 참가자별 구역 배정을 매치마다 섞고 각 구역 안의 슬롯도 무작위로 사용합니다.
- 입력된 닉네임을 순회하여
combat.js:updateFighter(): 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.applyHit(): 일반 공격 피해량은ATTACK_DAMAGE_MIN/MAX범위에서 계산하고, 치명타 적중은Critical!표기와 즉시 처치/카메라 흔들림을 처리합니다.killFighter(): 사망한 캐릭터를 반투명 처리하고 살아있는 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않게 합니다.applyKillReward(): 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은KILL_GROWTH_MAX_MULTIPLIER로 제한합니다.clampFighterInsideArena(): 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다.maybeSplitFighter(): 사망한 캐릭터의traits.splitOnDeath특성을 확인하고 분열 생성을 요청합니다.projectilePathHitsDefender(): 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
[Assets & UI]
fighterAssets.js: 원본 캐릭터 스프라이트의 alpha 값을 읽어 흰색 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠한 뒤,fighterFactory.js에서 팀 색상으로 tint 처리합니다.fighterFactory.js: 캐릭터 히트박스, 이름표, 체력바, 팀 색상 마커 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. 사망한 캐릭터의 HUD와 팀 색상 마커는 숨겨 전투 화면을 덜 가리게 합니다. 생성 옵션의hp,maxHp,canSplitOnDeath를 통해 개별 전투원의 체력과 분열 가능 여부를 지정할 수 있습니다.fighterManifest.js: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. 각 스킨은 사망 통계용species를 가지며,combat.type(melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다.stats와traits로 캐릭터별 기본 체력과 특수 규칙을 정의합니다.matchForm.js:index.html의 입력을 읽어ArenaScene에 매치 구성을 전달하고, 검증/결과 상태 메시지를 DOM에 반영할 수 있는 setter를 제공합니다. 팀당 인원 숫자 입력과 range 슬라이더를 같은1~100범위로 동기화하며, 참가자 닉네임, 팀당 인원, 리스폰 배치 모드는 브라우저localStorage에 저장/복원합니다. 실제 전투 중 하단 안내바는 숨기고, 처치 내역은ArenaScene의 좌측 하단 킬로그가 담당합니다.deathStats.js:GET /api/death-stats/today,POST /api/death-stats/today를 호출하는 프론트엔드 API 래퍼입니다.visitorCounter.js:POST /api/visitors/check를 호출하고, 응답의uniqueVisitors값을 전투 화면 우측 하단의#visitor-count배지에 표시합니다.
2. 주요 로직 구현 세부 사항
지능형 카메라 추적 (Lerp & Jittering 방지)
카메라가 소수점 단위의 평균 좌표를 즉시 따라가면 화면이 떨려 보일 수 있습니다. 이를 방지하기 위해:
- 목표 좌표(
targetX, targetY)를Math.round()로 정수화합니다. - 현재 카메라 위치에서 목표 지점까지 매 프레임
0.1의 배율로 거리를 좁혀나가는Lerp연산을 수행합니다.
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
최종교전 관전은 두 단계로 나뉩니다. 생존 4명 이하에서는 SPECTATOR_RANDOM_FOCUS_INTERVAL마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다. 잔여 팀이 2팀이고 생존 합이 8명 이하인 구간에서는 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다. FINAL_COMBAT_SLOW_MOTION_ENABLED를 켜면 최종교전 상태에서 combat.js가 공격 모션을 시작할 때 전역 time scale을 FINAL_COMBAT_SLOW_MOTION_SCALE까지 낮추고, 타격과 이동이 느린 구간에 남도록 유지한 뒤 복귀 램프로 기본 속도에 돌아갑니다.
미니맵 가이드라인
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(graphics)을 그려줍니다.
camera.displayWidth / zoom등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
전투 진입 UI와 HUD 레이아웃
초기 화면은 로컬 저장 옵션과 분리된 10팀 x 팀당 5명 Phaser 프리뷰 전투와 CSS 스프라이트 프리뷰를 낮은 투명도의 배경으로 깔고, 중앙에 Arena 로고와 Start 버튼을 둡니다.
Start클릭 시#app.options-open이 설정되고 우측 옵션 drawer가 열립니다. 홈에서 drawer가 열린 상태에서는Start버튼을 숨기되 중앙Arena로고 위치는 유지합니다.전투 시작제출 시#app.match-live가 설정되며 실제 전투가 시작됩니다. drawer는 닫히지 않고 우측 compact 패널로 유지됩니다.- 전투 중 drawer 우측 상단의
옵션 접기/옵션 펼치기버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다. - drawer 안의
재시작버튼은 현재 입력값으로 새 매치를 시작합니다. - drawer 안의
일시정지버튼은ArenaScene.togglePause()를 호출하고, 정지 중에는 버튼 문구를계속으로 바꾸며#app.match-paused를 설정합니다. - 참가자 닉네임과 팀당 인원은 입력이 바뀔 때마다
localStorage에 저장하고 앱 로드 시 먼저 복원합니다. 팀당 인원은 숫자 입력과 range 슬라이더가 즉시 동기화되며, 복원된 값은 옵션 폼과 실제 전투에만 쓰고 최초 대기 전투 프리뷰 설정은 바꾸지 않습니다. - 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수
--score-panel-width,--score-rail-width로 계산되며 미니맵과 겹치지 않습니다. - badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다.
- 전투 중 하단
match-status안내바는 숨깁니다. 처치가 발생하면 좌측 하단kill-log가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명,manifest.key를 표시합니다. 중앙에는 칼 아이콘과처치텍스트를 두며, 오른쪽 피처치자 아이콘에는 빨간 X를 겹쳐 왼쪽 캐릭터가 처치했다는 관계를 명확히 보여줍니다. - 실제 전투가 5초 이상 지속되면
#battle-notice상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다. - 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은
#visitor-count배지로 표시합니다. - 승리 판정이 나면
ArenaScene이 기존 결과 레이어를 정리한 뒤.victory-celebration을 생성합니다.styles.css는 중앙 결과 배너, 회전 광선, 컨페티 burst, 축소 모션 설정을 담당하고, 무승부 상태는 같은 레이아웃을 더 조용한 톤으로 재사용합니다.
상시 팀 색상 실루엣
팀 색상 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다.
fighterAssets.js가 로드된 원본 스프라이트시트의 alpha 데이터를 캔버스에서 읽습니다.- 원본 alpha 픽셀 주변
SELECTED_FIGHTER_OUTLINE_GAP범위는 공백 마스크로 남깁니다. - 그 바깥
SELECTED_FIGHTER_OUTLINE_WIDTH범위에만 흰색 outline을 칠합니다. fighterFactory.js가 생존 캐릭터 뒤에teamMarkersprite를 배치하고, 팀 색상으로 tint 처리합니다.syncFighterHud()가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다.- 사망 캐릭터는 팀 색상 마커와 HUD를 숨기며, 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다.
캐릭터별 특성: Slime
Slime은 fighterManifest.js의 stats와 traits로 특수 규칙을 선언합니다.
stats.maxHp: 1: 처음 배정되어 스폰되는 Slime과 분열로 생성되는 Slime 모두 최대 체력이 1입니다.traits.spawnMultiplier: 10: Slime 스킨이 배정된 기본 스폰 슬롯 1개를 실제 Slime 10마리로 확장합니다.traits.splitOnDeath.chance: 0.5: Slime이 사망할 때 50% 확률로 분열합니다.traits.splitOnDeath.count: 2: 분열 성공 시 같은 팀의 Slime 2마리를 사망 위치 근처에 생성합니다.traits.splitOnDeath.childMaxHp: 1: 분열체도 최대 체력 1로 생성됩니다.traits.splitOnDeath.childCanSplit: false: 분열체는 다시 분열하지 않습니다.
처치 보상은 최대 체력을 증가시키지 않습니다. applyKillReward()는 현재 체력을 maxHp 상한 안에서 회복하고, 처치 누적 배율로 크기/공격속도/이동속도만 증가시킵니다. 배율은 KILL_GROWTH_MAX_MULTIPLIER로 제한되므로 Slime이 적을 처치해도 최대 체력은 1로 유지되고, 장기전에서 캐릭터 scale/속도가 무한정 커지지 않습니다.
유니크 방문자 체크
브라우저가 직접 MongoDB에 연결하지 않고, Fastify API가 MongoDB 커넥션 풀을 유지합니다.
- 프론트엔드가 앱 로드 시
POST /api/visitors/check를 호출합니다. - 서버가
arena_visitor_id쿠키를 검사합니다. - 쿠키가 없거나 유효하지 않으면
crypto.randomUUID()로 새 방문자 ID를 만들고HttpOnly쿠키로 내려줍니다. - MongoDB에는
_id,firstSeenAt,lastSeenAt,visits,firstUserAgent,lastUserAgent를 저장합니다. countDocuments()로 전체 유니크 방문자 수를 계산해 반환합니다.
방문자 체크는 인증 기능이 아니며, 브라우저/쿠키 단위의 단순 유니크 카운트입니다.
전투 사망 통계
프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
fighterManifest.js의 모든 스킨은species를 가집니다. 사용 가능한 값은human,orc,skeleton,slime,wolf,bear입니다.combat.js의 처치 흐름이ArenaScene.recordKill()을 호출하면,ArenaScene은 피처치자의skin.species를 현재 전투의battleDeathCounts에 누적합니다.- 전투가 5초 이상 이어지면
GET /api/death-stats/today로 가져온 오늘 집계와 현재 전투의 사망 수를 합산해#battle-notice에 표시합니다. - 전투 종료 시
POST /api/death-stats/today로deathsBySpecies만 보냅니다. - 서버는 별도의 매치별 문서를 만들지 않고,
daily_death_stats컬렉션에서 오늘 일자의battles,totalDeaths,deathsBySpecies.*값을$inc로 갱신합니다.
3. 개발 및 유지보수 규칙
- 신규 캐릭터 추가:
public/assets/characters/에 에셋 배치 후fighterManifest.js에 정의를 추가하면 즉시 게임에 반영됩니다. - 종족값 유지: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록
species를human,orc,skeleton,slime,wolf,bear중 하나로 지정해야 합니다. - 물리 수치 조정: 캐릭터의 속도나 사거리 등은
src/constants.js또는fighterManifest.js내 개별 설정을 통해 변경하십시오. - 처치 성장 상한 조정: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은
src/constants.js의KILL_GROWTH_MAX_MULTIPLIER를 수정합니다. - 공격력 조정: 기본 피해량은
src/constants.js의ATTACK_DAMAGE_MIN,ATTACK_DAMAGE_MAX를 수정합니다. 캐릭터별 특수 공격 방식은fighterManifest.js의combat설정을 우선 확인합니다. - 캐릭터별 스탯/특성 추가: 기본 체력은
fighterManifest.js의stats.maxHp에 정의하고, 스폰 배율이나 사망 분열 같은 특성은traits에 선언합니다. 전투 중 새 전투원을 만들 필요가 있는 특성은ArenaScene.js의 생성 헬퍼를 통해this.fighters와team.size를 함께 갱신해야 합니다. - DOM 접근: 성능을 위해
ArenaScene은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다. - 서버 설정:
.env대신config.json을 사용합니다.config.json은 로컬 전용 파일이며, 저장소에는config.json.sample만 공유합니다. - 패키지 락 파일: 이 프로젝트는
package-lock.json을 저장소에서 제외합니다. 의존성 변경 시package.json을 기준으로 관리합니다. - 기본 포트:
SERVER_PORT기본값은9736입니다. - MongoDB 연결: DB 접속 정보는
config.json의MONGODB_HOST,MONGODB_PORT,MONGODB_DB, 선택적MONGODB_USER,MONGODB_PASS로 관리합니다. - API 변경:
/api/*경로는 Fastify 라우트가 담당합니다. 개발 모드에서 Vite 미들웨어가 API 요청을 SPA HTML로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다. 전투 사망 통계 API는 MongoDB 설정이 없으면 방문자 통계와 마찬가지로 503 응답을 반환합니다.