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