diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java index d75ff9b..df2d074 100644 --- a/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/GameList.java @@ -17,6 +17,7 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.fastbridge.Fastbr import eu.mhsl.minenet.minigames.instance.game.stateless.types.highGround.HighGroundFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.types.jumpDive.JumpDiveFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.types.minerun.MinerunFactory; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArenaFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.types.pillars.PillarsFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.types.playerRace.PlayerRaceFactory; import eu.mhsl.minenet.minigames.instance.game.stateless.types.spaceSnake.SpaceSnakeFactory; @@ -56,7 +57,8 @@ public enum GameList { BLOCKBATTLE(new BlockBattleFactory(), GameType.PVP), COLORJUMP(new ColorJumpFactory(), GameType.JUMPNRUN), PLAYERRACE(new PlayerRaceFactory(), GameType.JUMPNRUN), - BUILDBATTLE(new BuildBattleFactory(), GameType.PROTOTYPE); + BUILDBATTLE(new BuildBattleFactory(), GameType.PROTOTYPE), + MOBARENA(new MobArenaFactory(), GameType.PVE); private final GameFactory factory; private final GameType type; diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/Knockback.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/Knockback.java new file mode 100644 index 0000000..ee6ab92 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/Knockback.java @@ -0,0 +1,18 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; + +/** + * Knockback in viewing direction of the attacker + */ +public record Knockback(double x, double z) { + public static Knockback of(Entity attacker) { + double yaw = Math.toRadians(attacker.getPosition().yaw()); + return new Knockback(Math.sin(yaw), -Math.cos(yaw)); + } + + public void apply(LivingEntity victim, float strength) { + victim.takeKnockback(strength, this.x, this.z); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArena.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArena.java new file mode 100644 index 0000000..0450e14 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArena.java @@ -0,0 +1,342 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena; + +import eu.mhsl.minenet.minigames.instance.Dimension; +import eu.mhsl.minenet.minigames.instance.game.stateless.StatelessGame; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.MobType; +import eu.mhsl.minenet.minigames.message.type.ActionBarMessage; +import eu.mhsl.minenet.minigames.message.type.TitleMessage; +import eu.mhsl.minenet.minigames.score.PointsWinScore; +import eu.mhsl.minenet.minigames.util.BatchUtil; +import eu.mhsl.minenet.minigames.util.NumberUtil; +import eu.mhsl.minenet.minigames.world.BlockPallet; +import eu.mhsl.minenet.minigames.world.generator.terrain.CircularPlateTerrainGenerator; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.ItemEntity; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.attribute.AttributeModifier; +import net.minestom.server.entity.attribute.AttributeOperation; +import net.minestom.server.entity.damage.Damage; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.entity.EntityDamageEvent; +import net.minestom.server.event.entity.EntityDeathEvent; +import net.minestom.server.event.item.PickupExperienceEvent; +import net.minestom.server.event.item.PickupItemEvent; +import net.minestom.server.instance.batch.AbsoluteBlockBatch; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.network.packet.server.play.ParticlePacket; +import net.minestom.server.particle.Particle; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.TaskSchedule; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.Collection; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CompletableFuture; + +public class MobArena extends StatelessGame { + private static final int MAX_MOBS = 150; + private static final int SPAWN_INTERVAL_SECONDS = 2; + private static final float SWORD_DAMAGE = 6; + private static final double SWEEP_RANGE = 3; + private static final long ATTACK_COOLDOWN_MILLIS = 500; + private static final int WALL_HEIGHT = 3; + private static final Key SPEED_MODIFIER = Key.key("minenet", "mob_arena_speed"); + + private final int seconds; + private final int radius; + private final int difficulty; + private final int deathPenalty; + private final boolean powerUps; + private int elapsedSeconds = 0; + + private final Map points = new WeakHashMap<>(); + private final Map lastAttack = new WeakHashMap<>(); + private final Map strengthUntil = new WeakHashMap<>(); + private final Map speedUntil = new WeakHashMap<>(); + + public MobArena(int seconds, int radius, int difficulty, int deathPenalty, boolean powerUps) { + super(Dimension.OVERWORLD.key, "MobArena", new PointsWinScore()); + this.seconds = seconds; + this.radius = radius; + this.difficulty = difficulty; + this.deathPenalty = deathPenalty; + this.powerUps = powerUps; + this.setTime(18000); + this.defaultClock().pause(); + + this.setGenerator( + new CircularPlateTerrainGenerator(radius) + .setPlateHeight(1) + .setPlatePallet(BlockPallet.STONE) + ); + + this.eventNode() + .addListener(EntityAttackEvent.class, this::onPlayerAttack) + .addListener(EntityDamageEvent.class, this::onPlayerDamage) + .addListener(EntityDeathEvent.class, this::onMobDeath) + .addListener(PickupItemEvent.class, this::onPickupItem) + .addListener(PickupExperienceEvent.class, pickupExperienceEvent -> { + // Orbs sind nur Effekt, das Level zeigt die Punkte an + pickupExperienceEvent.setCancelled(true); + Knockback knockback = Knockback.of(pickupExperienceEvent.getPlayer()); + pickupExperienceEvent.getExperienceOrb().setVelocity(new Vec(knockback.x(), 0, knockback.z()).mul(5)); + }); + } + + @Override + protected void onLoad(@NotNull CompletableFuture callback) { + // Mauer am Plattformrand, damit weder Spieler noch Mobs die Arena verlassen + AbsoluteBlockBatch batch = new AbsoluteBlockBatch(); + int outer = this.radius + 2; + for(int x = -outer; x <= outer; x++) { + for(int z = -outer; z <= outer; z++) { + double distance = new Pos(x, 0, z).distance(Pos.ZERO); + if(distance <= this.radius || distance > this.radius + 1.5) continue; + for(int y = 0; y <= WALL_HEIGHT; y++) batch.setBlock(x, y, z, Block.STONE_BRICKS); + } + } + BatchUtil.loadAndApplyBatch(batch, this, () -> callback.complete(null)); + } + + @Override + protected void onStart() { + this.getPlayers().forEach(player -> { + this.points.put(player, 0); + player.setLevel(0); + player.getInventory().clear(); + player.getInventory().addItemStack(ItemStack.of(Material.IRON_SWORD)); + player.setHealth(this.maxHealth(player)); + }); + + this.setTimeLimit(this.seconds); + + this.scheduler().submitTask(() -> { + if(!this.isRunning) return TaskSchedule.stop(); + int amount = (int) Math.ceil(this.getPlayers().size() * this.difficulty / 2d * this.escalation()); + for(int i = 0; i < amount; i++) this.spawnMob(MobType.getRandom().create()); + this.elapsedSeconds += SPAWN_INTERVAL_SECONDS; + return TaskSchedule.seconds(SPAWN_INTERVAL_SECONDS); + }); + + this.scheduler().submitTask(() -> { + if(!this.isRunning) return TaskSchedule.stop(); + this.getPlayers().forEach(player -> this.heal(player, 1)); + return TaskSchedule.seconds(3); + }); + } + + @Override + protected void onStop() { + this.points.forEach((player, points) -> this.getScore().insertResult(player, points)); + } + + private void onPlayerAttack(@NotNull EntityAttackEvent event) { + if(!(event.getEntity() instanceof Player attacker)) return; + if(!(event.getTarget() instanceof ArenaMob target)) return; + if(!this.isRunning) return; + + long now = System.currentTimeMillis(); + if(now - this.lastAttack.getOrDefault(attacker, 0L) < ATTACK_COOLDOWN_MILLIS) return; + this.lastAttack.put(attacker, now); + + float damage = now < this.strengthUntil.getOrDefault(attacker, 0L) ? SWORD_DAMAGE * 2 : SWORD_DAMAGE; + Collection nearby = this.getNearbyEntities(target.getPosition(), SWEEP_RANGE); + if(nearby.size() > 1) + ArenaMob.particle(this, Particle.SWEEP_ATTACK, target.getPosition().add(0, 1, 0), new Vec(0.5), (int) NumberUtil.map(nearby.size(), 1, 10, 1, 5)); + + Knockback knockback = Knockback.of(attacker); + for(Entity entity : nearby) { + if(!(entity instanceof ArenaMob mob) || mob.isDead()) continue; + double distance = mob.getDistance(target); + + mob.damage(Damage.fromPlayer(attacker, (float) NumberUtil.map(distance, SWEEP_RANGE, 0, 0, damage))); + knockback.apply(mob, (float) NumberUtil.map(distance, SWEEP_RANGE, 0, 0.1, 0.5)); + ArenaMob.particle(this, Particle.FALLING_OBSIDIAN_TEAR, mob.getPosition(), new Vec(knockback.x(), 0, knockback.z()), 10); + + this.scheduler().scheduleNextTick(() -> { + if(mob.isDead()) knockback.apply(mob, 1f); + }); + } + } + + private void onPlayerDamage(@NotNull EntityDamageEvent event) { + if(!(event.getEntity() instanceof Player player)) return; + if(!this.isRunning) { + event.setCancelled(true); + return; + } + if(event.getDamage().getAmount() < player.getHealth()) return; + + // Spieler sterben nicht wirklich, sondern verlieren Punkte und starten am Spawn neu + event.setCancelled(true); + this.addPoints(player, -this.deathPenalty); + player.teleport(this.getSpawn()); + player.setHealth(this.maxHealth(player)); + player.setInvulnerable(true); + player.scheduler().scheduleTask(() -> player.setInvulnerable(false), TaskSchedule.seconds(3), TaskSchedule.stop()); + + new TitleMessage(Duration.ofSeconds(1)) + .subtitle(subtitleMessage -> subtitleMessage.appendStatic(Component.text("-" + this.deathPenalty, NamedTextColor.RED))) + .appendTranslated("game_MobArena#death") + .send(player); + } + + private void onMobDeath(@NotNull EntityDeathEvent event) { + if(!(event.getEntity() instanceof ArenaMob mob)) return; + if(!this.isRunning) return; + if(mob.getLastDamageSource() == null) return; + if(!(mob.getLastDamageSource().getAttacker() instanceof Player killer)) return; + + this.addPoints(killer, mob.getPoints()); + this.heal(killer, 1); + if(this.powerUps && this.rnd.nextDouble() < mob.getDropChance()) this.dropPowerUp(mob.getPosition()); + } + + private void dropPowerUp(Pos pos) { + ItemEntity item = new ItemEntity(ItemStack.of(PowerUp.getRandom().getMaterial()).withGlowing(true)); + item.setPickupDelay(Duration.ofMillis(500)); + item.setInstance(this, pos.add(0, 0.5, 0)); + item.setVelocity(new Vec(0, 4, 0)); + item.scheduleRemove(Duration.ofSeconds(15)); + } + + private void onPickupItem(@NotNull PickupItemEvent event) { + if(!(event.getLivingEntity() instanceof Player player)) { + event.setCancelled(true); + return; + } + + PowerUp.byMaterial(event.getItemStack().material()).ifPresentOrElse(powerUp -> { + this.applyPowerUp(player, powerUp); + player.playSound(Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.PLAYER, 0.5f, 1.5f)); + new ActionBarMessage().appendTranslated(powerUp.getTranslationKey()).send(player); + }, () -> event.setCancelled(true)); + } + + private void applyPowerUp(Player player, PowerUp powerUp) { + long until = System.currentTimeMillis() + PowerUp.DURATION_SECONDS * 1000L; + switch(powerUp) { + case HEAL -> this.heal(player, 8); + case STRENGTH -> this.strengthUntil.put(player, until); + case SPEED -> { + this.speedUntil.put(player, until); + player.getAttribute(Attribute.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER); + player.getAttribute(Attribute.MOVEMENT_SPEED).addModifier(new AttributeModifier(SPEED_MODIFIER, 0.4, AttributeOperation.ADD_MULTIPLIED_BASE)); + player.scheduler().scheduleTask(() -> { + // ein erneut aufgesammeltes Power-up verlaengert die Wirkung + if(System.currentTimeMillis() < this.speedUntil.getOrDefault(player, 0L)) return; + player.getAttribute(Attribute.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER); + }, TaskSchedule.seconds(PowerUp.DURATION_SECONDS), TaskSchedule.stop()); + } + } + } + + @Override + protected void onUnload() { + this.getPlayers().forEach(player -> player.getAttribute(Attribute.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER)); + } + + private void addPoints(Player player, int amount) { + int newPoints = Math.max(0, this.points.getOrDefault(player, 0) + amount); + this.points.put(player, newPoints); + player.setLevel(newPoints); + } + + private float maxHealth(Player player) { + return (float) player.getAttributeValue(Attribute.MAX_HEALTH); + } + + private void heal(Player player, float amount) { + player.setHealth(Math.min(this.maxHealth(player), player.getHealth() + amount)); + } + + public void spawnMob(ArenaMob mob) { + this.spawnMob(mob, this.randomSpawnPosition(), 0); + } + + /** + * Only living arena mobs count towards the limits, orbs, items, projectiles and corpses are ignored + */ + public void spawnMob(ArenaMob mob, Pos pos, int area) { + if(!this.isRunning) return; + int players = this.getPlayers().size(); + + int maxMobs = (int) Math.min(MAX_MOBS, (16 + 8 * players) * this.difficulty / 2d * this.escalation()); + long livingMobs = this.livingMobs(ArenaMob.class); + if(livingMobs >= maxMobs) return; + if(this.livingMobs(mob.getClass()) >= mob.maxAlive(players)) return; + + Pos spawnPos = area > 0 ? pos.add(this.rnd.nextInt(-area, area), 0, this.rnd.nextInt(-area, area)) : pos; + mob.setInstance(this, spawnPos).thenRun(() -> ArenaMob.particle(this, Particle.POOF, spawnPos, new Vec(1), 3)); + } + + /** + * Grows over the course of the round, more and more mobs spawn the longer the game runs + */ + private double escalation() { + return Math.min(2.5, 1 + this.elapsedSeconds / 90d); + } + + public float getDamageMultiplier() { + return switch(this.difficulty) { + case 1 -> 0.5f; + case 3 -> 1f; + default -> 0.75f; + }; + } + + private long livingMobs(Class type) { + return this.getEntities().stream() + .filter(type::isInstance) + .filter(entity -> !((ArenaMob) entity).isDead()) + .count(); + } + + /** + * Random position on the arena floor, preferably not right next to a player + */ + private Pos randomSpawnPosition() { + Pos pos = this.getSpawn(); + for(int i = 0; i < 10; i++) { + double angle = this.rnd.nextDouble(2 * Math.PI); + double distance = this.rnd.nextDouble(this.radius - 3); + pos = this.getSpawn().add(Math.cos(angle) * distance, 0, Math.sin(angle) * distance); + + Pos candidate = pos; + if(this.getPlayers().stream().noneMatch(player -> player.getPosition().distance(candidate) < 6)) break; + } + return pos; + } + + public void explode(Pos pos, @Nullable Entity source, @Nullable Entity attacker, float damage, double range) { + this.sendGroupedPacket(new ParticlePacket(Particle.EXPLOSION, pos.x(), pos.y(), pos.z(), 1, 1, 1, 0, 5)); + this.playSound(Sound.sound(SoundEvent.ENTITY_GENERIC_EXPLODE, Sound.Source.NEUTRAL, 0.5f, 1), pos.x(), pos.y(), pos.z()); + + for(Entity entity : this.getNearbyEntities(pos, range)) { + if(!(entity instanceof LivingEntity livingEntity) || livingEntity == source) continue; + float amount = (float) NumberUtil.map(entity.getDistance(pos), range, 0, 0, damage); + if(livingEntity instanceof Player) amount *= this.getDamageMultiplier(); + livingEntity.damage(new Damage(DamageType.EXPLOSION, source, attacker, pos, amount)); + } + } + + @Override + public Pos getSpawn() { + return new Pos(0.5, 1, 0.5); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArenaFactory.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArenaFactory.java new file mode 100644 index 0000000..06a90bb --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/MobArenaFactory.java @@ -0,0 +1,51 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena; + +import eu.mhsl.minenet.minigames.instance.game.Game; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.ConfigManager; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.GameFactory; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.Option; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.common.BoolOption; +import eu.mhsl.minenet.minigames.instance.game.stateless.config.common.NumericOption; +import eu.mhsl.minenet.minigames.instance.room.Room; +import eu.mhsl.minenet.minigames.message.component.TranslatedComponent; +import net.minestom.server.item.Material; + +import java.util.Map; + +public class MobArenaFactory implements GameFactory { + @Override + public TranslatedComponent name() { + return TranslatedComponent.byId("game_MobArena#name"); + } + + @Override + public TranslatedComponent description() { + return TranslatedComponent.byId("game_MobArena#description"); + } + + @Override + public Material symbol() { + return Material.ZOMBIE_HEAD; + } + + @Override + public ConfigManager configuration() { + return new ConfigManager() + .addOption(new NumericOption("seconds", Material.CLOCK, TranslatedComponent.byId("optionCommon#seconds"), 120, 180, 300, 60)) + .addOption(new NumericOption("radius", Material.HEART_OF_THE_SEA, TranslatedComponent.byId("optionCommon#radius"), 30, 40, 50, 20)) + .addOption(new NumericOption("difficulty", Material.SKELETON_SKULL, TranslatedComponent.byId("game_MobArena#difficulty"), 2, 3, 1)) + .addOption(new NumericOption("deathPenalty", Material.TOTEM_OF_UNDYING, TranslatedComponent.byId("game_MobArena#deathPenalty"), 3, 5, 10, 0)) + .addOption(new BoolOption("powerUps", Material.GOLDEN_APPLE, TranslatedComponent.byId("game_MobArena#powerUps"))); + } + + @Override + public Game manufacture(Room parent, Map> configuration) { + return new MobArena( + configuration.get("seconds").getAsInt(), + configuration.get("radius").getAsInt(), + configuration.get("difficulty").getAsInt(), + configuration.get("deathPenalty").getAsInt(), + configuration.get("powerUps").getAsBoolean() + ).setParent(parent); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/PowerUp.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/PowerUp.java new file mode 100644 index 0000000..3743171 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/PowerUp.java @@ -0,0 +1,40 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena; + +import net.minestom.server.item.Material; + +import java.util.Arrays; +import java.util.Optional; +import java.util.Random; + +public enum PowerUp { + HEAL(Material.GOLDEN_APPLE, "game_MobArena#powerUp_heal"), + STRENGTH(Material.BLAZE_POWDER, "game_MobArena#powerUp_strength"), + SPEED(Material.SUGAR, "game_MobArena#powerUp_speed"); + + public static final int DURATION_SECONDS = 10; + private static final Random RANDOM = new Random(); + + private final Material material; + private final String translationKey; + + PowerUp(Material material, String translationKey) { + this.material = material; + this.translationKey = translationKey; + } + + public Material getMaterial() { + return this.material; + } + + public String getTranslationKey() { + return this.translationKey; + } + + public static Optional byMaterial(Material material) { + return Arrays.stream(values()).filter(powerUp -> powerUp.material == material).findFirst(); + } + + public static PowerUp getRandom() { + return values()[RANDOM.nextInt(values().length)]; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/ArenaMob.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/ArenaMob.java new file mode 100644 index 0000000..09a8ab4 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/ArenaMob.java @@ -0,0 +1,193 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.Knockback; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArena; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.ExperienceOrb; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.attribute.Attribute; +import net.minestom.server.entity.attribute.AttributeInstance; +import net.minestom.server.entity.ai.goal.MeleeAttackGoal; +import net.minestom.server.entity.ai.goal.RandomLookAroundGoal; +import net.minestom.server.entity.ai.goal.RandomStrollGoal; +import net.minestom.server.entity.ai.target.ClosestEntityTarget; +import net.minestom.server.entity.damage.Damage; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.event.entity.EntityDamageEvent; +import net.minestom.server.event.entity.EntityDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ParticlePacket; +import net.minestom.server.particle.Particle; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.time.TimeUnit; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public class ArenaMob extends EntityCreature { + private static final int MAX_ORBS = 8; + private static final int BLOCK_LENGTH = 6; + private static final List CHARACTERS = List.of("", "▏", "▎", "▍", "▌", "▋", "▊", "▉"); + private static final String FULL_BLOCK_CHAR = "█"; + + private float damage = 2; + private int experience = 10; + private int points = 1; + private double dropChance = 0.08; + + public ArenaMob(@NotNull EntityType entityType) { + super(entityType); + + this.set(DataComponents.CUSTOM_NAME, generateHealthBar(this.getMaxHealth(), this.getHealth())); + this.setCustomNameVisible(true); + this.setRemovalAnimationDelay(3500); + + this.aiGroup(); + + this.eventNode() + .addListener(EntityAttackEvent.class, this::onAttack) + .addListener(EntityDamageEvent.class, this::onDamage) + .addListener(EntityDeathEvent.class, this::onDeath); + } + + public static void particle(Instance instance, Particle particle, Point pos, Vec offset, int count) { + instance.sendGroupedPacket(new ParticlePacket(particle, pos.x(), pos.y(), pos.z(), (float) offset.x(), (float) offset.y(), (float) offset.z(), 0, count)); + } + + protected void particle(Particle particle, Point pos, float offset, int count) { + if(this.getInstance() == null) return; + particle(this.getInstance(), particle, pos, new Vec(offset), count); + } + + protected ClosestEntityTarget playerTarget() { + return new ClosestEntityTarget(this, 32, entity -> entity instanceof Player player && player.getGameMode() != GameMode.SPECTATOR); + } + + protected void aiGroup() { + this.addAIGroup( + List.of( + new MeleeAttackGoal(this, 1.2, 20, TimeUnit.SERVER_TICK), + new RandomLookAroundGoal(this, 30), + new RandomStrollGoal(this, 5) + ), + List.of(this.playerTarget()) + ); + } + + protected void onAttack(@NotNull EntityAttackEvent event) { + if(!(event.getTarget() instanceof Player target)) return; + + this.particle(Particle.LANDING_LAVA, target.getPosition(), 0.5f, 10); + Knockback.of(this).apply(target, 0.2f); + target.damage(Damage.fromEntity(this, this.scaledDamage(this.damage))); + } + + protected void onDamage(@NotNull EntityDamageEvent event) { + this.particle(Particle.DAMAGE_INDICATOR, this.getPosition(), 1, 5); + event.setSound(SoundEvent.BLOCK_AMETHYST_BLOCK_STEP); + } + + protected void onDeath(@NotNull EntityDeathEvent event) { + Instance instance = this.getInstance(); + this.particle(Particle.SOUL, this.getPosition(), 0.5f, 3); + + this.setCustomNameVisible(false); + + for(int i = 0; i < Math.min(this.experience, MAX_ORBS); i++) { + ExperienceOrb orb = new ExperienceOrb((short) 1); + orb.setNoGravity(true); + orb.setInstance(instance, this.getPosition().add(0, 0.1, 0)); + orb.setVelocity(new Vec(this.random(-5, 5), this.random(1, 3), this.random(-5, 5))); + + instance.scheduler().scheduleTask(() -> { + particle(instance, Particle.LAVA, orb.getPosition(), Vec.ZERO, 1); + orb.remove(); + }, TaskSchedule.millis((long) this.random(1000, 5000)), TaskSchedule.stop()); + } + + this.scheduler().scheduleTask(() -> this.teleport(this.getPosition().sub(0, 0.1, 0)), TaskSchedule.seconds(3), TaskSchedule.stop()); + } + + private double random(double min, double max) { + return min + Math.random() * (max - min); + } + + @Override + public void setHealth(float health) { + super.setHealth(health); + if(!this.isDead()) this.set(DataComponents.CUSTOM_NAME, generateHealthBar(this.getMaxHealth(), this.getHealth())); + } + + /** + * Damage against players, scaled by the arena difficulty + */ + protected float scaledDamage(float damage) { + if(this.getInstance() instanceof MobArena arena) return damage * arena.getDamageMultiplier(); + return damage; + } + + protected void setMaxHealth(float maxHealth) { + this.getAttribute(Attribute.MAX_HEALTH).setBaseValue(maxHealth); + this.setHealth(maxHealth); + } + + protected void multiplySpeed(double factor) { + AttributeInstance speed = this.getAttribute(Attribute.MOVEMENT_SPEED); + speed.setBaseValue(speed.getBaseValue() * factor); + } + + /** + * Maximum amount of living mobs of this type in the arena + */ + public int maxAlive(int players) { + return 6 + 4 * players; + } + + public double getDropChance() { + return this.dropChance; + } + + public void setDropChance(double dropChance) { + this.dropChance = dropChance; + } + + public float getMaxHealth() { + return (float) this.getAttributeValue(Attribute.MAX_HEALTH); + } + + public void setDamage(float damage) { + this.damage = damage; + } + + public void setExperience(int experience) { + this.experience = experience; + } + + public int getPoints() { + return this.points; + } + + public void setPoints(int points) { + this.points = points; + } + + private static @NotNull Component generateHealthBar(float maxHealth, float currentHealth) { + // Converts the health percentage into a number from 0-{blockLength} -- only 0 if the mob's health is 0 + final double charHealth = (currentHealth / maxHealth) * BLOCK_LENGTH; + return Component.text() + .append(Component.text(FULL_BLOCK_CHAR.repeat((int) Math.floor(charHealth)), NamedTextColor.DARK_RED)) + .append(Component.text( + CHARACTERS.get((int) Math.round((charHealth - Math.floor(charHealth)) * (CHARACTERS.size() - 1))), + NamedTextColor.GOLD + )) + .build(); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobArrow.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobArrow.java new file mode 100644 index 0000000..9629392 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobArrow.java @@ -0,0 +1,52 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob; + +import io.github.togar2.pvp.entity.projectile.CustomEntityProjectile; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.damage.Damage; +import net.minestom.server.entity.damage.DamageType; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; + +/** + * Arrow shot by arena mobs, only hits players and flies through other mobs + */ +public class MobArrow extends CustomEntityProjectile { + private final float damage; + + public MobArrow(@NotNull Entity shooter, float damage) { + super(shooter, EntityType.ARROW); + this.damage = damage; + } + + /** + * Velocity has to be set before spawning, otherwise the client never receives it + */ + public void shootAt(Point target, double power, double spread) { + Entity shooter = this.getShooter(); + if(shooter == null || shooter.getInstance() == null) return; + this.shootTo(target, power, spread); + this.setInstance(shooter.getInstance(), shooter.getPosition().add(0, shooter.getEyeHeight(), 0)); + } + + @Override + protected boolean canHit(Entity entity) { + return entity instanceof Player player && player.getGameMode() != GameMode.SPECTATOR; + } + + @Override + public boolean onHit(Entity entity) { + if(entity instanceof Player player) player.damage(new Damage(DamageType.ARROW, this, this.getShooter(), null, this.damage)); + return true; + } + + @Override + public boolean onStuck() { + this.scheduleRemove(Duration.ofSeconds(2)); + return false; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobType.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobType.java new file mode 100644 index 0000000..e035420 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/MobType.java @@ -0,0 +1,47 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Creeper; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Evoker; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Pillager; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Skeleton; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Slime; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Spider; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature.Zombie; + +import java.util.Arrays; +import java.util.Random; +import java.util.function.Supplier; + +public enum MobType { + ZOMBIE(Zombie::new, 30), + SKELETON(Skeleton::new, 14), + SPIDER(Spider::new, 14), + PILLAGER(Pillager::new, 12), + CREEPER(Creeper::new, 10), + SLIME(Slime::new, 10), + EVOKER(Evoker::new, 6); + + private static final Random RANDOM = new Random(); + private static final int TOTAL_WEIGHT = Arrays.stream(values()).mapToInt(type -> type.weight).sum(); + + private final Supplier factory; + private final int weight; + + MobType(Supplier factory, int weight) { + this.factory = factory; + this.weight = weight; + } + + public ArenaMob create() { + return this.factory.get(); + } + + public static MobType getRandom() { + int roll = RANDOM.nextInt(TOTAL_WEIGHT); + for(MobType type : values()) { + roll -= type.weight; + if(roll < 0) return type; + } + return ZOMBIE; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Creeper.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Creeper.java new file mode 100644 index 0000000..33a0af6 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Creeper.java @@ -0,0 +1,39 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArena; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.metadata.monster.CreeperMeta; +import net.minestom.server.event.entity.EntityAttackEvent; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.TaskSchedule; +import org.jetbrains.annotations.NotNull; + +public class Creeper extends ArenaMob { + private boolean fusing = false; + + public Creeper() { + super(EntityType.CREEPER); + this.setMaxHealth(12); + this.setPoints(2); + } + + /** + * Ignites instead of attacking and explodes shortly after, no matter if the target is still close + */ + @Override + protected void onAttack(@NotNull EntityAttackEvent event) { + if(this.fusing) return; + this.fusing = true; + + ((CreeperMeta) this.getEntityMeta()).setState(CreeperMeta.State.FUSE); + this.getInstance().playSound(Sound.sound(SoundEvent.ENTITY_CREEPER_PRIMED, Sound.Source.HOSTILE, 1, 1), this.getPosition()); + + this.scheduler().scheduleTask(() -> { + if(this.isDead() || this.isRemoved()) return; + if(this.getInstance() instanceof MobArena arena) arena.explode(this.getPosition(), this, null, 8, 4); + this.remove(); + }, TaskSchedule.millis(1500), TaskSchedule.stop()); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Evoker.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Evoker.java new file mode 100644 index 0000000..9d72c32 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Evoker.java @@ -0,0 +1,121 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArena; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.goal.RangedAttackGoal; +import net.minestom.server.entity.metadata.monster.raider.EvokerMeta; +import net.minestom.server.entity.metadata.monster.raider.SpellcasterIllagerMeta; +import net.minestom.server.event.entity.EntityDamageEvent; +import net.minestom.server.particle.Particle; +import net.minestom.server.sound.SoundEvent; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.time.Cooldown; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.List; +import java.util.Random; +import java.util.function.Consumer; + +public class Evoker extends ArenaMob { + private boolean hasTotem; + + public Evoker() { + super(EntityType.EVOKER); + this.hasTotem = Math.random() < 0.5; + this.setMaxHealth(14); + this.setPoints(3); + this.setDropChance(0.3); + } + + @Override + protected void aiGroup() { + Random rnd = new Random(); + int range = rnd.nextInt(3, 16); + + // keeps distance to the target without actually shooting + RangedAttackGoal keepDistance = new RangedAttackGoal(this, Duration.ofDays(1), range, range, true, 0, 0); + keepDistance.setProjectileGenerator((shooter, to, power, spread) -> {}); + + this.addAIGroup( + List.of( + new ActionGoal(this, Duration.ofSeconds(25), target -> { + this.lookAt(target); + ((EvokerMeta) this.getEntityMeta()).setSpell(SpellcasterIllagerMeta.Spell.SUMMON_VEX); + + this.scheduler().scheduleTask(() -> { + if(this.isDead()) return; + if(this.getInstance() instanceof MobArena arena) { + int amount = rnd.nextInt(1, 3); + for(int i = 0; i < amount; i++) arena.spawnMob(new Vex(), this.getPosition().add(0, 2, 0), 3); + } + ((EvokerMeta) this.getEntityMeta()).setSpell(SpellcasterIllagerMeta.Spell.NONE); + }, TaskSchedule.seconds(2), TaskSchedule.stop()); + }), + keepDistance + ), + List.of(this.playerTarget()) + ); + } + + @Override + protected void onDamage(@NotNull EntityDamageEvent event) { + super.onDamage(event); + if(!this.hasTotem || event.getDamage().getAmount() < this.getHealth()) return; + + event.setCancelled(true); + this.hasTotem = false; + this.particle(Particle.TOTEM_OF_UNDYING, this.getPosition(), 2, 60); + this.getInstance().playSound(Sound.sound(SoundEvent.ITEM_TOTEM_USE, Sound.Source.HOSTILE, 0.5f, 1), this.getPosition()); + this.setHealth(6); + } + + private static final class ActionGoal extends GoalSelector { + private final Duration cooldown; + private final Consumer consumer; + private long lastAction; + + public ActionGoal(@NotNull EntityCreature entityCreature, @NotNull Duration cooldown, Consumer consumer) { + super(entityCreature); + this.cooldown = cooldown; + this.consumer = consumer; + } + + @Override + public boolean shouldStart() { + Entity target = this.entityCreature.getTarget(); + if(target == null) target = this.findTarget(); + if(target == null) return false; + if(Cooldown.hasCooldown(System.currentTimeMillis(), this.lastAction, this.cooldown)) return false; + this.entityCreature.setTarget(target); + return true; + } + + @Override + public void start() { + } + + @Override + public void tick(long time) { + if(!Cooldown.hasCooldown(time, this.lastAction, this.cooldown) && this.entityCreature.getTarget() != null) { + this.lastAction = time; + this.consumer.accept(this.entityCreature.getTarget()); + } + } + + @Override + public boolean shouldEnd() { + final Entity target = this.entityCreature.getTarget(); + return target == null || target.isRemoved() || Cooldown.hasCooldown(System.currentTimeMillis(), this.lastAction, this.cooldown); + } + + @Override + public void end() { + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Pillager.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Pillager.java new file mode 100644 index 0000000..02a1f04 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Pillager.java @@ -0,0 +1,15 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.minestom.server.entity.EntityType; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; + +public class Pillager extends ArenaMob { + public Pillager() { + super(EntityType.PILLAGER); + this.setItemInMainHand(ItemStack.of(Material.STONE_AXE)); + this.setMaxHealth(12); + this.setDamage(3); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Skeleton.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Skeleton.java new file mode 100644 index 0000000..11490b7 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Skeleton.java @@ -0,0 +1,141 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.MobArrow; +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.ai.goal.RandomStrollGoal; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.sound.SoundEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public class Skeleton extends ArenaMob { + private static final float ARROW_DAMAGE = 2; + + public Skeleton() { + super(EntityType.SKELETON); + this.setItemInMainHand(ItemStack.of(Material.BOW)); + this.setMaxHealth(10); + this.multiplySpeed(0.6); + this.setPoints(2); + } + + @Override + protected void aiGroup() { + this.addAIGroup( + List.of(new ArcherGoal(this), new RandomStrollGoal(this, 5)), + List.of(this.playerTarget()) + ); + } + + @Override + public int maxAlive(int players) { + return 3 + 2 * players; + } + + private void shoot(Entity target) { + if(this.getInstance() == null) return; + Pos to = target.getPosition().add(0, target.getEyeHeight() * 0.7, 0); + new MobArrow(this, this.scaledDamage(ARROW_DAMAGE)).shootAt(to, 1.6, 4); + this.getInstance().playSound(Sound.sound(SoundEvent.ENTITY_SKELETON_SHOOT, Sound.Source.HOSTILE, 1, 1), this.getPosition()); + } + + /** + * Keeps a medium distance to the target, backs off when it gets too close and shoots in fixed intervals + */ + private static final class ArcherGoal extends GoalSelector { + private static final double MIN_DISTANCE = 5; + private static final double MAX_DISTANCE = 12; + private static final double SHOOT_RANGE = 18; + private static final long DRAW_MILLIS = 1000; + private static final long COOLDOWN_MILLIS = 2500; + + private final Skeleton skeleton; + private long drawStart = 0; + private long lastShot = 0; + private long lastPathUpdate = 0; + private boolean retreating = false; + + ArcherGoal(@NotNull Skeleton skeleton) { + super(skeleton); + this.skeleton = skeleton; + } + + @Override + public boolean shouldStart() { + return this.findTarget() != null; + } + + @Override + public void start() { + } + + @Override + public void tick(long time) { + Entity target = this.findTarget(); + if(target == null) return; + this.entityCreature.setTarget(target); + + double distance = this.entityCreature.getDistance(target); + this.updateMovement(target, distance, time); + this.entityCreature.lookAt(target); + + // beim Zurückweichen wird nicht geschossen + if(this.retreating || distance > SHOOT_RANGE) { + this.stopDrawing(); + return; + } + + if(this.drawStart == 0) { + if(time - this.lastShot < COOLDOWN_MILLIS) return; + this.drawStart = time; + this.skeleton.refreshActiveHand(true, false, false); + } else if(time - this.drawStart >= DRAW_MILLIS) { + this.skeleton.shoot(target); + this.lastShot = time; + this.stopDrawing(); + } + } + + private void updateMovement(Entity target, double distance, long time) { + if(time - this.lastPathUpdate < 500) return; + this.lastPathUpdate = time; + + Pos position = this.entityCreature.getPosition(); + this.retreating = distance < MIN_DISTANCE; + if(distance > MAX_DISTANCE) { + this.entityCreature.getNavigator().setPathTo(target.getPosition()); + } else if(this.retreating) { + Vec away = position.sub(target.getPosition()).asVec().withY(0); + if(away.isZero()) away = new Vec(1, 0, 0); + this.entityCreature.getNavigator().setPathTo(position.add(away.normalize().mul(4))); + } else { + this.entityCreature.getNavigator().setPathTo(null); + } + } + + private void stopDrawing() { + if(this.drawStart == 0) return; + this.drawStart = 0; + this.skeleton.refreshActiveHand(false, false, false); + } + + @Override + public boolean shouldEnd() { + return this.findTarget() == null; + } + + @Override + public void end() { + this.stopDrawing(); + this.entityCreature.getNavigator().setPathTo(null); + } + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Slime.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Slime.java new file mode 100644 index 0000000..8776f31 --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Slime.java @@ -0,0 +1,34 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArena; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.metadata.cube.SlimeMeta; +import net.minestom.server.event.entity.EntityDeathEvent; +import org.jetbrains.annotations.NotNull; + +public class Slime extends ArenaMob { + private final int size; + + public Slime() { + this(Math.random() < 0.25 ? 3 : 2); + } + + public Slime(int size) { + super(EntityType.SLIME); + this.size = size; + + ((SlimeMeta) this.getEntityMeta()).setSize(size); + this.setMaxHealth(size * size); + this.setDamage(size); + this.setExperience(size * 2); + } + + @Override + protected void onDeath(@NotNull EntityDeathEvent event) { + super.onDeath(event); + if(this.size <= 1 || !(this.getInstance() instanceof MobArena arena)) return; + + for(int i = 0; i < 2; i++) arena.spawnMob(new Slime(this.size - 1), this.getPosition(), 1); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Spider.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Spider.java new file mode 100644 index 0000000..44562ad --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Spider.java @@ -0,0 +1,12 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.minestom.server.entity.EntityType; + +public class Spider extends ArenaMob { + public Spider() { + super(EntityType.SPIDER); + this.setMaxHealth(4); + this.setDamage(1); + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Vex.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Vex.java new file mode 100644 index 0000000..fc3584e --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Vex.java @@ -0,0 +1,32 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.minestom.server.entity.EntityType; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.particle.Particle; +import net.minestom.server.timer.TaskSchedule; + +public class Vex extends ArenaMob { + private static final int LIFETIME_SECONDS = 12; + + public Vex() { + super(EntityType.VEX); + this.setItemInMainHand(ItemStack.of(Material.GOLDEN_SWORD)); + this.setMaxHealth(4); + this.setDamage(1); + this.setExperience(2); + this.setDropChance(0); + + this.scheduler().scheduleTask(() -> { + if(this.isDead() || this.isRemoved()) return; + this.particle(Particle.POOF, this.getPosition(), 0.3f, 5); + this.remove(); + }, TaskSchedule.seconds(LIFETIME_SECONDS), TaskSchedule.stop()); + } + + @Override + public int maxAlive(int players) { + return 2 + players / 2; + } +} diff --git a/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Zombie.java b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Zombie.java new file mode 100644 index 0000000..8fb499f --- /dev/null +++ b/src/main/java/eu/mhsl/minenet/minigames/instance/game/stateless/types/mobArena/mob/creature/Zombie.java @@ -0,0 +1,75 @@ +package eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.creature; + +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.MobArena; +import eu.mhsl.minenet.minigames.instance.game.stateless.types.mobArena.mob.ArenaMob; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.metadata.monster.zombie.ZombieMeta; +import net.minestom.server.event.entity.EntityDeathEvent; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import org.jetbrains.annotations.NotNull; + +public class Zombie extends ArenaMob { + private enum Variant { + NORMAL, BABY, ARMORED, EXPLODER, EXPERIENCE; + + static Variant random() { + double roll = Math.random(); + if(roll < 0.10) return EXPLODER; + if(roll < 0.18) return EXPERIENCE; + if(roll < 0.33) return BABY; + if(roll < 0.45) return ARMORED; + return NORMAL; + } + } + + private final Variant variant = Variant.random(); + + public Zombie() { + super(EntityType.ZOMBIE); + this.setMaxHealth(14); + + switch(this.variant) { + case BABY -> { + ((ZombieMeta) this.getEntityMeta()).setBaby(true); + this.setMaxHealth(6); + this.multiplySpeed(1.4); + this.setDamage(1); + this.setPoints(2); + } + case ARMORED -> { + this.setEquipment(EquipmentSlot.HELMET, ItemStack.of(Material.IRON_HELMET)); + this.setEquipment(EquipmentSlot.CHESTPLATE, ItemStack.of(Material.IRON_CHESTPLATE)); + this.setItemInMainHand(ItemStack.of(Material.IRON_SWORD)); + this.setMaxHealth(24); + this.setDamage(3); + this.multiplySpeed(0.8); + this.setPoints(3); + this.setDropChance(0.25); + } + case EXPLODER -> { + this.setItemInMainHand(ItemStack.of(Material.TNT)); + this.setItemInOffHand(ItemStack.of(Material.FLINT_AND_STEEL)); + } + case EXPERIENCE -> { + this.setItemInMainHand(ItemStack.of(Material.EXPERIENCE_BOTTLE)); + this.setExperience(50); + this.setPoints(3); + this.setDropChance(0.5); + } + case NORMAL -> { + } + } + } + + @Override + protected void onDeath(@NotNull EntityDeathEvent event) { + super.onDeath(event); + if(this.variant == Variant.EXPLODER && this.getInstance() instanceof MobArena arena) { + Entity killer = this.getLastDamageSource() != null ? this.getLastDamageSource().getAttacker() : null; + arena.explode(this.getPosition(), this, killer, 7, 3); + } + } +} diff --git a/src/main/resources/lang/locales.map.csv b/src/main/resources/lang/locales.map.csv index 9e66330..76e0a83 100644 --- a/src/main/resources/lang/locales.map.csv +++ b/src/main/resources/lang/locales.map.csv @@ -195,4 +195,15 @@ description;Get to the other side as quickly as possible!;Komme so schnell wie m ns:game_BuildBattle#;; name;Build Battle;Bauwettbewerb description;Build the best creation for the given theme before time runs out and win through the other players’ ratings.;Baue in der Zeit das beste Bauwerk zum Thema und gewinne durch die Bewertung der anderen Spieler. -seconds;Build Time;Bauzeit \ No newline at end of file +seconds;Build Time;Bauzeit +;; +ns:game_MobArena#;; +name;Mob Arena;Mob-Arena +description;Fight hordes of monsters with a sweeping sword and collect points for every kill. Dying costs points!;Kämpfe mit deinem Schwert gegen Horden von Monstern und sammle Punkte für jeden Kill. Sterben kostet Punkte! +death;You died!;Du bist gestorben! +difficulty;Difficulty;Schwierigkeit +deathPenalty;Points lost on death;Punktabzug bei Tod +powerUps;Power-ups;Power-ups +powerUp_heal;Healed!;Geheilt! +powerUp_strength;Double damage for 10 seconds!;Doppelter Schaden für 10 Sekunden! +powerUp_speed;Speed for 10 seconds!;Schneller für 10 Sekunden!