From 23376e8cbb21b973f6255349283555b46c4a85af Mon Sep 17 00:00:00 2001 From: Horoli Date: Fri, 29 May 2026 10:36:50 +0900 Subject: [PATCH] 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. --- agent.md | 12 +++ context/combat.md | 1 + context/core.md | 1 + context/match-ui.md | 2 +- src/constants.js | 30 ++++---- src/game/arena/ArenaScene.js | 8 ++ src/game/combat/combat.js | 142 ++++++++++++++++++++++++++++++++++- 7 files changed, 181 insertions(+), 15 deletions(-) diff --git a/agent.md b/agent.md index 1acb517..16e5d18 100644 --- a/agent.md +++ b/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`. diff --git a/context/combat.md b/context/combat.md index 0bb25e9..d265b2f 100644 --- a/context/combat.md +++ b/context/combat.md @@ -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. diff --git a/context/core.md b/context/core.md index 2f01c2d..b2faef9 100644 --- a/context/core.md +++ b/context/core.md @@ -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. diff --git a/context/match-ui.md b/context/match-ui.md index c3515de..d0ac8cf 100644 --- a/context/match-ui.md +++ b/context/match-ui.md @@ -42,7 +42,7 @@ - **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다. ### 전투 화면 레이아웃 (HUD) -- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. +- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. 이미 고정된 동일 팀 Badge를 다시 클릭하면 선택을 해제하고 기본 줌을 요청합니다. 단, 자동 관전 줌 조건이 활성화되어 있으면 다음 카메라 갱신에서 자동 줌이 즉시 다시 적용됩니다. - **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다. - **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다. - **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다. diff --git a/src/constants.js b/src/constants.js index bc41624..758e758 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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, diff --git a/src/game/arena/ArenaScene.js b/src/game/arena/ArenaScene.js index 57ccb23..fb958d1 100644 --- a/src/game/arena/ArenaScene.js +++ b/src/game/arena/ArenaScene.js @@ -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, ); diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js index 668fa39..1048877 100644 --- a/src/game/combat/combat.js +++ b/src/game/combat/combat.js @@ -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);