853 lines
22 KiB
JavaScript
853 lines
22 KiB
JavaScript
import Phaser from "phaser";
|
|
import {
|
|
ARENA,
|
|
FIGHTER,
|
|
COMBAT,
|
|
PERFORMANCE,
|
|
PROJECTILE,
|
|
} from "../../constants.js";
|
|
import {
|
|
getAttackSpeedMultiplier,
|
|
getMovementSpeedMultiplier,
|
|
} from "./combatSettings.js";
|
|
import {
|
|
ensureFighterTeamAnimation,
|
|
fighterAttackEffectAnimationKey,
|
|
fighterAttackEffectKey,
|
|
fighterProjectileKey,
|
|
healEffectAnimationKey,
|
|
healEffectKey,
|
|
} from "../fighter/fighterAssets.js";
|
|
import { getFighterStats } from "../fighter/fighterStats.js";
|
|
|
|
const TARGET_SCAN_INTERVAL_MS = 180;
|
|
const TARGET_SCAN_JITTER_MS = 90;
|
|
|
|
export function prepareCombatFrame(scene) {
|
|
scene.combatTargetIndex = createTargetSpatialIndex(scene.fighters ?? []);
|
|
}
|
|
|
|
export function updateFighter(scene, fighter, time, onWinner) {
|
|
if (!fighter.active || fighter.isDead || fighter.isFrostStunned || fighter.isLocked) {
|
|
fighter.body?.setVelocity(0, 0);
|
|
return;
|
|
}
|
|
|
|
const enemy = resolveTargetEnemy(scene, fighter, time);
|
|
|
|
if (!enemy) {
|
|
fighter.body?.setVelocity(0, 0);
|
|
return;
|
|
}
|
|
|
|
const deltaX = fighter.x - enemy.x;
|
|
const deltaY = fighter.y - enemy.y;
|
|
const distance = deltaX * deltaX + deltaY * deltaY;
|
|
const attackRange = getAttackRange(fighter);
|
|
fighter.setFlipX(enemy.x < fighter.x);
|
|
|
|
if (distance > attackRange * attackRange) {
|
|
scene.physics.moveToObject(
|
|
fighter,
|
|
enemy,
|
|
combatStatsFor(fighter).moveSpeed * fighterMovementSpeedMultiplier(fighter),
|
|
);
|
|
playIfNeeded(fighter, "walk");
|
|
return;
|
|
}
|
|
|
|
fighter.body.setVelocity(0, 0);
|
|
|
|
if (time >= fighter.nextAttackAt) {
|
|
beginAttack(scene, fighter, enemy, time, onWinner);
|
|
return;
|
|
}
|
|
|
|
playIfNeeded(fighter, "idle");
|
|
}
|
|
|
|
export function clearCombatObjects(scene) {
|
|
scene.combatObjects?.forEach((object) => {
|
|
object.cleanup?.();
|
|
object.destroy();
|
|
});
|
|
scene.combatObjects?.clear();
|
|
}
|
|
|
|
function beginAttack(scene, attacker, defender, time, onWinner) {
|
|
const attack = createAttackProfile(attacker);
|
|
attacker.nextAttackAt =
|
|
time + scaledAttackDelay(combatStatsFor(attacker).attackCooldown, attacker);
|
|
attacker.isLocked = true;
|
|
scene.observeCombat?.(attacker, defender);
|
|
scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation);
|
|
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
|
|
|
|
switch (getCombatType(attacker)) {
|
|
case "projectile":
|
|
queueProjectile(scene, attacker, defender, onWinner, attack);
|
|
return;
|
|
case "instant-spell":
|
|
queueInstantSpell(scene, attacker, defender, onWinner, attack);
|
|
return;
|
|
default:
|
|
queueMeleeHit(scene, attacker, defender, onWinner, attack);
|
|
}
|
|
}
|
|
|
|
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
|
const matchId = scene.matchId;
|
|
|
|
scene.time.delayedCall(
|
|
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
|
() => {
|
|
applyHit(scene, attacker, defender, onWinner, matchId, {
|
|
isCritical: attack.isCritical,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function queueProjectile(scene, attacker, defender, onWinner, attack) {
|
|
const matchId = scene.matchId;
|
|
|
|
scene.time.delayedCall(
|
|
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
|
() => {
|
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
|
return;
|
|
}
|
|
|
|
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
|
|
},
|
|
);
|
|
}
|
|
|
|
function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
|
|
const matchId = scene.matchId;
|
|
|
|
scene.time.delayedCall(
|
|
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
|
() => {
|
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
|
return;
|
|
}
|
|
|
|
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
|
|
},
|
|
);
|
|
}
|
|
|
|
function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|
const defenderHitPoint = fighterHitPoint(defender);
|
|
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
|
|
const projectile = scene.physics.add.image(
|
|
projectileOrigin.x,
|
|
projectileOrigin.y,
|
|
fighterProjectileKey(attacker.skin),
|
|
);
|
|
projectile.setDepth(3);
|
|
projectile.setScale(2);
|
|
projectile.body.setCircle(
|
|
PROJECTILE.HIT_RADIUS,
|
|
PROJECTILE.BODY_OFFSET,
|
|
PROJECTILE.BODY_OFFSET,
|
|
);
|
|
projectile.setRotation(
|
|
Phaser.Math.Angle.Between(
|
|
projectile.x,
|
|
projectile.y,
|
|
defenderHitPoint.x,
|
|
defenderHitPoint.y,
|
|
),
|
|
);
|
|
scene.physics.moveTo(
|
|
projectile,
|
|
defenderHitPoint.x,
|
|
defenderHitPoint.y,
|
|
combatStatsFor(attacker).projectileSpeed *
|
|
fighterAttackSpeedMultiplier(attacker),
|
|
);
|
|
trackCombatObject(scene, projectile);
|
|
|
|
projectile.lastHitCheckX = projectile.x;
|
|
projectile.lastHitCheckY = projectile.y;
|
|
|
|
const hitDefender = () => {
|
|
if (projectile.hasHit) {
|
|
return;
|
|
}
|
|
|
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
|
disposeCombatObject(scene, projectile);
|
|
return;
|
|
}
|
|
|
|
projectile.hasHit = true;
|
|
disposeCombatObject(scene, projectile);
|
|
applyHit(scene, attacker, defender, onWinner, matchId, {
|
|
isCritical: attack.isCritical,
|
|
});
|
|
};
|
|
|
|
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
|
|
const checkProjectilePath = () => {
|
|
if (!projectile.active || projectile.hasHit) {
|
|
return;
|
|
}
|
|
|
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
|
disposeCombatObject(scene, projectile);
|
|
return;
|
|
}
|
|
|
|
if (projectilePathHitsDefender(projectile, defender)) {
|
|
hitDefender();
|
|
return;
|
|
}
|
|
|
|
projectile.lastHitCheckX = projectile.x;
|
|
projectile.lastHitCheckY = projectile.y;
|
|
};
|
|
|
|
scene.events.on(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
|
|
|
|
projectile.cleanup = () => {
|
|
overlap.destroy();
|
|
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
|
|
};
|
|
|
|
scene.time.delayedCall(PROJECTILE.LIFETIME, () => {
|
|
disposeCombatObject(scene, projectile);
|
|
});
|
|
}
|
|
|
|
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
|
|
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
|
|
effect.setDepth(3);
|
|
effect.setScale(FIGHTER.SCALE);
|
|
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
|
|
trackCombatObject(scene, effect);
|
|
|
|
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
|
|
disposeCombatObject(scene, effect);
|
|
});
|
|
|
|
scene.time.delayedCall(
|
|
scaledAttackDelay(combatStatsFor(attacker).effectHitDelay, attacker),
|
|
() => {
|
|
applyHit(scene, attacker, defender, onWinner, matchId, {
|
|
isCritical: attack.isCritical,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = false } = {}) {
|
|
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
|
return;
|
|
}
|
|
|
|
if (isCritical) {
|
|
spawnCriticalHitLabel(scene, defender);
|
|
}
|
|
|
|
const attackerStats = combatStatsFor(attacker);
|
|
defender.hp = isCritical
|
|
? 0
|
|
: Math.max(
|
|
0,
|
|
defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax),
|
|
);
|
|
defender.body.setVelocity(0, 0);
|
|
|
|
if (defender.hp === 0) {
|
|
killFighter(defender, attacker, onWinner);
|
|
return;
|
|
}
|
|
|
|
defender.isLocked = true;
|
|
playAnimation(defender, "hurt");
|
|
}
|
|
|
|
export function applyWorldEffectDamage(scene, defender, damage) {
|
|
if (scene.matchOver || !defender?.active || defender.isDead) {
|
|
return false;
|
|
}
|
|
|
|
const resolvedDamage = Math.max(0, Math.round(Number(damage) || 0));
|
|
|
|
if (resolvedDamage === 0) {
|
|
return false;
|
|
}
|
|
|
|
defender.hp = Math.max(0, defender.hp - resolvedDamage);
|
|
defender.body?.setVelocity(0, 0);
|
|
|
|
if (defender.hp === 0) {
|
|
killFighter(defender);
|
|
return true;
|
|
}
|
|
|
|
defender.isLocked = true;
|
|
playAnimation(defender, "hurt");
|
|
return false;
|
|
}
|
|
|
|
function spawnCriticalHitLabel(scene, defender) {
|
|
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
|
|
const label = scene.add
|
|
.text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", {
|
|
color: "#ffe45c",
|
|
fontFamily: "Inter, Pretendard, sans-serif",
|
|
fontSize: "24px",
|
|
fontStyle: "900",
|
|
stroke: "#7b1b11",
|
|
strokeThickness: 5,
|
|
})
|
|
.setOrigin(0.5)
|
|
.setDepth(6);
|
|
|
|
label.cleanup = () => {
|
|
scene.tweens.killTweensOf(label);
|
|
};
|
|
|
|
trackCombatObject(scene, label);
|
|
scene.tweens.add({
|
|
targets: label,
|
|
y: label.y - 32,
|
|
alpha: 0,
|
|
scaleX: 1.12,
|
|
scaleY: 1.12,
|
|
duration: 520,
|
|
ease: "Cubic.Out",
|
|
onComplete: () => disposeCombatObject(scene, label),
|
|
});
|
|
}
|
|
|
|
function getAttackRange(fighter) {
|
|
return combatStatsFor(fighter).attackRange;
|
|
}
|
|
|
|
function getCombatType(fighter) {
|
|
return fighter.skin.combat?.type ?? "melee";
|
|
}
|
|
|
|
function createAttackProfile(attacker) {
|
|
const isCritical = Math.random() < getCriticalChance(attacker);
|
|
|
|
return {
|
|
animation:
|
|
isCritical && attacker.skin.animations.attack03 ? "attack03" : "attack",
|
|
isCritical,
|
|
};
|
|
}
|
|
|
|
function getCriticalChance(fighter) {
|
|
return combatStatsFor(fighter).criticalChance;
|
|
}
|
|
|
|
function isAttackValid(scene, attacker, defender, matchId) {
|
|
return (
|
|
!scene.matchOver &&
|
|
matchId === scene.matchId &&
|
|
attacker.active &&
|
|
defender.active &&
|
|
!attacker.isDead &&
|
|
!defender.isDead
|
|
);
|
|
}
|
|
|
|
function fighterHitPoint(fighter) {
|
|
if (!fighter.body) {
|
|
return { x: fighter.x, y: fighter.y };
|
|
}
|
|
|
|
return {
|
|
x: fighter.body.center.x,
|
|
y: fighter.body.center.y,
|
|
};
|
|
}
|
|
|
|
function projectileSpawnPoint(attacker, target) {
|
|
const direction = new Phaser.Math.Vector2(target.x - attacker.x, target.y - attacker.y);
|
|
|
|
if (direction.lengthSq() === 0) {
|
|
return { x: attacker.x, y: attacker.y };
|
|
}
|
|
|
|
direction.normalize();
|
|
|
|
return {
|
|
x: attacker.x + direction.x * PROJECTILE.SPAWN_DISTANCE,
|
|
y: attacker.y + direction.y * PROJECTILE.SPAWN_DISTANCE,
|
|
};
|
|
}
|
|
|
|
function projectilePathHitsDefender(projectile, defender) {
|
|
if (!defender.body) {
|
|
return false;
|
|
}
|
|
|
|
const projectilePath = new Phaser.Geom.Line(
|
|
projectile.lastHitCheckX,
|
|
projectile.lastHitCheckY,
|
|
projectile.x,
|
|
projectile.y,
|
|
);
|
|
const defenderHitArea = new Phaser.Geom.Rectangle(
|
|
defender.body.x - PROJECTILE.HIT_PADDING,
|
|
defender.body.y - PROJECTILE.HIT_PADDING,
|
|
defender.body.width + PROJECTILE.HIT_PADDING * 2,
|
|
defender.body.height + PROJECTILE.HIT_PADDING * 2,
|
|
);
|
|
|
|
return (
|
|
Phaser.Geom.Rectangle.Contains(defenderHitArea, projectile.x, projectile.y) ||
|
|
Phaser.Geom.Intersects.LineToRectangle(projectilePath, defenderHitArea)
|
|
);
|
|
}
|
|
|
|
function killFighter(defender, winner, onWinner) {
|
|
defender.isDead = true;
|
|
defender.isLocked = true;
|
|
defender.body.setVelocity(0, 0);
|
|
defender.body.enable = false;
|
|
defender.setDepth(FIGHTER.DEAD_DEPTH);
|
|
defender.disableInteractive();
|
|
defender.releaseHud?.();
|
|
playAnimation(defender, "death");
|
|
|
|
if (winner) {
|
|
winner.isLocked = false;
|
|
winner.body.setVelocity(0, 0);
|
|
playAnimation(winner, "idle");
|
|
winner.scene.recordKill?.(winner, defender);
|
|
applyKillReward(winner);
|
|
} else {
|
|
defender.scene.recordDeath?.(defender);
|
|
}
|
|
|
|
maybeSplitFighter(defender);
|
|
onWinner?.(winner);
|
|
scheduleDeadFighterDespawn(defender);
|
|
}
|
|
|
|
function scheduleDeadFighterDespawn(fighter) {
|
|
const scene = fighter.scene;
|
|
const delay = resolveDeadDespawnDelay(scene);
|
|
const matchId = scene.matchId;
|
|
|
|
fighter.deadDespawnTimer?.remove(false);
|
|
fighter.deadDespawnTween?.remove();
|
|
fighter.deadDespawnTween = null;
|
|
|
|
const despawn = () => {
|
|
const despawnTween = fighter.deadDespawnTween;
|
|
|
|
fighter.deadDespawnTimer = null;
|
|
fighter.deadDespawnTween = null;
|
|
despawnTween?.remove();
|
|
|
|
if (!fighter.active || !fighter.isDead || scene.matchId !== matchId) {
|
|
return;
|
|
}
|
|
|
|
if (scene.selectedFighter === fighter) {
|
|
scene.clearSelectedFighter?.();
|
|
}
|
|
|
|
removeFighterFromBattlefield(scene, fighter);
|
|
fighter.destroy();
|
|
};
|
|
|
|
if (delay === 0) {
|
|
despawn();
|
|
return;
|
|
}
|
|
|
|
fighter.deadDespawnTween = scene.tweens.add({
|
|
targets: fighter,
|
|
alpha: FIGHTER.DEAD_DESPAWN_ALPHA,
|
|
duration: delay,
|
|
ease: "Sine.easeIn",
|
|
});
|
|
|
|
const timer = scene.time.delayedCall(delay, despawn);
|
|
fighter.deadDespawnTimer = timer;
|
|
fighter.once("destroy", () => {
|
|
if (fighter.deadDespawnTimer === timer) {
|
|
fighter.deadDespawnTimer = null;
|
|
}
|
|
|
|
timer.remove(false);
|
|
fighter.deadDespawnTween?.remove();
|
|
fighter.deadDespawnTween = null;
|
|
});
|
|
}
|
|
|
|
function removeFighterFromBattlefield(scene, fighter) {
|
|
if (!Array.isArray(scene.fighters)) {
|
|
return;
|
|
}
|
|
|
|
scene.fighters = scene.fighters.filter((candidate) => candidate !== fighter);
|
|
}
|
|
|
|
function maybeSplitFighter(fighter) {
|
|
const splitOnDeath = fighter.canSplitOnDeath === false
|
|
? null
|
|
: fighter.skin.traits?.splitOnDeath;
|
|
|
|
if (!splitOnDeath || typeof fighter.scene.spawnSplitFighters !== "function") {
|
|
return;
|
|
}
|
|
|
|
const chance = Phaser.Math.Clamp(Number(splitOnDeath.chance ?? 1), 0, 1);
|
|
|
|
if (Math.random() >= chance) {
|
|
return;
|
|
}
|
|
|
|
fighter.scene.spawnSplitFighters(fighter, splitOnDeath);
|
|
}
|
|
|
|
function applyKillReward(winner) {
|
|
winner.killCount = (winner.killCount ?? 0) + 1;
|
|
|
|
const rewardMultiplier = Math.min(
|
|
COMBAT.KILL_GROWTH_MAX_MULTIPLIER,
|
|
COMBAT.KILL_GROWTH_MULTIPLIER ** winner.killCount,
|
|
);
|
|
const previousHp = winner.hp;
|
|
const nextHp = recoveredHealth(winner);
|
|
winner.killRewardMultiplier = rewardMultiplier;
|
|
winner.hp = nextHp;
|
|
|
|
const nextScaleX = (winner.baseScaleX ?? FIGHTER.SCALE) * rewardMultiplier;
|
|
const nextScaleY = (winner.baseScaleY ?? FIGHTER.SCALE) * rewardMultiplier;
|
|
|
|
if (nextHp > previousHp) {
|
|
spawnKillHealEffect(winner, Math.max(Math.abs(nextScaleX), Math.abs(nextScaleY)));
|
|
}
|
|
|
|
winner.scene.tweens.add({
|
|
targets: winner,
|
|
scaleX: nextScaleX,
|
|
scaleY: nextScaleY,
|
|
duration: COMBAT.KILL_GROWTH_TWEEN_DURATION,
|
|
ease: "Back.Out",
|
|
onUpdate: () => clampFighterInsideArena(winner),
|
|
onComplete: () => clampFighterInsideArena(winner),
|
|
});
|
|
}
|
|
|
|
function clampFighterInsideArena(fighter) {
|
|
if (!fighter?.active || !fighter.body) {
|
|
return;
|
|
}
|
|
|
|
const halfWidth = Math.min(
|
|
ARENA.SIZE / 2,
|
|
Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
|
|
);
|
|
const halfHeight = Math.min(
|
|
ARENA.SIZE / 2,
|
|
Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
|
|
);
|
|
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA.SIZE - halfWidth);
|
|
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA.SIZE - halfHeight);
|
|
|
|
fighter.setPosition(x, y);
|
|
fighter.body.updateFromGameObject?.();
|
|
}
|
|
|
|
function recoveredHealth(fighter) {
|
|
const maxHp = fighter.maxHp ?? combatStatsFor(fighter).maxHp;
|
|
const recovery = Math.ceil(fighter.hp * COMBAT.KILL_HEALTH_RECOVERY_RATIO);
|
|
|
|
return Math.min(maxHp, fighter.hp + recovery);
|
|
}
|
|
|
|
function spawnKillHealEffect(fighter, effectScale) {
|
|
const scene = fighter.scene;
|
|
const effect = scene.add
|
|
.sprite(fighter.x, fighter.y, healEffectKey())
|
|
.setDepth(fighter.depth + 1)
|
|
.setScale(effectScale);
|
|
|
|
const syncEffectPosition = () => {
|
|
if (!effect.active || !fighter.active) {
|
|
return;
|
|
}
|
|
|
|
effect.setPosition(fighter.x, fighter.y);
|
|
effect.setDepth(fighter.depth + 1);
|
|
};
|
|
|
|
effect.cleanup = () => {
|
|
scene.events.off(Phaser.Scenes.Events.UPDATE, syncEffectPosition);
|
|
};
|
|
|
|
scene.events.on(Phaser.Scenes.Events.UPDATE, syncEffectPosition);
|
|
effect.play(healEffectAnimationKey());
|
|
trackCombatObject(scene, effect);
|
|
|
|
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
|
|
disposeCombatObject(scene, effect);
|
|
});
|
|
}
|
|
|
|
function resolveDeadDespawnDelay(scene) {
|
|
const fighterCount = scene.fighters?.length ?? 0;
|
|
const isLargeBattle =
|
|
fighterCount >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD);
|
|
const delay = isLargeBattle
|
|
? PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS
|
|
: FIGHTER.DEAD_DESPAWN_DELAY_MS;
|
|
|
|
return Math.max(0, Number(delay) || 0);
|
|
}
|
|
|
|
function createTargetSpatialIndex(fighters) {
|
|
const cellSize = Math.max(1, Number(PERFORMANCE.TARGET_GRID_CELL_SIZE) || ARENA.TILE_SIZE);
|
|
const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize);
|
|
const maxCellY = Math.floor((ARENA.SIZE - 1) / cellSize);
|
|
const cells = new Map();
|
|
const livingFighters = [];
|
|
|
|
fighters.forEach((fighter) => {
|
|
if (!fighter?.active || fighter.isDead) {
|
|
return;
|
|
}
|
|
|
|
const cellX = clampCell(fighter.x, cellSize, maxCellX);
|
|
const cellY = clampCell(fighter.y, cellSize, maxCellY);
|
|
const key = targetCellKey(cellX, cellY);
|
|
const cell = cells.get(key) ?? [];
|
|
|
|
cell.push(fighter);
|
|
cells.set(key, cell);
|
|
livingFighters.push(fighter);
|
|
});
|
|
|
|
return {
|
|
cellSize,
|
|
cells,
|
|
livingCount: livingFighters.length,
|
|
livingFighters,
|
|
maxCellX,
|
|
maxCellY,
|
|
maxSearchRing: Math.max(maxCellX, maxCellY) + 1,
|
|
};
|
|
}
|
|
|
|
function findNearestEnemy(scene, fighter) {
|
|
const targetIndex = scene.combatTargetIndex;
|
|
|
|
if (!targetIndex) {
|
|
return findNearestEnemyByFullScan(scene.fighters ?? [], fighter);
|
|
}
|
|
|
|
return findNearestEnemyBySpatialIndex(targetIndex, fighter);
|
|
}
|
|
|
|
function findNearestEnemyByFullScan(fighters, fighter) {
|
|
let nearestEnemy;
|
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
fighters.forEach((candidate) => {
|
|
if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = fighter.x - candidate.x;
|
|
const deltaY = fighter.y - candidate.y;
|
|
const distance = deltaX * deltaX + deltaY * deltaY;
|
|
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestEnemy = candidate;
|
|
}
|
|
});
|
|
|
|
return nearestEnemy;
|
|
}
|
|
|
|
function findNearestEnemyBySpatialIndex(targetIndex, fighter) {
|
|
const cellX = clampCell(fighter.x, targetIndex.cellSize, targetIndex.maxCellX);
|
|
const cellY = clampCell(fighter.y, targetIndex.cellSize, targetIndex.maxCellY);
|
|
let nearestEnemy;
|
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
for (let ring = 0; ring <= targetIndex.maxSearchRing; ring += 1) {
|
|
forEachTargetCellInRing(targetIndex, cellX, cellY, ring, (candidate) => {
|
|
if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = fighter.x - candidate.x;
|
|
const deltaY = fighter.y - candidate.y;
|
|
const distance = deltaX * deltaX + deltaY * deltaY;
|
|
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestEnemy = candidate;
|
|
}
|
|
});
|
|
|
|
if (
|
|
nearestEnemy
|
|
&& ring > 0
|
|
&& nearestDistance <= (ring * targetIndex.cellSize) ** 2
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return nearestEnemy;
|
|
}
|
|
|
|
function forEachTargetCellInRing(targetIndex, cellX, cellY, ring, callback) {
|
|
if (ring === 0) {
|
|
forEachTargetCell(targetIndex, cellX, cellY, callback);
|
|
return;
|
|
}
|
|
|
|
for (let x = cellX - ring; x <= cellX + ring; x += 1) {
|
|
forEachTargetCell(targetIndex, x, cellY - ring, callback);
|
|
forEachTargetCell(targetIndex, x, cellY + ring, callback);
|
|
}
|
|
|
|
for (let y = cellY - ring + 1; y <= cellY + ring - 1; y += 1) {
|
|
forEachTargetCell(targetIndex, cellX - ring, y, callback);
|
|
forEachTargetCell(targetIndex, cellX + ring, y, callback);
|
|
}
|
|
}
|
|
|
|
function forEachTargetCell(targetIndex, cellX, cellY, callback) {
|
|
if (
|
|
cellX < 0
|
|
|| cellY < 0
|
|
|| cellX > targetIndex.maxCellX
|
|
|| cellY > targetIndex.maxCellY
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const cell = targetIndex.cells.get(targetCellKey(cellX, cellY));
|
|
|
|
if (!cell) {
|
|
return;
|
|
}
|
|
|
|
cell.forEach(callback);
|
|
}
|
|
|
|
function targetCellKey(cellX, cellY) {
|
|
return `${cellX}:${cellY}`;
|
|
}
|
|
|
|
function clampCell(value, cellSize, maxCell) {
|
|
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
|
|
}
|
|
|
|
function resolveTargetEnemy(scene, fighter, time) {
|
|
const now = Number.isFinite(time) ? time : scene.time?.now ?? 0;
|
|
|
|
if (
|
|
isValidEnemyTarget(fighter, fighter.targetEnemy)
|
|
&& now < (fighter.nextTargetScanAt ?? 0)
|
|
) {
|
|
return fighter.targetEnemy;
|
|
}
|
|
|
|
const enemy = findNearestEnemy(scene, fighter) ?? null;
|
|
fighter.targetEnemy = enemy;
|
|
scheduleNextTargetScan(fighter, now);
|
|
|
|
return enemy;
|
|
}
|
|
|
|
function scheduleNextTargetScan(fighter, now) {
|
|
fighter.nextTargetScanAt =
|
|
now
|
|
+ TARGET_SCAN_INTERVAL_MS
|
|
+ Phaser.Math.Between(0, TARGET_SCAN_JITTER_MS);
|
|
}
|
|
|
|
function isValidEnemyTarget(fighter, candidate) {
|
|
return Boolean(
|
|
candidate?.active
|
|
&& !candidate.isDead
|
|
&& candidate.team?.id !== fighter.team?.id,
|
|
);
|
|
}
|
|
|
|
function playIfNeeded(fighter, action) {
|
|
const key = resolveFighterAnimationKey(fighter, action);
|
|
|
|
if (fighter.anims.currentAnim?.key !== key) {
|
|
playResolvedAnimation(fighter, key);
|
|
}
|
|
}
|
|
|
|
function playAnimation(fighter, action, timeScale = 1) {
|
|
playResolvedAnimation(fighter, resolveFighterAnimationKey(fighter, action), timeScale);
|
|
}
|
|
|
|
function playResolvedAnimation(fighter, key, timeScale = 1) {
|
|
fighter.anims.timeScale = timeScale;
|
|
fighter.play(key, true);
|
|
}
|
|
|
|
function resolveFighterAnimationKey(fighter, action) {
|
|
const key = ensureFighterTeamAnimation(
|
|
fighter.scene,
|
|
fighter.skin,
|
|
action,
|
|
fighter.team?.color,
|
|
);
|
|
|
|
return key;
|
|
}
|
|
|
|
function scaledAttackDelay(duration, fighter) {
|
|
return duration / fighterAttackSpeedMultiplier(fighter);
|
|
}
|
|
|
|
function fighterAttackSpeedMultiplier(fighter) {
|
|
return (
|
|
getAttackSpeedMultiplier()
|
|
* (fighter.killRewardMultiplier ?? 1)
|
|
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
|
);
|
|
}
|
|
|
|
function fighterMovementSpeedMultiplier(fighter) {
|
|
return (
|
|
getMovementSpeedMultiplier()
|
|
* (fighter.killRewardMultiplier ?? 1)
|
|
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
|
);
|
|
}
|
|
|
|
function combatStatsFor(fighter) {
|
|
return fighter.combatStats ?? getFighterStats(fighter.skin);
|
|
}
|
|
|
|
export function trackCombatObject(scene, object) {
|
|
scene.combatObjects ??= new Set();
|
|
scene.combatObjects.add(object);
|
|
}
|
|
|
|
export function disposeCombatObject(scene, object) {
|
|
if (!object?.active) {
|
|
return;
|
|
}
|
|
|
|
object.cleanup?.();
|
|
scene.combatObjects?.delete(object);
|
|
object.destroy();
|
|
}
|