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:
2026-05-29 10:36:50 +09:00
parent 9ca343c214
commit 23376e8cbb
7 changed files with 181 additions and 15 deletions
+17 -13
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,
+8
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,
);
+141 -1
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);