diff --git a/src/constants.js b/src/constants.js index 758e758..4afc2ee 100644 --- a/src/constants.js +++ b/src/constants.js @@ -76,7 +76,7 @@ export const FIGHTER = { TYPE: ["melee", "magic"], STACK_SIZE: 100, VISUAL_SCALE_MULTIPLIER: 5, - ATTACK_EFFECT_SCALE_MULTIPLIER: 5, + ATTACK_EFFECT_SCALE_MULTIPLIER: 2, HP_BONUS_RATIO: 2, ATTACK_RANGE_MULTIPLIER: 1.5, ATTACK_DAMAGE_BONUS_MULTIPLIER: 1.1, @@ -146,6 +146,12 @@ export const COMBAT = { FINAL_SLOW_MOTION_HOLD_DURATION: 14000, FINAL_SLOW_MOTION_EXIT_DURATION: 14000, FINAL_SLOW_MOTION_SCALE: 0.28, + // 전투원 간 공간 분리 (spatial grid 기반 군중 밀착 방지) + FIGHTER_SEPARATION_ENABLED: true, + // 전투원 중심 간 최소 이격 거리(px). HITBOX_WIDTH=22, SCALE=3이므로 약 1.5배 + FIGHTER_SEPARATION_DISTANCE: 42 * 3, + // 밀착 시 밀어내는 힘. moveSpeed(148~163)보다 낮아야 자연스러움 + FIGHTER_SEPARATION_FORCE: 148 / 2, }; // 5. PROJECTILE 도메인 diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js index 1048877..3b4ea4b 100644 --- a/src/game/combat/combat.js +++ b/src/game/combat/combat.js @@ -38,16 +38,20 @@ export function updateFighter(scene, fighter, time, onWinner) { || !fighter.active || fighter.isDead || fighter.isFrostStunned - || fighter.isLocked ) { fighter.body?.setVelocity(0, 0); return; } + if (fighter.isLocked) { + fighter.body?.setVelocity(0, 0); + return; + } + const enemy = resolveTargetEnemy(scene, fighter, time); if (!enemy) { - fighter.body?.setVelocity(0, 0); + applySeparationVelocity(scene, fighter); return; } @@ -63,11 +67,12 @@ export function updateFighter(scene, fighter, time, onWinner) { enemy, combatStatsFor(fighter).moveSpeed * fighterMovementSpeedMultiplier(fighter), ); + applySeparationVelocity(scene, fighter, { blend: true }); playIfNeeded(fighter, "walk"); return; } - fighter.body.setVelocity(0, 0); + applySeparationVelocity(scene, fighter); if (time >= fighter.nextAttackAt) { beginAttack(scene, fighter, enemy, time, onWinner); @@ -77,6 +82,68 @@ export function updateFighter(scene, fighter, time, onWinner) { playIfNeeded(fighter, "idle"); } +function applySeparationVelocity(scene, fighter, { blend = false } = {}) { + if (!COMBAT.FIGHTER_SEPARATION_ENABLED) { + if (!blend) { + fighter.body?.setVelocity(0, 0); + } + return; + } + + const targetIndex = scene.combatTargetIndex; + + if (!targetIndex) { + fighter.body?.setVelocity(0, 0); + return; + } + + const cellX = clampCell(fighter.x, targetIndex.cellSize, targetIndex.maxCellX); + const cellY = clampCell(fighter.y, targetIndex.cellSize, targetIndex.maxCellY); + const sepDist = COMBAT.FIGHTER_SEPARATION_DISTANCE; + const sepDistSq = sepDist * sepDist; + const maxForce = COMBAT.FIGHTER_SEPARATION_FORCE; + let forceX = 0; + let forceY = 0; + + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + const cell = targetIndex.cells.get(targetCellKey(cellX + dx, cellY + dy)); + + if (!cell) { + continue; + } + + cell.forEach((candidate) => { + if (candidate === fighter || !candidate.active || candidate.isDead) { + return; + } + + const deltaX = fighter.x - candidate.x; + const deltaY = fighter.y - candidate.y; + const distSq = deltaX * deltaX + deltaY * deltaY; + + if (distSq >= sepDistSq || distSq < 0.001) { + return; + } + + const dist = Math.sqrt(distSq); + const strength = (1 - dist / sepDist) * maxForce; + + forceX += (deltaX / dist) * strength; + forceY += (deltaY / dist) * strength; + }); + } + } + + if (blend) { + fighter.body.velocity.x += forceX; + fighter.body.velocity.y += forceY; + return; + } + + fighter.body.setVelocity(forceX, forceY); +} + export function clearCombatObjects(scene) { scene.combatObjects?.forEach((object) => { object.cleanup?.(); diff --git a/src/game/combat/specialEffects.js b/src/game/combat/specialEffects.js index 1a3bf4f..27b9d06 100644 --- a/src/game/combat/specialEffects.js +++ b/src/game/combat/specialEffects.js @@ -1,8 +1,5 @@ import Phaser from "phaser"; -import { - ARENA, - SPECIAL_EFFECT, -} from "../../constants.js"; +import { ARENA, SPECIAL_EFFECT } from "../../constants.js"; import { applySpecialEffectInstantKill, disposeCombatObject, @@ -12,10 +9,7 @@ import { ensureFighterTeamAnimation, fighterSheetKey, } from "../fighter/fighterAssets.js"; -import { - FIGHTER_TYPES, - getFighterType, -} from "../fighter/fighterStats.js"; +import { FIGHTER_TYPES, getFighterType } from "../fighter/fighterStats.js"; const SPECIAL_ANIMATION_SUFFIX = "anim"; @@ -103,7 +97,8 @@ function maybeRetrySpecialEffect(scene, matchId) { return; } - const elapsed = scene.time.now - (scene.specialEffectStartedAt ?? scene.time.now); + const elapsed = + scene.time.now - (scene.specialEffectStartedAt ?? scene.time.now); if (elapsed >= SPECIAL_EFFECT.TRIGGER_DELAY_MAX_MS) { return; @@ -156,14 +151,19 @@ function beginSpecialCast(scene, caster, target, matchId) { caster.setFlipX(direction.x < 0); playCasterAttack(scene, state, caster); - addStateTimer(scene, state, SPECIAL_EFFECT.CASTER.ATTACK_LAUNCH_DELAY_MS, () => { - if (!isSpecialCastValid(scene, caster, matchId)) { - cleanupSpecialCastState(scene, state, { restoreCaster: true }); - return; - } + addStateTimer( + scene, + state, + SPECIAL_EFFECT.CASTER.ATTACK_LAUNCH_DELAY_MS, + () => { + if (!isSpecialCastValid(scene, caster, matchId)) { + cleanupSpecialCastState(scene, state, { restoreCaster: true }); + return; + } - spawnSpecialProjectile(scene, state, caster, direction); - }); + spawnSpecialProjectile(scene, state, caster, direction); + }, + ); }); } @@ -198,7 +198,10 @@ function playCasterAttack(scene, state, caster) { return; } - caster.off(Phaser.Animations.Events.ANIMATION_COMPLETE, clearSpecialCastLock); + caster.off( + Phaser.Animations.Events.ANIMATION_COMPLETE, + clearSpecialCastLock, + ); state.attackCompleteHandler = null; if (!caster.active || caster.isDead) { @@ -208,7 +211,8 @@ function playCasterAttack(scene, state, caster) { caster.isSpecialCasting = false; caster.isLocked = false; caster.anims.timeScale = 1; - caster.nextAttackAt = scene.time.now + SPECIAL_EFFECT.CASTER.POST_CAST_COOLDOWN_MS; + caster.nextAttackAt = + scene.time.now + SPECIAL_EFFECT.CASTER.POST_CAST_COOLDOWN_MS; }; state.attackCompleteHandler = clearSpecialCastLock; @@ -226,12 +230,19 @@ function spawnSpecialProjectile(scene, state, caster, direction) { ); const travelDistance = ARENA.TILE_SIZE * projectileConfig.travelTiles; const end = resolveProjectileEndPoint(start, direction, travelDistance); - const actualTravelDistance = Phaser.Math.Distance.Between(start.x, start.y, end.x, end.y); + const actualTravelDistance = Phaser.Math.Distance.Between( + start.x, + start.y, + end.x, + end.y, + ); const duration = Math.max( 1, Math.min( projectileConfig.maxLifetimeMs, - Math.round((actualTravelDistance / Math.max(1, projectileConfig.speed)) * 1000), + Math.round( + (actualTravelDistance / Math.max(1, projectileConfig.speed)) * 1000, + ), ), ); const projectile = scene.physics.add @@ -344,7 +355,10 @@ function resolveSpecialProjectileVisual(caster) { function grantSpecialCasterInvulnerability(caster) { const now = resolveRealtimeNow(); - const duration = Math.max(0, Number(SPECIAL_EFFECT.CASTER.INVULNERABLE_MS) || 0); + const duration = Math.max( + 0, + Number(SPECIAL_EFFECT.CASTER.INVULNERABLE_MS) || 0, + ); const invulnerableUntil = Math.max( Number(caster.specialInvulnerableUntil) || 0, now + duration, @@ -360,15 +374,18 @@ function grantSpecialCasterInvulnerability(caster) { caster.isSpecialInvulnerable = true; caster.specialInvulnerableUntil = invulnerableUntil; - caster.specialInvulnerabilityTimer = globalThis.setTimeout(() => { - if (caster.specialInvulnerableUntil !== invulnerableUntil) { - return; - } + caster.specialInvulnerabilityTimer = globalThis.setTimeout( + () => { + if (caster.specialInvulnerableUntil !== invulnerableUntil) { + return; + } - caster.isSpecialInvulnerable = false; - caster.specialInvulnerableUntil = 0; - caster.specialInvulnerabilityTimer = null; - }, Math.max(0, invulnerableUntil - now)); + caster.isSpecialInvulnerable = false; + caster.specialInvulnerableUntil = 0; + caster.specialInvulnerabilityTimer = null; + }, + Math.max(0, invulnerableUntil - now), + ); } function resolveRealtimeNow() { @@ -388,10 +405,10 @@ function resolveProjectileHits(scene, projectile, state, attacker) { [...scene.fighters].forEach((fighter) => { if ( - !isLivingFighter(fighter) - || fighter === attacker - || state.hitFighters.has(fighter) - || !projectileSegmentHitsFighter(segmentStart, segmentEnd, fighter) + !isLivingFighter(fighter) || + fighter === attacker || + state.hitFighters.has(fighter) || + !projectileSegmentHitsFighter(segmentStart, segmentEnd, fighter) ) { return; } @@ -420,12 +437,13 @@ function projectileReachedEnd(projectile, start, end, travelDistance) { const pathY = end.y - start.y; const traveledAlongPath = traveledX * pathX + traveledY * pathY; - return traveledAlongPath >= (travelDistance * travelDistance); + return traveledAlongPath >= travelDistance * travelDistance; } function projectileSegmentHitsFighter(segmentStart, segmentEnd, fighter) { const center = fighter.body?.center ?? fighter; - const hitRadius = SPECIAL_EFFECT.PROJECTILE.hitRadius + fighterHitRadius(fighter); + const hitRadius = + SPECIAL_EFFECT.PROJECTILE.hitRadius + fighterHitRadius(fighter); const distanceSq = pointToSegmentDistanceSq(center, segmentStart, segmentEnd); return distanceSq <= hitRadius * hitRadius; @@ -443,8 +461,9 @@ function pointToSegmentDistanceSq(point, segmentStart, segmentEnd) { } const t = Phaser.Math.Clamp( - ((point.x - segmentStart.x) * deltaX + (point.y - segmentStart.y) * deltaY) - / lengthSq, + ((point.x - segmentStart.x) * deltaX + + (point.y - segmentStart.y) * deltaY) / + lengthSq, 0, 1, ); @@ -457,14 +476,13 @@ function pointToSegmentDistanceSq(point, segmentStart, segmentEnd) { } function fighterHitRadius(fighter) { - const bodyRadius = Math.max( - fighter.body?.width ?? 0, - fighter.body?.height ?? 0, - ) / 2; - const visualRadius = Math.max( - Math.abs(fighter.displayWidth ?? 0), - Math.abs(fighter.displayHeight ?? 0), - ) * 0.14; + const bodyRadius = + Math.max(fighter.body?.width ?? 0, fighter.body?.height ?? 0) / 2; + const visualRadius = + Math.max( + Math.abs(fighter.displayWidth ?? 0), + Math.abs(fighter.displayHeight ?? 0), + ) * 0.14; return Math.max(8, bodyRadius, visualRadius); } @@ -516,10 +534,10 @@ function cleanupSpecialCastState( } if ( - restoreCaster - && state.caster?.active - && !state.caster.isDead - && state.caster.isSpecialCasting + restoreCaster && + state.caster?.active && + !state.caster.isDead && + state.caster.isSpecialCasting ) { state.caster.isSpecialCasting = false; state.caster.isLocked = false; @@ -626,16 +644,14 @@ function pauseSpecialPreparationCombat(scene, state) { object.anims.pause(); } - scene.tweens - ?.getTweensOf(object) - ?.forEach((tween) => { - if (tween.paused) { - return; - } + scene.tweens?.getTweensOf(object)?.forEach((tween) => { + if (tween.paused) { + return; + } - pausedTweens.add(tween); - tween.pause(); - }); + pausedTweens.add(tween); + tween.pause(); + }); combatObjectStates.set(object, objectState); }); @@ -762,9 +778,16 @@ function createBlurredBattlefieldLayer(scene, config) { .setOrigin(0, 0) .setDepth(config.BLUR_DEPTH) .setAlpha(0); - const entries = scene.children.getChildren().filter((gameObject) => - shouldDrawInSpecialFocusSnapshot(scene, gameObject, renderTexture, config), - ); + const entries = scene.children + .getChildren() + .filter((gameObject) => + shouldDrawInSpecialFocusSnapshot( + scene, + gameObject, + renderTexture, + config, + ), + ); try { renderTexture.draw(entries); @@ -785,14 +808,19 @@ function createBlurredBattlefieldLayer(scene, config) { return renderTexture; } -function shouldDrawInSpecialFocusSnapshot(scene, gameObject, renderTexture, config) { +function shouldDrawInSpecialFocusSnapshot( + scene, + gameObject, + renderTexture, + config, +) { return Boolean( - gameObject - && gameObject !== renderTexture - && gameObject.active - && gameObject.visible - && gameObject !== scene.minimapGraphics - && gameObject.depth < config.BLUR_DEPTH + gameObject && + gameObject !== renderTexture && + gameObject.active && + gameObject.visible && + gameObject !== scene.minimapGraphics && + gameObject.depth < config.BLUR_DEPTH, ); } @@ -856,10 +884,11 @@ function selectSpecialCaster(scene) { .filter((summary) => summary.count === highestCount) .map((summary) => summary.teamId), ); - const candidates = livingFighters.filter((fighter) => - !leadingTeamIds.has(fighter.team?.id) - && !fighter.isElite - && getFighterType(fighter.skin) !== FIGHTER_TYPES.MAGIC, + const candidates = livingFighters.filter( + (fighter) => + !leadingTeamIds.has(fighter.team?.id) && + // && !fighter.isElite + getFighterType(fighter.skin) !== FIGHTER_TYPES.MAGIC, ); if (candidates.length === 0) { @@ -906,9 +935,8 @@ function findDensestSpecialTarget(fighters) { SPECIAL_EFFECT.PROJECTILE.targetAreaTiles, ARENA.GRID_SIZE, ); - const tileCounts = Array.from( - { length: ARENA.GRID_SIZE }, - () => Array(ARENA.GRID_SIZE).fill(0), + const tileCounts = Array.from({ length: ARENA.GRID_SIZE }, () => + Array(ARENA.GRID_SIZE).fill(0), ); fighters.forEach((fighter) => { @@ -974,7 +1002,10 @@ function livingTeamSummaries(livingFighters) { function resolveLaunchDirection(caster, target) { if (target) { - const direction = new Phaser.Math.Vector2(target.x - caster.x, target.y - caster.y); + const direction = new Phaser.Math.Vector2( + target.x - caster.x, + target.y - caster.y, + ); if (direction.lengthSq() > 0) { return direction.normalize(); @@ -992,7 +1023,10 @@ function pointInDirection(origin, direction, distance) { } function resolveProjectileEndPoint(start, direction, travelDistance) { - const padding = Math.max(0, Number(SPECIAL_EFFECT.PROJECTILE.arenaEdgePadding) || 0); + const padding = Math.max( + 0, + Number(SPECIAL_EFFECT.PROJECTILE.arenaEdgePadding) || 0, + ); const maximumDistance = Math.max(0, Number(travelDistance) || 0); const edgeDistance = distanceToArenaEdge(start, direction, padding); const distance = Math.max(0, Math.min(maximumDistance, edgeDistance)); @@ -1041,18 +1075,17 @@ function clampPointInsideArena(point, padding) { } function createSummedAreaTable(tileCounts) { - const sums = Array.from( - { length: ARENA.GRID_SIZE + 1 }, - () => Array(ARENA.GRID_SIZE + 1).fill(0), + const sums = Array.from({ length: ARENA.GRID_SIZE + 1 }, () => + Array(ARENA.GRID_SIZE + 1).fill(0), ); for (let row = 0; row < ARENA.GRID_SIZE; row += 1) { for (let column = 0; column < ARENA.GRID_SIZE; column += 1) { sums[row + 1][column + 1] = - tileCounts[row][column] - + sums[row][column + 1] - + sums[row + 1][column] - - sums[row][column]; + tileCounts[row][column] + + sums[row][column + 1] + + sums[row + 1][column] - + sums[row][column]; } } @@ -1064,10 +1097,10 @@ function sumArea(sums, column, row, areaTiles) { const right = column + areaTiles; return ( - sums[bottom][right] - - sums[row][right] - - sums[bottom][column] - + sums[row][column] + sums[bottom][right] - + sums[row][right] - + sums[bottom][column] + + sums[row][column] ); } @@ -1078,7 +1111,11 @@ function resolveTileCount(value, maximum) { function resolveFighterSheetKey(scene, fighter, action) { ensureFighterTeamAnimation(scene, fighter.skin, action, fighter.team?.color); - const teamSheetKey = fighterSheetKey(fighter.skin, action, fighter.team?.color); + const teamSheetKey = fighterSheetKey( + fighter.skin, + action, + fighter.team?.color, + ); if (scene.textures.exists(teamSheetKey)) { return teamSheetKey; @@ -1089,12 +1126,20 @@ function resolveFighterSheetKey(scene, fighter, action) { function playFighterAnimation(scene, fighter, action) { fighter.play( - ensureFighterTeamAnimation(scene, fighter.skin, action, fighter.team?.color), + ensureFighterTeamAnimation( + scene, + fighter.skin, + action, + fighter.team?.color, + ), true, ); } -function createSpecialAnimation(scene, { frameRate, frameSequence, frames, key, repeat }) { +function createSpecialAnimation( + scene, + { frameRate, frameSequence, frames, key, repeat }, +) { const animationKey = specialAnimationKey(key); if (scene.anims.exists(animationKey)) { @@ -1103,7 +1148,11 @@ function createSpecialAnimation(scene, { frameRate, frameSequence, frames, key, scene.anims.create({ key: animationKey, - frames: resolveSpecialAnimationFrames(scene, { frameSequence, frames, key }), + frames: resolveSpecialAnimationFrames(scene, { + frameSequence, + frames, + key, + }), frameRate, repeat, }); @@ -1139,18 +1188,11 @@ function resolveInitialDelayMs() { } function isSpecialCastValid(scene, caster, matchId) { - return ( - isLiveMatch(scene, matchId) - && caster?.active - && !caster.isDead - ); + return isLiveMatch(scene, matchId) && caster?.active && !caster.isDead; } function isLivingEnemy(fighter, candidate) { - return ( - isLivingFighter(candidate) - && candidate.team?.id !== fighter.team?.id - ); + return isLivingFighter(candidate) && candidate.team?.id !== fighter.team?.id; } function isLivingFighter(fighter) { @@ -1166,5 +1208,7 @@ function randomEntry(entries) { } function isLiveMatch(scene, matchId = scene.matchId) { - return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId; + return ( + !scene.matchOver && !scene.presentationMode && scene.matchId === matchId + ); }