feat: implement elite kill splash and optimize team focus toggle

- Add ELITE_KILL_SPLASH mechanic where elite fighters trigger area damage on kill.
- Implement pixel-dot splash visual effects for elite kills.
- Toggle camera focus when clicking the same team card in the HUD.
- Adjust elite fighter balancing (HP, damage exponents) and world effect intervals.
- Update documentation in agent.md and context/ files.
This commit is contained in:
Horoli 2026-05-29 10:36:50 +09:00
parent 9ca343c214
commit 23376e8cbb
7 changed files with 181 additions and 15 deletions

View File

@ -1,3 +1,15 @@
# Update: Elite Kill Splash
- Elite fighters now trigger a kill splash when they directly kill an enemy. The splash is centered on the killed fighter's body position.
- Splash damage is `COMBAT.ELITE_KILL_SPLASH_DAMAGE_PERCENT` of the killed fighter's max HP and applies to living enemy fighters inside `COMBAT.ELITE_KILL_SPLASH_RADIUS`.
- Splash kills still use the normal kill/death flow for logs, death statistics, despawn, split-on-death, scoreboard, and match-finish checks. `COMBAT.ELITE_KILL_SPLASH_CHAIN_ENABLED` is `false` by default, so splash kills do not recursively trigger more splashes unless explicitly enabled.
- A short team-colored pixel-dot burst is rendered for the splash when supplemental combat effects are enabled, avoiding smooth vector circles.
# Update: Team Card Focus Toggle
- Team cards in the left HUD still select a random living fighter from the clicked team and zoom the camera in.
- Clicking the same already-focused team card again clears the selected fighter, requests `CAMERA.MIN_ZOOM`, restores the match status summary, and removes the focused team-card state. If automatic spectator focus is active, that camera mode immediately reapplies its own zoom; this is intended.
# Update: Special Effect Projectile
- Special battle effects are implemented separately from meteor/frost barrages in `src/game/combat/specialEffects.js`.

View File

@ -21,6 +21,7 @@
- `combat.js` uses `fighter.isElite` to split damage rules. Elite critical hits deal the greater of the ordinary hit or `COMBAT.CRITICAL_DAMAGE_PERCENT` of max HP; normal critical hits deal `NORMAL_CRITICAL_DAMAGE_MULTIPLIER` times the ordinary hit.
- Elite attack and movement speed are calculated through `FIGHTER.ELITE.ATTACK_SPEED_*` and `MOVE_SPEED_*` constants. Each multiplier is additive: `0` removes its added stack bonus, while `1` applies its configured exponent.
- Elite direct kills trigger a kill splash at the killed fighter's body position. The splash deals `COMBAT.ELITE_KILL_SPLASH_DAMAGE_PERCENT` of that killed fighter's max HP to living enemies inside `COMBAT.ELITE_KILL_SPLASH_RADIUS`; splash kills are recorded normally, recursive splash chaining is controlled by `ELITE_KILL_SPLASH_CHAIN_ENABLED`, and the optional visual uses square pixel dots rather than smooth circles.
- Kills still record the attacker/defender and drive match resolution, but `COMBAT.KILL_REWARD_ENABLED = false` prevents heal effects, scale growth, and kill-derived speed multipliers in compressed elite battles.
- `worldEffects.js` passes an effect type into `applyWorldEffectDamage()`: normal targets retain fixed fire/frost damage, while elite targets take `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` or `FROST_DAMAGE_PERCENT` of max HP.
- Dense-area target scanning adds each fighter's represented `stackCount` into its tile, preventing compressed armies from disappearing from meteor/frost targeting pressure.

View File

@ -25,6 +25,7 @@
- `FIGHTER.ELITE` contains elite type, stack/appearance/HP/range tuning, attack-damage and speed tuning, and randomized large-team compression settings inside the fighter domain.
- `COMBAT.CRITICAL_DAMAGE_PERCENT` sets elite critical damage from target max HP, while `COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER` replaces normal-fighter instant critical kills with multiplied attack damage.
- `COMBAT.ELITE_KILL_SPLASH_ENABLED`, `ELITE_KILL_SPLASH_DAMAGE_PERCENT`, `ELITE_KILL_SPLASH_RADIUS`, and `ELITE_KILL_SPLASH_CHAIN_ENABLED` tune the elite-only on-kill area damage centered on the killed fighter.
- `COMBAT.KILL_REWARD_ENABLED` is `false` by default because one compressed kill is not equivalent to one represented casualty. The legacy heal/growth constants remain available only for an explicitly re-enabled mode.
- `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` and `WORLD_EFFECT.FROST_DAMAGE_PERCENT` apply only to elite targets. Existing fixed `METEOR_DAMAGE` and `FROST_DAMAGE` remain the normal-target values.
- `ATTACK_DAMAGE_*`, `ATTACK_SPEED_*`, and `MOVE_SPEED_*` constants control elite stack bonuses. For each bonus, multiplier `0` removes the added bonus and multiplier `1` applies the configured stack exponent fully.

View File

@ -42,7 +42,7 @@
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. 이미 고정된 동일 팀 Badge를 다시 클릭하면 선택을 해제하고 기본 줌을 요청합니다. 단, 자동 관전 줌 조건이 활성화되어 있으면 다음 카메라 갱신에서 자동 줌이 즉시 다시 적용됩니다.
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.

View File

@ -46,7 +46,7 @@ export const FIGHTER = {
attackCooldown: 840,
damageMin: 14,
damageMax: 24,
criticalChance: 0.2,
criticalChance: 0.05,
windupDelay: 260,
},
ranged: {
@ -77,12 +77,12 @@ export const FIGHTER = {
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
ATTACK_EFFECT_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
HP_BONUS_RATIO: 2,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.1,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1.1,
ATTACK_DAMAGE_STACK_EXPONENT: 1,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0.1,
ATTACK_SPEED_STACK_EXPONENT: 0.4,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
@ -116,8 +116,8 @@ export const SPAWN = {
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 20000,
FIGHTERS_PER_STARTING_ZONE: 1000,
STARTING_ZONE_RADIUS: 2,
FIGHTERS_PER_STARTING_ZONE: 500,
STARTING_ZONE_RADIUS: 3,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
@ -136,6 +136,10 @@ export const COMBAT = {
KILL_GROWTH_TWEEN_DURATION: 180,
CRITICAL_DAMAGE_PERCENT: 0.1,
NORMAL_CRITICAL_DAMAGE_MULTIPLIER: 2,
ELITE_KILL_SPLASH_ENABLED: true,
ELITE_KILL_SPLASH_DAMAGE_PERCENT: 0.1,
ELITE_KILL_SPLASH_RADIUS: TILE_SIZE * 2,
ELITE_KILL_SPLASH_CHAIN_ENABLED: false,
// 최종교전 슬로우모션 설정
FINAL_SLOW_MOTION_ENABLED: false,
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
@ -158,13 +162,13 @@ const WORLD_EFFECT_CONFIG = {
// Delay from match start until the first barrage.
INTERVAL: 8000,
// Delay between barrages after the first one has fired.
REPEAT_INTERVAL: 12000,
REPEAT_INTERVAL: 20000,
AREA_TILES: 40,
// How long the large dense-area warning marker remains visible.
WARNING_DURATION_MS: 2000,
IMPACT_AREA_TILES: 10,
IMPACT_COUNT_MIN: 10,
IMPACT_COUNT_MAX: 15,
IMPACT_COUNT_MIN: 5,
IMPACT_COUNT_MAX: 10,
IMPACT_STAGGER_MS: 140,
IMPACT_VISUAL_SCALE: 15,
SIZE_SCALE_VARIANCE: 1,
@ -194,7 +198,7 @@ export const SPECIAL_EFFECT = {
ENABLED: true,
// A special effect is picked once per battle, no earlier than the first world-effect delay.
TRIGGER_DELAY_MIN_MS: 10000,
TRIGGER_DELAY_MAX_MS: 50000,
TRIGGER_DELAY_MAX_MS: 11000,
RETRY_DELAY_MS: 2000,
CASTER: {
HURT_FRAME_INDEX: 1,
@ -202,8 +206,8 @@ export const SPECIAL_EFFECT = {
ATTACK_LAUNCH_DELAY_MS: 360,
ATTACK_TIME_SCALE: 0.9,
POST_CAST_COOLDOWN_MS: 1200,
BALANCE_NON_MAGIC_TYPES: true,
INVULNERABLE_MS: 7000,
BALANCE_NON_MAGIC_TYPES: false,
INVULNERABLE_MS: 4500,
},
CAMERA: {
ZOOM: 3,

View File

@ -1112,6 +1112,14 @@ update(time) {
return;
}
if (isLivingFighter(this.selectedFighter) && this.selectedFighter.team.id === teamId) {
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
this.setStatus(matchStatusText(this.teams));
this.updateScoreboard();
return;
}
const candidates = this.fighters.filter(
(fighter) => isLivingFighter(fighter) && fighter.team.id === teamId,
);

View File

@ -524,7 +524,12 @@ function projectilePathHitsDefender(projectile, defender) {
);
}
function killFighter(defender, winner, onWinner) {
function killFighter(
defender,
winner,
onWinner,
{ triggerEliteKillSplash = true } = {},
) {
defender.isDead = true;
defender.isLocked = true;
defender.body.setVelocity(0, 0);
@ -543,6 +548,10 @@ function killFighter(defender, winner, onWinner) {
if (COMBAT.KILL_REWARD_ENABLED) {
applyKillReward(winner);
}
if (triggerEliteKillSplash) {
applyEliteKillSplash(defender, winner);
}
} else {
defender.scene.recordDeath?.(defender);
}
@ -552,6 +561,137 @@ function killFighter(defender, winner, onWinner) {
scheduleDeadFighterDespawn(defender);
}
function applyEliteKillSplash(defender, winner) {
if (
!COMBAT.ELITE_KILL_SPLASH_ENABLED
|| !winner?.isElite
|| !winner.active
|| winner.isDead
) {
return;
}
const scene = winner.scene ?? defender.scene;
const radius = Math.max(0, Number(COMBAT.ELITE_KILL_SPLASH_RADIUS) || 0);
const damage = eliteKillSplashDamageFor(defender);
if (!scene || scene.matchOver || radius <= 0 || damage <= 0 || !Array.isArray(scene.fighters)) {
return;
}
const center = fighterHitPoint(defender);
const radiusSq = radius * radius;
if (shouldRenderCombatEffects(scene)) {
spawnEliteKillSplashEffect(scene, center, radius, winner.team?.color);
}
scene.fighters.forEach((fighter) => {
if (
scene.matchOver
|| fighter === defender
|| fighter === winner
|| !fighter?.active
|| fighter.isDead
|| fighter.team?.id === winner.team?.id
|| isFighterSpecialInvulnerable(fighter)
) {
return;
}
const target = fighterHitPoint(fighter);
const deltaX = target.x - center.x;
const deltaY = target.y - center.y;
if (deltaX * deltaX + deltaY * deltaY > radiusSq) {
return;
}
fighter.hp = Math.max(0, fighter.hp - damage);
fighter.body?.setVelocity(0, 0);
if (fighter.hp === 0) {
killFighter(fighter, winner, undefined, {
triggerEliteKillSplash: Boolean(COMBAT.ELITE_KILL_SPLASH_CHAIN_ENABLED),
});
return;
}
fighter.isLocked = true;
playAnimation(fighter, "hurt");
});
}
function eliteKillSplashDamageFor(defender) {
const percentage = Math.max(0, Number(COMBAT.ELITE_KILL_SPLASH_DAMAGE_PERCENT) || 0);
const maxHp = Math.max(1, Number(defender.maxHp ?? combatStatsFor(defender).maxHp) || 1);
return Math.ceil(maxHp * percentage);
}
function spawnEliteKillSplashEffect(scene, center, radius, color) {
const parsedColor = Number.parseInt(String(color ?? "#f6d365").replace(/^#/, ""), 16);
const effectColor = Number.isFinite(parsedColor) ? parsedColor : 0xf6d365;
const splash = scene.add
.graphics()
.setPosition(Math.round(center.x), Math.round(center.y))
.setDepth(5);
drawEliteKillSplashPixels(splash, radius, effectColor);
splash.cleanup = () => {
scene.tweens.killTweensOf(splash);
};
trackCombatObject(scene, splash);
scene.tweens.add({
targets: splash,
alpha: 0,
duration: 260,
ease: "Cubic.Out",
onComplete: () => disposeCombatObject(scene, splash),
});
}
function drawEliteKillSplashPixels(graphics, radius, color) {
const dotSize = 8;
const step = 12;
const halfDot = dotSize / 2;
const radiusSq = radius * radius;
const edgeThickness = Math.max(step * 2, radius * 0.18);
const innerRadius = Math.max(0, radius - edgeThickness);
const innerRadiusSq = innerRadius * innerRadius;
const coreRadiusSq = (radius * 0.42) ** 2;
for (let y = -radius; y <= radius; y += step) {
for (let x = -radius; x <= radius; x += step) {
const distanceSq = x * x + y * y;
if (distanceSq > radiusSq) {
continue;
}
const gridX = Math.round((x + radius) / step);
const gridY = Math.round((y + radius) / step);
const isEdgeDot = distanceSq >= innerRadiusSq;
const isInteriorDot =
distanceSq > coreRadiusSq &&
((gridX * 3 + gridY * 5) % 7 === 0 || (gridX + gridY) % 11 === 0);
if (!isEdgeDot && !isInteriorDot) {
continue;
}
graphics.fillStyle(color, isEdgeDot ? 0.72 : 0.28);
graphics.fillRect(
Math.round(x - halfDot),
Math.round(y - halfDot),
dotSize,
dotSize,
);
}
}
}
function scheduleDeadFighterDespawn(fighter) {
const scene = fighter.scene;
const delay = resolveDeadDespawnDelay(scene);