rebalance towerdefense: welcome title, spectator switcher, projectile/aim fixes, HP scaling

This commit is contained in:
jannis committed 2026-11-09 21:35:28 +01:00
1 parent d021f12ad0
commit b6c3f2c1e0
9 files changed
+231 -63

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
//for jar packaging see https://github.com/Protonull/BasicMinestomServer/blob/master/build.gradle.kts
plugins {
@@ -6,27 +6,38 @@ import eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.enem
import eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.generator.MazeGenerator;
import eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.towers.*;
import eu.mhsl.minenet.minigames.score.LastWinsScore;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
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.sound.SoundEvent;
import net.minestom.server.timer.Task;
import net.minestom.server.timer.TaskSchedule;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
public class Towerdefense extends StatelessGame {
private final Random random = new Random();
private final AbsoluteBlockBatch mazeBatch = new AbsoluteBlockBatch();
private final List<Pos> mazePath = new ArrayList<>();
private final List<TowerdefenseRoom> instances = new ArrayList<>();
private final Set<Player> spectators = new HashSet<>();
private final WaveGenerator waveGenerator;
private Task waveTask;
public static final Component SWITCHER_NAME = Component.text("Spieler wechseln", NamedTextColor.AQUA);
private final Map<Material, Class<? extends Tower>> availableTowers = Map.of(
Material.SKELETON_SPAWN_EGG, SkeletonTower.class,
Material.ZOMBIE_SPAWN_EGG, ZombieTower.class,
@@ -59,7 +70,9 @@ public class Towerdefense extends StatelessGame {
@Override
protected void checkAbandoned() {
this.scheduleNextTick((instance) -> {
if(this.instances.stream().allMatch(room -> room.getPlayers().isEmpty()) && this.getPlayers().isEmpty()) {
boolean noAlive = this.instances.stream().allMatch(room -> room.getPlayers().isEmpty());
boolean noSpectators = this.spectators.stream().noneMatch(Player::isOnline);
if(noAlive && this.getPlayers().isEmpty() && noSpectators) {
this.waveTask.cancel();
this.unload();
}
@@ -71,15 +84,67 @@ public class Towerdefense extends StatelessGame {
this.getPlayers().forEach(player -> {
TowerdefenseRoom newRoom = new TowerdefenseRoom(player, this);
this.instances.add(newRoom);
player.setInstance(newRoom, new Pos(0, 1, 0));
player.setInstance(newRoom, new Pos(0, 1, 0)).thenRun(() -> this.welcome(player));
});
this.waveTask = MinecraftServer.getSchedulerManager().scheduleTask(
this.waveGenerator::startNextWave,
TaskSchedule.seconds(3),
TaskSchedule.seconds(25)
TaskSchedule.seconds(8),
TaskSchedule.seconds(15)
);
}
private void welcome(Player player) {
player.showTitle(Title.title(
Component.text("Tower Defense", NamedTextColor.GOLD),
Component.text("Verteidige dich gegen die Mobs!", NamedTextColor.YELLOW),
Title.Times.times(Duration.ofMillis(400), Duration.ofSeconds(3), Duration.ofMillis(600))
));
player.playSound(Sound.sound(SoundEvent.UI_TOAST_CHALLENGE_COMPLETE, Sound.Source.MASTER, 1f, 1f));
player.sendMessage(Component.text("Die erste Welle kommt in 8 Sekunden...", NamedTextColor.GRAY));
}
public List<TowerdefenseRoom> getAliveInstances() {
synchronized (this.instances) {
return new ArrayList<>(this.instances);
}
}
public void removeInstance(TowerdefenseRoom room) {
synchronized (this.instances) {
this.instances.remove(room);
}
}
public Set<Player> getSpectators() {
return this.spectators;
}
public void beginSpectating(Player player) {
this.spectators.add(player);
ItemStack switcher = ItemStack.of(Material.COMPASS).withCustomName(SWITCHER_NAME);
player.getInventory().clear();
player.getInventory().setItemStack(4, switcher);
player.sendMessage(Component.text("Du bist gestorben! Nutze den Kompass, um andere Spieler zu beobachten.", NamedTextColor.RED));
this.cycleSpectator(player);
}
public void cycleSpectator(Player player) {
List<TowerdefenseRoom> alive = this.getAliveInstances();
if(alive.isEmpty()) {
player.sendMessage(Component.text("Kein Spieler mehr am Leben.", NamedTextColor.GRAY));
return;
}
TowerdefenseRoom current = alive.stream()
.filter(r -> r == player.getInstance())
.findFirst().orElse(null);
int nextIdx = current == null ? 0 : (alive.indexOf(current) + 1) % alive.size();
TowerdefenseRoom target = alive.get(nextIdx);
player.setInstance(target, new Pos(0, 2, 0)).thenRun(() -> {
player.setGameMode(net.minestom.server.entity.GameMode.SPECTATOR);
player.sendActionBar(Component.text("Du beobachtest: " + target.getPlayer().getUsername(), NamedTextColor.AQUA));
});
}
private void generateMaze() {
Pos position = new Pos(0, 0, 0);
this.addMazePosition(position, Block.GREEN_WOOL);
@@ -10,6 +10,7 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.collision.Aerodynamics;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.*;
import net.minestom.server.entity.attribute.Attribute;
@@ -42,6 +43,7 @@ public class TowerdefenseRoom extends InstanceContainer {
private final List<Tower> towers = new ArrayList<>();
private final Cursor cursor;
private int money = 0;
private boolean dead = false;
public TowerdefenseRoom(Player player, Towerdefense game) {
super(UUID.randomUUID(), Dimension.OVERWORLD.key);
@@ -60,10 +62,12 @@ public class TowerdefenseRoom extends InstanceContainer {
this.player.getInventory().addItemStack(
ItemStack.of(Material.BARRIER).withCustomName(Component.text("Löschen", TextColor.color(255,0,0)))
);
this.game.getAvailableTowers().forEach((material, tower) -> {
int price = this.game.getPrices().get(tower);
this.player.getInventory().addItemStack(ItemStack.of(material).withMaxStackSize(price).withAmount(price));
});
this.game.getAvailableTowers().entrySet().stream()
.sorted(java.util.Comparator.comparingInt(e -> this.game.getPrices().get(e.getValue())))
.forEach(entry -> {
int price = this.game.getPrices().get(entry.getValue());
this.player.getInventory().addItemStack(ItemStack.of(entry.getKey()).withMaxStackSize(price).withAmount(price));
});
this.setGenerator(new MazeGenerator());
BatchUtil.loadAndApplyBatch(this.game.getMazeBatch(), this, () -> {});
@@ -77,11 +81,14 @@ public class TowerdefenseRoom extends InstanceContainer {
if(!(event.getEntity() instanceof EntityCreature enemy)) return;
this.enemies.remove(enemy);
})
.addListener(PlayerTickEvent.class, event -> this.cursor.updateCursorPosition(this.player))
.addListener(PlayerUseItemEvent.class, event -> this.useItem())
.addListener(PlayerUseItemOnBlockEvent.class, event -> this.useItem())
.addListener(PlayerEntityInteractEvent.class, event -> this.useItem())
.addListener(PlayerHandAnimationEvent.class, event -> this.useItem())
.addListener(PlayerTickEvent.class, event -> {
if(event.getPlayer() != this.player || this.dead) return;
this.cursor.updateCursorPosition(this.player);
})
.addListener(PlayerUseItemEvent.class, event -> this.handleUse(event.getPlayer()))
.addListener(PlayerUseItemOnBlockEvent.class, event -> this.handleUse(event.getPlayer()))
.addListener(PlayerEntityInteractEvent.class, event -> this.handleUse(event.getPlayer()))
.addListener(PlayerHandAnimationEvent.class, event -> this.handleUse(event.getPlayer()))
.addListener(ItemDropEvent.class, CommonEventHandles::cancel)
.addListener(InventoryPreClickEvent.class, CommonEventHandles::cancel)
.addListener(PlayerSwapItemEvent.class, CommonEventHandles::cancel)
@@ -90,6 +97,17 @@ public class TowerdefenseRoom extends InstanceContainer {
});
}
private void handleUse(Player eventPlayer) {
if(this.game.getSpectators().contains(eventPlayer)) {
if(eventPlayer.getItemInMainHand().material() == Material.COMPASS) {
this.game.cycleSpectator(eventPlayer);
}
return;
}
if(eventPlayer != this.player || this.dead) return;
this.useItem();
}
private void useItem() {
Material itemInHand = this.player.getItemInMainHand().material();
if(itemInHand.equals(Material.BOW)) {
@@ -108,23 +126,31 @@ public class TowerdefenseRoom extends InstanceContainer {
Player p = this.player;
this.lastPlayerAttack = System.currentTimeMillis();
EntityProjectile projectile = new EntityProjectile(p, EntityType.SPECTRAL_ARROW);
projectile.setView(p.getPosition().yaw(), p.getPosition().pitch());
projectile.setNoGravity(true);
projectile.setAerodynamics(new Aerodynamics(0, 1, 1));
projectile.setAerodynamics(new Aerodynamics(0, 0.99, 0.99));
projectile.setView(p.getPosition().yaw(), p.getPosition().pitch());
Vec projectileVelocity = p.getPosition().direction().normalize().mul(20);
projectile.setVelocity(projectileVelocity);
Pos spawn = p.getPosition().add(0, p.getEyeHeight() - 0.2, 0);
projectile.scheduleRemove(Duration.ofSeconds(5));
projectile.setInstance(this, p.getPosition().add(0, p.getEyeHeight()-0.5, 0));
projectile.eventNode()
.addListener(ProjectileCollideWithEntityEvent.class, hitEvent -> {
if(!(hitEvent.getTarget() instanceof EntityCreature target)) return;
if(!this.getEnemies().stream().filter(entityCreature -> !entityCreature.isDead()).toList().contains(target)) return;
target.damage(DamageType.PLAYER_ATTACK, 0.1f);
if(target.isDead()) this.addMoney((int) target.getAttribute(Attribute.MAX_HEALTH).getBaseValue());
projectile.remove();
if(hitEvent.getTarget() == projectile.getShooter()) return;
try {
if(hitEvent.getTarget() instanceof EntityCreature target
&& !target.isDead()
&& this.enemies.contains(target)) {
target.damage(DamageType.PLAYER_ATTACK, 1.2f);
if(target.isDead()) this.addMoney(Math.max(1, (int) target.getAttribute(Attribute.MAX_HEALTH).getBaseValue() / 3));
}
} finally {
projectile.remove();
}
})
.addListener(ProjectileCollideWithBlockEvent.class, collisionEvent -> projectile.remove());
projectile.setInstance(this, spawn).thenRun(() -> {
Vec projectileVelocity = p.getPosition().direction().normalize().mul(60);
projectile.setVelocity(projectileVelocity);
});
}
public void addMoney(int amount) {
@@ -163,9 +189,18 @@ public class TowerdefenseRoom extends InstanceContainer {
}
private void removeTower() {
Entity entity = this.player.getLineOfSightEntity(reach, entity1 -> true);
if(!(entity instanceof Tower tower)) return;
if(!this.towers.contains(tower)) return;
Tower tower = null;
Entity entity = this.player.getLineOfSightEntity(reach, e -> e instanceof Tower);
if(entity instanceof Tower t && this.towers.contains(t)) {
tower = t;
} else if(this.cursor.getTargetBlockPosition() != null) {
Pos target = new Pos(this.cursor.getTargetBlockPosition()).add(0.5, 0.5, 0.5);
tower = this.towers.stream()
.filter(t -> t.getPosition().distanceSquared(target) <= 4)
.min(java.util.Comparator.comparingDouble(t -> t.getPosition().distanceSquared(target)))
.orElse(null);
}
if(tower == null) return;
this.setBlock(tower.getPosition().sub(0, 0.5, 0), Block.BLACK_WOOL);
this.addMoney(tower.getSellingPrice(this.game.getPrices().get(tower.getClass())));
@@ -207,15 +242,10 @@ public class TowerdefenseRoom extends InstanceContainer {
if(positionIndex == this.game.getMazePath().size()-1) {
this.enemies.remove(enemy);
enemy.remove();
if(this.dead) return;
float damage = (float) Math.ceil(enemy.getHealth()/10);
if(this.player.getHealth() - damage <= 0) {
this.getEnemies().forEach(Entity::remove);
this.towers.forEach(Entity::remove);
this.player.getAttribute(Attribute.BLOCK_INTERACTION_RANGE).setBaseValue(this.player.getAttribute(Attribute.BLOCK_INTERACTION_RANGE).attribute().defaultValue());
this.player.getAttribute(Attribute.ENTITY_INTERACTION_RANGE).setBaseValue(this.player.getAttribute(Attribute.ENTITY_INTERACTION_RANGE).attribute().defaultValue());
this.player.setInstance(this.game).thenRun(() -> MinecraftServer.getInstanceManager().unregisterInstance(this));
this.player.heal();
this.game.getScore().insertResult(this.player);
this.handleDeath();
return;
}
this.player.damage(DamageType.PLAYER_ATTACK, damage);
@@ -224,6 +254,28 @@ public class TowerdefenseRoom extends InstanceContainer {
enemy.getNavigator().setPathTo(this.game.getMazePath().get(positionIndex+1), 0.6, () -> this.changeEnemyGoal(enemy, positionIndex+1));
}
private void handleDeath() {
this.dead = true;
this.game.removeInstance(this);
new ArrayList<>(this.enemies).forEach(Entity::remove);
this.enemies.clear();
new ArrayList<>(this.towers).forEach(Entity::remove);
this.towers.clear();
this.player.getAttribute(Attribute.BLOCK_INTERACTION_RANGE).setBaseValue(this.player.getAttribute(Attribute.BLOCK_INTERACTION_RANGE).attribute().defaultValue());
this.player.getAttribute(Attribute.ENTITY_INTERACTION_RANGE).setBaseValue(this.player.getAttribute(Attribute.ENTITY_INTERACTION_RANGE).attribute().defaultValue());
this.player.heal();
this.game.getScore().insertResult(this.player);
if(!this.game.getAliveInstances().isEmpty()) {
this.game.beginSpectating(this.player);
} else {
this.player.setInstance(this.game).thenRun(() -> MinecraftServer.getInstanceManager().unregisterInstance(this));
}
}
public boolean isDead() {
return this.dead;
}
public Towerdefense getGame() {
return this.game;
}
@@ -2,10 +2,12 @@ package eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.ene
import eu.mhsl.minenet.minigames.instance.game.stateless.types.towerdefense.Towerdefense;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.EntityCreature;
import net.minestom.server.entity.EntityType;
import net.minestom.server.timer.TaskSchedule;
import java.util.*;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
@@ -32,44 +34,71 @@ public class WaveGenerator {
this.currentLevel = startLevel - 1;
}
private static final int MAX_ENEMIES_PER_WAVE = 60;
private static final int MAX_ALIVE_PER_ROOM = 150;
public void startNextWave() {
this.currentLevel += 1;
if(this.game.getAliveInstances().isEmpty()) return;
List<EnemyFactory> enemyList = this.chooseEnemies();
if(enemyList.isEmpty()) return;
Collections.shuffle(enemyList);
int averageDelay = Math.min(20000 / enemyList.size(), 1500);
AtomicInteger delay = new AtomicInteger(0);
enemyList.forEach(enemy -> {
for (EnemyFactory enemy : enemyList) {
delay.addAndGet(averageDelay);
MinecraftServer.getSchedulerManager().scheduleTask(
() -> this.spawnEnemy(enemyList.removeFirst()),
() -> this.spawnEnemy(enemy),
TaskSchedule.millis(delay.get() + this.random.nextInt(averageDelay-200, averageDelay+200)),
TaskSchedule.stop()
);
});
}
}
private void spawnEnemy(EnemyFactory enemy) {
this.game.getInstances().forEach(instance -> instance.addEnemy(enemy.buildEntity()));
float hpMultiplier = this.getHealthMultiplier();
this.game.getAliveInstances().forEach(instance -> {
if(instance.isDead()) return;
if(instance.getEnemies().size() >= MAX_ALIVE_PER_ROOM) return;
try {
EntityCreature entity = enemy.buildEntity();
if(hpMultiplier > 1f) {
float scaledHp = (float) Math.min(1024, entity.getAttribute(net.minestom.server.entity.attribute.Attribute.MAX_HEALTH).getBaseValue() * hpMultiplier);
entity.getAttribute(net.minestom.server.entity.attribute.Attribute.MAX_HEALTH).setBaseValue(scaledHp);
entity.setHealth(scaledHp);
}
instance.addEnemy(entity);
} catch (Exception ignored) {
}
});
}
private float getHealthMultiplier() {
// Levels 1-3: unchanged. Then +12% HP per level, capped so tower
// damage still matters at very high levels.
return Math.min(5f, 1f + Math.max(0, this.currentLevel - 3) * 0.12f);
}
private int getCurrentLevelStrength() {
return (int) (0.5 * Math.pow(2, this.currentLevel));
// Linear scaling (was 0.5 * 2^level — exponential blow-up).
int alive = Math.max(1, this.game.getAliveInstances().size());
return (int) (15 + this.currentLevel * 6 * Math.sqrt(alive));
}
private List<EnemyFactory> chooseEnemies() {
int strength = this.getCurrentLevelStrength();
final List<EnemyFactory> usedEnemies = new ArrayList<>();
while(strength > 0) {
while(strength > 0 && usedEnemies.size() < MAX_ENEMIES_PER_WAVE) {
int finalStrength = strength;
Map.Entry<EnemyFactory, Integer> chosenEnemy = this.availableEnemies.entrySet().stream()
Optional<Map.Entry<EnemyFactory, Integer>> chosenEnemy = this.availableEnemies.entrySet().stream()
.filter(e -> e.getValue() <= finalStrength)
.sorted((o1, o2) -> ThreadLocalRandom.current().nextInt(-1, 2))
.findAny()
.get();
usedEnemies.add(chosenEnemy.getKey());
strength -= chosenEnemy.getKey().getStrength();
.findAny();
if(chosenEnemy.isEmpty()) break;
usedEnemies.add(chosenEnemy.get().getKey());
strength -= chosenEnemy.get().getKey().getStrength();
}
return usedEnemies;
@@ -5,7 +5,7 @@ import net.minestom.server.entity.EntityType;
public class EnderDragonTower extends ShootingTower{
public EnderDragonTower() {
super(EntityType.ENDER_DRAGON, 40, 7, 20);
super(EntityType.ENDER_DRAGON, 20, 3, 16);
}
@Override
@@ -43,12 +43,18 @@ public abstract class ShootingTower extends Tower {
projectile.setInstance(this.getInstance(), startingPoint);
projectile.eventNode()
.addListener(ProjectileCollideWithEntityEvent.class, event -> {
if(!(event.getTarget() instanceof EntityCreature target)) return;
if(!this.getRoomInstance().getEnemies().contains(target)) return;
this.causeDamage(target);
this.onProjectileHit(target);
target.setFlyingWithElytra(true);
projectile.remove();
if(event.getTarget() == projectile.getShooter()) return;
try {
if(event.getTarget() instanceof EntityCreature target
&& !target.isDead()
&& this.getRoomInstance().getEnemies().contains(target)) {
this.causeDamage(target);
this.onProjectileHit(target);
if(target.isDead()) target.setFlyingWithElytra(true);
}
} finally {
projectile.remove();
}
})
.addListener(ProjectileCollideWithBlockEvent.class, event -> projectile.remove());
}
@@ -7,7 +7,7 @@ import net.minestom.server.item.Material;
public class SkeletonTower extends ShootingTower {
public SkeletonTower() {
super(EntityType.SKELETON, 1, 2, 10);
super(EntityType.SKELETON, 3, 2.5, 12);
this.setItemInMainHand(ItemStack.of(Material.BOW));
}
@@ -87,14 +87,15 @@ public abstract class Tower extends EntityCreature {
.filter(enemy -> !enemy.isDead())
.filter(enemy -> enemy.getPosition().distance(this.getPosition()) <= this.range);
return switch (this.priority.getType()) {
case PATH_DISTANCE -> enemyStream
.sorted((o1, o2) -> {
Pos endPoint = this.getRoomInstance().getGame().getMazePath().getLast();
return Double.compare(
o1.getPosition().distance(endPoint),
o2.getPosition().distance(endPoint)
);
}).toList();
case PATH_DISTANCE -> {
List<Pos> path = this.getRoomInstance().getGame().getMazePath();
yield enemyStream
.sorted((o1, o2) -> Integer.compare(
pathProgress(o1, path),
pathProgress(o2, path)
))
.toList();
}
case TOWER_DISTANCE -> enemyStream
.sorted((o1, o2) -> Double.compare(
o1.getPosition().distance(this.getPosition()),
@@ -106,6 +107,21 @@ public abstract class Tower extends EntityCreature {
};
}
private static int pathProgress(EntityCreature enemy, List<Pos> path) {
// Highest index == furthest along the path. Returned as negative so a
// natural ascending sort puts the most-advanced enemy first.
double bestDist = Double.MAX_VALUE;
int bestIdx = 0;
for(int i = 0; i < path.size(); i++) {
double d = enemy.getPosition().distanceSquared(path.get(i));
if(d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return -bestIdx;
}
protected TowerdefenseRoom getRoomInstance() {
return (TowerdefenseRoom) this.getInstance();
}
@@ -128,7 +144,7 @@ public abstract class Tower extends EntityCreature {
protected void causeDamage(EntityCreature enemy, float amount) {
enemy.damage(DamageType.PLAYER_ATTACK, amount);
if(enemy.isDead()) this.getRoomInstance().addMoney((int) enemy.getAttribute(Attribute.MAX_HEALTH).getBaseValue());
if(enemy.isDead()) this.getRoomInstance().addMoney(Math.max(1, (int) enemy.getAttribute(Attribute.MAX_HEALTH).getBaseValue() / 3));
}
protected abstract void attack(EntityCreature enemy);
+2 -1
View File
@@ -20,4 +20,5 @@ api:
admins:
- minetec
- 28Pupsi28
- 28Pupsi28
- jannis_mg