added MobArena game mode with custom mobs and arena mechanics

This commit is contained in:
MineTec committed 2026-11-06 21:49:22 +01:00
1 parent dc32b053a3
commit 9e757378c1
17 files changed
+1227 -2

No files matched your search

@@ -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;
@@ -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);
}
}
@@ -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<Player, Integer> points = new WeakHashMap<>();
private final Map<Player, Long> lastAttack = new WeakHashMap<>();
private final Map<Player, Long> strengthUntil = new WeakHashMap<>();
private final Map<Player, Long> 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<Void> 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<Entity> 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<? extends ArenaMob> 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);
}
}
@@ -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<String, Option<?>> 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);
}
}
@@ -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<PowerUp> byMaterial(Material material) {
return Arrays.stream(values()).filter(powerUp -> powerUp.material == material).findFirst();
}
public static PowerUp getRandom() {
return values()[RANDOM.nextInt(values().length)];
}
}
@@ -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<String> 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();
}
}
@@ -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;
}
}
@@ -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<ArenaMob> factory;
private final int weight;
MobType(Supplier<ArenaMob> 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;
}
}
@@ -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());
}
}
@@ -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<Entity> consumer;
private long lastAction;
public ActionGoal(@NotNull EntityCreature entityCreature, @NotNull Duration cooldown, Consumer<Entity> 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() {
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
}