I needed a Minecraft server plugin that enables keepInventory and keepLevel, but only within certain designated areas. I couldn't find one that did what I needed and nothing more, so I wrote it.
You can download the
.jar from the plugin's
Modrinth page
to add it to your server (still under review at the time of this
writing). Find the source code on the
GitHub repository.
I started by defining a PvPArea class that represents a rectangular
area in the overworld. Each area has a set of 4 integers representing
the minimum and maximum x and z coordinates. I overwrote the
equals and hashCode methods so that areas
are decided to be equal purely based off of their coordinates.
public class PvPArea {
private final int xMin, xMax, zMin, zMax;
public PvPArea(int xMin, int xMax, int zMin, int zMax) {
if (xMin >= xMax || zMin >= zMax) {
throw new RuntimeException("Coordinate minimums must be less than maximums");
}
this.xMin = xMin;
this.xMax = xMax;
this.zMin = zMin;
this.zMax = zMax;
}
public boolean hasPlayerWithin(Player player) {
Location playerLoc = player.getLocation();
int pX = playerLoc.getBlockX();
int pZ = playerLoc.getBlockZ();
return pX >= xMin && pX <= xMax && pZ >= zMin && pZ <= zMax;
}
public boolean overlapsArea(PvPArea other) {
if (this.equals(other)) {
return true;
}
return !(zMin >= other.getZMax() && zMax > other.getZMax()) &&
!(zMin < other.getZMin() && zMax <= other.getZMin()) &&
!(xMin >= other.getXMax() && xMax > other.getXMax()) &&
!(xMin < other.getXMin() && xMax <= other.getXMin());
}
public boolean overlapsAnyArea(Set<PvPArea> others) {
for (PvPArea other : others) {
if (this.overlapsArea(other)) {
return true;
}
}
return false;
}
public int getXMin() {
return xMin;
}
public int getXMax() {
return xMax;
}
public int getZMin() {
return zMin;
}
public int getZMax() {
return zMax;
}
@Override
public String toString() {
return "(x-min=" + xMin + ", x-max=" + xMax + ", z-min=" + zMin + ", z-max=" + zMax + ")";
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof PvPArea area)) {
return false;
}
return this.xMin == area.getXMin() && this.xMax == area.getXMax()
&& this.zMin == area.getZMin() && this.zMax == area.getZMax();
}
@Override
public int hashCode() {
return Objects.hash(xMin, xMax, zMin, zMax);
}
}
Notice that the overlapsAnyArea method scans through a
set of areas and returns true if the current area overlaps with any of
them. This works for now because the plugin was designed for a small
server, but I will get into possible optimizations for the future
below after reviewing the main plugin class and custom listener.
The plugin maintains a set of PvPArea objects and
initializes a custom event listener that listens for any
PlayerDeathEvent. If the player is within any of the PvP
areas, we set keepInventory and keepLevel to
true and prevent the player from dropping anything with
event.getDrops().clear() and
event.setDroppedExp(0). Here is the listener's code:
public class DeathListener implements Listener {
private Set<PvPArea> areaSet;
public DeathListener(Set<PvPArea> areaSet) {
this.areaSet = areaSet;
}
public void setAreaSet(Set<PvPArea> areaSet) {
this.areaSet = areaSet;
}
@EventHandler
public void onPlayerDeath(PlayerDeathEvent event) {
Player player = event.getPlayer();
for (PvPArea area : areaSet) {
if (area.hasPlayerWithin(player) && player.getWorld().getEnvironment() == World.Environment.NORMAL) {
event.setKeepInventory(true);
event.getDrops().clear();
event.setKeepLevel(true);
event.setDroppedExp(0);
player.sendRichMessage("You died in a PvP area, so keepInventory and keepLevel were enabled.");
}
}
}
}
The area data is saved in a World's
PersistentDataContainer as an array of integers. The
World is selected by choosing the first item from
Bukkit.getWorlds() whose environment is
World.Environment.NORMAL. This is a current limitation,
and support for other types of worlds may be added in future releases.
The array is formatted as follows:
[xMin1, xMax1, zMin1, zMax1, xMin2, xMax2, zMin2, zMax2, ...]
and is read in to the areaSet when the plugin is enabled.
Any time an operator adds or clears all areas, the
PersistentDataContainer is updated accordingly.
public class PvPAreaPlugin extends JavaPlugin {
private final Set<PvPArea> areaSet = new HashSet<>();
private final DeathListener areasListener = new DeathListener(areaSet);
World world;
PersistentDataContainer pdc;
@Override
public void onEnable() {
getLogger().info("PvPArea enabled!");
Keys.init(this);
world = Bukkit.getWorlds().stream()
.filter(w -> w.getEnvironment() == World.Environment.NORMAL)
.findFirst()
.orElseThrow();
pdc = world.getPersistentDataContainer();
int[] areasInts = pdc.get(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY);
if (areasInts == null || areasInts.length == 0) {
getLogger().info("No areas found in PDC. Proceeding with 0 PvPAreas...");
} else if (areasInts.length % 4 != 0) {
getLogger().info("PDC integer array contains an incompatible number of ints.");
} else {
for (int i = 0; i < areasInts.length; i += 4) {
int xMin = areasInts[i];
int xMax = areasInts[i + 1];
int zMin = areasInts[i + 2];
int zMax = areasInts[i + 3];
try {
PvPArea area = new PvPArea(xMin, xMax, zMin, zMax);
if (!area.overlapsAnyArea(areaSet)) {
areaSet.add(area);
} else {
getLogger().info("Area overlaps existing areas. Skipping...");
}
} catch (RuntimeException e) {
getLogger().info("Invalid area. Skipping...");
}
}
}
getServer().getPluginManager().registerEvents(areasListener, this);
registerCommands();
}
private void registerCommands() {
...
}
@Override
public void onDisable() {
getLogger().info("PvPArea disabled!");
}
}
Finally, we register the commands for adding, listing, and clearing
all areas. The /pvparea add command takes in 4 integers
for the coordinates of the area and adds it to the
areaSet and PersistentDataContainer if it
doesn't overlap with any existing areas. The
/pvparea list command sends a message to the player with
all of the current areas, and the
/pvparea clear-all command clears all areas from the set
and clears the areaSet and
PersistentDataContainer.
I will admit this code is a little messy. Blame the silly nested command builder syntax. I might clean it up, I might not.
private void registerCommands() {
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("pvparea");
root.then(Commands.literal("list")
.executes(ctx -> {
CommandSender sender = ctx.getSource().getSender();
if (areaSet.isEmpty()) {
sender.sendMessage("There are no PvP areas currently active.");
return Command.SINGLE_SUCCESS;
}
StringBuilder response;
if (areaSet.size() == 1) {
response = new StringBuilder("There is " + areaSet.size() + " PvP area currently active.\n");
} else {
response = new StringBuilder("There are " + areaSet.size() + " PvP areas currently active.\n");
}
int i = 0;
for (PvPArea a : areaSet) {
response.append("area").append(i + 1).append(": ").append(a.toString());
if (i != areaSet.size() - 1) {
response.append(", ");
}
i++;
}
sender.sendMessage(response.toString());
return Command.SINGLE_SUCCESS;
})
);
root.then(Commands.literal("add")
.requires(sender -> sender.getSender().isOp())
.then(Commands.argument("x-min", IntegerArgumentType.integer())
.then(Commands.argument("x-max", IntegerArgumentType.integer())
.then(Commands.argument("z-min", IntegerArgumentType.integer())
.then(Commands.argument("z-max", IntegerArgumentType.integer())
.executes(ctx -> {
PvPArea newArea;
CommandSender sender = ctx.getSource().getSender();
try {
newArea = new PvPArea(
IntegerArgumentType.getInteger(ctx, "x-min"),
IntegerArgumentType.getInteger(ctx, "x-max"),
IntegerArgumentType.getInteger(ctx, "z-min"),
IntegerArgumentType.getInteger(ctx, "z-max")
);
} catch (RuntimeException e) {
sender.sendRichMessage("<red>Coordinate minimums must be " +
"less than maximums.</red>");
return Command.SINGLE_SUCCESS;
}
if (!newArea.overlapsAnyArea(areaSet)) {
areaSet.add(newArea);
int[] areasInts = pdc.get(Keys.PVP_AREA,
PersistentDataType.INTEGER_ARRAY);
if (areasInts == null) {
pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY,
new int[] {
newArea.getXMin(),
newArea.getXMax(),
newArea.getZMin(),
newArea.getZMax()
}
);
} else {
int[] newIntsArray = new int[areasInts.length + 4];
System.arraycopy(areasInts, 0, newIntsArray,
0, newIntsArray.length - 4);
newIntsArray[newIntsArray.length - 4] = newArea.getXMin();
newIntsArray[newIntsArray.length - 3] = newArea.getXMax();
newIntsArray[newIntsArray.length - 2] = newArea.getZMin();
newIntsArray[newIntsArray.length - 1] = newArea.getZMax();
pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY,
newIntsArray);
}
areasListener.setAreaSet(areaSet);
sender.sendRichMessage("Added new PvP area at " + newArea);
} else {
sender.sendRichMessage("<red>Area overlaps existing areas. " +
"Try different values.</red>");
}
return Command.SINGLE_SUCCESS;
})
)
)
)
)
);
root.then(Commands.literal("clear-all")
.requires(sender -> sender.getSender().isOp())
.executes(ctx -> {
pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY, new int[]{});
areaSet.clear();
CommandSender sender = ctx.getSource().getSender();
sender.sendMessage("All PvP areas have been cleared.");
return Command.SINGLE_SUCCESS;
})
);
LiteralCommandNode<CommandSourceStack> builtRoot = root.build();
this.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS,
commands -> commands.registrar().register(builtRoot));
}
Like I mentioned above, the methodology for checking if a player is
within an area or for checking if an area overlaps another will become
inefficient with a large number of areas or players. To improve this
in the future, the Set of areas could be replaced with a
HashMap<Integer, Set<PvPArea>> where each
chunk overlapping any area is hashed and mapped to those area(s).
Then, when a player dies or an area needs to be checked for overlap,
we can hash the chunk(s) in question and only check against the areas
mapped to those chunks.