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:
Horoli 2026-05-22 09:13:49 +09:00
parent 1d0d791001
commit 104bf4fe48
12 changed files with 421 additions and 83 deletions

44
CONTEXT.md Normal file
View File

@ -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에 접근합니다.

58
agent.md Normal file
View File

@ -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): 작업 내역 및 잔여 이슈 관리

64
src/constants.js Normal file
View File

@ -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",
];

View File

@ -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,6 +156,85 @@ 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() {
@ -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;
}

View File

@ -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);
}

View File

@ -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 (

View File

@ -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",
];

View File

@ -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,

View File

@ -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 }) {

View File

@ -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));

View File

@ -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";

View File

@ -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);
}