import Phaser from "phaser"; import { ATTACK_COOLDOWN, ATTACK_RANGE, FIGHTER_SCALE, MELEE_CRITICAL_CHANCE, MOVE_SPEED, PROJECTILE_LIFETIME, PROJECTILE_SPEED, RANGED_CRITICAL_CHANCE, RANGED_ATTACK_RANGE, } from "./config.js"; import { getAttackSpeedMultiplier, getMovementSpeedMultiplier, } from "./combatSettings.js"; import { fighterAnimationKey, fighterAttackEffectAnimationKey, fighterAttackEffectKey, fighterProjectileKey, } from "./fighterAssets.js"; const MELEE_HIT_DELAY = 260; const PROJECTILE_FIRE_DELAY = 360; const PROJECTILE_HIT_RADIUS = 8; const SPELL_CAST_DELAY = 340; const SPELL_HIT_DELAY = 160; 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 * getMovementSpeedMultiplier()); 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.isLocked = true; playAnimation(attacker, attack.animation, getAttackSpeedMultiplier()); 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), () => { 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), () => { 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), () => { if (!isAttackValid(scene, attacker, defender, matchId)) { return; } spawnSpellEffect(scene, attacker, defender, onWinner, matchId); }); } function spawnProjectile(scene, attacker, defender, onWinner, matchId) { const direction = defender.x < attacker.x ? -1 : 1; const projectile = scene.physics.add.image( attacker.x + direction * 42, attacker.y + 4, fighterProjectileKey(attacker.skin), ); projectile.setDepth(3); projectile.setScale(2); projectile.body.setCircle(PROJECTILE_HIT_RADIUS, 8, 8); projectile.setRotation(Phaser.Math.Angle.Between(projectile.x, projectile.y, defender.x, defender.y)); scene.physics.moveToObject( projectile, defender, (attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(), ); 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), () => { 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(14, 24)); defender.body.setVelocity(0, 0); if (defender.hp === 0) { killFighter(defender, attacker, onWinner); return; } defender.isLocked = true; playAnimation(defender, "hurt"); 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 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_RADIUS, defender.body.y - PROJECTILE_HIT_RADIUS, defender.body.width + PROJECTILE_HIT_RADIUS * 2, defender.body.height + PROJECTILE_HIT_RADIUS * 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"); onWinner(winner); } 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) { return duration / getAttackSpeedMultiplier(); } 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(); }