package tc.oc.pgm.stamina; import java.util.ArrayDeque; import java.util.Deque; import java.util.Map; import javax.annotation.Nullable; import com.google.common.base.Predicate; import com.google.common.base.Strings; import com.google.common.collect.ImmutableRangeMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Range; import com.google.common.collect.RangeMap; import com.google.common.collect.Sets; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.chat.BaseComponent; import net.md_5.bungee.api.chat.TranslatableComponent; import org.bukkit.enchantments.Enchantment; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockDamageEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.event.player.PlayerAnimationEvent; import org.bukkit.event.player.PlayerAnimationType; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import tc.oc.commons.bukkit.item.ItemUtils; import tc.oc.commons.core.chat.Component; import tc.oc.commons.core.util.Numbers; import tc.oc.commons.core.util.DefaultMapAdapter; import tc.oc.pgm.match.MatchPlayer; import tc.oc.commons.bukkit.event.BlockPunchEvent; import tc.oc.pgm.stamina.mutators.StaminaMutator; import tc.oc.pgm.stamina.symptoms.PotionSymptom; import tc.oc.pgm.stamina.symptoms.StaminaSymptom; public class PlayerStaminaState { final StaminaOptions options; final MatchPlayer player; double stamina; // player's stamina (0..1) boolean moving; // moved during current tick boolean onGround = true; // was on the ground for last movement static final long SWING_TICK_WINDOW = 2; boolean swinging; // swung weapon within the last SWING_TICK_WINDOW long lastSwingTick; // world tick time of last weapon swing static final long SPRINT_TICK_WINDOW = 20; long lastSprintTick; // Map of total amount of stamina consumed by each mutator (see explanation in #mutateStamina) final Map<StaminaMutator, Double> depletionCauses = new DefaultMapAdapter<>(0d); // last potion effect levels applied to the player from stamina symptoms (zero-based) Map<PotionEffectType, Integer> lastPotionLevels = new DefaultMapAdapter<>(-1); PlayerStaminaState(StaminaOptions options, MatchPlayer player) { this.options = options; this.player = player; mutateStamina(null, 1d); } Iterable<StaminaSymptom> getActiveSymptoms() { return Iterables.filter(options.symptoms, new Predicate<StaminaSymptom>() { @Override public boolean apply(StaminaSymptom symptom) { return symptom.range.contains(stamina); } }); } void mutateStamina(@Nullable StaminaMutator mutator, double newStamina) { newStamina = Numbers.clamp(newStamina, 0, 1); if(stamina == newStamina) return; double oldStamina = stamina; stamina = newStamina; if(newStamina < oldStamina) { if(mutator != null) { // If stamina went down, add the loss to the total for the causing mutator. depletionCauses.put(mutator, depletionCauses.get(mutator) + oldStamina - newStamina); } } else { // If stamina went up, reduce all depletion causes proportionally. for(StaminaMutator cause : ImmutableSet.copyOf(depletionCauses.keySet())) { depletionCauses.put(cause, depletionCauses.get(cause) / (1 - oldStamina) * (1 - newStamina)); } } applySymptoms(oldStamina); refreshPotions(); refreshMeter(); } void mutateStamina(StaminaMutator mutator) { mutateStamina(mutator, mutator.getNumericModifier().credit(stamina)); } void mutateStaminaTick(StaminaMutator mutator) { mutateStamina(mutator, mutator.getNumericModifier().credit(stamina, 1d / 20d)); } void applySymptoms(double oldStamina) { for(StaminaSymptom symptom : options.symptoms) { if(!symptom.range.contains(oldStamina) && symptom.range.contains(stamina)) { symptom.apply(player); } else if(symptom.range.contains(oldStamina) && !symptom.range.contains(stamina)) { symptom.remove(player); } } } // Number of bars private static final int METER_SCALE = 88; private static final RangeMap<Double, ChatColor> METER_COLORS = ImmutableRangeMap.<Double, ChatColor>builder() .put(Range.closedOpen(0.2, 0.3), ChatColor.BLUE) .put(Range.closedOpen(0.3, 0.5), ChatColor.DARK_AQUA) .put(Range.closedOpen(0.5, 0.7), ChatColor.AQUA) .put(Range.closedOpen(0.7, 1.0), ChatColor.WHITE) .put(Range.singleton(1.0), ChatColor.YELLOW) .build(); private static final BaseComponent SPACE = new Component(" "); void refreshMeter() { BaseComponent label; ChatColor color = METER_COLORS.get(stamina); if(color != null) { int segments = METER_COLORS.asMapOfRanges().size(); Deque<BaseComponent> parts = new ArrayDeque<>(segments * 2 + 3); parts.add(SPACE); parts.add(new TranslatableComponent("stamina.label")); parts.add(SPACE); for(Map.Entry<Range<Double>, ChatColor> entry : METER_COLORS.asMapOfRanges().entrySet()) { int length = (int) (METER_SCALE * (Numbers.clamp(stamina, entry.getKey()) - entry.getKey().lowerEndpoint())); Component segment = new Component(Strings.repeat("\u23d0", length), entry.getValue()); parts.addFirst(segment); parts.addLast(segment); } label = new Component(color).extra(parts); } else { StaminaMutator cause = getDepletionCause(); if(cause != null) { label = new Component(new TranslatableComponent("stamina.depletedFromMutator", new Component(cause.getDescription(), ChatColor.AQUA)), ChatColor.RED); } else { label = new Component(new TranslatableComponent("stamina.depleted"), ChatColor.RED); } } player.sendHotbarMessage(label); } @Nullable StaminaMutator getDepletionCause() { StaminaMutator cause = null; double max = 0; for(Map.Entry<StaminaMutator, Double> entry : depletionCauses.entrySet()) { if(entry.getValue() > max) { max = entry.getValue(); cause = entry.getKey(); } } return cause; } void refreshPotions() { // Get the maximum level of every potion type caused by current symptoms. // It would be nice if PotionSymptom could do this itself, but there doesn't // seem to be an easy way. Map<PotionEffectType, Integer> newPotionLevels = new DefaultMapAdapter<>(-1); for(StaminaSymptom symptom : getActiveSymptoms()) { if(symptom instanceof PotionSymptom) { PotionSymptom potionSymptom = (PotionSymptom) symptom; int maxLevel = newPotionLevels.get(potionSymptom.effect); if(maxLevel < potionSymptom.amplifier) { newPotionLevels.put(potionSymptom.effect, potionSymptom.amplifier); } } } // Sync the client's effects for(PotionEffectType effect : ImmutableSet.copyOf(Sets.union(lastPotionLevels.keySet(), newPotionLevels.keySet()))) { int newLevel = newPotionLevels.get(effect); if(newLevel != lastPotionLevels.get(effect)) { if(newLevel > -1) { lastPotionLevels.put(effect, newLevel); player.getBukkit().addPotionEffect(new PotionEffect(effect, Integer.MAX_VALUE, newLevel, true), true); } else { // We remove an effect by setting its time to 2 seconds. This provides a bit of // hysteresis, and also avoids a bug that causes a nether portal to appear when // a nausea potion is removed suddenly. int oldLevel = lastPotionLevels.remove(effect); player.getBukkit().addPotionEffect(new PotionEffect(effect, 40, oldLevel, true), true); } } } } void tick() { long now = player.getMatch().getClock().now().tick; if(player.getBukkit().isSprinting()) { lastSprintTick = now; } // To detect movement, we just set the moving flag on every move event, // and check and clear it here every tick. if(moving) { moving = false; if(lastSprintTick + SPRINT_TICK_WINDOW > now) { mutateStaminaTick(options.mutators.run); } else if(player.getBukkit().isSneaking()) { mutateStaminaTick(options.mutators.sneak); } else { mutateStaminaTick(options.mutators.walk); } } else { mutateStaminaTick(options.mutators.stand); } // If a swing was not explained by some other event within the time window, // assume it was a swing at the air. if(swinging && lastSwingTick + SWING_TICK_WINDOW <= now) { swinging = false; mutateStamina(options.mutators.meleeMiss); } refreshMeter(); } void onEvent(PlayerMoveEvent event) { moving = true; if(onGround && !player.getBukkit().isOnGround() && event.getFrom().getY() < event.getTo().getY()) { if(player.getBukkit().isSprinting()) { mutateStamina(options.mutators.runJump); } else { mutateStamina(options.mutators.jump); } } onGround = event.getPlayer().isOnGround(); } boolean isHoldingWeapon() { ItemStack holding = player.getBukkit().getItemInHand(); return holding != null && (ItemUtils.isWeapon(holding) || holding.getEnchantmentLevel(Enchantment.DAMAGE_ALL) > 0); } void onEvent(PlayerAnimationEvent event) { if(event.getAnimationType() != PlayerAnimationType.ARM_SWING) return; if(event.getPlayer().isDigging()) return; if(!isHoldingWeapon()) return; swinging = true; lastSwingTick = player.getMatch().getClock().now().tick; } void onEvent(BlockPunchEvent event) { swinging = false; } void onEvent(BlockDamageEvent event) { swinging = false; } void onEvent(BlockBreakEvent event) { swinging = false; } void onEvent(EntityDamageEvent event) { if(event.getEntity() == player.getBukkit()) { // Player took damage mutateStamina(options.mutators.injury); } else if(event.getCause() == EntityDamageEvent.DamageCause.ENTITY_ATTACK && event instanceof EntityDamageByEntityEvent && ((EntityDamageByEntityEvent) event).getDamager() == player.getBukkit()) { // Player is damager and attack is melee swinging = false; for(StaminaSymptom symptom : getActiveSymptoms()) { symptom.onAttack(event); } mutateStamina(options.mutators.meleeHit); } } void onEvent(ProjectileLaunchEvent event) { for(StaminaSymptom symptom : getActiveSymptoms()) { symptom.onShoot(event); } mutateStamina(options.mutators.archery); } }