import Phaser from "phaser"; import { ATTACK_COOLDOWN, ATTACK_DAMAGE_MAX, ATTACK_DAMAGE_MIN, ATTACK_RANGE, FIGHTER_MAX_HP, FIGHTER_SCALE, KILL_HEALTH_RECOVERY_RATIO, 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"); applyKillReward(winner); onWinner(winner); } function applyKillReward(winner) { winner.killCount = (winner.killCount ?? 0) + 1; const rewardMultiplier = 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", }); } 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(); }