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:
2026-05-22 09:13:49 +09:00
parent 1d0d791001
commit 104bf4fe48
12 changed files with 421 additions and 83 deletions
+187 -19
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,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 -2
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);
}
+60 -18
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 (
-26
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",
];
+2 -13
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,
+1 -1
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 }) {
+1 -1
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));