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