feat: implement fighter separation logic and optimize special effect casting

This commit is contained in:
Horoli 2026-05-29 17:44:24 +09:00
parent 23376e8cbb
commit 3b1a883787
3 changed files with 223 additions and 106 deletions

View File

@ -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 도메인

View File

@ -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?.();

View File

@ -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
);
}