arena/src/game/combat.js

534 lines
14 KiB
JavaScript

import Phaser from "phaser";
import {
ARENA_SIZE,
ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN,
ATTACK_RANGE,
FIGHTER_MAX_HP,
FIGHTER_SCALE,
KILL_HEALTH_RECOVERY_RATIO,
KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER,
KILL_GROWTH_TWEEN_DURATION,
MELEE_HIT_DELAY,
MELEE_CRITICAL_CHANCE,
MOVE_SPEED,
PROJECTILE_BODY_OFFSET,
PROJECTILE_FIRE_DELAY,
PROJECTILE_HIT_PADDING,
PROJECTILE_HIT_RADIUS,
PROJECTILE_LIFETIME,
PROJECTILE_SPAWN_DISTANCE,
PROJECTILE_SPEED,
SPELL_CAST_DELAY,
SPELL_HIT_DELAY,
RANGED_CRITICAL_CHANCE,
RANGED_ATTACK_RANGE,
} from "../constants.js";
import {
getAttackSpeedMultiplier,
getMovementSpeedMultiplier,
} from "./combatSettings.js";
import {
fighterAnimationKey,
fighterAttackEffectAnimationKey,
fighterAttackEffectKey,
fighterProjectileKey,
healEffectAnimationKey,
healEffectKey,
} from "./fighterAssets.js";
export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter);
if (!enemy || fighter.isDead || enemy.isDead || fighter.isLocked) {
fighter.body.setVelocity(0, 0);
return;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, enemy.x, enemy.y);
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * 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(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
attacker.isLocked = true;
scene.observeCombat?.(attacker, defender);
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
switch (getCombatType(attacker)) {
case "projectile":
queueProjectile(scene, attacker, defender, onWinner);
return;
case "instant-spell":
queueInstantSpell(scene, attacker, defender, onWinner);
return;
default:
queueMeleeHit(scene, attacker, defender, onWinner, attack);
}
}
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
applyHit(scene, attacker, defender, onWinner, matchId, {
instantKill: attack.isCritical,
});
});
}
function queueProjectile(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnProjectile(scene, attacker, defender, onWinner, matchId);
});
}
function queueInstantSpell(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnSpellEffect(scene, attacker, defender, onWinner, matchId);
});
}
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
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,
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
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);
};
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) {
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(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
() => {
applyHit(scene, attacker, defender, onWinner, matchId);
},
);
}
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
defender.hp = instantKill
? 0
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
killFighter(defender, attacker, onWinner);
return;
}
defender.isLocked = true;
playAnimation(defender, "hurt");
if (instantKill) {
scene.cameras.main.shake(90, 0.002);
}
}
function getAttackRange(fighter) {
if (getCombatType(fighter) === "melee") {
return ATTACK_RANGE;
}
return fighter.skin.combat?.range ?? RANGED_ATTACK_RANGE;
}
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) {
if (getCombatType(fighter) !== "melee") {
return fighter.skin.combat?.criticalChance ?? RANGED_CRITICAL_CHANCE;
}
return fighter.skin.combat?.criticalChance ?? MELEE_CRITICAL_CHANCE;
}
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.healthBar.width = 0;
playAnimation(defender, "death");
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
maybeSplitFighter(defender);
onWinner(winner);
}
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(
KILL_GROWTH_MAX_MULTIPLIER,
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: 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 ?? FIGHTER_MAX_HP;
const recovery = Math.ceil(fighter.hp * 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 findNearestEnemy(fighters, fighter) {
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((candidate) => {
if (
candidate === fighter ||
candidate.isDead ||
candidate.team.id === fighter.team.id
) {
return;
}
const distance = Phaser.Math.Distance.Between(
fighter.x,
fighter.y,
candidate.x,
candidate.y,
);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestEnemy = candidate;
}
});
return nearestEnemy;
}
function playIfNeeded(fighter, action) {
const key = fighterAnimationKey(fighter.skin, action);
if (fighter.anims.currentAnim?.key !== key) {
playAnimation(fighter, action);
}
}
function playAnimation(fighter, action, timeScale = 1) {
fighter.anims.timeScale = timeScale;
fighter.play(fighterAnimationKey(fighter.skin, action), true);
}
function scaledAttackDelay(duration, fighter) {
return duration / fighterAttackSpeedMultiplier(fighter);
}
function fighterAttackSpeedMultiplier(fighter) {
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
}
function fighterMovementSpeedMultiplier(fighter) {
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
}
function trackCombatObject(scene, object) {
scene.combatObjects ??= new Set();
scene.combatObjects.add(object);
}
function disposeCombatObject(scene, object) {
if (!object?.active) {
return;
}
object.cleanup?.();
scene.combatObjects?.delete(object);
object.destroy();
}