feat(tyrant): summon arena bosses only near players
This commit is contained in:
@@ -12,14 +12,29 @@ final class RoleArenaBarrier {
|
||||
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
|
||||
private boolean built;
|
||||
|
||||
void build(
|
||||
boolean build(
|
||||
World world,
|
||||
ArenaLocation center,
|
||||
double radius,
|
||||
int wallHeight
|
||||
) {
|
||||
if (built) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
restorePending();
|
||||
if (!replaced.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int minX = ((int) Math.floor(center.x() - radius)) >> 4;
|
||||
int maxX = ((int) Math.floor(center.x() + radius)) >> 4;
|
||||
int minZ = ((int) Math.floor(center.z() - radius)) >> 4;
|
||||
int maxZ = ((int) Math.floor(center.z() + radius)) >> 4;
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
if (!world.isChunkLoaded(x, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
built = true;
|
||||
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
|
||||
@@ -38,18 +53,33 @@ final class RoleArenaBarrier {
|
||||
block.setType(Material.BARRIER, false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
for (Map.Entry<Location, BlockData> entry : replaced.entrySet()) {
|
||||
built = false;
|
||||
restorePending();
|
||||
}
|
||||
|
||||
void restorePending() {
|
||||
if (built) {
|
||||
return;
|
||||
}
|
||||
var iterator = replaced.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<Location, BlockData> entry = iterator.next();
|
||||
Location location = entry.getKey();
|
||||
World world = location.getWorld();
|
||||
if (world != null) {
|
||||
if (world != null && world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
world.getBlockAt(location).setBlockData(entry.getValue(), false);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
replaced.clear();
|
||||
built = false;
|
||||
}
|
||||
|
||||
boolean occupies(org.bukkit.Chunk chunk) {
|
||||
return replaced.keySet().stream().anyMatch(location -> location.getWorld() == chunk.getWorld()
|
||||
&& location.getBlockX() >> 4 == chunk.getX() && location.getBlockZ() >> 4 == chunk.getZ());
|
||||
}
|
||||
|
||||
boolean active() {
|
||||
|
||||
@@ -33,6 +33,9 @@ import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.event.world.EntitiesLoadEvent;
|
||||
import org.bukkit.event.world.EntitiesUnloadEvent;
|
||||
|
||||
public final class RoleArenaController implements Listener, Runnable {
|
||||
private static final String BOSS_TAG = "spigottyrant-role-arena-boss";
|
||||
@@ -40,6 +43,7 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
private final Server server;
|
||||
private final ArenaLocationStore locations;
|
||||
private final PluginSettings settings;
|
||||
private final java.util.function.BiConsumer<Zombie, PluginSettings> configureBossAttributes;
|
||||
private final VigilanteArenaSuccessionService vigilanteSuccession =
|
||||
new VigilanteArenaSuccessionService();
|
||||
private final TyrantArenaSuccessionService tyrantSuccession =
|
||||
@@ -47,27 +51,49 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
private final RoleArenaBarrier barrier = new RoleArenaBarrier();
|
||||
private final Set<UUID> relocating = new HashSet<>();
|
||||
private UUID bossId;
|
||||
private Zombie bossEntity;
|
||||
private UUID challengerId;
|
||||
private boolean spawningBoss;
|
||||
private Zombie spawningBoss;
|
||||
private int spawnRetrySweeps;
|
||||
private boolean spawnWarning;
|
||||
private ArenaRole previousOpenRole;
|
||||
private ArenaRole bossRole;
|
||||
private ArenaRole warnedMissingRole;
|
||||
private ArenaLocation observedLocation;
|
||||
private boolean reconciledLoadedBosses;
|
||||
|
||||
public RoleArenaController(
|
||||
TyrantStateManager stateManager,
|
||||
Server server,
|
||||
ArenaLocationStore locations,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this(stateManager, server, locations, settings, RoleArenaController::configureBossAttributes);
|
||||
}
|
||||
|
||||
// Isolate registry-backed attributes from the lifecycle adapter's headless tests.
|
||||
RoleArenaController(
|
||||
TyrantStateManager stateManager, Server server, ArenaLocationStore locations,
|
||||
PluginSettings settings,
|
||||
java.util.function.BiConsumer<Zombie, PluginSettings> configureBossAttributes
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.server = server;
|
||||
this.locations = locations;
|
||||
this.settings = settings;
|
||||
this.configureBossAttributes = configureBossAttributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (spawnRetrySweeps > 0) {
|
||||
spawnRetrySweeps--;
|
||||
}
|
||||
barrier.restorePending();
|
||||
if (!reconciledLoadedBosses) {
|
||||
server.getWorlds().forEach(world -> removeStaleBosses(world.getEntities()));
|
||||
reconciledLoadedBosses = true;
|
||||
}
|
||||
Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
|
||||
Optional<ArenaLocation> configured = locations.location();
|
||||
if (configured.isEmpty()) {
|
||||
@@ -100,8 +126,15 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
}
|
||||
previousOpenRole = role;
|
||||
drawBoundary(world, arena, role);
|
||||
int chunkX = ((int) Math.floor(arena.x())) >> 4;
|
||||
int chunkZ = ((int) Math.floor(arena.z())) >> 4;
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ)
|
||||
|| !world.getChunkAt(chunkX, chunkZ).isEntitiesLoaded()) {
|
||||
closeFight();
|
||||
return;
|
||||
}
|
||||
removeOtherMobs(world, arena);
|
||||
if (role == null) {
|
||||
if (role == null || !hasNearbyPlayer(world, arena)) {
|
||||
closeFight();
|
||||
return;
|
||||
}
|
||||
@@ -109,14 +142,21 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
Zombie boss = boss(world);
|
||||
if (boss == null || bossRole != role) {
|
||||
closeFight();
|
||||
if (spawnRetrySweeps > 0) {
|
||||
return;
|
||||
}
|
||||
boss = spawnBoss(world, arena, role);
|
||||
}
|
||||
if (boss == null) {
|
||||
return;
|
||||
}
|
||||
boss.setFireTicks(0);
|
||||
if (!contains(arena, boss.getLocation())) {
|
||||
boss.teleport(toBukkit(world, arena));
|
||||
}
|
||||
Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
|
||||
if (challenger == null || challenger.isDead()
|
||||
if (challenger == null || !challenger.isOnline() || challenger.isDead()
|
||||
|| !withinBossRange(arena, challenger.getLocation())
|
||||
|| !contains(arena, challenger.getLocation())
|
||||
|| !RoleArenaPolicy.isEligible(
|
||||
stateManager.game(), role, playerState(challenger)
|
||||
@@ -189,10 +229,45 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onChunkUnload(ChunkUnloadEvent event) {
|
||||
Zombie current = bossEntity;
|
||||
boolean bossChunk = current != null && current.getWorld() == event.getWorld()
|
||||
&& (current.getLocation().getBlockX() >> 4) == event.getChunk().getX()
|
||||
&& (current.getLocation().getBlockZ() >> 4) == event.getChunk().getZ();
|
||||
if (bossChunk || barrier.occupies(event.getChunk())) {
|
||||
// Restore walls while block chunks are still loaded, before entities unload.
|
||||
closeFight();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onEntitiesLoad(EntitiesLoadEvent event) {
|
||||
// Event payloads may not yet be visible through world/server entity lookups.
|
||||
removeStaleBosses(event.getEntities());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onEntitiesUnload(EntitiesUnloadEvent event) {
|
||||
removeStaleBosses(event.getEntities());
|
||||
if (event.getEntities().stream().anyMatch(entity -> entity.getUniqueId().equals(bossId))) {
|
||||
closeFight();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeStaleBosses(Iterable<Entity> entities) {
|
||||
for (Entity entity : entities) {
|
||||
if (entity.getScoreboardTags().contains(BOSS_TAG) && !entity.getUniqueId().equals(bossId)) {
|
||||
entity.setPersistent(false);
|
||||
entity.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCreatureSpawn(CreatureSpawnEvent event) {
|
||||
Optional<ArenaLocation> configured = locations.location();
|
||||
if (!spawningBoss && configured.isPresent()
|
||||
if (event.getEntity() != spawningBoss && configured.isPresent()
|
||||
&& contains(configured.orElseThrow(), event.getLocation())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
@@ -253,8 +328,10 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
return;
|
||||
}
|
||||
UUID defeatedBoss = bossId;
|
||||
Zombie defeatedEntity = bossEntity;
|
||||
ArenaRole defeatedRole = bossRole;
|
||||
bossId = null;
|
||||
bossEntity = null;
|
||||
bossRole = null;
|
||||
Player killer = event.getEntity().getKiller();
|
||||
if (killer != null && challengerId != null && defeatedRole != null
|
||||
@@ -266,6 +343,7 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
return;
|
||||
}
|
||||
bossId = defeatedBoss;
|
||||
bossEntity = defeatedEntity;
|
||||
bossRole = defeatedRole;
|
||||
resetFight();
|
||||
}
|
||||
@@ -276,13 +354,14 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
|
||||
private boolean enforcePosition(Player player, Location from, Location to) {
|
||||
Optional<ArenaLocation> configured = locations.location();
|
||||
if (configured.isEmpty() || relocating.contains(player.getUniqueId())) {
|
||||
if (configured.isEmpty() || !player.isOnline() || player.isDead()
|
||||
|| relocating.contains(player.getUniqueId())) {
|
||||
return false;
|
||||
}
|
||||
ArenaLocation arena = configured.orElseThrow();
|
||||
boolean inside = contains(arena, to);
|
||||
if (player.getUniqueId().equals(challengerId)) {
|
||||
if (!inside) {
|
||||
if (!inside || !withinBossRange(arena, to)) {
|
||||
resetFight();
|
||||
}
|
||||
return false;
|
||||
@@ -291,23 +370,29 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
return false;
|
||||
}
|
||||
PlayerState state = playerState(player);
|
||||
ArenaRole role = bossRole;
|
||||
if (challengerId == null && bossId != null && role != null
|
||||
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
||||
challengerId = player.getUniqueId();
|
||||
World world = server.getWorld(arena.worldName());
|
||||
if (world != null) {
|
||||
barrier.build(
|
||||
world, arena, settings.vigilanteArenaRadiusBlocks(),
|
||||
settings.arenaBarrierHeightBlocks()
|
||||
);
|
||||
Zombie boss = boss(world);
|
||||
if (boss != null) {
|
||||
boss.setHealth(settings.vigilanteBossHealth());
|
||||
boss.teleport(toBukkit(world, arena));
|
||||
ArenaBossState.engage(boss, player);
|
||||
}
|
||||
if (bossId == null && withinBossRange(arena, to)) {
|
||||
Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
|
||||
if (openRole.isPresent() && RoleArenaPolicy.isEligible(
|
||||
stateManager.game(), openRole.orElseThrow(), state)) {
|
||||
// Allow an eligible arrival to commit; the sweep summons/admits, not a
|
||||
// proposed teleport which another listener could still cancel.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ArenaRole role = bossRole;
|
||||
if (challengerId == null && bossId != null && role != null && withinBossRange(arena, to)
|
||||
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
||||
World world = server.getWorld(arena.worldName());
|
||||
Zombie boss = world == null ? null : boss(world);
|
||||
if (boss == null || !barrier.build(
|
||||
world, arena, settings.vigilanteArenaRadiusBlocks(),
|
||||
settings.arenaBarrierHeightBlocks())) {
|
||||
return false;
|
||||
}
|
||||
challengerId = player.getUniqueId();
|
||||
boss.setHealth(settings.vigilanteBossHealth());
|
||||
boss.teleport(toBukkit(world, arena));
|
||||
ArenaBossState.engage(boss, player);
|
||||
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
|
||||
+ readable(role) + "!");
|
||||
return false;
|
||||
@@ -350,25 +435,22 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
private void closeFight() {
|
||||
challengerId = null;
|
||||
barrier.clear();
|
||||
if (bossId != null) {
|
||||
Entity entity = server.getEntity(bossId);
|
||||
if (entity != null) {
|
||||
entity.remove();
|
||||
}
|
||||
bossId = null;
|
||||
}
|
||||
Zombie retiring = bossEntity;
|
||||
bossEntity = null;
|
||||
bossId = null;
|
||||
bossRole = null;
|
||||
if (retiring != null) {
|
||||
bossParticles(retiring.getLocation());
|
||||
retiring.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) {
|
||||
for (Entity entity : world.getEntities()) {
|
||||
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
|
||||
entity.remove();
|
||||
}
|
||||
}
|
||||
spawningBoss = true;
|
||||
try {
|
||||
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
|
||||
spawningBoss = zombie;
|
||||
zombie.setPersistent(false);
|
||||
zombie.addScoreboardTag(BOSS_TAG);
|
||||
zombie.setAdult();
|
||||
zombie.setCustomName(role == ArenaRole.TYRANT
|
||||
? ChatColor.RED + "Tyrant's Trial"
|
||||
@@ -377,27 +459,48 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
zombie.setRemoveWhenFarAway(false);
|
||||
zombie.setCanPickupItems(false);
|
||||
zombie.setAI(false);
|
||||
zombie.addScoreboardTag(BOSS_TAG);
|
||||
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
|
||||
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
|
||||
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
|
||||
configureBossAttributes.accept(zombie, settings);
|
||||
zombie.setHealth(settings.vigilanteBossHealth());
|
||||
});
|
||||
spawningBoss = boss;
|
||||
// Reassert after spawn listeners and never own a cancelled/removed candidate.
|
||||
boss.setPersistent(false);
|
||||
if (!boss.isValid() || boss.isDead()) {
|
||||
boss.remove();
|
||||
spawnFailed();
|
||||
return null;
|
||||
}
|
||||
spawnWarning = false;
|
||||
bossEntity = boss;
|
||||
bossId = boss.getUniqueId();
|
||||
bossRole = role;
|
||||
bossParticles(boss.getLocation());
|
||||
return boss;
|
||||
} catch (RuntimeException exception) {
|
||||
bossEntity = null;
|
||||
bossId = null;
|
||||
bossRole = null;
|
||||
if (spawningBoss != null) {
|
||||
spawningBoss.remove();
|
||||
}
|
||||
spawnFailed();
|
||||
return null;
|
||||
} finally {
|
||||
spawningBoss = false;
|
||||
spawningBoss = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnFailed() {
|
||||
spawnRetrySweeps = 10; // Five seconds at the controller's ten-tick cadence.
|
||||
if (!spawnWarning) {
|
||||
server.getLogger().warning("Tyrant arena boss spawn failed or was cancelled; retrying in five seconds.");
|
||||
spawnWarning = true;
|
||||
}
|
||||
}
|
||||
|
||||
private Zombie boss(World world) {
|
||||
if (bossId == null) {
|
||||
return null;
|
||||
}
|
||||
Entity entity = server.getEntity(bossId);
|
||||
return entity instanceof Zombie zombie && entity.getWorld().equals(world)
|
||||
&& !zombie.isDead() ? zombie : null;
|
||||
return bossEntity != null && bossEntity.getWorld().equals(world)
|
||||
&& bossEntity.isValid() && !bossEntity.isDead() ? bossEntity : null;
|
||||
}
|
||||
|
||||
void assignRole(UUID playerId, ArenaRole role) {
|
||||
@@ -528,6 +631,26 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private void bossParticles(Location location) {
|
||||
World world = location.getWorld();
|
||||
if (world != null && world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
world.spawnParticle(Particle.PORTAL, location.clone().add(0, 1, 0),
|
||||
60, 0.6, 1.0, 0.6, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasNearbyPlayer(World world, ArenaLocation arena) {
|
||||
return world.getPlayers().stream().anyMatch(player -> player.isOnline() && !player.isDead()
|
||||
&& withinBossRange(arena, player.getLocation()));
|
||||
}
|
||||
|
||||
private boolean withinBossRange(ArenaLocation arena, Location location) {
|
||||
World world = location.getWorld();
|
||||
double range = settings.vigilanteArenaRadiusBlocks() + 8.0;
|
||||
return world != null && world.getName().equals(arena.worldName())
|
||||
&& location.distanceSquared(toBukkit(world, arena)) <= range * range;
|
||||
}
|
||||
|
||||
private boolean protects(Location location) {
|
||||
Optional<ArenaLocation> configured = locations.location();
|
||||
World world = location.getWorld();
|
||||
@@ -603,6 +726,12 @@ public final class RoleArenaController implements Listener, Runnable {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void configureBossAttributes(Zombie zombie, PluginSettings settings) {
|
||||
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
|
||||
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
|
||||
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
|
||||
}
|
||||
|
||||
private static void setAttribute(Zombie zombie, Attribute attribute, double value) {
|
||||
if (zombie.getAttribute(attribute) != null) {
|
||||
zombie.getAttribute(attribute).setBaseValue(value);
|
||||
|
||||
Reference in New Issue
Block a user