From 104bf4fe4849860785d1ea67de70b561d7e6033b Mon Sep 17 00:00:00 2001 From: Horoli Date: Fri, 22 May 2026 09:13:49 +0900 Subject: [PATCH] docs: create agent.md and CONTEXT.md with full project structure - Add agent.md for high-level project overview and feature list - Add CONTEXT.md for detailed development guide and logic explanation - Refactor project structure: move config.js to constants.js and update references - Improve camera tracking logic with Lerp and jittering prevention - Update ArenaScene to support intelligent combat observation and minimap viewport - Fine-tune combat mechanics: optimize projectile spawn points and hit detection --- CONTEXT.md | 44 ++++++++ agent.md | 58 +++++++++++ src/constants.js | 64 ++++++++++++ src/game/ArenaScene.js | 206 +++++++++++++++++++++++++++++++++---- src/game/arenaRenderer.js | 3 +- src/game/combat.js | 78 ++++++++++---- src/game/config.js | 26 ----- src/game/fighterAssets.js | 15 +-- src/game/fighterFactory.js | 2 +- src/game/matchSetup.js | 2 +- src/main.js | 2 +- src/ui/matchForm.js | 4 +- 12 files changed, 421 insertions(+), 83 deletions(-) create mode 100644 CONTEXT.md create mode 100644 agent.md create mode 100644 src/constants.js delete mode 100644 src/game/config.js diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..bd685be --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,44 @@ +# Context: Arena Picker 개발 가이드 + +## 1. 모듈별 상세 역할 + +### [Core Engine] +- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다. +- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다. + - `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도. + - `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기. + - `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE). + +### [Game Logic - src/game/] +- **`ArenaScene.js`**: + - `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다. + - `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다. + - `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다. +- **`matchSetup.js`**: + - 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다. +- **`combat.js`**: + - `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. + - `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다. + +### [Assets & UI] +- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. +- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다. + +## 2. 주요 로직 구현 세부 사항 + +### 지능형 카메라 추적 (Lerp & Jittering 방지) +카메라가 소수점 단위의 평균 좌표를 즉시 따라가면 화면이 떨려 보일 수 있습니다. 이를 방지하기 위해: +1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다. +2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다. +```javascript +this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; +``` + +### 미니맵 가이드라인 +미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다. +- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다. + +## 3. 개발 및 유지보수 규칙 +- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다. +- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오. +- **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 DOM에 접근합니다. diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..85c0cf8 --- /dev/null +++ b/agent.md @@ -0,0 +1,58 @@ +# Agent: Arena Picker + +## 1. 프로젝트 정의 + +**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다. + +## 2. 프로젝트 전체 구조 (Directory Tree) + +```text +├── index.html # 메인 HTML 진입점 및 UI 레이아웃 +├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite) +├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서) +├── CONTEXT.md # 상세 개발 가이드 및 로직 설명 +├── todo.md # 작업 내역 및 잔여 이슈 관리 +├── public/ # 정적 리소스 (게임 에셋) +│ └── assets/ +│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋 +│ ├── archer/, armored-axeman/, armored-orc/, ... (중략) +│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함 +└── src/ # 소스 코드 root + ├── main.js # Phaser 게임 인스턴스 생성 및 초기화 + ├── constants.js # 전역 물리/UI 상수 통합 관리 (줌, 카메라 속도 등) + ├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션) + ├── game/ # 게임 로직 모듈 + │ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어) + │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링 + │ ├── combat.js # 전투 AI 및 피격 판정 로직 + │ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 + │ ├── fighterAssets.js# 스프라이트 시트 로드 및 애니메이션 생성 + │ ├── fighterFactory.js# 캐릭터 객체 및 HUD 생성 + │ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정) + │ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 + │ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 + └── ui/ + └── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 +``` + +## 3. 핵심 기능 + +- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. +- **지능형 카메라 시스템**: + - **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다. + - **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다. +- **역동적인 전투 연출**: + - 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션. + - 치명타(Critical) 발생 시 화면 흔들림 효과 및 대미지 가중치 적용. +- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다. + +## 4. 기술 사양 + +- **Framework**: Phaser 3.90.0 (Arcade Physics 기반) +- **Build Tool**: Vite 7.1.12 +- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용) + +## 5. 관련 문서 + +- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독) +- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리 diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..5027382 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,64 @@ +export const GRID_SIZE = 50; +export const TILE_SIZE = 64; +export const ARENA_SIZE = GRID_SIZE * TILE_SIZE; + +export const ATTACK_RANGE = 84; +export const ATTACK_COOLDOWN = 840; +export const DEFAULT_TEAM_SIZE = 5; +export const FIGHTER_SCALE = 3; +export const MAX_TEAM_SIZE = 100; +export const MELEE_CRITICAL_CHANCE = 0.05; +export const MOVE_SPEED = 148; +export const PROJECTILE_LIFETIME = 1800; +export const PROJECTILE_SPEED = 420; +export const RANGED_CRITICAL_CHANCE = 0; +export const RANGED_ATTACK_RANGE = TILE_SIZE * 5; + +export const MELEE_HIT_DELAY = 260; +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; +export const SPELL_CAST_DELAY = 340; +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_FIGHT_ZOOM = 2; + +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 }, + idle: { frameRate: 7, repeat: -1 }, + walk: { frameRate: 10, repeat: -1 }, + walk02: { frameRate: 10, repeat: -1 }, +}; + +export const TEAM_COLORS = [ + "#da6a48", + "#5fb4d9", + "#9bd15a", + "#d6a94a", + "#d477b8", + "#7f90e8", + "#63c5a6", + "#d98755", +]; diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js index 8e93767..a94de24 100644 --- a/src/game/ArenaScene.js +++ b/src/game/ArenaScene.js @@ -1,7 +1,22 @@ import Phaser from "phaser"; +import { + ARENA_SIZE, + CAMERA_MAX_ZOOM, + CAMERA_MIN_ZOOM, + CAMERA_ZOOM_STEP, + MINIMAP_ALPHA, + MINIMAP_MARGIN, + MINIMAP_VIEWPORT_SIZE, + MINIMAP_VIEW_FRAME_OUTLINE, + MINIMAP_VIEW_FRAME_STROKE, + SPECTATOR_CAMERA_LERP, + SPECTATOR_FINAL_FIGHTER_THRESHOLD, + SPECTATOR_FINAL_FIGHT_ZOOM, + SPECTATOR_LATE_FIGHTER_THRESHOLD, + SPECTATOR_LATE_FIGHT_ZOOM, +} from "../constants.js"; import { drawArena } from "./arenaRenderer.js"; import { clearCombatObjects, updateFighter } from "./combat.js"; -import { ARENA_SIZE } from "./config.js"; import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js"; import { createFighter, syncFighterHud } from "./fighterFactory.js"; import { fighterManifest } from "./fighterManifest.js"; @@ -29,6 +44,7 @@ export class ArenaScene extends Phaser.Scene { document.querySelector(".arena-shell").appendChild(banner); } }; + this.observedCombat = []; this.teams = []; } @@ -44,20 +60,28 @@ export class ArenaScene extends Phaser.Scene { createFighterAnimations(this, fighterManifest); // 미니맵 카메라 설정 - this.minimapCamera = this.cameras.add(10, 10, 150, 150).setZoom(150 / ARENA_SIZE).setName('minimap'); + this.minimapCamera = this.cameras + .add(MINIMAP_MARGIN, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE) + .setZoom(MINIMAP_VIEWPORT_SIZE / ARENA_SIZE) + .setName("minimap"); this.minimapCamera.setBackgroundColor(0x000000); - this.minimapCamera.scrollX = 0; - this.minimapCamera.scrollY = 0; + this.minimapCamera.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); + this.minimapViewportFrame = this.add.graphics().setDepth(10); + this.cameras.main.ignore(this.minimapViewportFrame); + this.updateMinimapViewportFrame(); this.minimapCamera.setAlpha(0); // 기본적으로는 숨김 // 마우스 휠로 줌 조절 this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => { - const zoomStep = 0.1; - const newZoom = Phaser.Math.Clamp(this.cameras.main.zoom + (deltaY > 0 ? -zoomStep : zoomStep), 1, 3); - this.cameras.main.setZoom(newZoom); + const newZoom = Phaser.Math.Clamp( + this.cameras.main.zoom + (deltaY > 0 ? -CAMERA_ZOOM_STEP : CAMERA_ZOOM_STEP), + CAMERA_MIN_ZOOM, + CAMERA_MAX_ZOOM, + ); + this.setMainCameraZoom(newZoom); + // 확대 시 미니맵 표시 - this.minimapCamera.setAlpha(newZoom > 1 ? 0.8 : 0); }); this.ready = true; @@ -79,6 +103,9 @@ export class ArenaScene extends Phaser.Scene { this.matchId += 1; this.matchOver = false; + this.observedCombat = []; + this.setMainCameraZoom(CAMERA_MIN_ZOOM); + this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); clearCombatObjects(this); this.fighters.forEach((fighter) => fighter.destroy()); this.teams = matchSetup.teams; @@ -96,25 +123,29 @@ update(time) { this.fighters.forEach(syncFighterHud); if (this.matchOver) { + this.updateMinimapViewportFrame(); return; } // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 - if (this.cameras.main.zoom > 1) { - const aliveFighters = this.fighters.filter(f => !f.isDead); - if (aliveFighters.length > 0) { - const avgX = aliveFighters.reduce((sum, f) => sum + f.x, 0) / aliveFighters.length; - const avgY = aliveFighters.reduce((sum, f) => sum + f.y, 0) / aliveFighters.length; + const livingFighterCount = this.fighters.filter(isLivingFighter).length; + const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount); + + if (forcedSpectatorZoom) { + this.setMainCameraZoom(forcedSpectatorZoom); + + const combatCenter = this.getObservedCombatCenter(); + if (combatCenter) { // 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용 - const targetX = Math.round(avgX); - const targetY = Math.round(avgY); + const targetX = Math.round(combatCenter.x); + const targetY = Math.round(combatCenter.y); - // 현재 카메라 위치에서 목표 위치로 서서히 이동 (0.1은 따라가는 속도) - this.cameras.main.scrollX += (targetX - this.cameras.main.centerX) * 0.1; - this.cameras.main.scrollY += (targetY - this.cameras.main.centerY) * 0.1; + // Move from the current world-space camera center toward the target. + this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; + this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP; } - } else { + } else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) { // 줌이 1일 때는 경기장 중앙에 고정 this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); } @@ -125,8 +156,87 @@ update(time) { this.finishMatch(); }); }); + + this.updateMinimapViewportFrame(); } + setMainCameraZoom(zoom) { + const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM); + + this.cameras.main.setZoom(newZoom); + + if (newZoom === CAMERA_MIN_ZOOM) { + this.observedCombat = []; + } + + this.minimapCamera.setAlpha(newZoom > CAMERA_MIN_ZOOM ? MINIMAP_ALPHA : 0); + this.updateMinimapViewportFrame(); + } + + updateMinimapViewportFrame() { + if (!this.minimapViewportFrame) { + return; + } + + const camera = this.cameras.main; + + this.minimapViewportFrame.clear(); + this.minimapViewportFrame.setVisible(camera.zoom > CAMERA_MIN_ZOOM); + + if (camera.zoom <= CAMERA_MIN_ZOOM) { + return; + } + + const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE); + const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE); + const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX; + const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY; + const cameraMidX = scrollX + camera.width / 2; + const cameraMidY = scrollY + camera.height / 2; + const frameX = Phaser.Math.Clamp(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth); + const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight); + + this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95); + this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight); + this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1); + this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight); + } + + observeCombat(attacker, defender) { + const canObserveCombat = Boolean( + getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length), + ); + + if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) { + return; + } + + if ( + !isLivingOpponentPair(this.observedCombat) || + this.observedCombat.includes(attacker) || + this.observedCombat.includes(defender) + ) { + this.observedCombat = [attacker, defender]; + } + } + + getObservedCombatCenter() { + if (!isLivingOpponentPair(this.observedCombat)) { + this.observedCombat = findClosestOpponentPair(this.fighters) ?? []; + } + + if (!isLivingOpponentPair(this.observedCombat)) { + return null; + } + + const [fighterA, fighterB] = this.observedCombat; + + return { + x: (fighterA.x + fighterB.x) / 2, + y: (fighterA.y + fighterB.y) / 2, + }; + } + updateScoreboard() { const scoreLeft = document.getElementById("score-left"); const scoreRight = document.getElementById("score-right"); @@ -180,3 +290,61 @@ update(time) { } } } + +function findClosestOpponentPair(fighters) { + let closestPair; + let closestDistance = Number.POSITIVE_INFINITY; + + fighters.forEach((fighter, index) => { + if (!isLivingFighter(fighter)) { + return; + } + + for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) { + const candidate = fighters[candidateIndex]; + + if (!isLivingOpponentPair([fighter, candidate])) { + continue; + } + + const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y); + + if (distance < closestDistance) { + closestDistance = distance; + closestPair = [fighter, candidate]; + } + } + }); + + return closestPair; +} + +function getForcedSpectatorZoom(livingFighterCount) { + if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) { + return SPECTATOR_FINAL_FIGHT_ZOOM; + } + + if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) { + return SPECTATOR_LATE_FIGHT_ZOOM; + } + + return null; +} + +function isLivingOpponentPair(pair) { + if (pair.length !== 2) { + return false; + } + + const [fighterA, fighterB] = pair; + + return ( + isLivingFighter(fighterA) && + isLivingFighter(fighterB) && + fighterA.team.id !== fighterB.team.id + ); +} + +function isLivingFighter(fighter) { + return fighter?.active && !fighter.isDead; +} diff --git a/src/game/arenaRenderer.js b/src/game/arenaRenderer.js index 773be24..da717da 100644 --- a/src/game/arenaRenderer.js +++ b/src/game/arenaRenderer.js @@ -1,4 +1,4 @@ -import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "./config.js"; +import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../constants.js"; export function drawArena(scene) { const graphics = scene.add.graphics(); @@ -27,4 +27,3 @@ export function drawArena(scene) { graphics.lineStyle(2, 0xd3bd72, 0.35); graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24); } - diff --git a/src/game/combat.js b/src/game/combat.js index 0552b9f..dc3ea9c 100644 --- a/src/game/combat.js +++ b/src/game/combat.js @@ -3,13 +3,21 @@ import { ATTACK_COOLDOWN, ATTACK_RANGE, FIGHTER_SCALE, + MELEE_HIT_DELAY, MELEE_CRITICAL_CHANCE, MOVE_SPEED, + PROJECTILE_BODY_OFFSET, + PROJECTILE_FIRE_DELAY, + PROJECTILE_HIT_PADDING, + PROJECTILE_HIT_RADIUS, PROJECTILE_LIFETIME, + PROJECTILE_SPAWN_DISTANCE, PROJECTILE_SPEED, + SPELL_CAST_DELAY, + SPELL_HIT_DELAY, RANGED_CRITICAL_CHANCE, RANGED_ATTACK_RANGE, -} from "./config.js"; +} from "../constants.js"; import { getAttackSpeedMultiplier, getMovementSpeedMultiplier, @@ -21,12 +29,6 @@ import { fighterProjectileKey, } from "./fighterAssets.js"; -const MELEE_HIT_DELAY = 260; -const PROJECTILE_FIRE_DELAY = 360; -const PROJECTILE_HIT_RADIUS = 8; -const SPELL_CAST_DELAY = 340; -const SPELL_HIT_DELAY = 160; - export function updateFighter(scene, fighter, time, onWinner) { const enemy = findNearestEnemy(scene.fighters, fighter); @@ -66,6 +68,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) { const attack = createAttackProfile(attacker); attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN); attacker.isLocked = true; + scene.observeCombat?.(attacker, defender); playAnimation(attacker, attack.animation, getAttackSpeedMultiplier()); switch (getCombatType(attacker)) { @@ -115,19 +118,32 @@ function queueInstantSpell(scene, attacker, defender, onWinner) { } function spawnProjectile(scene, attacker, defender, onWinner, matchId) { - const direction = defender.x < attacker.x ? -1 : 1; + const defenderHitPoint = fighterHitPoint(defender); + const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint); const projectile = scene.physics.add.image( - attacker.x + direction * 42, - attacker.y + 4, + projectileOrigin.x, + projectileOrigin.y, fighterProjectileKey(attacker.skin), ); projectile.setDepth(3); projectile.setScale(2); - projectile.body.setCircle(PROJECTILE_HIT_RADIUS, 8, 8); - projectile.setRotation(Phaser.Math.Angle.Between(projectile.x, projectile.y, defender.x, defender.y)); - scene.physics.moveToObject( + projectile.body.setCircle( + PROJECTILE_HIT_RADIUS, + PROJECTILE_BODY_OFFSET, + PROJECTILE_BODY_OFFSET, + ); + projectile.setRotation( + Phaser.Math.Angle.Between( + projectile.x, + projectile.y, + defenderHitPoint.x, + defenderHitPoint.y, + ), + ); + scene.physics.moveTo( projectile, - defender, + defenderHitPoint.x, + defenderHitPoint.y, (attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(), ); trackCombatObject(scene, projectile); @@ -260,6 +276,32 @@ function isAttackValid(scene, attacker, defender, matchId) { ); } +function fighterHitPoint(fighter) { + if (!fighter.body) { + return { x: fighter.x, y: fighter.y }; + } + + return { + x: fighter.body.center.x, + y: fighter.body.center.y, + }; +} + +function projectileSpawnPoint(attacker, target) { + const direction = new Phaser.Math.Vector2(target.x - attacker.x, target.y - attacker.y); + + if (direction.lengthSq() === 0) { + return { x: attacker.x, y: attacker.y }; + } + + direction.normalize(); + + return { + x: attacker.x + direction.x * PROJECTILE_SPAWN_DISTANCE, + y: attacker.y + direction.y * PROJECTILE_SPAWN_DISTANCE, + }; +} + function projectilePathHitsDefender(projectile, defender) { if (!defender.body) { return false; @@ -272,10 +314,10 @@ function projectilePathHitsDefender(projectile, defender) { projectile.y, ); const defenderHitArea = new Phaser.Geom.Rectangle( - defender.body.x - PROJECTILE_HIT_RADIUS, - defender.body.y - PROJECTILE_HIT_RADIUS, - defender.body.width + PROJECTILE_HIT_RADIUS * 2, - defender.body.height + PROJECTILE_HIT_RADIUS * 2, + defender.body.x - PROJECTILE_HIT_PADDING, + defender.body.y - PROJECTILE_HIT_PADDING, + defender.body.width + PROJECTILE_HIT_PADDING * 2, + defender.body.height + PROJECTILE_HIT_PADDING * 2, ); return ( diff --git a/src/game/config.js b/src/game/config.js deleted file mode 100644 index c810ab7..0000000 --- a/src/game/config.js +++ /dev/null @@ -1,26 +0,0 @@ -export const GRID_SIZE = 50; -export const TILE_SIZE = 64; -export const ARENA_SIZE = GRID_SIZE * TILE_SIZE; - -export const ATTACK_RANGE = 84; -export const ATTACK_COOLDOWN = 840; -export const DEFAULT_TEAM_SIZE = 5; -export const FIGHTER_SCALE = 3; -export const MAX_TEAM_SIZE = 100; -export const MELEE_CRITICAL_CHANCE = 0.05; -export const MOVE_SPEED = 148; -export const PROJECTILE_LIFETIME = 1800; -export const PROJECTILE_SPEED = 420; -export const RANGED_CRITICAL_CHANCE = 0; -export const RANGED_ATTACK_RANGE = TILE_SIZE * 5; - -export const TEAM_COLORS = [ - "#da6a48", - "#5fb4d9", - "#9bd15a", - "#d6a94a", - "#d477b8", - "#7f90e8", - "#63c5a6", - "#d98755", -]; diff --git a/src/game/fighterAssets.js b/src/game/fighterAssets.js index a63eb61..5778e12 100644 --- a/src/game/fighterAssets.js +++ b/src/game/fighterAssets.js @@ -1,15 +1,4 @@ -const animationOptions = { - 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 }, - idle: { frameRate: 7, repeat: -1 }, - walk: { frameRate: 10, repeat: -1 }, - walk02: { frameRate: 10, repeat: -1 }, -}; +import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js"; export function fighterSheetKey(skin, action) { return `${skin.key}-${action}`; @@ -54,7 +43,7 @@ export function createFighterAnimations(scene, skins) { return; } - const { frameRate, repeat } = animationOptions[action]; + const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action]; scene.anims.create({ key, diff --git a/src/game/fighterFactory.js b/src/game/fighterFactory.js index e68fcd7..03dbfa3 100644 --- a/src/game/fighterFactory.js +++ b/src/game/fighterFactory.js @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { FIGHTER_SCALE } from "./config.js"; +import { FIGHTER_SCALE } from "../constants.js"; import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js"; export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) { diff --git a/src/game/matchSetup.js b/src/game/matchSetup.js index 2d5d9e5..72c71cc 100644 --- a/src/game/matchSetup.js +++ b/src/game/matchSetup.js @@ -5,7 +5,7 @@ import { MAX_TEAM_SIZE, TEAM_COLORS, TILE_SIZE, -} from "./config.js"; +} from "../constants.js"; export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) { const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE)); diff --git a/src/main.js b/src/main.js index 99b6217..c0e6103 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ import Phaser from "phaser"; import { ArenaScene } from "./game/ArenaScene.js"; -import { ARENA_SIZE } from "./game/config.js"; +import { ARENA_SIZE } from "./constants.js"; import { createMatchForm } from "./ui/matchForm.js"; import "./styles.css"; diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js index 7ca7cad..321744a 100644 --- a/src/ui/matchForm.js +++ b/src/ui/matchForm.js @@ -1,4 +1,4 @@ -const nicknameLength = 18; +import { NICKNAME_LENGTH } from "../constants.js"; export function createMatchForm() { const form = getElement("#fighter-form"); @@ -46,7 +46,7 @@ function getElement(selector) { function nicknameValues(value) { return value .split(/\r?\n|,/) - .map((name) => name.trim().slice(0, nicknameLength)) + .map((name) => name.trim().slice(0, NICKNAME_LENGTH)) .filter(Boolean); }