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
This commit is contained in:
parent
1d0d791001
commit
104bf4fe48
|
|
@ -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에 접근합니다.
|
||||
|
|
@ -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): 작업 내역 및 잔여 이슈 관리
|
||||
|
|
@ -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",
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue