package tc.oc.pgm.tracker.trackers; import java.util.HashMap; import java.util.Map; import java.util.logging.Logger; import javax.annotation.Nullable; import javax.inject.Inject; import org.bukkit.Location; import org.bukkit.entity.Entity; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerOnGroundEvent; import tc.oc.commons.bukkit.util.Materials; import tc.oc.commons.core.logging.Loggers; import tc.oc.pgm.events.ListenerScope; import tc.oc.pgm.match.Match; import tc.oc.pgm.match.MatchPlayer; import tc.oc.pgm.match.MatchScope; import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; import tc.oc.pgm.time.TickTime; import tc.oc.pgm.tracker.EventResolver; import tc.oc.pgm.tracker.damage.DamageInfo; import tc.oc.pgm.tracker.damage.FallInfo; import tc.oc.pgm.tracker.damage.FallState; import tc.oc.pgm.tracker.damage.GenericFallInfo; import tc.oc.pgm.tracker.damage.PhysicalInfo; import tc.oc.pgm.tracker.event.PlayerSpleefEvent; import tc.oc.pgm.tracker.resolvers.DamageResolver; /** * Tracks the state of falls caused by other players and resolves the damage caused by them. */ @ListenerScope(MatchScope.RUNNING) public class FallTracker implements Listener, DamageResolver { private final Map<MatchPlayer, FallState> falls = new HashMap<>(); private final EventResolver eventResolver; private final Match match; private final Logger logger; @Inject FallTracker(Loggers loggers, Match match, EventResolver eventResolver) { this.logger = loggers.get(getClass()); this.eventResolver = eventResolver; this.match = match; } @Override public @Nullable FallInfo resolveDamage(EntityDamageEvent.DamageCause damageType, Entity victim, @Nullable PhysicalInfo damager) { FallState fall = getFall(victim); if(fall != null) { switch(damageType) { case VOID: fall.to = FallInfo.To.VOID; break; case FALL: fall.to = FallInfo.To.GROUND; break; case LAVA: fall.to = FallInfo.To.LAVA; break; case FIRE_TICK: if(fall.isInLava) { fall.to = FallInfo.To.LAVA; } else { return null; } break; default: return null; } return fall; } else { switch(damageType) { case FALL: return new GenericFallInfo(FallInfo.To.GROUND, victim.getLocation(), victim.getFallDistance()); case VOID: return new GenericFallInfo(FallInfo.To.VOID, victim.getLocation(), victim.getFallDistance()); } return null; } } @Nullable FallState getFall(Entity victim) { MatchPlayer player = match.getPlayer(victim); if(player == null) return null; FallState fall = falls.get(player); if(fall == null || !fall.isStarted || fall.isEnded) return null; return fall; } void endFall(FallState fall) { endFall(fall.victim); } void endFall(MatchPlayer victim) { FallState fall = this.falls.remove(victim); if(fall != null) { fall.isEnded = true; logger.fine("Ended " + fall); } } void checkFallTimeout(final FallState fall) { TickTime now = match.getClock().now(); if((fall.isStarted && fall.isEndedSafely(now)) || (!fall.isStarted && fall.isExpired(now))) { endFall(fall); } } void scheduleCheckFallTimeout(final FallState fall, final long delay) { match.getScheduler(MatchScope.RUNNING).createDelayedTask(delay + 1, () -> { if(!fall.isEnded) { checkFallTimeout(fall); } }); } /** * Called whenever the player becomes "unsupported" to check if they were attacked recently enough for the attack * to be responsible for the fall */ private void playerBecameUnsupported(FallState fall) { if(!fall.isStarted && !fall.isSupported() && match.getClock().now().tick - fall.startTime.tick <= FallState.MAX_KNOCKBACK_TICKS) { fall.isStarted = true; logger.fine("Started " + fall); } } /** * Called when a player is damaged in a way that could initiate a Fall, * i.e. damage from another entity that causes knockback */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAttack(final EntityDamageEvent event) { // Filter out damage types that don't cause knockback switch(event.getCause()) { case ENTITY_ATTACK: case PROJECTILE: case BLOCK_EXPLOSION: case ENTITY_EXPLOSION: case MAGIC: case CUSTOM: break; default: return; } MatchPlayer victim = match.getParticipant(event.getEntity()); if(victim == null) return; if(this.falls.containsKey(victim)) { // A new fall can't be initiated if the victim is already falling return; } Location loc = victim.getBukkit().getLocation(); boolean isInLava = Materials.isLava(loc); boolean isClimbing = Materials.isClimbable(loc); boolean isSwimming = Materials.isWater(loc); DamageInfo cause = eventResolver.resolveDamage(event); // Note the victim's situation when the attack happened FallInfo.From from; if(isClimbing) { from = FallInfo.From.LADDER; } else if(isSwimming) { from = FallInfo.From.WATER; } else { from = FallInfo.From.GROUND; } FallState fall = new FallState(victim, from, cause); this.falls.put(victim, fall); fall.isClimbing = isClimbing; fall.isSwimming = isSwimming; fall.isInLava = isInLava; // If the victim is already in the air, immediately confirm that they are falling. // Otherwise, the fall will be confirmed when they leave the ground, if it happens // within the time window. fall.isStarted = !fall.isSupported(); if(!fall.isStarted) { this.scheduleCheckFallTimeout(fall, FallState.MAX_KNOCKBACK_TICKS); } logger.fine("Attacked " + fall); } /** * Called when a player moves in a way that could affect their fall i.e. landing on a ladder or in liquid */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerMove(final PlayerMoveEvent event) { MatchPlayer player = match.getParticipant(event.getPlayer()); if(player == null) return; FallState fall = this.falls.get(player); if(fall != null) { boolean isClimbing = Materials.isClimbable(event.getTo()); boolean isSwimming = Materials.isWater(event.getTo()); boolean isInLava = Materials.isLava(event.getTo()); boolean becameUnsupported = false; TickTime now = match.getClock().now(); if(isClimbing != fall.isClimbing) { if((fall.isClimbing = isClimbing)) { // Player moved onto a ladder, cancel the fall if they are still on it after MAX_CLIMBING_TIME fall.climbingTick = now.tick; this.scheduleCheckFallTimeout(fall, FallState.MAX_CLIMBING_TICKS + 1); } else { becameUnsupported = true; } } if(isSwimming != fall.isSwimming) { if((fall.isSwimming = isSwimming)) { // Player moved into water, cancel the fall if they are still in it after MAX_SWIMMING_TIME fall.swimmingTick = now.tick; this.scheduleCheckFallTimeout(fall, FallState.MAX_SWIMMING_TICKS + 1); } else { becameUnsupported = true; } } if(becameUnsupported) { // Player moved out of water or off a ladder, check if it was caused by the attack this.playerBecameUnsupported(fall); } if(isInLava != fall.isInLava) { if((fall.isInLava = isInLava)) { fall.inLavaTick = now.tick; } else { // Because players continue to "fall" as long as they are in lava, moving out of lava // can immediately end their fall this.checkFallTimeout(fall); } } } } /** * Called when the player touches or leaves the ground */ @EventHandler(priority = EventPriority.MONITOR) public void onPlayerOnGroundChanged(final PlayerOnGroundEvent event) { MatchPlayer player = match.getParticipant(event.getPlayer()); if(player == null) return; FallState fall = this.falls.get(player); if(fall != null) { if(event.getOnGround()) { // Falling player landed on the ground, cancel the fall if they are still there after MAX_ON_GROUND_TIME fall.onGroundTick = match.getClock().now().tick; fall.groundTouchCount++; this.scheduleCheckFallTimeout(fall, FallState.MAX_ON_GROUND_TICKS + 1); } else { // Falling player left the ground, check if it was caused by the attack this.playerBecameUnsupported(fall); } } } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerSpleef(final PlayerSpleefEvent event) { MatchPlayer victim = event.getVictim(); FallState fall = this.falls.get(victim); if(fall == null || !fall.isStarted) { if(fall != null) { // End the existing fall and replace it with the spleef endFall(fall); } fall = new FallState(victim, FallInfo.From.GROUND, event.getSpleefInfo()); fall.isStarted = true; Location loc = victim.getBukkit().getLocation(); fall.isClimbing = Materials.isClimbable(loc); fall.isSwimming = Materials.isWater(loc); fall.isInLava = Materials.isLava(loc); this.falls.put(victim, fall); logger.fine("Spleefed " + fall); } } // NOTE: This must be called after anything that tries to resolve the death @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerDeath(final PlayerDeathEvent event) { MatchPlayer player = match.getParticipant(event.getEntity()); if(player != null) endFall(player); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerDespawn(final ParticipantDespawnEvent event) { endFall(event.getPlayer()); } }