From 8b616ba2a2d65d85ade526c6a825a8f08fb13738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 22:33:48 +0200 Subject: [PATCH 1/7] added Arena appliances for fights on a separate server --- arena/build.gradle | 17 + .../spawn/arena/appliances/arena/Arena.java | 380 ++++++++++++++++++ .../arena/appliances/arena/ArenaResetter.java | 127 ++++++ .../arena/appliances/arena/ArenaSettings.java | 94 +++++ .../appliances/arena/ArenaSetupCommand.java | 116 ++++++ .../arena/appliances/arena/ArenaSlot.java | 44 ++ .../arena/appliances/arena/ArenaWorlds.java | 89 ++++ .../arena/appliances/arena/BuildCommand.java | 26 ++ .../appliances/arena/CraftAttackCommand.java | 17 + .../arena/appliances/arena/Displays.java | 31 ++ .../spawn/arena/appliances/arena/Fight.java | 292 ++++++++++++++ .../arena/appliances/arena/FightListener.java | 93 +++++ .../appliances/arena/InventoryStore.java | 43 ++ .../spawn/arena/appliances/arena/Lobby.java | 299 ++++++++++++++ .../arena/appliances/arena/LockListener.java | 99 +++++ .../arena/appliances/arena/ModeMenu.java | 74 ++++ .../appliances/arena/ModeMenuListener.java | 21 + .../arena/PlayerConnectionListener.java | 22 + .../spawn/arena/appliances/arena/Style.java | 55 +++ .../spawn/arena/appliances/arena/Teams.java | 48 +++ .../arena/WorldProtectionListener.java | 145 +++++++ .../appliances/arena/logic/FightMode.java | 36 ++ .../appliances/arena/logic/PadLobby.java | 164 ++++++++ .../arena/appliances/arena/logic/Region.java | 58 +++ .../appliances/arena/logic/ResetCursor.java | 52 +++ .../appliances/arena/logic/FightModeTest.java | 27 ++ .../appliances/arena/logic/PadLobbyTest.java | 151 +++++++ .../appliances/arena/logic/RegionTest.java | 52 +++ .../spawn/core/api/client/HttpRepository.java | 22 +- .../spawn/core/api/server/HttpServer.java | 3 +- core/src/main/resources/config.yml | 38 ++ .../arenaPortal/ArenaCommand.java | 17 + .../metaGameplay/arenaPortal/ArenaPortal.java | 124 ++++++ .../arenaPortal/ArenaRepository.java | 33 ++ .../arenaPortal/MoveArenaVillagerCommand.java | 26 ++ local.gradle.example | 11 + settings.gradle | 1 + 37 files changed, 2945 insertions(+), 2 deletions(-) create mode 100644 arena/build.gradle create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java create mode 100644 arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java create mode 100644 arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java create mode 100644 arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java diff --git a/arena/build.gradle b/arena/build.gradle new file mode 100644 index 0000000..88090e6 --- /dev/null +++ b/arena/build.gradle @@ -0,0 +1,17 @@ +dependencies { + implementation project(':common') + implementation project(':core') + + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' + compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT' + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + implementation 'com.sparkjava:spark-core:2.9.4' + + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java new file mode 100644 index 0000000..17d5e6f --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java @@ -0,0 +1,380 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import com.google.gson.Gson; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.api.server.HttpServer; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.spawn.core.util.entity.DisplayVillager; +import eu.mhsl.craftattack.spawn.core.util.listener.DismissInventoryOpenFromHolder; +import eu.mhsl.craftattack.spawn.core.util.listener.PlayerInteractAtEntityEventListener; +import eu.mhsl.craftattack.spawn.core.util.server.PluginMessage; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.event.Listener; +import org.bukkit.potion.PotionEffect; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; + +/** + * The arena server: tribune, start pads, arenas, fights and resets. + * The main server sends a copy of the player's inventory to {@code /api/arena/inventory} before connecting the player. + */ +public class Arena extends Appliance { + public enum PlayerState { + TRIBUNE, + PAD, + FIGHT, + BUILD + } + + private record InventoryRequest(UUID player, String items) { + } + + private final Map states = new HashMap<>(); + private ArenaWorlds worlds; + private ArenaSettings settings; + private Teams teams; + private ArenaResetter resetter; + private final List slots = new ArrayList<>(); + private @Nullable Lobby lobby; + private @Nullable DisplayVillager.ConfigBound villager; + private @Nullable BukkitTask tickTask; + + public Arena() { + super("arena"); + } + + @Override + public void onEnable() { + this.worlds = new ArenaWorlds(); + this.villager = new DisplayVillager.ConfigBound(this.localConfig(), villager -> { + villager.customName(Component.text("Zurück zu CraftAttack", NamedTextColor.GOLD)); + villager.setProfession(Villager.Profession.CARTOGRAPHER); + villager.setVillagerType(Villager.Type.PLAINS); + }); + this.villager.getVillager().addScoreboardTag(Displays.MANAGED_TAG); + this.start(); + } + + @Override + public void onDisable() { + this.stop(); + } + + /** + * Reads the settings and builds pads, arenas and displays. Separate from onEnable so setup changes + * can be applied without restarting the appliance. + */ + private void start() { + this.settings = ArenaSettings.read(this.localConfig(), this.worlds.play()); + this.teams = new Teams(); + this.resetter = new ArenaResetter(this.worlds, this.settings.resetBlocksPerTick()); + + this.settings.arenas().stream() + .filter(ArenaSettings.Slot::isComplete) + .forEach(slot -> this.slots.add(new ArenaSlot(slot, this.worlds.play()))); + this.settings.arenas().stream() + .filter(slot -> !slot.isComplete()) + .forEach(slot -> Main.logger().warning("Arena '" + slot.name() + "' is incomplete (region, spawn1, spawn2) and stays disabled")); + + if(this.settings.padsComplete()) this.lobby = new Lobby(this, this.settings.pads()); + else Main.logger().warning("Arena: pads are not set up, fights cannot be started"); + + this.syncRegions().forEach(region -> this.worlds.keepLoaded(region, Main.instance())); + this.placeStarterPlatform(); + this.syncAll(); + + Bukkit.getOnlinePlayers().stream() + .filter(player -> this.state(player) != PlayerState.BUILD) + .forEach(this::toTribune); + this.tickTask = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tick, 1, 1); + } + + private void stop() { + if(this.tickTask != null) this.tickTask.cancel(); + this.slots.forEach(slot -> { + if(slot.fight != null) slot.fight.abort(); + slot.remove(); + }); + this.slots.clear(); + if(this.lobby != null) this.lobby.shutdown(); + this.lobby = null; + this.resetter.cancel(); + this.teams.clear(); + this.worlds.releaseChunks(Main.instance()); + } + + public void reload() { + if(this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FIGHT)) + throw new ApplianceCommand.Error("Es läuft gerade ein Kampf"); + if(this.lobby != null && !this.lobby.isIdle()) + throw new ApplianceCommand.Error("Gerade steht jemand auf einem Startfeld"); + this.stop(); + this.start(); + } + + /** + * Copies the whole tribune area and all arenas from the template. + */ + public void syncAll() { + this.slots.forEach(slot -> slot.state = ArenaSlot.State.RESET); + List regions = this.syncRegions(); + for(int i = 0; i < regions.size(); i++) { + boolean last = i == regions.size() - 1; + this.resetter.reset(regions.get(i), () -> { + if(!last) return; + this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE); + Main.logger().info("Arena: all regions synced from the template"); + }); + } + if(regions.isEmpty()) this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE); + } + + public boolean isBusy() { + return this.slots.stream().anyMatch(slot -> slot.state != ArenaSlot.State.FREE) + || (this.lobby != null && !this.lobby.isIdle()); + } + + private List syncRegions() { + return Stream.concat( + Stream.ofNullable(this.settings.syncRegion()), + this.slots.stream().map(slot -> slot.settings.region()) + ).toList(); + } + + private void tick() { + this.resetter.tick(); + if(this.lobby != null) this.lobby.tick(); + if(Bukkit.getCurrentTick() % 10 != 0) return; + this.slots.forEach(ArenaSlot::updateDisplay); + + // nobody takes damage outside of fights, so falling into the void would never end + World play = this.worlds.play(); + play.getPlayers().stream() + .filter(player -> player.getY() < play.getMinHeight() - 16 && this.state(player) != PlayerState.FIGHT) + .forEach(this::toTribune); + } + + /** + * A fresh, empty world gets a small platform at the tribune spawn, so the first visitors do not fall into the void. + */ + private void placeStarterPlatform() { + Location spawn = this.settings.tribuneSpawn(); + if(spawn == null) return; + for(World world : new World[]{this.worlds.play(), this.worlds.template()}) { + Location below = spawn.clone(); + below.setWorld(world); + below.subtract(0, 1, 0); + if(!below.getBlock().getType().isAir()) return; + } + for(World world : new World[]{this.worlds.play(), this.worlds.template()}) { + for(int dx = -3; dx <= 3; dx++) { + for(int dz = -3; dz <= 3; dz++) { + world.getBlockAt(spawn.getBlockX() + dx, spawn.getBlockY() - 1, spawn.getBlockZ() + dz).setType(Material.SMOOTH_STONE, false); + } + } + } + Main.logger().info("Arena: placed a starter platform at the tribune spawn"); + } + + boolean hasFreeSlot() { + return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE); + } + + void startFight(Player red, Player blue, UUID challenger, FightMode mode) { + ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null); + if(slot == null) { + this.toTribune(red); + this.toTribune(blue); + return; + } + new Fight(this, slot, red, blue, challenger, mode).start(); + } + + void resetSlot(ArenaSlot slot) { + if(!this.slots.contains(slot)) return; + slot.state = ArenaSlot.State.RESET; + this.resetter.reset(slot.settings.region(), () -> { + if(!this.slots.contains(slot)) return; + slot.state = ArenaSlot.State.FREE; + Location display = slot.settings.displayLocation(this.worlds.play()); + Style.play(display, Sound.BLOCK_BEACON_ACTIVATE, 1.5f, 1.2f); + }); + } + + /** + * Brings the player back to the tribune with the own inventory, locked. + */ + public void toTribune(Player player) { + if(this.lobby != null) this.lobby.leave(player); + this.setState(player, PlayerState.TRIBUNE); + this.teams.leave(player); + player.closeInventory(); + Location spawn = this.settings.tribuneSpawn(); + player.teleport(spawn != null ? spawn : this.worlds.play().getSpawnLocation()); + restore(player, GameMode.ADVENTURE); + InventoryStore.apply(player, player.getUniqueId()); + } + + /** + * Full health and food, no effects, no fire. + */ + static void restore(Player player, GameMode gameMode) { + player.setGameMode(gameMode); + player.getActivePotionEffects().stream().map(PotionEffect::getType).forEach(player::removePotionEffect); + player.setHealth(Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()); + player.setAbsorptionAmount(0); + player.setFoodLevel(20); + player.setSaturation(5); + player.setExhaustion(0); + player.setFireTicks(0); + player.setFreezeTicks(0); + player.setFallDistance(0); + player.setArrowsInBody(0, false); + player.setVelocity(new Vector()); + } + + public void toggleBuild(Player player) { + PlayerState state = this.state(player); + if(state == PlayerState.BUILD) { + Location target = player.getLocation().clone(); + target.setWorld(this.worlds.play()); + this.toTribune(player); + if(!this.isInsideArena(target)) player.teleport(target); + player.sendMessage(Style.message("Bau-Modus beendet. Änderungen an der Vorlage übernimmst du mit /arenasetup sync.")); + return; + } + if(state != PlayerState.TRIBUNE) throw new ApplianceCommand.Error("Nur von der Tribüne aus möglich"); + + this.setState(player, PlayerState.BUILD); + this.teams.leave(player); + Location target = player.getLocation().clone(); + target.setWorld(this.worlds.template()); + player.getInventory().clear(); + player.teleport(target); + player.setGameMode(GameMode.CREATIVE); + player.sendMessage(Style.message("Du bist in der Vorlage. Mit /build kommst du zurück.")); + } + + void moveVillager(Location location) { + Objects.requireNonNull(this.villager).updateLocation(location); + } + + List arenaNames() { + return this.settings.arenas().stream().map(ArenaSettings.Slot::name).toList(); + } + + public void sendToCraftAttack(Player player) { + PluginMessage.connect(player, this.settings.returnServer()); + } + + public void handleQuit(Player player) { + Fight fight = this.fightOf(player); + if(fight != null && this.state(player) == PlayerState.FIGHT) fight.forfeit(player.getUniqueId()); + if(this.lobby != null) this.lobby.leave(player); + this.teams.leave(player); + this.states.remove(player.getUniqueId()); + } + + public PlayerState state(Player player) { + return this.states.getOrDefault(player.getUniqueId(), PlayerState.TRIBUNE); + } + + void setState(Player player, PlayerState state) { + this.states.put(player.getUniqueId(), state); + } + + /** + * The fight the player is part of, also during the short celebration after it ended. + */ + public @Nullable Fight fightOf(Player player) { + return this.slots.stream() + .map(slot -> slot.fight) + .filter(fight -> fight != null && fight.involves(player.getUniqueId())) + .findFirst() + .orElse(null); + } + + /** + * Whether blocks may change at this position of the play world: only inside arenas. + */ + public boolean isInsideArena(Location location) { + return this.slots.stream().anyMatch(slot -> slot.settings.region().contains(location.getX(), location.getY(), location.getZ())); + } + + public @Nullable Region arenaRegionOf(Player player) { + Fight fight = this.fightOf(player); + return fight == null ? null : fight.slot().settings.region(); + } + + public @Nullable Lobby lobby() { + return this.lobby; + } + + public ArenaWorlds worlds() { + return this.worlds; + } + + ArenaSettings settings() { + return this.settings; + } + + Teams teams() { + return this.teams; + } + + @Override + public void httpApi(HttpServer.ApiBuilder apiBuilder) { + Gson gson = new Gson(); + apiBuilder.rawPost("inventory", (request, response) -> { + InventoryRequest body = gson.fromJson(request.body(), InventoryRequest.class); + if(body == null || body.player() == null || body.items() == null) throw new IllegalArgumentException("player and items are required"); + InventoryStore.put(body.player(), Base64.getDecoder().decode(body.items())); + return HttpServer.nothing; + }); + } + + @Override + @NotNull + protected List> commands() { + return List.of(new BuildCommand(), new ArenaSetupCommand(), new CraftAttackCommand()); + } + + @Override + @NotNull + protected List listeners() { + UUID villager = Objects.requireNonNull(this.villager).getUniqueId(); + return List.of( + new PlayerConnectionListener(), + new LockListener(), + new FightListener(), + new WorldProtectionListener(), + new ModeMenuListener(), + new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())), + new DismissInventoryOpenFromHolder(villager) + ); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java new file mode 100644 index 0000000..23ac8d3 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java @@ -0,0 +1,127 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.ResetCursor; +import eu.mhsl.craftattack.spawn.core.Main; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Container; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.util.BoundingBox; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +/** + * Copies regions from the template world over the play world. Only blocks that differ are written, + * without physics. Block entities (chests, signs, banners) are always copied, so changed contents are reset too. + * The work is spread over ticks with a fixed block budget. + */ +final class ArenaResetter { + private record Job(Region region, ResetCursor cursor, Runnable onDone) { + } + + private final World play; + private final World template; + private final int blocksPerTick; + private final Queue jobs = new ArrayDeque<>(); + private Job current; + + ArenaResetter(ArenaWorlds worlds, int blocksPerTick) { + this.play = worlds.play(); + this.template = worlds.template(); + this.blocksPerTick = blocksPerTick; + } + + void reset(Region region, Runnable onDone) { + this.jobs.add(new Job(region, new ResetCursor(region), onDone)); + } + + boolean isBusy() { + return this.current != null || !this.jobs.isEmpty(); + } + + void cancel() { + this.jobs.clear(); + this.current = null; + } + + /** + * Called every server tick. + */ + void tick() { + int budget = this.blocksPerTick; + while(budget > 0) { + if(this.current == null) { + this.current = this.jobs.poll(); + if(this.current == null) return; + this.prepare(this.current.region); + } + + budget -= this.current.cursor.advance(budget, this::copyBlock); + if(!this.current.cursor.isDone()) return; + + this.finish(this.current.region); + Job done = this.current; + this.current = null; + try { + done.onDone.run(); + } catch(Exception e) { + Main.logger().warning("Arena: error after reset: " + e.getMessage()); + } + } + } + + private void prepare(Region region) { + this.removeEntities(region); + // empty containers first, otherwise replacing them would drop their contents + for(BlockState state : this.blockEntities(this.play, region, false)) { + if(state instanceof Container container) container.getInventory().clear(); + } + } + + private void copyBlock(int x, int y, int z) { + BlockData source = this.template.getBlockAt(x, y, z).getBlockData(); + Block target = this.play.getBlockAt(x, y, z); + if(!target.getBlockData().equals(source)) target.setBlockData(source, false); + } + + private void finish(Region region) { + for(BlockState state : this.blockEntities(this.template, region, true)) { + state.copy(new Location(this.play, state.getX(), state.getY(), state.getZ())).update(true, false); + } + this.removeEntities(region); + } + + private List blockEntities(World world, Region region, boolean snapshot) { + List states = new ArrayList<>(); + for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) { + for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) { + Chunk chunk = world.getChunkAt(cx, cz); + for(BlockState state : chunk.getTileEntities(snapshot)) { + if(region.contains(state.getX(), state.getY(), state.getZ())) states.add(state); + } + } + } + return states; + } + + private void removeEntities(Region region) { + BoundingBox box = new BoundingBox( + region.minX(), region.minY(), region.minZ(), + region.maxX() + 1, region.maxY() + 1, region.maxZ() + 1 + ); + for(Entity entity : this.play.getNearbyEntities(box)) { + if(entity instanceof Player) continue; + if(entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue; + entity.remove(); + } + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java new file mode 100644 index 0000000..41876d8 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java @@ -0,0 +1,94 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import eu.mhsl.craftattack.spawn.core.config.ConfigUtil; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Positions and limits from the {@code arena} config section. All positions refer to the play world, + * no matter which world was stored. + */ +record ArenaSettings( + @Nullable Location tribuneSpawn, + @Nullable Region syncRegion, + @Nullable Region[] pads, + List arenas, + int fightSeconds, + int countdownSeconds, + int resetBlocksPerTick, + String returnServer +) { + static final String[] PAD_KEYS = {"red", "blue"}; + + record Slot(String name, @Nullable Region region, @Nullable Location[] spawns, @Nullable Location display) { + boolean isComplete() { + return this.region != null && this.spawns[0] != null && this.spawns[1] != null; + } + + Location displayLocation(World world) { + if(this.display != null) return this.display; + return new Location(world, this.region.centerX(), this.region.maxY() + 1.5, this.region.centerZ()); + } + } + + static ArenaSettings read(ConfigurationSection config, World world) { + Region[] pads = new Region[PAD_KEYS.length]; + for(int i = 0; i < PAD_KEYS.length; i++) { + pads[i] = readRegion(config.getConfigurationSection("pads." + PAD_KEYS[i])); + } + + List arenas = new ArrayList<>(); + ConfigurationSection arenaSection = config.getConfigurationSection("arenas"); + if(arenaSection != null) { + for(String name : arenaSection.getKeys(false)) { + ConfigurationSection slot = arenaSection.getConfigurationSection(name); + if(slot == null) continue; + arenas.add(new Slot( + name, + readRegion(slot.getConfigurationSection("region")), + new Location[]{ + readLocation(slot.getConfigurationSection("spawn1"), world), + readLocation(slot.getConfigurationSection("spawn2"), world) + }, + readLocation(slot.getConfigurationSection("display"), world) + )); + } + } + + return new ArenaSettings( + readLocation(config.getConfigurationSection("tribuneSpawn"), world), + readRegion(config.getConfigurationSection("syncRegion")), + pads, + arenas, + Math.max(1, config.getInt("fightMinutes", 10)) * 60, + Math.max(1, config.getInt("countdownSeconds", 5)), + Math.max(1000, config.getInt("resetBlocksPerTick", 20000)), + config.getString("connect-server-name", "craftattack") + ); + } + + boolean padsComplete() { + return this.pads[0] != null && this.pads[1] != null; + } + + private static @Nullable Region readRegion(@Nullable ConfigurationSection section) { + if(section == null) return null; + List a = section.getIntegerList("pos1"); + List b = section.getIntegerList("pos2"); + if(a.size() != 3 || b.size() != 3) return null; + return Region.of(a.get(0), a.get(1), a.get(2), b.get(0), b.get(1), b.get(2)); + } + + private static @Nullable Location readLocation(@Nullable ConfigurationSection section, World world) { + if(section == null) return null; + Location location = ConfigUtil.Position.paseLocation(section); + location.setWorld(world); + return location; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java new file mode 100644 index 0000000..26d1eaa --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java @@ -0,0 +1,116 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.spawn.core.config.ConfigUtil; +import eu.mhsl.craftattack.spawn.core.config.Configuration; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Setting up tribune, pads and arenas at the own position. Works in the play world and in the template. + * Changes take effect with {@code /arenasetup reload}. + */ +class ArenaSetupCommand extends ApplianceCommand.PlayerChecked { + private static final String USAGE = String.join("\n", + "/arenasetup tribune", + "/arenasetup villager", + "/arenasetup pad ", + "/arenasetup arena ", + "/arenasetup syncregion ", + "/arenasetup sync", + "/arenasetup reload" + ); + + ArenaSetupCommand() { + super("arenasetup"); + } + + @Override + public @Nullable String permission() { + return "admin"; + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(args.length == 0) throw new Error("\n" + USAGE); + Location location = this.getPlayer().getLocation().clone(); + location.setWorld(this.getAppliance().worlds().play()); + ConfigurationSection config = this.getAppliance().localConfig(); + + switch(args[0]) { + case "tribune" -> this.writeLocation(config, "tribuneSpawn", location); + case "villager" -> this.getAppliance().moveVillager(location); + case "pad" -> { + if(args.length < 3 || !List.of(ArenaSettings.PAD_KEYS).contains(args[1])) throw new Error(USAGE); + this.writeCorner(config, "pads." + args[1], args[2], location); + } + case "arena" -> { + if(args.length < 3) throw new Error(USAGE); + String path = "arenas." + args[1]; + switch(args[2]) { + case "pos1", "pos2" -> this.writeCorner(config, path + ".region", args[2], location); + case "spawn1", "spawn2", "display" -> this.writeLocation(config, path + "." + args[2], location); + case "remove" -> config.set(path, null); + default -> throw new Error(USAGE); + } + } + case "syncregion" -> { + if(args.length < 2) throw new Error(USAGE); + this.writeCorner(config, "syncRegion", args[1], location); + } + case "sync" -> { + if(this.getAppliance().isBusy()) throw new Error("Es läuft gerade ein Kampf, Countdown oder Reset"); + this.getAppliance().syncAll(); + sender.sendMessage(Style.message("Die Spielwelt wird aus der Vorlage übernommen.")); + return; + } + case "reload" -> { + this.getAppliance().reload(); + sender.sendMessage(Style.message("Einstellungen übernommen. Die Arenen werden aus der Vorlage aufgebaut.")); + return; + } + default -> throw new Error("\n" + USAGE); + } + + Configuration.saveChanges(); + sender.sendMessage(Style.message("Gespeichert. Mit /arenasetup reload übernehmen.")); + } + + private void writeCorner(ConfigurationSection config, String path, String corner, Location location) { + if(!corner.equals("pos1") && !corner.equals("pos2")) throw new Error(USAGE); + config.set(path + "." + corner, List.of(location.getBlockX(), location.getBlockY(), location.getBlockZ())); + } + + private void writeLocation(ConfigurationSection config, String path, Location location) { + ConfigurationSection section = Optional.ofNullable(config.getConfigurationSection(path)).orElseGet(() -> config.createSection(path)); + ConfigUtil.Position.writeLocation(section, location); + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + List options = switch(args.length) { + case 1 -> List.of("tribune", "villager", "pad", "arena", "syncregion", "sync", "reload"); + case 2 -> switch(args[0]) { + case "pad" -> Arrays.asList(ArenaSettings.PAD_KEYS); + case "syncregion" -> List.of("pos1", "pos2"); + case "arena" -> this.getAppliance().arenaNames(); + default -> List.of(); + }; + case 3 -> switch(args[0]) { + case "pad" -> List.of("pos1", "pos2"); + case "arena" -> List.of("pos1", "pos2", "spawn1", "spawn2", "display", "remove"); + default -> List.of(); + }; + default -> List.of(); + }; + return this.tabCompleteReducer(options, args); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java new file mode 100644 index 0000000..e9b7c9a --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java @@ -0,0 +1,44 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.World; +import org.bukkit.entity.TextDisplay; +import org.jetbrains.annotations.Nullable; + +/** + * One arena: free, in a fight or being reset. + */ +final class ArenaSlot { + enum State { + FREE, + FIGHT, + RESET + } + + final ArenaSettings.Slot settings; + State state = State.RESET; + @Nullable Fight fight; + private final TextDisplay display; + + ArenaSlot(ArenaSettings.Slot settings, World world) { + this.settings = settings; + this.display = Displays.spawn(settings.displayLocation(world)); + } + + void updateDisplay() { + Component title = Component.text("Arena " + this.settings.name(), NamedTextColor.GOLD); + Component status = switch(this.state) { + case FREE -> Component.text("Frei", NamedTextColor.GREEN); + case RESET -> Component.text("Wird zurückgesetzt", NamedTextColor.YELLOW); + case FIGHT -> this.fight == null + ? Component.text("Kampf", NamedTextColor.RED) + : this.fight.headline().appendNewline().append(Component.text(Style.time(this.fight.secondsLeft()), NamedTextColor.WHITE)); + }; + this.display.text(title.appendNewline().append(status)); + } + + void remove() { + this.display.remove(); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java new file mode 100644 index 0000000..c7477a7 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java @@ -0,0 +1,89 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import org.bukkit.Bukkit; +import org.bukkit.GameRules; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.plugin.Plugin; + +import java.util.Objects; + +/** + * The play world and its template. Players only fight in the play world; admins build in the template, + * which is copied over the play world on every reset. + */ +final class ArenaWorlds { + static final String PLAY = "arena"; + static final String TEMPLATE = "arena_template"; + + private static final class VoidGenerator extends ChunkGenerator { + } + + private final World play; + private final World template; + + ArenaWorlds() { + this.play = load(PLAY); + this.template = load(TEMPLATE); + + for(World world : new World[]{this.play, this.template}) { + world.setGameRule(GameRules.ADVANCE_TIME, false); + world.setGameRule(GameRules.ADVANCE_WEATHER, false); + world.setGameRule(GameRules.SPAWN_MOBS, false); + world.setGameRule(GameRules.SPAWN_MONSTERS, false); + world.setGameRule(GameRules.SPAWN_PHANTOMS, false); + world.setGameRule(GameRules.SPAWN_PATROLS, false); + world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false); + world.setGameRule(GameRules.SPAWN_WARDENS, false); + world.setGameRule(GameRules.RAIDS, false); + world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false); + world.setStorm(false); + world.setThundering(false); + world.setTime(6000); + } + this.play.setGameRule(GameRules.IMMEDIATE_RESPAWN, true); + this.play.setGameRule(GameRules.KEEP_INVENTORY, true); + this.template.setGameRule(GameRules.RANDOM_TICK_SPEED, 0); + this.template.setGameRule(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0); + } + + private static World load(String name) { + World world = Bukkit.getWorld(name); + if(world != null) return world; + return Objects.requireNonNull( + new WorldCreator(name).generator(new VoidGenerator()).generateStructures(false).createWorld(), + "Could not create world " + name + ); + } + + World play() { + return this.play; + } + + World template() { + return this.template; + } + + boolean isPlay(World world) { + return this.play.equals(world); + } + + /** + * Keeps all chunks of the region loaded in both worlds, so resets never wait for chunk loading. + */ + void keepLoaded(Region region, Plugin plugin) { + for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) { + for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) { + this.play.addPluginChunkTicket(cx, cz, plugin); + this.template.addPluginChunkTicket(cx, cz, plugin); + } + } + } + + void releaseChunks(Plugin plugin) { + this.play.removePluginChunkTickets(plugin); + this.template.removePluginChunkTickets(plugin); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java new file mode 100644 index 0000000..725b9d1 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java @@ -0,0 +1,26 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Switches between the play world and the template at the same position. + */ +class BuildCommand extends ApplianceCommand.PlayerChecked { + BuildCommand() { + super("build"); + } + + @Override + public @Nullable String permission() { + return "arena.build"; + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + this.getAppliance().toggleBuild(this.getPlayer()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java new file mode 100644 index 0000000..53a9fc1 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java @@ -0,0 +1,17 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +class CraftAttackCommand extends ApplianceCommand.PlayerChecked { + CraftAttackCommand() { + super("craftattack"); + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + this.getAppliance().sendToCraftAttack(this.getPlayer()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java new file mode 100644 index 0000000..3d7b1a5 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java @@ -0,0 +1,31 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.Main; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.TextDisplay; + +/** + * Floating texts above pads and arenas. They are not saved with the world and come back with every start. + */ +final class Displays { + static final String MANAGED_TAG = "arena_managed"; + + private Displays() { + } + + static TextDisplay spawn(Location location) { + // displays are not saved, they would vanish with an unloaded chunk + location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance()); + return location.getWorld().spawn(location, TextDisplay.class, display -> { + display.setPersistent(false); + display.setBillboard(Display.Billboard.CENTER); + display.setAlignment(TextDisplay.TextAlignment.CENTER); + display.setShadowed(true); + display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0)); + display.setLineWidth(220); + display.addScoreboardTag(MANAGED_TAG); + }); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java new file mode 100644 index 0000000..c580d7a --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java @@ -0,0 +1,292 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.core.Main; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.title.Title; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * A running fight in one arena. Fighter 0 comes from the red pad and uses spawn 1, fighter 1 the blue pad and spawn 2. + */ +final class Fight { + private static final int CELEBRATION_TICKS = 60; + private static final double SPECTATOR_RANGE = 24; + + private final Arena arena; + private final ArenaSlot slot; + private final UUID[] fighters; + private final String[] names; + private final UUID challenger; + private final FightMode mode; + private final BossBar bossBar; + private final Set bossBarViewers = new HashSet<>(); + private int secondsLeft; + private boolean over; + private @Nullable BukkitTask timer; + + Fight(Arena arena, ArenaSlot slot, Player red, Player blue, UUID challenger, FightMode mode) { + this.arena = arena; + this.slot = slot; + this.fighters = new UUID[]{red.getUniqueId(), blue.getUniqueId()}; + this.names = new String[]{red.getName(), blue.getName()}; + this.challenger = challenger; + this.mode = mode; + this.secondsLeft = arena.settings().fightSeconds(); + this.bossBar = BossBar.bossBar(Component.empty(), 1f, BossBar.Color.RED, BossBar.Overlay.NOTCHED_10); + } + + void start() { + this.slot.state = ArenaSlot.State.FIGHT; + this.slot.fight = this; + + FightMode.Role[] roles = new FightMode.Role[2]; + for(int i = 0; i < 2; i++) { + roles[i] = this.fighters[i].equals(this.challenger) ? FightMode.Role.CHALLENGER : FightMode.Role.OPPONENT; + } + UUID challengerId = this.challenger; + UUID opponentId = this.fighters[0].equals(challengerId) ? this.fighters[1] : this.fighters[0]; + + for(int i = 0; i < 2; i++) { + Player player = Bukkit.getPlayer(this.fighters[i]); + if(player == null) continue; + UUID kitOwner = this.mode.kitSource(roles[i]) == FightMode.Role.CHALLENGER ? challengerId : opponentId; + + this.arena.setState(player, Arena.PlayerState.FIGHT); + this.arena.teams().join(player, Teams.fight(i)); + player.closeInventory(); + player.teleport(this.slot.settings.spawns()[i]); + Arena.restore(player, GameMode.SURVIVAL); + InventoryStore.apply(player, kitOwner); + + player.showTitle(Title.title( + Component.text("Kampf!", NamedTextColor.GOLD), + Component.text("gegen ", NamedTextColor.GRAY).append(Component.text(this.names[1 - i], Style.PAD_TEXT_COLORS[1 - i])), + Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500)) + )); + Style.play(player, Sound.EVENT_RAID_HORN, 1.2f); + } + + Bukkit.broadcast(Style.message( + this.headline().append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(), NamedTextColor.GRAY)) + )); + this.updateBossBar(); + this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20); + } + + Component headline() { + return Component.text(this.names[0], Style.PAD_TEXT_COLORS[0]) + .append(Component.text(" gegen ", NamedTextColor.GRAY)) + .append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1])); + } + + int secondsLeft() { + return this.secondsLeft; + } + + boolean isOver() { + return this.over; + } + + boolean involves(UUID player) { + return this.fighters[0].equals(player) || this.fighters[1].equals(player); + } + + ArenaSlot slot() { + return this.slot; + } + + private void tickSecond() { + if(this.over) return; + if(--this.secondsLeft <= 0) { + this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY)); + return; + } + if(this.secondsLeft == 60 || this.secondsLeft == 10) { + this.fighterPlayers().forEach(player -> { + player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f); + }); + } + this.updateBossBar(); + } + + private void updateBossBar() { + this.bossBar.name(this.headline().append(Component.text(" " + Style.time(this.secondsLeft), NamedTextColor.WHITE))); + this.bossBar.progress(Math.clamp((float) this.secondsLeft / this.arena.settings().fightSeconds(), 0f, 1f)); + + Set viewers = new HashSet<>(); + for(Player player : this.arena.worlds().play().getPlayers()) { + if(this.involves(player.getUniqueId()) || this.isSpectating(player)) viewers.add(player.getUniqueId()); + } + for(UUID uuid : this.bossBarViewers) { + Player player = Bukkit.getPlayer(uuid); + if(player != null && !viewers.contains(uuid)) player.hideBossBar(this.bossBar); + } + for(UUID uuid : viewers) { + Player player = Bukkit.getPlayer(uuid); + if(player != null) player.showBossBar(this.bossBar); + } + this.bossBarViewers.clear(); + this.bossBarViewers.addAll(viewers); + } + + private boolean isSpectating(Player player) { + if(this.arena.state(player) != Arena.PlayerState.TRIBUNE && this.arena.state(player) != Arena.PlayerState.PAD) return false; + var region = this.slot.settings.region(); + double dx = Math.max(0, Math.max(region.minX() - player.getX(), player.getX() - (region.maxX() + 1))); + double dz = Math.max(0, Math.max(region.minZ() - player.getZ(), player.getZ() - (region.maxZ() + 1))); + return Math.hypot(dx, dz) <= SPECTATOR_RANGE; + } + + /** + * The player took lethal damage. + */ + void defeated(Player loser) { + int index = this.indexOf(loser.getUniqueId()); + if(index == -1 || this.over) return; + Player winner = Bukkit.getPlayer(this.fighters[1 - index]); + Component detail = winner == null + ? Component.empty() + : Component.text("(" + this.mode.displayName() + ", " + Style.hearts(winner.getHealth() + winner.getAbsorptionAmount()) + " ❤ übrig)", NamedTextColor.GRAY); + this.finish(1 - index, detail); + } + + /** + * The player left the server during the fight. + */ + void forfeit(UUID quitter) { + int index = this.indexOf(quitter); + if(index == -1 || this.over) return; + this.finish(1 - index, Component.text("(" + this.names[index] + " hat die Arena verlassen)", NamedTextColor.GRAY)); + } + + /** + * Ends the fight without a winner and without celebration, for example on shutdown. + */ + void abort() { + if(this.over) return; + this.over = true; + this.stopTimer(); + this.hideBossBar(); + this.fighterPlayers().forEach(this.arena::toTribune); + this.slot.fight = null; + } + + private void finish(int winnerIndex, Component detail) { + this.over = true; + this.stopTimer(); + + if(winnerIndex == -1) { + Bukkit.broadcast(Style.message( + Component.text(this.names[0], Style.PAD_TEXT_COLORS[0]) + .append(Component.text(" und ", NamedTextColor.GRAY)) + .append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1])) + .append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)) + .append(detail) + )); + this.fighterPlayers().forEach(player -> { + player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail)); + Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f); + this.arena.toTribune(player); + }); + this.afterCelebration(); + return; + } + + int loserIndex = 1 - winnerIndex; + Bukkit.broadcast(Style.message( + Component.text(this.names[winnerIndex], Style.PAD_TEXT_COLORS[winnerIndex]) + .append(Component.text(" hat gegen ", NamedTextColor.GRAY)) + .append(Component.text(this.names[loserIndex], Style.PAD_TEXT_COLORS[loserIndex])) + .append(Component.text(" gewonnen ", NamedTextColor.GRAY)) + .append(detail) + )); + + Player loser = Bukkit.getPlayer(this.fighters[loserIndex]); + if(loser != null) { + loser.showTitle(Title.title( + Component.text("Verloren", NamedTextColor.RED), + Component.text(this.names[winnerIndex] + " hat gewonnen", NamedTextColor.GRAY) + )); + Style.play(loser, Sound.BLOCK_BEACON_DEACTIVATE, 1f); + this.arena.toTribune(loser); + } + + Player winner = Bukkit.getPlayer(this.fighters[winnerIndex]); + if(winner != null) { + winner.showTitle(Title.title( + Component.text("Gewonnen!", NamedTextColor.GOLD), + Component.text("gegen " + this.names[loserIndex], NamedTextColor.GRAY) + )); + Style.play(winner, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f); + this.celebrate(winner.getLocation(), Style.PAD_PARTICLE_COLORS[winnerIndex]); + } + this.afterCelebration(); + } + + private void celebrate(Location location, Color color) { + Location top = location.clone().add(0, 2.2, 0); + Particle.FIREWORK.builder().location(top).count(40).offset(0.6, 0.6, 0.6).extra(0.15).receivers(64).spawn(); + Particle.DUST.builder().location(top).count(60).offset(1, 1, 1).data(new Particle.DustOptions(color, 1.6f)).receivers(64).spawn(); + Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_TWINKLE, 2f, 1f); + Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_LARGE_BLAST, 2f, 1f); + } + + private void hideBossBar() { + this.bossBarViewers.forEach(uuid -> { + Player player = Bukkit.getPlayer(uuid); + if(player != null) player.hideBossBar(this.bossBar); + }); + this.bossBarViewers.clear(); + } + + private void afterCelebration() { + this.hideBossBar(); + Bukkit.getScheduler().runTaskLater(Main.instance(), () -> { + for(UUID uuid : this.fighters) { + Player player = Bukkit.getPlayer(uuid); + if(player != null && this.arena.fightOf(player) == this) this.arena.toTribune(player); + } + this.slot.fight = null; + this.arena.resetSlot(this.slot); + }, CELEBRATION_TICKS); + } + + private void stopTimer() { + if(this.timer != null) this.timer.cancel(); + this.timer = null; + } + + private int indexOf(UUID player) { + if(this.fighters[0].equals(player)) return 0; + if(this.fighters[1].equals(player)) return 1; + return -1; + } + + private List fighterPlayers() { + List players = new ArrayList<>(); + for(UUID uuid : this.fighters) { + Player player = Bukkit.getPlayer(uuid); + if(player != null) players.add(player); + } + return players; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java new file mode 100644 index 0000000..b4f02b1 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java @@ -0,0 +1,93 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import io.papermc.paper.datacomponent.DataComponentTypes; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.ItemStack; + +/** + * Lethal damage ends the fight instead of killing the player. Totems still work as usual. + */ +class FightListener extends ApplianceListener { + private boolean isFighting(Player player) { + Fight fight = this.getAppliance().fightOf(player); + return fight != null && !fight.isOver() && this.getAppliance().state(player) == Arena.PlayerState.FIGHT; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onDamageByPlayer(EntityDamageByEntityEvent event) { + if(!(event.getEntity() instanceof Player)) return; + Player attacker = switch(event.getDamager()) { + case Player player -> player; + case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter; + default -> null; + }; + if(attacker == null || attacker == event.getEntity()) return; + if(!this.isFighting(attacker)) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if(!(event.getEntity() instanceof Player player)) return; + if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT) return; + Fight fight = this.getAppliance().fightOf(player); + if(fight == null || fight.isOver()) { + event.setCancelled(true); + return; + } + if(event.getFinalDamage() < player.getHealth()) return; + if(this.hasDeathProtection(player.getInventory().getItemInMainHand()) + || this.hasDeathProtection(player.getInventory().getItemInOffHand())) return; + + event.setCancelled(true); + fight.defeated(player); + } + + private boolean hasDeathProtection(ItemStack item) { + return !item.isEmpty() && item.hasData(DataComponentTypes.DEATH_PROTECTION); + } + + /** + * Safety net if a death gets through anyway: nothing drops and the fight ends. + */ + @EventHandler + public void onDeath(PlayerDeathEvent event) { + event.setKeepInventory(true); + event.getDrops().clear(); + event.setKeepLevel(true); + event.setDroppedExp(0); + event.deathMessage(null); + Fight fight = this.getAppliance().fightOf(event.getPlayer()); + if(fight != null) fight.defeated(event.getPlayer()); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + Bukkit.getScheduler().runTask(Main.instance(), () -> this.getAppliance().toTribune(event.getPlayer())); + } + + /** + * Ender pearls and chorus fruits must not leave the arena. + */ + @EventHandler(ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + if(event.getCause() == PlayerTeleportEvent.TeleportCause.PLUGIN || event.getCause() == PlayerTeleportEvent.TeleportCause.COMMAND) return; + if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.FIGHT) return; + Region region = this.getAppliance().arenaRegionOf(event.getPlayer()); + if(region == null) return; + if(event.getTo().getWorld() != event.getFrom().getWorld() || !region.contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) { + event.setCancelled(true); + } + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java new file mode 100644 index 0000000..61fe4ed --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java @@ -0,0 +1,43 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Inventory copies sent by the main server. Kept in memory only: after a restart of the arena server + * players arrive with an empty inventory until they come over again. + */ +final class InventoryStore { + static final int MAX_BYTES = 16 * 1024 * 1024; + private static final Map snapshots = new ConcurrentHashMap<>(); + + private InventoryStore() { + } + + static void put(UUID player, byte[] serializedItems) { + if(serializedItems.length > MAX_BYTES) throw new IllegalArgumentException("Inventory too large"); + snapshots.put(player, serializedItems); + } + + /** + * @return a fresh copy of the player's inventory, empty if none was sent + */ + static ItemStack[] get(UUID player) { + byte[] bytes = snapshots.get(player); + if(bytes == null) return new ItemStack[0]; + return ItemStack.deserializeItemsFromBytes(bytes); + } + + static void apply(Player player, UUID source) { + ItemStack[] items = get(source); + int size = player.getInventory().getSize(); + ItemStack[] contents = new ItemStack[size]; + System.arraycopy(items, 0, contents, 0, Math.min(items.length, size)); + player.getInventory().setContents(contents); + player.setItemOnCursor(null); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java new file mode 100644 index 0000000..6f1c15a --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java @@ -0,0 +1,299 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; +import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.title.Title; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * The two start pads in the world: who stands where, pushing others away from taken pads, + * inventories, countdown, texts, particles and sounds. The rules themselves live in {@link PadLobby}. + */ +final class Lobby implements PadLobby.Listener { + /** How far around a taken pad other players get pushed away. */ + private static final double PUSH_MARGIN = 0.4; + + private final Arena arena; + private final Region[] regions; + private final PadLobby pads; + private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS]; + private final Map lastPushSound = new HashMap<>(); + private long ticks; + + Lobby(Arena arena, Region[] regions) { + this.arena = arena; + this.regions = regions; + this.pads = new PadLobby(this, arena.settings().countdownSeconds()); + for(int i = 0; i < PadLobby.PADS; i++) { + Region region = regions[i]; + this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ())); + } + } + + void tick() { + this.ticks++; + for(Player player : this.arena.worlds().play().getPlayers()) { + Arena.PlayerState state = this.arena.state(player); + if(state == Arena.PlayerState.PAD) this.checkLeave(player); + else if(state == Arena.PlayerState.TRIBUNE) this.checkEnter(player); + } + + this.pads.tick(this.arena.hasFreeSlot()); + + if(this.ticks % 3 == 0) this.drawParticles(); + if(this.ticks % 5 == 0) this.updateDisplays(); + } + + private void checkLeave(Player player) { + int pad = this.pads.padOf(player.getUniqueId()); + if(pad != -1 && this.isOn(player, pad, 0)) return; + this.leave(player); + } + + private void checkEnter(Player player) { + for(int pad = 0; pad < PadLobby.PADS; pad++) { + if(this.pads.occupant(pad) != null) { + if(this.isOn(player, pad, PUSH_MARGIN)) this.pushAway(player, pad); + continue; + } + if(!this.isOn(player, pad, 0)) continue; + if(!this.pads.enter(pad, player.getUniqueId())) continue; + + this.arena.setState(player, Arena.PlayerState.PAD); + this.arena.teams().join(player, Teams.pad(pad)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f); + return; + } + } + + /** + * Removes the player from the pad, if standing on one. + */ + void leave(Player player) { + if(this.pads.padOf(player.getUniqueId()) == -1) return; + this.pads.leave(player.getUniqueId()); + if(ModeMenu.isOpen(player)) player.closeInventory(); + this.arena.teams().leave(player); + this.arena.setState(player, Arena.PlayerState.TRIBUNE); + } + + void chooseMode(Player player, FightMode mode) { + if(!this.pads.setMode(player.getUniqueId(), mode)) { + player.closeInventory(); + return; + } + InteractSounds.of(player).click(); + player.closeInventory(); + player.sendActionBar(Component.text("Modus: " + mode.displayName(), NamedTextColor.GOLD)); + } + + boolean isIdle() { + return this.pads.isIdle(); + } + + void shutdown() { + for(int pad = 0; pad < PadLobby.PADS; pad++) { + UUID occupant = this.pads.occupant(pad); + Player player = occupant == null ? null : Bukkit.getPlayer(occupant); + if(player != null) this.leave(player); + } + for(TextDisplay display : this.displays) display.remove(); + } + + private boolean isOn(Player player, int pad, double margin) { + Region region = this.regions[pad]; + return player.getX() >= region.minX() - margin && player.getX() < region.maxX() + 1 + margin + && player.getZ() >= region.minZ() - margin && player.getZ() < region.maxZ() + 1 + margin + && player.getY() >= region.minY() - 0.5 && player.getY() < region.maxY() + 2; + } + + private void pushAway(Player player, int pad) { + Region region = this.regions[pad]; + Vector direction = new Vector(player.getX() - region.centerX(), 0, player.getZ() - region.centerZ()); + if(direction.lengthSquared() < 0.01) direction = new Vector(1, 0, 0); + player.setVelocity(direction.normalize().multiply(0.6).setY(0.25)); + + long now = System.currentTimeMillis(); + if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return; + this.lastPushSound.put(player.getUniqueId(), now); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); + player.sendActionBar(Component.text("Dieses Feld ist besetzt", NamedTextColor.RED)); + } + + @Override + public void challengerSelected(UUID challenger) { + Player player = Bukkit.getPlayer(challenger); + if(player == null) return; + player.sendMessage(Style.message("Du forderst heraus. Wähle den Modus. Wer sich auf das andere Feld stellt, nimmt an.")); + InteractSounds.of(player).open(); + ModeMenu.open(player, this.pads.mode()); + } + + @Override + public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { + Player challengerPlayer = Bukkit.getPlayer(challenger); + Player opponentPlayer = Bukkit.getPlayer(opponent); + if(challengerPlayer == null || opponentPlayer == null) return; + if(ModeMenu.isOpen(challengerPlayer)) challengerPlayer.closeInventory(); + + InventoryStore.apply(challengerPlayer, mode.kitSource(FightMode.Role.CHALLENGER) == FightMode.Role.CHALLENGER ? challenger : opponent); + InventoryStore.apply(opponentPlayer, mode.kitSource(FightMode.Role.OPPONENT) == FightMode.Role.CHALLENGER ? challenger : opponent); + + String kitText = switch(mode) { + case OWN -> "Jeder kämpft mit seinem eigenen Inventar."; + case CHALLENGER -> "Ihr kämpft beide mit dem Inventar von " + challengerPlayer.getName() + "."; + case OPPONENT -> "Ihr kämpft beide mit dem Inventar von " + opponentPlayer.getName() + "."; + }; + opponentPlayer.sendMessage(Style.message("Du nimmst die Herausforderung von " + challengerPlayer.getName() + " an. " + kitText + " Verlasse das Feld, um abzubrechen.")); + challengerPlayer.sendMessage(Style.message(opponentPlayer.getName() + " nimmt deine Herausforderung an. " + kitText + " Verlasse das Feld, um abzubrechen.")); + for(Player player : new Player[]{challengerPlayer, opponentPlayer}) { + Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f); + } + } + + @Override + public void kitReverted(UUID uuid) { + Player player = Bukkit.getPlayer(uuid); + if(player != null) InventoryStore.apply(player, uuid); + } + + @Override + public void countdown(int secondsLeft) { + for(int pad = 0; pad < PadLobby.PADS; pad++) { + Player player = this.occupantPlayer(pad); + Player other = this.occupantPlayer(1 - pad); + if(player == null || other == null) continue; + player.showTitle(Title.title( + Component.text(secondsLeft, NamedTextColor.GOLD), + Component.text("Kampf gegen ", NamedTextColor.GRAY).append(Component.text(other.getName(), Style.PAD_TEXT_COLORS[1 - pad])), + Title.Times.times(Duration.ZERO, Duration.ofMillis(1100), Duration.ZERO) + )); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f); + } + } + + @Override + public void countdownCancelled() { + for(int pad = 0; pad < PadLobby.PADS; pad++) { + Player player = this.occupantPlayer(pad); + if(player == null) continue; + player.clearTitle(); + player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f); + } + } + + @Override + public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { + Player challengerPlayer = Bukkit.getPlayer(challenger); + Player opponentPlayer = Bukkit.getPlayer(opponent); + if(challengerPlayer == null || opponentPlayer == null) { + if(challengerPlayer != null) this.arena.toTribune(challengerPlayer); + if(opponentPlayer != null) this.arena.toTribune(opponentPlayer); + return; + } + Player red = challengerPad == 0 ? challengerPlayer : opponentPlayer; + Player blue = challengerPad == 0 ? opponentPlayer : challengerPlayer; + this.arena.startFight(red, blue, challenger, mode); + } + + private @Nullable Player occupantPlayer(int pad) { + UUID occupant = this.pads.occupant(pad); + return occupant == null ? null : Bukkit.getPlayer(occupant); + } + + private void updateDisplays() { + boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot(); + Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); + String modeText = "Modus: " + this.pads.mode().displayName(); + + for(int pad = 0; pad < PadLobby.PADS; pad++) { + Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[pad]); + Player occupant = this.occupantPlayer(pad); + Component body; + if(waiting) { + body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() + .append(Component.text("Gleich geht es los", NamedTextColor.WHITE)); + } else if(this.pads.isCountingDown()) { + body = Component.text(occupant == null ? "" : occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline() + .append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD)); + } else if(occupant != null) { + body = Component.text(occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline() + .append(Component.text("fordert heraus", NamedTextColor.GRAY)).appendNewline() + .append(Component.text(modeText, NamedTextColor.WHITE)); + } else if(challenger != null) { + body = Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline() + .append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[1 - pad])) + .append(Component.text(" zu kämpfen", NamedTextColor.WHITE)).appendNewline() + .append(Component.text(modeText, NamedTextColor.GRAY)); + } else if(!this.arena.hasFreeSlot()) { + body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() + .append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE)); + } else { + body = Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline() + .append(Component.text("um herauszufordern", NamedTextColor.WHITE)); + } + this.displays[pad].text(title.appendNewline().append(body)); + } + } + + private void drawParticles() { + boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot(); + boolean countdown = this.pads.isCountingDown() && !waiting; + for(int pad = 0; pad < PadLobby.PADS; pad++) { + Region region = this.regions[pad]; + Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad]; + boolean taken = this.pads.occupant(pad) != null; + double spacing = countdown ? 0.25 : taken ? 0.4 : 0.6; + this.drawOutline(region, new Particle.DustOptions(color, taken ? 1.2f : 0.9f), spacing); + + Player occupant = this.occupantPlayer(pad); + if(occupant == null) continue; + double speed = countdown ? 0.6 : 0.3; + for(int strand = 0; strand < 2; strand++) { + double angle = this.ticks * speed + strand * Math.PI; + double height = (this.ticks % 40) / 20.0; + Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7); + Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn(); + } + } + } + + private void drawOutline(Region region, Particle.DustOptions dust, double spacing) { + double y = region.minY() + 0.1; + double minX = region.minX(); + double maxX = region.maxX() + 1; + double minZ = region.minZ(); + double maxZ = region.maxZ() + 1; + Location location = new Location(this.arena.worlds().play(), 0, y, 0); + for(double x = minX; x <= maxX; x += spacing) { + this.dust(location.set(x, y, minZ), dust); + this.dust(location.set(x, y, maxZ), dust); + } + for(double z = minZ; z <= maxZ; z += spacing) { + this.dust(location.set(minX, y, z), dust); + this.dust(location.set(maxX, y, z), dust); + } + } + + private void dust(Location location, Particle.DustOptions dust) { + Particle.DUST.builder().location(location).data(dust).receivers(48).spawn(); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java new file mode 100644 index 0000000..8125d88 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java @@ -0,0 +1,99 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.EntityToggleGlideEvent; +import org.bukkit.event.entity.FoodLevelChangeEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerSwapHandItemsEvent; + +/** + * Outside of a fight the inventory can be looked at, but nothing can be moved, dropped or used. + * Nobody takes damage or gets hungry on the tribune. + */ +class LockListener extends ApplianceListener { + private boolean isLocked(Object entity) { + if(!(entity instanceof Player player)) return false; + Arena.PlayerState state = this.getAppliance().state(player); + return state == Arena.PlayerState.TRIBUNE || state == Arena.PlayerState.PAD; + } + + private void cancelIfLocked(Object entity, Cancellable event) { + if(this.isLocked(entity)) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.LOW) + public void onClick(InventoryClickEvent event) { + this.cancelIfLocked(event.getWhoClicked(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onDrag(InventoryDragEvent event) { + this.cancelIfLocked(event.getWhoClicked(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onDrop(PlayerDropItemEvent event) { + this.cancelIfLocked(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onSwapHands(PlayerSwapHandItemsEvent event) { + this.cancelIfLocked(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onInteract(PlayerInteractEvent event) { + this.cancelIfLocked(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onConsume(PlayerItemConsumeEvent event) { + this.cancelIfLocked(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onPickup(EntityPickupItemEvent event) { + this.cancelIfLocked(event.getEntity(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onLaunch(ProjectileLaunchEvent event) { + this.cancelIfLocked(event.getEntity().getShooter(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onGlide(EntityToggleGlideEvent event) { + if(event.isGliding()) this.cancelIfLocked(event.getEntity(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onHunger(FoodLevelChangeEvent event) { + this.cancelIfLocked(event.getEntity(), event); + } + + @EventHandler(priority = EventPriority.LOW) + public void onDamage(EntityDamageEvent event) { + this.cancelIfLocked(event.getEntity(), event); + } + + /** + * Ender chests would keep items between fights. + */ + @EventHandler + public void onOpen(InventoryOpenEvent event) { + if(event.getInventory().getType() == InventoryType.ENDER_CHEST) event.setCancelled(true); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java new file mode 100644 index 0000000..db49275 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java @@ -0,0 +1,74 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.core.util.inventory.ItemBuilder; +import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Chest menu in which the challenger picks the mode. Works for Bedrock players as well. + */ +final class ModeMenu implements InventoryHolder { + private static final int[] SLOTS = {11, 13, 15}; + private static final FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT}; + + private final Inventory inventory; + + private ModeMenu(Player challenger, FightMode current) { + this.inventory = Bukkit.createInventory(this, 27, Component.text("Wähle den Kampfmodus")); + for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane); + for(int i = 0; i < MODES.length; i++) this.inventory.setItem(SLOTS[i], this.item(MODES[i], challenger, MODES[i] == current)); + } + + static void open(Player challenger, FightMode current) { + challenger.openInventory(new ModeMenu(challenger, current).getInventory()); + } + + static boolean isOpen(Player player) { + return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu; + } + + @Nullable FightMode modeAt(int slot) { + for(int i = 0; i < SLOTS.length; i++) { + if(SLOTS[i] == slot) return MODES[i]; + } + return null; + } + + private ItemStack item(FightMode mode, Player challenger, boolean selected) { + ItemBuilder builder = switch(mode) { + case OWN -> ItemBuilder.of(Material.IRON_SWORD) + .lore("Ihr kämpft beide mit eurem eigenen Inventar."); + case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD) + .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(challenger)) + .lore("Ihr kämpft beide mit deinem Inventar."); + case OPPONENT -> ItemBuilder.of(Material.CHEST) + .lore("Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt."); + }; + builder.displayName(Component.text(mode.displayName(), NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); + if(selected) { + builder.glint().appendLore(Component.empty()) + .appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)); + } else { + builder.appendLore(Component.empty()) + .appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)); + } + return builder.build(); + } + + @Override + public @NotNull Inventory getInventory() { + return this.inventory; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java new file mode 100644 index 0000000..8ca40a8 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java @@ -0,0 +1,21 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.inventory.InventoryClickEvent; + +class ModeMenuListener extends ApplianceListener { + @EventHandler + public void onClick(InventoryClickEvent event) { + if(!(event.getView().getTopInventory().getHolder(false) instanceof ModeMenu menu)) return; + event.setCancelled(true); + if(!(event.getWhoClicked() instanceof Player player)) return; + if(event.getRawSlot() >= event.getView().getTopInventory().getSize()) return; + + FightMode mode = menu.modeAt(event.getRawSlot()); + Lobby lobby = this.getAppliance().lobby(); + if(mode != null && lobby != null) lobby.chooseMode(player, mode); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java new file mode 100644 index 0000000..b9030d0 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java @@ -0,0 +1,22 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +class PlayerConnectionListener extends ApplianceListener { + @EventHandler + public void onJoin(PlayerJoinEvent event) { + event.getPlayer().getEnderChest().clear(); + this.getAppliance().toTribune(event.getPlayer()); + event.getPlayer().sendMessage(Style.message( + "Willkommen in der Arena! Stell dich auf ein Startfeld, um jemanden herauszufordern. Zurück geht es beim Villager oder mit /craftattack." + )); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + this.getAppliance().handleQuit(event.getPlayer()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java new file mode 100644 index 0000000..8d0da13 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java @@ -0,0 +1,55 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.entity.Player; + +import java.util.Locale; + +/** + * Colors, texts and sounds of the arena, so everything looks and sounds the same everywhere. + * Player facing texts use no dashes. + */ +final class Style { + static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD); + + static final NamedTextColor[] PAD_TEXT_COLORS = {NamedTextColor.RED, NamedTextColor.BLUE}; + static final Color[] PAD_PARTICLE_COLORS = {Color.fromRGB(255, 70, 70), Color.fromRGB(80, 120, 255)}; + static final String[] PAD_NAMES = {"Rotes Feld", "Blaues Feld"}; + static final Color WAITING_PARTICLE_COLOR = Color.fromRGB(150, 150, 150); + + private Style() { + } + + static Component message(String text) { + return PREFIX.append(Component.text(text, NamedTextColor.GRAY)); + } + + static Component message(Component text) { + return PREFIX.append(text.colorIfAbsent(NamedTextColor.GRAY)); + } + + static Component name(String name, TextColor color) { + return Component.text(name, color); + } + + static String hearts(double health) { + return String.format(Locale.GERMAN, "%.1f", health / 2); + } + + static String time(int seconds) { + return String.format("%d:%02d", seconds / 60, seconds % 60); + } + + static void play(Player player, Sound sound, float pitch) { + player.playSound(player, sound, 1f, pitch); + } + + static void play(Location location, Sound sound, float volume, float pitch) { + location.getWorld().playSound(location, sound, volume, pitch); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java new file mode 100644 index 0000000..b519785 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java @@ -0,0 +1,48 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +/** + * Scoreboard teams for the colored names. Players on a pad cannot be pushed (no collision), + * on the tribune and in a fight collision stays on. + */ +final class Teams { + private static final String[] NAMES = {"arena_pad_red", "arena_pad_blue", "arena_fight_red", "arena_fight_blue"}; + + private final Team[] teams = new Team[NAMES.length]; + + Teams() { + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + for(int i = 0; i < NAMES.length; i++) { + Team team = scoreboard.getTeam(NAMES[i]); + if(team == null) team = scoreboard.registerNewTeam(NAMES[i]); + team.color(Style.PAD_TEXT_COLORS[i % 2]); + team.setOption(Team.Option.COLLISION_RULE, i < 2 ? Team.OptionStatus.NEVER : Team.OptionStatus.ALWAYS); + this.teams[i] = team; + } + } + + static int pad(int pad) { + return pad; + } + + static int fight(int side) { + return 2 + side; + } + + void join(Player player, int team) { + this.leave(player); + this.teams[team].addEntry(player.getName()); + } + + void leave(Player player) { + for(Team team : this.teams) team.removeEntry(player.getName()); + } + + void clear() { + for(Team team : this.teams) team.getEntries().forEach(team::removeEntry); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java new file mode 100644 index 0000000..7bc4201 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java @@ -0,0 +1,145 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.event.Cancellable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.block.LeavesDecayEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.world.StructureGrowEvent; + +import java.util.List; + +/** + * In the play world blocks may only change inside the arenas. Everything else (tribune, pads, the space + * between arenas) stays exactly as in the template. + */ +class WorldProtectionListener extends ApplianceListener { + private boolean isProtected(Block block) { + return this.isProtected(block.getLocation()); + } + + private boolean isProtected(Location location) { + if(!this.getAppliance().worlds().isPlay(location.getWorld())) return false; + return !this.getAppliance().isInsideArena(location); + } + + private void cancelIfProtected(Block block, Cancellable event) { + if(this.isProtected(block)) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBucketEmpty(PlayerBucketEmptyEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBucketFill(PlayerBucketFillEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onFlow(BlockFromToEvent event) { + this.cancelIfProtected(event.getToBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onSpread(BlockSpreadEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onForm(BlockFormEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onFade(BlockFadeEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onIgnite(BlockIgniteEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBurn(BlockBurnEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onLeavesDecay(LeavesDecayEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onEntityChangeBlock(EntityChangeBlockEvent event) { + this.cancelIfProtected(event.getBlock(), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onHangingPlace(HangingPlaceEvent event) { + this.cancelIfProtected(event.getBlock().getRelative(event.getBlockFace()), event); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onGrow(StructureGrowEvent event) { + if(!this.getAppliance().worlds().isPlay(event.getWorld())) return; + event.getBlocks().removeIf(state -> this.isProtected(state.getLocation())); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPistonExtend(BlockPistonExtendEvent event) { + if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ())) + event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPistonRetract(BlockPistonRetractEvent event) { + if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ())) + event.setCancelled(true); + } + + private boolean anyProtected(Block piston, List moved, int dx, int dy, int dz) { + if(this.isProtected(piston) || this.isProtected(piston.getRelative(dx, dy, dz))) return true; + return moved.stream().anyMatch(block -> this.isProtected(block) || this.isProtected(block.getRelative(dx, dy, dz))); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + event.blockList().removeIf(this::isProtected); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + event.blockList().removeIf(this::isProtected); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java new file mode 100644 index 0000000..560cdb9 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java @@ -0,0 +1,36 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +/** + * Which inventory the two fighters use. The challenger chooses it. + */ +public enum FightMode { + OWN("Jeder sein eigenes"), + CHALLENGER("Beide mit meinem"), + OPPONENT("Beide mit dem des Gegners"); + + public enum Role { + CHALLENGER, + OPPONENT + } + + private final String displayName; + + FightMode(String displayName) { + this.displayName = displayName; + } + + public String displayName() { + return this.displayName; + } + + /** + * @return whose inventory the fighter with the given role gets + */ + public Role kitSource(Role fighter) { + return switch(this) { + case OWN -> fighter; + case CHALLENGER -> Role.CHALLENGER; + case OPPONENT -> Role.OPPONENT; + }; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java new file mode 100644 index 0000000..9eecd13 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java @@ -0,0 +1,164 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * State of the two start pads. The first player on a pad becomes the challenger and picks the mode, + * stepping on the other pad means accepting. Once both stand there long enough, the fight starts. + */ +public class PadLobby { + public static final int PADS = 2; + + public interface Listener { + /** The player is now the challenger and should pick a mode. */ + void challengerSelected(UUID challenger); + + /** Both pads are taken: give both the inventories of the chosen mode. */ + void kitsApplied(UUID challenger, UUID opponent, FightMode mode); + + /** The player gets the own inventory back. */ + void kitReverted(UUID player); + + void countdown(int secondsLeft); + + void countdownCancelled(); + + void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad); + } + + private final Listener listener; + private final int countdownTicks; + private final UUID[] occupants = new UUID[PADS]; + private int challengerPad = -1; + private FightMode mode = FightMode.OWN; + private int ticksLeft = -1; + + public PadLobby(Listener listener, int countdownSeconds) { + this.listener = listener; + this.countdownTicks = countdownSeconds * 20; + } + + /** + * @return false if the pad is already taken + */ + public boolean enter(int pad, UUID player) { + if(this.occupants[pad] != null) return false; + if(this.padOf(player) != -1) return false; + + this.occupants[pad] = player; + if(this.challengerPad == -1) { + this.challengerPad = pad; + this.mode = FightMode.OWN; + this.listener.challengerSelected(player); + return true; + } + + this.listener.kitsApplied(this.challenger(), this.opponent(), this.mode); + this.ticksLeft = this.countdownTicks; + return true; + } + + public void leave(UUID player) { + int pad = this.padOf(player); + if(pad == -1) return; + + int other = 1 - pad; + boolean bothThere = this.isFull(); + this.occupants[pad] = null; + this.listener.kitReverted(player); + if(bothThere) { + this.ticksLeft = -1; + this.listener.kitReverted(this.occupants[other]); + this.listener.countdownCancelled(); + } + + if(pad != this.challengerPad) return; + if(this.occupants[other] == null) { + this.challengerPad = -1; + return; + } + this.challengerPad = other; + this.mode = FightMode.OWN; + this.listener.challengerSelected(this.occupants[other]); + } + + /** + * Only the challenger can change the mode, and only while nobody stands on the other pad. + */ + public boolean setMode(UUID player, FightMode mode) { + if(!player.equals(this.challenger()) || this.isFull()) return false; + this.mode = mode; + return true; + } + + /** + * Called every server tick. The countdown only runs while an arena is free. + */ + public void tick(boolean arenaAvailable) { + if(this.ticksLeft < 0) return; + if(!arenaAvailable) { + this.ticksLeft = this.countdownTicks; + return; + } + + if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20); + if(this.ticksLeft-- > 0) return; + + UUID challenger = this.challenger(); + UUID opponent = this.opponent(); + FightMode mode = this.mode; + int challengerPad = this.challengerPad; + this.occupants[0] = null; + this.occupants[1] = null; + this.challengerPad = -1; + this.mode = FightMode.OWN; + this.ticksLeft = -1; + this.listener.start(challenger, opponent, mode, challengerPad); + } + + public int padOf(UUID player) { + for(int i = 0; i < PADS; i++) { + if(player.equals(this.occupants[i])) return i; + } + return -1; + } + + public @Nullable UUID occupant(int pad) { + return this.occupants[pad]; + } + + public @Nullable UUID challenger() { + return this.challengerPad == -1 ? null : this.occupants[this.challengerPad]; + } + + public @Nullable UUID opponent() { + return this.challengerPad == -1 ? null : this.occupants[1 - this.challengerPad]; + } + + public int challengerPad() { + return this.challengerPad; + } + + public FightMode mode() { + return this.mode; + } + + public boolean isFull() { + return this.occupants[0] != null && this.occupants[1] != null; + } + + public boolean isCountingDown() { + return this.ticksLeft >= 0; + } + + public boolean isIdle() { + return this.occupants[0] == null && this.occupants[1] == null; + } + + /** Seconds until the start, rounded up; -1 without countdown. */ + public int secondsLeft() { + return this.ticksLeft < 0 ? -1 : (this.ticksLeft + 19) / 20; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java new file mode 100644 index 0000000..293ba91 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java @@ -0,0 +1,58 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +/** + * Axis-aligned box of whole blocks, both corners inclusive. + */ +public record Region(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + public static Region of(int x1, int y1, int z1, int x2, int y2, int z2) { + return new Region( + Math.min(x1, x2), Math.min(y1, y2), Math.min(z1, z2), + Math.max(x1, x2), Math.max(y1, y2), Math.max(z1, z2) + ); + } + + public boolean contains(int x, int y, int z) { + return x >= this.minX && x <= this.maxX + && y >= this.minY && y <= this.maxY + && z >= this.minZ && z <= this.maxZ; + } + + public boolean contains(double x, double y, double z) { + return this.contains((int) Math.floor(x), (int) Math.floor(y), (int) Math.floor(z)); + } + + public Region expand(int horizontal, int up) { + return new Region( + this.minX - horizontal, this.minY, this.minZ - horizontal, + this.maxX + horizontal, this.maxY + up, this.maxZ + horizontal + ); + } + + public long volume() { + return (long) (this.maxX - this.minX + 1) * (this.maxY - this.minY + 1) * (this.maxZ - this.minZ + 1); + } + + public double centerX() { + return (this.minX + this.maxX + 1) / 2.0; + } + + public double centerZ() { + return (this.minZ + this.maxZ + 1) / 2.0; + } + + public int minChunkX() { + return this.minX >> 4; + } + + public int maxChunkX() { + return this.maxX >> 4; + } + + public int minChunkZ() { + return this.minZ >> 4; + } + + public int maxChunkZ() { + return this.maxZ >> 4; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java new file mode 100644 index 0000000..55730d6 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java @@ -0,0 +1,52 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +/** + * Walks through every block of a region in portions, so a reset can be spread over several ticks. + */ +public class ResetCursor { + @FunctionalInterface + public interface BlockVisitor { + void visit(int x, int y, int z); + } + + private final Region region; + private int x; + private int y; + private int z; + private boolean done; + + public ResetCursor(Region region) { + this.region = region; + this.x = region.minX(); + this.y = region.minY(); + this.z = region.minZ(); + } + + /** + * Visits up to {@code budget} blocks, continuing where the last call stopped. + * + * @return number of visited blocks + */ + public int advance(int budget, BlockVisitor visitor) { + int visited = 0; + while(!this.done && visited < budget) { + visitor.visit(this.x, this.y, this.z); + visited++; + this.step(); + } + return visited; + } + + public boolean isDone() { + return this.done; + } + + private void step() { + if(++this.y <= this.region.maxY()) return; + this.y = this.region.minY(); + if(++this.z <= this.region.maxZ()) return; + this.z = this.region.minZ(); + if(++this.x <= this.region.maxX()) return; + this.done = true; + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java new file mode 100644 index 0000000..336a9b6 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java @@ -0,0 +1,27 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.junit.jupiter.api.Test; + +import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.CHALLENGER; +import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.OPPONENT; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FightModeTest { + @Test + void ownKeepsEveryonesInventory() { + assertEquals(CHALLENGER, FightMode.OWN.kitSource(CHALLENGER)); + assertEquals(OPPONENT, FightMode.OWN.kitSource(OPPONENT)); + } + + @Test + void challengerKitForBoth() { + assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(CHALLENGER)); + assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(OPPONENT)); + } + + @Test + void opponentKitForBoth() { + assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(CHALLENGER)); + assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(OPPONENT)); + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java new file mode 100644 index 0000000..5ed9738 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java @@ -0,0 +1,151 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class PadLobbyTest { + private final UUID a = UUID.randomUUID(); + private final UUID b = UUID.randomUUID(); + private final UUID c = UUID.randomUUID(); + private final List events = new ArrayList<>(); + private PadLobby lobby; + + @BeforeEach + void setUp() { + this.lobby = new PadLobby(new PadLobby.Listener() { + public void challengerSelected(UUID challenger) { + PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger)); + } + + public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { + PadLobbyTest.this.events.add("kits " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode); + } + + public void kitReverted(UUID player) { + PadLobbyTest.this.events.add("revert " + PadLobbyTest.this.name(player)); + } + + public void countdown(int secondsLeft) { + PadLobbyTest.this.events.add("countdown " + secondsLeft); + } + + public void countdownCancelled() { + PadLobbyTest.this.events.add("cancelled"); + } + + public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { + PadLobbyTest.this.events.add("start " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode + " " + challengerPad); + } + }, 2); + } + + private String name(UUID uuid) { + if(uuid.equals(this.a)) return "a"; + if(uuid.equals(this.b)) return "b"; + return "c"; + } + + @Test + void firstOnPadBecomesChallenger() { + assertTrue(this.lobby.enter(1, this.a)); + assertEquals(List.of("challenger a"), this.events); + assertEquals(this.a, this.lobby.challenger()); + assertNull(this.lobby.opponent()); + assertFalse(this.lobby.isCountingDown()); + } + + @Test + void takenPadIsRefused() { + this.lobby.enter(0, this.a); + assertFalse(this.lobby.enter(0, this.b)); + assertFalse(this.lobby.enter(1, this.a), "a player can only take one pad"); + } + + @Test + void secondPlayerAppliesKitsAndStartsAfterCountdown() { + this.lobby.enter(0, this.a); + assertTrue(this.lobby.setMode(this.a, FightMode.CHALLENGER)); + this.lobby.enter(1, this.b); + assertFalse(this.lobby.setMode(this.a, FightMode.OWN), "mode is fixed once the opponent stands there"); + + for(int i = 0; i < 41; i++) this.lobby.tick(true); + + assertEquals(List.of( + "challenger a", + "kits ab CHALLENGER", + "countdown 2", + "countdown 1", + "start ab CHALLENGER 0" + ), this.events); + assertTrue(this.lobby.isIdle()); + assertNull(this.lobby.challenger()); + } + + @Test + void onlyChallengerMayChangeMode() { + this.lobby.enter(0, this.a); + assertFalse(this.lobby.setMode(this.b, FightMode.OPPONENT)); + assertEquals(FightMode.OWN, this.lobby.mode()); + } + + @Test + void countdownWaitsForFreeArena() { + this.lobby.enter(0, this.a); + this.lobby.enter(1, this.b); + for(int i = 0; i < 100; i++) this.lobby.tick(false); + assertFalse(this.events.contains("countdown 2")); + assertEquals(2, this.lobby.secondsLeft()); + + for(int i = 0; i < 41; i++) this.lobby.tick(true); + assertTrue(this.events.contains("start ab OWN 0")); + } + + @Test + void opponentLeavingCancelsAndRevertsBoth() { + this.lobby.enter(0, this.a); + this.lobby.setMode(this.a, FightMode.OPPONENT); + this.lobby.enter(1, this.b); + this.events.clear(); + + this.lobby.leave(this.b); + + assertEquals(List.of("revert b", "revert a", "cancelled"), this.events); + assertEquals(this.a, this.lobby.challenger()); + assertEquals(FightMode.OPPONENT, this.lobby.mode(), "challenger keeps the chosen mode"); + assertFalse(this.lobby.isCountingDown()); + } + + @Test + void challengerLeavingHandsOverRole() { + this.lobby.enter(0, this.a); + this.lobby.setMode(this.a, FightMode.CHALLENGER); + this.lobby.enter(1, this.b); + this.events.clear(); + + this.lobby.leave(this.a); + + assertEquals(List.of("revert a", "revert b", "cancelled", "challenger b"), this.events); + assertEquals(this.b, this.lobby.challenger()); + assertEquals(1, this.lobby.challengerPad()); + assertEquals(FightMode.OWN, this.lobby.mode()); + + this.lobby.enter(0, this.c); + assertEquals("kits bc OWN", this.events.getLast()); + } + + @Test + void lastPlayerLeavingResetsLobby() { + this.lobby.enter(0, this.a); + this.lobby.leave(this.a); + assertTrue(this.lobby.isIdle()); + assertNull(this.lobby.challenger()); + this.lobby.leave(this.b); + assertEquals(List.of("challenger a", "revert a"), this.events); + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java new file mode 100644 index 0000000..4fef3c1 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java @@ -0,0 +1,52 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class RegionTest { + @Test + void cornersAreNormalized() { + assertEquals(new Region(-2, 1, -5, 3, 4, 6), Region.of(3, 4, -5, -2, 1, 6)); + } + + @Test + void containsIsInclusiveAndFloorsNegativeCoordinates() { + Region region = Region.of(-2, 0, -2, 1, 3, 1); + assertTrue(region.contains(-2, 0, -2)); + assertTrue(region.contains(1, 3, 1)); + assertFalse(region.contains(2, 0, 0)); + assertTrue(region.contains(-1.5, 0.0, 1.99)); + assertFalse(region.contains(-2.01, 0.0, 0.0)); + assertFalse(region.contains(1.0, 4.0, 0.0)); + } + + @Test + void volumeAndChunks() { + Region region = Region.of(-1, 0, 15, 16, 9, 32); + assertEquals(18L * 10 * 18, region.volume()); + assertEquals(-1, region.minChunkX()); + assertEquals(1, region.maxChunkX()); + assertEquals(0, region.minChunkZ()); + assertEquals(2, region.maxChunkZ()); + } + + @Test + void resetCursorVisitsEveryBlockOnceInPortions() { + Region region = Region.of(0, 0, 0, 4, 3, 2); + ResetCursor cursor = new ResetCursor(region); + Set visited = new HashSet<>(); + int calls = 0; + while(!cursor.isDone()) { + int count = cursor.advance(7, (x, y, z) -> assertTrue(visited.add(x + "," + y + "," + z))); + assertTrue(count <= 7); + calls++; + } + assertEquals(region.volume(), visited.size()); + assertEquals((int) Math.ceil(region.volume() / 7.0), calls); + assertEquals(0, cursor.advance(7, (x, y, z) -> fail())); + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java index 74ced9f..849a655 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java @@ -49,6 +49,19 @@ public abstract class HttpRepository extends Repository { return this.execute(request, outputType, data); } + /** + * Like {@link #post(String, Object, Class)}, but the request fails with an exception once the timeout has passed. + */ + protected ReqResp post(String command, Duration timeout, TInput data, Class outputType) { + HttpRequest request = this.getRequestBuilder(this.getUri(command, parameters -> { + })) + .timeout(timeout) + .POST(HttpRequest.BodyPublishers.ofString(this.gson.toJson(data))) + .build(); + + return this.execute(request, outputType, data); + } + protected ReqResp put(String command, TInput data, Class outputType) { return this.put(command, parameters -> { }, data, outputType); @@ -117,12 +130,19 @@ public abstract class HttpRepository extends Repository { return builder; } + /** + * Whether request bodies are written to the log. Turn off for large or sensitive payloads. + */ + protected boolean logRequestData() { + return true; + } + private ReqResp execute(HttpRequest request, Class clazz, Object original) { ReqResp rawResponse = this.sendHttp(request); Main.logger().info(String.format( "Request: %s\nRequest-Data: %s\nResponse: %s", request, - this.gson.toJson(original), + this.logRequestData() ? this.gson.toJson(original) : "", rawResponse )); return new ReqResp<>(rawResponse.status(), this.gson.fromJson(rawResponse.data(), clazz)); diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java index 02be1c5..acd284c 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java @@ -4,6 +4,7 @@ import com.google.gson.Gson; import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.api.server.hooks.HttpHook; import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.config.Configuration; import org.bukkit.configuration.ConfigurationSection; import spark.Request; import spark.Spark; @@ -19,7 +20,7 @@ public class HttpServer { public static Object nothing = null; public HttpServer() { - Spark.port(8080); + Spark.port(Configuration.pluginConfig.getInt("httpPort", 8080)); Spark.get("/ping", (request, response) -> System.currentTimeMillis()); diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index ae6a280..c81dc53 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -1,5 +1,6 @@ plugin: httpServerEnabled: true + httpPort: 8080 disabledAppliances: - NameOfApplianceClass @@ -34,6 +35,43 @@ worldMuseum: yaw: 0 pitch: 0 +arenaPortal: + api: http://127.0.0.1:8081/ + connect-server-name: arena + uuid: + villagerLocation: + world: world + x: 0 + y: 0 + z: 0 + yaw: 0 + pitch: 0 + +arena: + connect-server-name: craftattack + fightMinutes: 10 + countdownSeconds: 5 + resetBlocksPerTick: 20000 + uuid: + villagerLocation: + world: arena + x: 0 + y: 64 + z: 0 + yaw: 0 + pitch: 0 + tribuneSpawn: + world: arena + x: 0.5 + y: 64 + z: 0.5 + yaw: 0 + pitch: 0 + # alles Weitere setzt /arenasetup auf dem Arena-Server + syncRegion: {} + pads: {} + arenas: {} + adminMarker: permission: admin color: AQUA diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java new file mode 100644 index 0000000..e97e355 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java @@ -0,0 +1,17 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +class ArenaCommand extends ApplianceCommand.PlayerChecked { + public ArenaCommand() { + super("arena"); + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + this.getAppliance().sendToArena(this.getPlayer()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java new file mode 100644 index 0000000..810cbc4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java @@ -0,0 +1,124 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal; + +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.api.client.ReqResp; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.spawn.core.config.ConfigUtil; +import eu.mhsl.craftattack.spawn.core.config.Configuration; +import eu.mhsl.craftattack.spawn.core.util.entity.DisplayVillager; +import eu.mhsl.craftattack.spawn.core.util.listener.DismissInventoryOpenFromHolder; +import eu.mhsl.craftattack.spawn.core.util.listener.PlayerInteractAtEntityEventListener; +import eu.mhsl.craftattack.spawn.core.util.server.PluginMessage; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Entrance to the arena server: sends a copy of the player's inventory there and connects the player. + * The inventory on this server is never changed. + */ +public class ArenaPortal extends Appliance { + private static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD); + + private DisplayVillager.ConfigBound villager; + private final Set pendingTransfers = new HashSet<>(); + + public ArenaPortal() { + super("arenaPortal"); + } + + @Override + public void onEnable() { + if(this.localConfig().getConfigurationSection("villagerLocation") == null) { + Main.logger().warning("ArenaPortal: no villagerLocation configured, villager disabled"); + return; + } + this.villager = new DisplayVillager.ConfigBound( + this.localConfig(), + villager -> { + villager.customName(Component.text("Arena", NamedTextColor.RED)); + villager.setProfession(Villager.Profession.WEAPONSMITH); + villager.setVillagerType(Villager.Type.SAVANNA); + } + ); + } + + /** + * @return false if the villager only appears after the next restart + */ + public boolean updateVillagerPosition(Location location) { + if(this.villager != null) { + this.villager.updateLocation(location); + return true; + } + ConfigUtil.Position.writeLocation(this.localConfig().createSection("villagerLocation"), location); + Configuration.saveChanges(); + return false; + } + + public void sendToArena(Player player) { + if(!this.pendingTransfers.add(player.getUniqueId())) return; + player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY)); + + List contents = new ArrayList<>(List.of(player.getInventory().getContents())); + String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(contents)); + ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items); + + Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> { + boolean success; + try { + ReqResp response = this.queryRepository(ArenaRepository.class).sendInventory(snapshot); + success = response.status() == 200 && response.data() != null && "SUCCESS".equals(response.data().status()); + } catch(Exception e) { + Main.logger().log(Level.WARNING, "ArenaPortal: arena server not reachable: " + e.getMessage()); + success = false; + } + + boolean finalSuccess = success; + Bukkit.getScheduler().runTask(Main.instance(), () -> { + this.pendingTransfers.remove(player.getUniqueId()); + if(!player.isOnline()) return; + if(!finalSuccess) { + player.sendMessage(PREFIX.append(Component.text( + "Die Arena ist gerade nicht erreichbar. Versuche es später noch einmal.", + NamedTextColor.RED + ))); + return; + } + Main.logger().info("Sending " + player.getName() + " to the arena"); + PluginMessage.connect(player, this.localConfig().getString("connect-server-name", "arena")); + }); + }); + } + + @Override + @NotNull + protected List> commands() { + return List.of(new ArenaCommand(), new MoveArenaVillagerCommand()); + } + + @Override + @NotNull + protected List listeners() { + if(this.villager == null) return List.of(); + return List.of( + new PlayerInteractAtEntityEventListener(this.villager.getUniqueId(), event -> this.sendToArena(event.getPlayer())), + new DismissInventoryOpenFromHolder(this.villager.getUniqueId()) + ); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java new file mode 100644 index 0000000..3f25a22 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java @@ -0,0 +1,33 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal; + +import eu.mhsl.craftattack.spawn.core.api.client.HttpRepository; +import eu.mhsl.craftattack.spawn.core.api.client.ReqResp; +import eu.mhsl.craftattack.spawn.core.config.Configuration; + +import java.net.URI; +import java.time.Duration; +import java.util.UUID; + +public class ArenaRepository extends HttpRepository { + public ArenaRepository() { + super(URI.create(Configuration.cfg.getString("arenaPortal.api", "http://127.0.0.1:8081").replaceAll("/+$", ""))); + } + + /** + * @param items Base64 of {@link org.bukkit.inventory.ItemStack#serializeItemsAsBytes(java.util.Collection)} + */ + public record InventorySnapshot(UUID player, String items) { + } + + public record Response(String status, String error) { + } + + public ReqResp sendInventory(InventorySnapshot snapshot) { + return this.post("api/arena/inventory", Duration.ofSeconds(5), snapshot, Response.class); + } + + @Override + protected boolean logRequestData() { + return false; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java new file mode 100644 index 0000000..259b21c --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java @@ -0,0 +1,26 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +class MoveArenaVillagerCommand extends ApplianceCommand.PlayerChecked { + public MoveArenaVillagerCommand() { + super("moveArenaVillager"); + } + + @Override + public @Nullable String permission() { + return "admin"; + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(!this.getAppliance().updateVillagerPosition(this.getPlayer().getLocation())) + sender.sendMessage(Component.text("Position gespeichert. Der Villager erscheint nach dem nächsten Neustart.", NamedTextColor.GOLD)); + } +} diff --git a/local.gradle.example b/local.gradle.example index 76f923e..c5e289e 100644 --- a/local.gradle.example +++ b/local.gradle.example @@ -22,3 +22,14 @@ tasks.register('deployCraftAttackPlugin', Copy) { into file('path') // path to plugins folder rename { fileName -> "craftattack.jar" } } + +tasks.register("uploadArenaPlugin") { + dependsOn(":arena:shadowJar") + + doLast { + def jarFile = project(":arena").tasks.named("shadowJar").get().outputs.files.singleFile + providers.exec { + commandLine "scp", "-4", "-P", "22", jarFile.absolutePath, "user@host:path/arena.jar" + }.result.get() + } +} diff --git a/settings.gradle b/settings.gradle index 618596b..1845a3c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,4 +5,5 @@ include 'craftattack' include 'common' include 'varo' include 'event' +include 'arena' From 65c48eae4551f822d8673015a0ff73d9c689a92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 22:42:03 +0200 Subject: [PATCH 2/7] fixed arena transfer with empty inventory slots and blocked advancements in the arena --- .../spawn/arena/appliances/arena/Arena.java | 1 + .../arena/appliances/arena/ArenaWorlds.java | 1 + .../appliances/arena/NoProgressListener.java | 21 +++++++++++++++++++ .../metaGameplay/arenaPortal/ArenaPortal.java | 4 +--- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java index 17d5e6f..fb72f05 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java @@ -373,6 +373,7 @@ public class Arena extends Appliance { new FightListener(), new WorldProtectionListener(), new ModeMenuListener(), + new NoProgressListener(), new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())), new DismissInventoryOpenFromHolder(villager) ); diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java index c7477a7..cccc52f 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java @@ -39,6 +39,7 @@ final class ArenaWorlds { world.setGameRule(GameRules.SPAWN_WARDENS, false); world.setGameRule(GameRules.RAIDS, false); world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false); + world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false); world.setStorm(false); world.setThundering(false); world.setTime(6000); diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java new file mode 100644 index 0000000..e4392af --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java @@ -0,0 +1,21 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerRecipeDiscoverEvent; + +/** + * Nothing done in the arena counts as progress: no advancements, no recipe unlocks. + */ +class NoProgressListener extends ApplianceListener { + @EventHandler + public void onCriterion(PlayerAdvancementCriterionGrantEvent event) { + event.setCancelled(true); + } + + @EventHandler + public void onRecipe(PlayerRecipeDiscoverEvent event) { + event.setCancelled(true); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java index 810cbc4..b9b4148 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java @@ -20,7 +20,6 @@ import org.bukkit.event.Listener; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; import java.util.List; @@ -75,8 +74,7 @@ public class ArenaPortal extends Appliance { if(!this.pendingTransfers.add(player.getUniqueId())) return; player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY)); - List contents = new ArrayList<>(List.of(player.getInventory().getContents())); - String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(contents)); + String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(player.getInventory().getContents())); ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items); Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> { From b8ba85f8c504504e30fc1119e29d233e9480330b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 22:52:07 +0200 Subject: [PATCH 3/7] widened arena spectator range --- .../eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java index c580d7a..d8733b2 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java @@ -28,7 +28,7 @@ import java.util.UUID; */ final class Fight { private static final int CELEBRATION_TICKS = 60; - private static final double SPECTATOR_RANGE = 24; + private static final double SPECTATOR_RANGE = 32; private final Arena arena; private final ArenaSlot slot; From a91f550153e03bc4ceb51bd6bc5246ccd4ccdf5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 22:55:54 +0200 Subject: [PATCH 4/7] fixed duplicated display villagers when entities were not loaded yet --- .../spawn/arena/appliances/arena/Arena.java | 21 +++++++++++++++++++ .../core/util/entity/DisplayVillager.java | 14 ++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java index fb72f05..6d3baa1 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java @@ -20,6 +20,7 @@ import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.World; import org.bukkit.attribute.Attribute; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.entity.Villager; import org.bukkit.event.Listener; @@ -104,6 +105,7 @@ public class Arena extends Appliance { else Main.logger().warning("Arena: pads are not set up, fights cannot be started"); this.syncRegions().forEach(region -> this.worlds.keepLoaded(region, Main.instance())); + this.removeStrayVillagers(); this.placeStarterPlatform(); this.syncAll(); @@ -178,6 +180,25 @@ public class Arena extends Appliance { .forEach(this::toTribune); } + /** + * Removes old copies of the return villager, for example after its position was changed in the config by hand. + */ + private void removeStrayVillagers() { + Region region = this.settings.syncRegion(); + if(region == null || this.villager == null) return; + UUID current = this.villager.getUniqueId(); + for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) { + for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) { + for(Entity entity : this.worlds.play().getChunkAt(cx, cz).getEntities()) { + if(!(entity instanceof Villager) || entity.getUniqueId().equals(current)) continue; + if(!entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue; + entity.remove(); + Main.logger().info("Arena: removed a stray return villager at " + entity.getLocation().toVector()); + } + } + } + } + /** * A fresh, empty world gets a small platform at the tribune spawn, so the first visitors do not fall into the void. */ diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java index 8970b21..e770052 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java @@ -8,6 +8,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; import org.bukkit.entity.Villager; +import java.util.Arrays; import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; @@ -19,11 +20,14 @@ public class DisplayVillager { public DisplayVillager(UUID uuid, Location location, Consumer villagerCreator) { this.location = location; - try { - ChunkUtils.loadChunkAtLocation(this.location); - this.villager = (Villager) this.location.getWorld().getEntity(uuid); - Objects.requireNonNull(this.villager); - } catch(NullPointerException | IllegalArgumentException e) { + // getEntities() waits for the chunk's entities; getEntity(uuid) misses them while they are still loading + this.villager = Arrays.stream(ChunkUtils.loadChunkAtLocation(this.location).getEntities()) + .filter(entity -> entity.getUniqueId().equals(uuid)) + .filter(Villager.class::isInstance) + .map(Villager.class::cast) + .findFirst() + .orElse(null); + if(this.villager == null) { this.villager = this.getBaseVillager(); villagerCreator.accept(this.villager); } From 4add446940c718af2bcd4deded3b2f97d24e30be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 23:08:19 +0200 Subject: [PATCH 5/7] doubled the size of arena status displays --- .../spawn/arena/appliances/arena/ArenaSlot.java | 3 ++- .../spawn/arena/appliances/arena/Displays.java | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java index e9b7c9a..897ffdb 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java @@ -23,7 +23,8 @@ final class ArenaSlot { ArenaSlot(ArenaSettings.Slot settings, World world) { this.settings = settings; - this.display = Displays.spawn(settings.displayLocation(world)); + // seen from the tribune across half an arena, so twice the size + this.display = Displays.spawn(settings.displayLocation(world), 2f); } void updateDisplay() { diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java index 3d7b1a5..0d4fd4c 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java @@ -5,6 +5,7 @@ import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.entity.Display; import org.bukkit.entity.TextDisplay; +import org.bukkit.util.Transformation; /** * Floating texts above pads and arenas. They are not saved with the world and come back with every start. @@ -16,6 +17,10 @@ final class Displays { } static TextDisplay spawn(Location location) { + return spawn(location, 1f); + } + + static TextDisplay spawn(Location location, float scale) { // displays are not saved, they would vanish with an unloaded chunk location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance()); return location.getWorld().spawn(location, TextDisplay.class, display -> { @@ -26,6 +31,11 @@ final class Displays { display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0)); display.setLineWidth(220); display.addScoreboardTag(MANAGED_TAG); + if(scale != 1f) { + Transformation transformation = display.getTransformation(); + transformation.getScale().set(scale); + display.setTransformation(transformation); + } }); } } From ae3bde626fcef480dcc2dc9599cd4774b8d3f166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 23:21:20 +0200 Subject: [PATCH 6/7] added team fights up to 4 vs 4 to the arena --- .../spawn/arena/appliances/arena/Arena.java | 11 +- .../spawn/arena/appliances/arena/Fight.java | 286 ++++++++++++------ .../arena/appliances/arena/FightListener.java | 12 +- .../spawn/arena/appliances/arena/Lobby.java | 214 +++++++------ .../arena/appliances/arena/ModeMenu.java | 122 ++++++-- .../appliances/arena/ModeMenuListener.java | 5 +- .../appliances/arena/logic/FightMode.java | 37 +-- .../appliances/arena/logic/PadLobby.java | 160 ++++++---- .../appliances/arena/logic/FightModeTest.java | 25 +- .../appliances/arena/logic/PadLobbyTest.java | 155 +++++++--- 10 files changed, 664 insertions(+), 363 deletions(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java index 6d3baa1..a9ceaa0 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java @@ -225,14 +225,13 @@ public class Arena extends Appliance { return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE); } - void startFight(Player red, Player blue, UUID challenger, FightMode mode) { + void startFight(List> teams, FightMode mode, Map kits) { ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null); if(slot == null) { - this.toTribune(red); - this.toTribune(blue); + teams.forEach(team -> team.forEach(this::toTribune)); return; } - new Fight(this, slot, red, blue, challenger, mode).start(); + new Fight(this, slot, teams, mode, kits).start(); } void resetSlot(ArenaSlot slot) { @@ -314,7 +313,7 @@ public class Arena extends Appliance { public void handleQuit(Player player) { Fight fight = this.fightOf(player); - if(fight != null && this.state(player) == PlayerState.FIGHT) fight.forfeit(player.getUniqueId()); + if(fight != null && fight.isAlive(player.getUniqueId())) fight.forfeit(player.getUniqueId()); if(this.lobby != null) this.lobby.leave(player); this.teams.leave(player); this.states.remove(player.getUniqueId()); @@ -348,7 +347,7 @@ public class Arena extends Appliance { public @Nullable Region arenaRegionOf(Player player) { Fight fight = this.fightOf(player); - return fight == null ? null : fight.slot().settings.region(); + return fight == null ? null : fight.region(); } public @Nullable Lobby lobby() { diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java index d8733b2..d160791 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java @@ -1,9 +1,11 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena; import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; import eu.mhsl.craftattack.spawn.core.Main; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.title.Title; import org.bukkit.Bukkit; @@ -18,37 +20,44 @@ import org.jetbrains.annotations.Nullable; import java.time.Duration; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; /** - * A running fight in one arena. Fighter 0 comes from the red pad and uses spawn 1, fighter 1 the blue pad and spawn 2. + * A running fight in one arena between team 0 (red pad, spawn 1) and team 1 (blue pad, spawn 2). + * A team loses once none of its players is left in the arena. */ final class Fight { private static final int CELEBRATION_TICKS = 60; private static final double SPECTATOR_RANGE = 32; + private static final double SPAWN_SPREAD = 2; private final Arena arena; private final ArenaSlot slot; - private final UUID[] fighters; - private final String[] names; - private final UUID challenger; + private final List> teams; + private final Map names = new HashMap<>(); + private final List> alive; private final FightMode mode; + private final Map kits; private final BossBar bossBar; private final Set bossBarViewers = new HashSet<>(); private int secondsLeft; private boolean over; private @Nullable BukkitTask timer; - Fight(Arena arena, ArenaSlot slot, Player red, Player blue, UUID challenger, FightMode mode) { + Fight(Arena arena, ArenaSlot slot, List> teams, FightMode mode, Map kits) { this.arena = arena; this.slot = slot; - this.fighters = new UUID[]{red.getUniqueId(), blue.getUniqueId()}; - this.names = new String[]{red.getName(), blue.getName()}; - this.challenger = challenger; + this.teams = teams.stream().map(team -> team.stream().map(Player::getUniqueId).toList()).toList(); + teams.forEach(team -> team.forEach(player -> this.names.put(player.getUniqueId(), player.getName()))); + this.alive = this.teams.stream().map(team -> (Set) new LinkedHashSet<>(team)).toList(); this.mode = mode; + this.kits = kits; this.secondsLeft = arena.settings().fightSeconds(); this.bossBar = BossBar.bossBar(Component.empty(), 1f, BossBar.Color.RED, BossBar.Overlay.NOTCHED_10); } @@ -57,44 +66,72 @@ final class Fight { this.slot.state = ArenaSlot.State.FIGHT; this.slot.fight = this; - FightMode.Role[] roles = new FightMode.Role[2]; - for(int i = 0; i < 2; i++) { - roles[i] = this.fighters[i].equals(this.challenger) ? FightMode.Role.CHALLENGER : FightMode.Role.OPPONENT; - } - UUID challengerId = this.challenger; - UUID opponentId = this.fighters[0].equals(challengerId) ? this.fighters[1] : this.fighters[0]; - - for(int i = 0; i < 2; i++) { - Player player = Bukkit.getPlayer(this.fighters[i]); - if(player == null) continue; - UUID kitOwner = this.mode.kitSource(roles[i]) == FightMode.Role.CHALLENGER ? challengerId : opponentId; - - this.arena.setState(player, Arena.PlayerState.FIGHT); - this.arena.teams().join(player, Teams.fight(i)); - player.closeInventory(); - player.teleport(this.slot.settings.spawns()[i]); - Arena.restore(player, GameMode.SURVIVAL); - InventoryStore.apply(player, kitOwner); - - player.showTitle(Title.title( - Component.text("Kampf!", NamedTextColor.GOLD), - Component.text("gegen ", NamedTextColor.GRAY).append(Component.text(this.names[1 - i], Style.PAD_TEXT_COLORS[1 - i])), - Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500)) - )); - Style.play(player, Sound.EVENT_RAID_HORN, 1.2f); + for(int team = 0; team < 2; team++) { + List members = this.teams.get(team); + for(int i = 0; i < members.size(); i++) { + Player player = Bukkit.getPlayer(members.get(i)); + if(player == null) continue; + this.arena.setState(player, Arena.PlayerState.FIGHT); + this.arena.teams().join(player, Teams.fight(team)); + player.closeInventory(); + player.teleport(this.spawnFor(team, i, members.size())); + Arena.restore(player, GameMode.SURVIVAL); + InventoryStore.apply(player, this.kits.getOrDefault(player.getUniqueId(), player.getUniqueId())); + + player.showTitle(Title.title( + Component.text("Kampf!", NamedTextColor.GOLD), + Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(1 - team)), + Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500)) + )); + Style.play(player, Sound.EVENT_RAID_HORN, 1.2f); + } } + // players who left during the countdown never arrived + for(int team = 0; team < 2; team++) this.alive.get(team).removeIf(uuid -> Bukkit.getPlayer(uuid) == null); Bukkit.broadcast(Style.message( - this.headline().append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(), NamedTextColor.GRAY)) + this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1)) + .append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(this.isTeamFight()), NamedTextColor.GRAY)) )); this.updateBossBar(); this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20); + if(this.alive.get(0).isEmpty() || this.alive.get(1).isEmpty()) this.checkEnd(); } + /** + * Team members stand in a small circle around the spawn, facing the same way. + */ + private Location spawnFor(int team, int index, int count) { + Location center = this.slot.settings.spawns()[team]; + if(count == 1) return center; + double angle = 2 * Math.PI * index / count; + Location spot = center.clone().add(Math.cos(angle) * SPAWN_SPREAD, 0, Math.sin(angle) * SPAWN_SPREAD); + boolean free = spot.getBlock().isPassable() && spot.clone().add(0, 1, 0).getBlock().isPassable() + && !spot.clone().subtract(0, 1, 0).getBlock().isPassable(); + return free ? spot : center; + } + + private boolean isTeamFight() { + return this.teams.get(0).size() > 1 || this.teams.get(1).size() > 1; + } + + private Component teamNames(int team) { + return Component.join( + JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)), + this.teams.get(team).stream().map(uuid -> Component.text(this.names.get(uuid), Style.PAD_TEXT_COLORS[team])).toList() + ); + } + + /** + * Short form for bossbar and displays: names in a duel, "Rot 2/3" in a team fight. + */ Component headline() { - return Component.text(this.names[0], Style.PAD_TEXT_COLORS[0]) + if(!this.isTeamFight()) { + return this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1)); + } + return Component.text("Rot " + this.alive.get(0).size() + "/" + this.teams.get(0).size(), Style.PAD_TEXT_COLORS[0]) .append(Component.text(" gegen ", NamedTextColor.GRAY)) - .append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1])); + .append(Component.text("Blau " + this.alive.get(1).size() + "/" + this.teams.get(1).size(), Style.PAD_TEXT_COLORS[1])); } int secondsLeft() { @@ -106,7 +143,20 @@ final class Fight { } boolean involves(UUID player) { - return this.fighters[0].equals(player) || this.fighters[1].equals(player); + return this.teams.get(0).contains(player) || this.teams.get(1).contains(player); + } + + boolean isAlive(UUID player) { + return this.alive.get(0).contains(player) || this.alive.get(1).contains(player); + } + + boolean sameTeam(UUID a, UUID b) { + int team = this.teamOf(a); + return team != -1 && team == this.teamOf(b); + } + + Region region() { + return this.slot.settings.region(); } ArenaSlot slot() { @@ -116,11 +166,11 @@ final class Fight { private void tickSecond() { if(this.over) return; if(--this.secondsLeft <= 0) { - this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY)); + this.timeUp(); return; } if(this.secondsLeft == 60 || this.secondsLeft == 10) { - this.fighterPlayers().forEach(player -> { + this.alivePlayers().forEach(player -> { player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW)); Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f); }); @@ -128,13 +178,40 @@ final class Fight { this.updateBossBar(); } + /** + * More survivors win, then more hearts left, otherwise a draw. + */ + private void timeUp() { + int red = this.alive.get(0).size(); + int blue = this.alive.get(1).size(); + if(red != blue) { + this.finish(red > blue ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Überlebende)", NamedTextColor.GRAY)); + return; + } + double redHealth = this.health(0); + double blueHealth = this.health(1); + if(Math.abs(redHealth - blueHealth) >= 0.5) { + this.finish(redHealth > blueHealth ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Herzen übrig)", NamedTextColor.GRAY)); + return; + } + this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY)); + } + + private double health(int team) { + return this.alive.get(team).stream() + .map(Bukkit::getPlayer) + .filter(player -> player != null) + .mapToDouble(player -> player.getHealth() + player.getAbsorptionAmount()) + .sum(); + } + private void updateBossBar() { this.bossBar.name(this.headline().append(Component.text(" " + Style.time(this.secondsLeft), NamedTextColor.WHITE))); this.bossBar.progress(Math.clamp((float) this.secondsLeft / this.arena.settings().fightSeconds(), 0f, 1f)); Set viewers = new HashSet<>(); for(Player player : this.arena.worlds().play().getPlayers()) { - if(this.involves(player.getUniqueId()) || this.isSpectating(player)) viewers.add(player.getUniqueId()); + if(this.isAlive(player.getUniqueId()) || this.isSpectating(player)) viewers.add(player.getUniqueId()); } for(UUID uuid : this.bossBarViewers) { Player player = Bukkit.getPlayer(uuid); @@ -150,32 +227,61 @@ final class Fight { private boolean isSpectating(Player player) { if(this.arena.state(player) != Arena.PlayerState.TRIBUNE && this.arena.state(player) != Arena.PlayerState.PAD) return false; - var region = this.slot.settings.region(); + Region region = this.slot.settings.region(); double dx = Math.max(0, Math.max(region.minX() - player.getX(), player.getX() - (region.maxX() + 1))); double dz = Math.max(0, Math.max(region.minZ() - player.getZ(), player.getZ() - (region.maxZ() + 1))); return Math.hypot(dx, dz) <= SPECTATOR_RANGE; } /** - * The player took lethal damage. + * The player took lethal damage: out of the fight and back to the tribune. */ - void defeated(Player loser) { - int index = this.indexOf(loser.getUniqueId()); - if(index == -1 || this.over) return; - Player winner = Bukkit.getPlayer(this.fighters[1 - index]); - Component detail = winner == null - ? Component.empty() - : Component.text("(" + this.mode.displayName() + ", " + Style.hearts(winner.getHealth() + winner.getAbsorptionAmount()) + " ❤ übrig)", NamedTextColor.GRAY); - this.finish(1 - index, detail); + void defeated(Player player) { + if(this.over || !this.eliminate(player.getUniqueId())) return; + player.showTitle(Title.title(Component.text("Ausgeschieden", NamedTextColor.RED), Component.empty())); + Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f); + this.arena.toTribune(player); + this.announceElimination(player.getUniqueId(), "ist ausgeschieden"); + this.checkEnd(); } /** * The player left the server during the fight. */ void forfeit(UUID quitter) { - int index = this.indexOf(quitter); - if(index == -1 || this.over) return; - this.finish(1 - index, Component.text("(" + this.names[index] + " hat die Arena verlassen)", NamedTextColor.GRAY)); + if(this.over || !this.eliminate(quitter)) return; + this.announceElimination(quitter, "hat die Arena verlassen"); + this.checkEnd(); + } + + private boolean eliminate(UUID player) { + int team = this.teamOf(player); + return team != -1 && this.alive.get(team).remove(player); + } + + private void announceElimination(UUID player, String what) { + if(!this.isTeamFight()) return; + int team = this.teamOf(player); + Component message = Component.text(this.names.get(player), Style.PAD_TEXT_COLORS[team]) + .append(Component.text(" " + what + ". ", NamedTextColor.GRAY)) + .append(this.headline()); + this.alivePlayers().forEach(fighter -> fighter.sendActionBar(message)); + this.updateBossBar(); + } + + private void checkEnd() { + if(this.over) return; + boolean redOut = this.alive.get(0).isEmpty(); + boolean blueOut = this.alive.get(1).isEmpty(); + if(redOut && blueOut) this.finish(-1, Component.text("Niemand ist mehr übrig.", NamedTextColor.GRAY)); + else if(redOut) this.finish(1, this.detail(1)); + else if(blueOut) this.finish(0, this.detail(0)); + } + + private Component detail(int winner) { + String hearts = Style.hearts(this.health(winner)); + String survivors = this.isTeamFight() ? this.alive.get(winner).size() + " übrig, " : ""; + return Component.text("(" + this.mode.displayName(this.isTeamFight()) + ", " + survivors + hearts + " ❤)", NamedTextColor.GRAY); } /** @@ -186,23 +292,20 @@ final class Fight { this.over = true; this.stopTimer(); this.hideBossBar(); - this.fighterPlayers().forEach(this.arena::toTribune); + this.alivePlayers().forEach(this.arena::toTribune); this.slot.fight = null; } - private void finish(int winnerIndex, Component detail) { + private void finish(int winner, Component detail) { this.over = true; this.stopTimer(); - if(winnerIndex == -1) { + if(winner == -1) { Bukkit.broadcast(Style.message( - Component.text(this.names[0], Style.PAD_TEXT_COLORS[0]) - .append(Component.text(" und ", NamedTextColor.GRAY)) - .append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1])) - .append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)) - .append(detail) + this.teamNames(0).append(Component.text(" und ", NamedTextColor.GRAY)).append(this.teamNames(1)) + .append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)).append(detail) )); - this.fighterPlayers().forEach(player -> { + this.alivePlayers().forEach(player -> { player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail)); Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f); this.arena.toTribune(player); @@ -211,33 +314,31 @@ final class Fight { return; } - int loserIndex = 1 - winnerIndex; + int loser = 1 - winner; + boolean plural = this.teams.get(winner).size() > 1; Bukkit.broadcast(Style.message( - Component.text(this.names[winnerIndex], Style.PAD_TEXT_COLORS[winnerIndex]) - .append(Component.text(" hat gegen ", NamedTextColor.GRAY)) - .append(Component.text(this.names[loserIndex], Style.PAD_TEXT_COLORS[loserIndex])) - .append(Component.text(" gewonnen ", NamedTextColor.GRAY)) + this.teamNames(winner) + .append(Component.text(plural ? " gewinnen gegen " : " hat gegen ", NamedTextColor.GRAY)) + .append(this.teamNames(loser)) + .append(Component.text(plural ? " " : " gewonnen ", NamedTextColor.GRAY)) .append(detail) )); - Player loser = Bukkit.getPlayer(this.fighters[loserIndex]); - if(loser != null) { - loser.showTitle(Title.title( - Component.text("Verloren", NamedTextColor.RED), - Component.text(this.names[winnerIndex] + " hat gewonnen", NamedTextColor.GRAY) - )); - Style.play(loser, Sound.BLOCK_BEACON_DEACTIVATE, 1f); - this.arena.toTribune(loser); + for(UUID uuid : this.teams.get(loser)) { + Player player = Bukkit.getPlayer(uuid); + if(player == null) continue; + player.showTitle(Title.title(Component.text("Verloren", NamedTextColor.RED), Component.empty())); + if(this.alive.get(loser).contains(uuid)) this.arena.toTribune(player); } - - Player winner = Bukkit.getPlayer(this.fighters[winnerIndex]); - if(winner != null) { - winner.showTitle(Title.title( + for(UUID uuid : this.teams.get(winner)) { + Player player = Bukkit.getPlayer(uuid); + if(player == null) continue; + player.showTitle(Title.title( Component.text("Gewonnen!", NamedTextColor.GOLD), - Component.text("gegen " + this.names[loserIndex], NamedTextColor.GRAY) + Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(loser)) )); - Style.play(winner, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f); - this.celebrate(winner.getLocation(), Style.PAD_PARTICLE_COLORS[winnerIndex]); + Style.play(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f); + if(this.alive.get(winner).contains(uuid)) this.celebrate(player.getLocation(), Style.PAD_PARTICLE_COLORS[winner]); } this.afterCelebration(); } @@ -261,9 +362,8 @@ final class Fight { private void afterCelebration() { this.hideBossBar(); Bukkit.getScheduler().runTaskLater(Main.instance(), () -> { - for(UUID uuid : this.fighters) { - Player player = Bukkit.getPlayer(uuid); - if(player != null && this.arena.fightOf(player) == this) this.arena.toTribune(player); + for(Player player : this.alivePlayers()) { + if(this.arena.fightOf(player) == this && this.arena.state(player) == Arena.PlayerState.FIGHT) this.arena.toTribune(player); } this.slot.fight = null; this.arena.resetSlot(this.slot); @@ -275,17 +375,19 @@ final class Fight { this.timer = null; } - private int indexOf(UUID player) { - if(this.fighters[0].equals(player)) return 0; - if(this.fighters[1].equals(player)) return 1; + private int teamOf(UUID player) { + if(this.teams.get(0).contains(player)) return 0; + if(this.teams.get(1).contains(player)) return 1; return -1; } - private List fighterPlayers() { + private List alivePlayers() { List players = new ArrayList<>(); - for(UUID uuid : this.fighters) { - Player player = Bukkit.getPlayer(uuid); - if(player != null) players.add(player); + for(Set team : this.alive) { + for(UUID uuid : team) { + Player player = Bukkit.getPlayer(uuid); + if(player != null) players.add(player); + } } return players; } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java index b4f02b1..0562df7 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java @@ -22,19 +22,23 @@ import org.bukkit.inventory.ItemStack; class FightListener extends ApplianceListener { private boolean isFighting(Player player) { Fight fight = this.getAppliance().fightOf(player); - return fight != null && !fight.isOver() && this.getAppliance().state(player) == Arena.PlayerState.FIGHT; + return fight != null && !fight.isOver() && fight.isAlive(player.getUniqueId()); } + /** + * Only fighters hit each other, and never their own team directly. Explosions still hurt everyone. + */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onDamageByPlayer(EntityDamageByEntityEvent event) { - if(!(event.getEntity() instanceof Player)) return; + if(!(event.getEntity() instanceof Player victim)) return; Player attacker = switch(event.getDamager()) { case Player player -> player; case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter; default -> null; }; - if(attacker == null || attacker == event.getEntity()) return; - if(!this.isFighting(attacker)) event.setCancelled(true); + if(attacker == null || attacker == victim) return; + Fight fight = this.getAppliance().fightOf(attacker); + if(!this.isFighting(attacker) || fight.sameTeam(attacker.getUniqueId(), victim.getUniqueId())) event.setCancelled(true); } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java index 6f1c15a..05f6f93 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java @@ -5,6 +5,7 @@ import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby; import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region; import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.title.Title; import org.bukkit.Bukkit; @@ -15,11 +16,12 @@ import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.entity.TextDisplay; import org.bukkit.util.Vector; -import org.jetbrains.annotations.Nullable; import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; /** @@ -69,7 +71,7 @@ final class Lobby implements PadLobby.Listener { private void checkEnter(Player player) { for(int pad = 0; pad < PadLobby.PADS; pad++) { - if(this.pads.occupant(pad) != null) { + if(this.pads.isPadFull(pad)) { if(this.isOn(player, pad, PUSH_MARGIN)) this.pushAway(player, pad); continue; } @@ -79,6 +81,10 @@ final class Lobby implements PadLobby.Listener { this.arena.setState(player, Arena.PlayerState.PAD); this.arena.teams().join(player, Teams.pad(pad)); Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f); + if(!player.getUniqueId().equals(this.pads.challenger())) { + player.sendActionBar(Component.text(Style.PAD_NAMES[pad] + " " + this.pads.players(pad).size() + "/" + this.pads.size(pad) + + ", Modus: " + this.modeName(), Style.PAD_TEXT_COLORS[pad])); + } return; } } @@ -94,14 +100,42 @@ final class Lobby implements PadLobby.Listener { this.arena.setState(player, Arena.PlayerState.TRIBUNE); } - void chooseMode(Player player, FightMode mode) { - if(!this.pads.setMode(player.getUniqueId(), mode)) { + void handleMenuClick(Player player, ModeMenu menu, ModeMenu.Click click) { + if(!this.pads.canConfigure(player.getUniqueId())) { player.closeInventory(); return; } + int ownSize = menu.ownSize; + int opponentSize = menu.opponentSize; + FightMode mode = menu.mode; + switch(click) { + case ModeMenu.OwnSize(int size) -> ownSize = size; + case ModeMenu.OpponentSize(int size) -> opponentSize = size; + case ModeMenu.Mode(FightMode chosen) -> mode = chosen; + case ModeMenu.Done() -> { + InteractSounds.of(player).success(); + player.closeInventory(); + player.sendActionBar(Component.text(this.pads.size(this.pads.challengerPad()) + " gegen " + this.pads.size(1 - this.pads.challengerPad()) + + ", Modus: " + this.modeName(), NamedTextColor.GOLD)); + return; + } + } + // switching to teams drops the opponent's inventory mode + if(!mode.isAllowed(ownSize, opponentSize)) mode = FightMode.OWN; + if(!this.pads.configure(player.getUniqueId(), ownSize, opponentSize, mode)) return; + menu.ownSize = ownSize; + menu.opponentSize = opponentSize; + menu.mode = mode; + menu.draw(); InteractSounds.of(player).click(); - player.closeInventory(); - player.sendActionBar(Component.text("Modus: " + mode.displayName(), NamedTextColor.GOLD)); + } + + private boolean isTeamFight() { + return this.pads.size(0) > 1 || this.pads.size(1) > 1; + } + + private String modeName() { + return this.pads.mode().displayName(this.isTeamFight()); } boolean isIdle() { @@ -109,11 +143,7 @@ final class Lobby implements PadLobby.Listener { } void shutdown() { - for(int pad = 0; pad < PadLobby.PADS; pad++) { - UUID occupant = this.pads.occupant(pad); - Player player = occupant == null ? null : Bukkit.getPlayer(occupant); - if(player != null) this.leave(player); - } + for(int pad = 0; pad < PadLobby.PADS; pad++) this.playersOn(pad).forEach(this::leave); for(TextDisplay display : this.displays) display.remove(); } @@ -134,121 +164,119 @@ final class Lobby implements PadLobby.Listener { if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return; this.lastPushSound.put(player.getUniqueId(), now); Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); - player.sendActionBar(Component.text("Dieses Feld ist besetzt", NamedTextColor.RED)); + player.sendActionBar(Component.text("Dieses Feld ist voll", NamedTextColor.RED)); } @Override - public void challengerSelected(UUID challenger) { + public void challengerSelected(UUID challenger, boolean canConfigure) { Player player = Bukkit.getPlayer(challenger); if(player == null) return; - player.sendMessage(Style.message("Du forderst heraus. Wähle den Modus. Wer sich auf das andere Feld stellt, nimmt an.")); - InteractSounds.of(player).open(); - ModeMenu.open(player, this.pads.mode()); - } - - @Override - public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { - Player challengerPlayer = Bukkit.getPlayer(challenger); - Player opponentPlayer = Bukkit.getPlayer(opponent); - if(challengerPlayer == null || opponentPlayer == null) return; - if(ModeMenu.isOpen(challengerPlayer)) challengerPlayer.closeInventory(); - - InventoryStore.apply(challengerPlayer, mode.kitSource(FightMode.Role.CHALLENGER) == FightMode.Role.CHALLENGER ? challenger : opponent); - InventoryStore.apply(opponentPlayer, mode.kitSource(FightMode.Role.OPPONENT) == FightMode.Role.CHALLENGER ? challenger : opponent); - - String kitText = switch(mode) { - case OWN -> "Jeder kämpft mit seinem eigenen Inventar."; - case CHALLENGER -> "Ihr kämpft beide mit dem Inventar von " + challengerPlayer.getName() + "."; - case OPPONENT -> "Ihr kämpft beide mit dem Inventar von " + opponentPlayer.getName() + "."; - }; - opponentPlayer.sendMessage(Style.message("Du nimmst die Herausforderung von " + challengerPlayer.getName() + " an. " + kitText + " Verlasse das Feld, um abzubrechen.")); - challengerPlayer.sendMessage(Style.message(opponentPlayer.getName() + " nimmt deine Herausforderung an. " + kitText + " Verlasse das Feld, um abzubrechen.")); - for(Player player : new Player[]{challengerPlayer, opponentPlayer}) { - Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f); + if(!canConfigure) { + player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Teams und Modus bleiben wie gewählt.")); + return; } + player.sendMessage(Style.message("Du forderst heraus. Wähle Teamgröße und Modus. Wer sich auf die Felder stellt, macht mit.")); + InteractSounds.of(player).open(); + int pad = this.pads.challengerPad(); + ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode()); } @Override - public void kitReverted(UUID uuid) { + public void kitAssigned(UUID uuid, UUID kitOwner) { Player player = Bukkit.getPlayer(uuid); - if(player != null) InventoryStore.apply(player, uuid); + if(player == null) return; + if(ModeMenu.isOpen(player)) player.closeInventory(); + InventoryStore.apply(player, kitOwner); + if(uuid.equals(kitOwner)) return; + Player owner = Bukkit.getPlayer(kitOwner); + player.sendMessage(Style.message("Du kämpfst mit dem Inventar von " + (owner == null ? "jemand anderem" : owner.getName()) + ". Verlasse das Feld, um abzubrechen.")); + Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f); } @Override public void countdown(int secondsLeft) { for(int pad = 0; pad < PadLobby.PADS; pad++) { - Player player = this.occupantPlayer(pad); - Player other = this.occupantPlayer(1 - pad); - if(player == null || other == null) continue; - player.showTitle(Title.title( - Component.text(secondsLeft, NamedTextColor.GOLD), - Component.text("Kampf gegen ", NamedTextColor.GRAY).append(Component.text(other.getName(), Style.PAD_TEXT_COLORS[1 - pad])), - Title.Times.times(Duration.ZERO, Duration.ofMillis(1100), Duration.ZERO) - )); - Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f); + Component opponents = this.namesOn(1 - pad); + for(Player player : this.playersOn(pad)) { + player.showTitle(Title.title( + Component.text(secondsLeft, NamedTextColor.GOLD), + Component.text("Kampf gegen ", NamedTextColor.GRAY).append(opponents), + Title.Times.times(Duration.ZERO, Duration.ofMillis(1100), Duration.ZERO) + )); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f); + } } } @Override public void countdownCancelled() { for(int pad = 0; pad < PadLobby.PADS; pad++) { - Player player = this.occupantPlayer(pad); - if(player == null) continue; - player.clearTitle(); - player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED)); - Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f); + for(Player player : this.playersOn(pad)) { + player.clearTitle(); + player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f); + } } } @Override - public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { - Player challengerPlayer = Bukkit.getPlayer(challenger); - Player opponentPlayer = Bukkit.getPlayer(opponent); - if(challengerPlayer == null || opponentPlayer == null) { - if(challengerPlayer != null) this.arena.toTribune(challengerPlayer); - if(opponentPlayer != null) this.arena.toTribune(opponentPlayer); + public void start(List> teams, UUID challenger, FightMode mode, Map kits) { + List> players = teams.stream() + .map(team -> team.stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList()) + .toList(); + if(players.get(0).isEmpty() || players.get(1).isEmpty()) { + players.forEach(team -> team.forEach(this.arena::toTribune)); return; } - Player red = challengerPad == 0 ? challengerPlayer : opponentPlayer; - Player blue = challengerPad == 0 ? opponentPlayer : challengerPlayer; - this.arena.startFight(red, blue, challenger, mode); + this.arena.startFight(players, mode, kits); } - private @Nullable Player occupantPlayer(int pad) { - UUID occupant = this.pads.occupant(pad); - return occupant == null ? null : Bukkit.getPlayer(occupant); + private List playersOn(int pad) { + return this.pads.players(pad).stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList(); + } + + private Component namesOn(int pad) { + return Component.join( + JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)), + this.playersOn(pad).stream().map(player -> Component.text(player.getName(), Style.PAD_TEXT_COLORS[pad])).toList() + ); } private void updateDisplays() { boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot(); Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); - String modeText = "Modus: " + this.pads.mode().displayName(); + int challengerPad = this.pads.challengerPad(); + String modeText = "Modus: " + this.modeName(); for(int pad = 0; pad < PadLobby.PADS; pad++) { + List players = this.playersOn(pad); Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[pad]); - Player occupant = this.occupantPlayer(pad); + if(challenger != null) title = title.append(Component.text(" " + players.size() + "/" + this.pads.size(pad), NamedTextColor.WHITE)); + Component body; if(waiting) { - body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() - .append(Component.text("Gleich geht es los", NamedTextColor.WHITE)); + body = this.namesOn(pad).appendNewline() + .append(Component.text("Alle Arenen belegt, gleich geht es los", NamedTextColor.GRAY)); } else if(this.pads.isCountingDown()) { - body = Component.text(occupant == null ? "" : occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline() + body = this.namesOn(pad).appendNewline() .append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD)); - } else if(occupant != null) { - body = Component.text(occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline() - .append(Component.text("fordert heraus", NamedTextColor.GRAY)).appendNewline() + } else if(challenger == null) { + body = !this.arena.hasFreeSlot() + ? Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() + .append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE)) + : Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline() + .append(Component.text("um herauszufordern", NamedTextColor.WHITE)); + } else if(pad == challengerPad) { + body = this.namesOn(pad).appendNewline() + .append(Component.text(players.size() == 1 ? "fordert heraus" : "fordern heraus", NamedTextColor.GRAY)).appendNewline() .append(Component.text(modeText, NamedTextColor.WHITE)); - } else if(challenger != null) { - body = Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline() - .append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[1 - pad])) - .append(Component.text(" zu kämpfen", NamedTextColor.WHITE)).appendNewline() - .append(Component.text(modeText, NamedTextColor.GRAY)); - } else if(!this.arena.hasFreeSlot()) { - body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() - .append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE)); } else { - body = Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline() - .append(Component.text("um herauszufordern", NamedTextColor.WHITE)); + Component who = players.isEmpty() + ? Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline() + .append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[challengerPad])) + .append(Component.text(" zu kämpfen", NamedTextColor.WHITE)) + : this.namesOn(pad).appendNewline().append(Component.text("nehmen an", NamedTextColor.GRAY)); + body = who.appendNewline().append(Component.text(modeText, NamedTextColor.GRAY)); } this.displays[pad].text(title.appendNewline().append(body)); } @@ -260,18 +288,18 @@ final class Lobby implements PadLobby.Listener { for(int pad = 0; pad < PadLobby.PADS; pad++) { Region region = this.regions[pad]; Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad]; - boolean taken = this.pads.occupant(pad) != null; - double spacing = countdown ? 0.25 : taken ? 0.4 : 0.6; - this.drawOutline(region, new Particle.DustOptions(color, taken ? 1.2f : 0.9f), spacing); + boolean full = this.pads.isPadFull(pad); + double spacing = countdown ? 0.25 : full ? 0.4 : 0.6; + this.drawOutline(region, new Particle.DustOptions(color, full ? 1.2f : 0.9f), spacing); - Player occupant = this.occupantPlayer(pad); - if(occupant == null) continue; double speed = countdown ? 0.6 : 0.3; - for(int strand = 0; strand < 2; strand++) { - double angle = this.ticks * speed + strand * Math.PI; - double height = (this.ticks % 40) / 20.0; - Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7); - Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn(); + for(Player occupant : this.playersOn(pad)) { + for(int strand = 0; strand < 2; strand++) { + double angle = this.ticks * speed + strand * Math.PI; + double height = (this.ticks % 40) / 20.0; + Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7); + Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn(); + } } } } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java index db49275..6c07806 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java @@ -1,6 +1,7 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena; import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; +import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby; import eu.mhsl.craftattack.spawn.core.util.inventory.ItemBuilder; import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems; import net.kyori.adventure.text.Component; @@ -14,55 +15,126 @@ import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SkullMeta; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** - * Chest menu in which the challenger picks the mode. Works for Bedrock players as well. + * Chest menu in which the challenger picks team sizes and mode. Every click applies at once. + * Works for Bedrock players as well. */ final class ModeMenu implements InventoryHolder { - private static final int[] SLOTS = {11, 13, 15}; + private static final int OWN_ROW = 10; + private static final int OPPONENT_ROW = 19; + private static final int[] MODE_SLOTS = {29, 31, 33}; private static final FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT}; + private static final int DONE_SLOT = 40; - private final Inventory inventory; - - private ModeMenu(Player challenger, FightMode current) { - this.inventory = Bukkit.createInventory(this, 27, Component.text("Wähle den Kampfmodus")); - for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane); - for(int i = 0; i < MODES.length; i++) this.inventory.setItem(SLOTS[i], this.item(MODES[i], challenger, MODES[i] == current)); + sealed interface Click { } - static void open(Player challenger, FightMode current) { - challenger.openInventory(new ModeMenu(challenger, current).getInventory()); + record OwnSize(int size) implements Click { + } + + record OpponentSize(int size) implements Click { + } + + record Mode(FightMode mode) implements Click { + } + + record Done() implements Click { + } + + private final Inventory inventory; + private final Player challenger; + private final int ownPad; + int ownSize; + int opponentSize; + FightMode mode; + + private ModeMenu(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) { + this.challenger = challenger; + this.ownPad = ownPad; + this.ownSize = ownSize; + this.opponentSize = opponentSize; + this.mode = mode; + this.inventory = Bukkit.createInventory(this, 45, Component.text("Teams und Kampfmodus")); + this.draw(); + } + + static void open(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) { + challenger.openInventory(new ModeMenu(challenger, ownPad, ownSize, opponentSize, mode).getInventory()); } static boolean isOpen(Player player) { return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu; } - @Nullable FightMode modeAt(int slot) { - for(int i = 0; i < SLOTS.length; i++) { - if(SLOTS[i] == slot) return MODES[i]; + Click clickAt(int slot) { + if(slot > OWN_ROW && slot <= OWN_ROW + PadLobby.MAX_TEAM_SIZE) return new OwnSize(slot - OWN_ROW); + if(slot > OPPONENT_ROW && slot <= OPPONENT_ROW + PadLobby.MAX_TEAM_SIZE) return new OpponentSize(slot - OPPONENT_ROW); + for(int i = 0; i < MODE_SLOTS.length; i++) { + if(MODE_SLOTS[i] == slot) return new Mode(MODES[i]); } + if(slot == DONE_SLOT) return new Done(); return null; } - private ItemStack item(FightMode mode, Player challenger, boolean selected) { + void draw() { + for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane); + int opponentPad = 1 - this.ownPad; + boolean teams = this.ownSize > 1 || this.opponentSize > 1; + + this.inventory.setItem(OWN_ROW, this.label(Material.PLAYER_HEAD, "Dein Team", this.ownPad, "So viele stehen mit dir auf dem " + Style.PAD_NAMES[this.ownPad] + ".") + .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger)).build()); + this.inventory.setItem(OPPONENT_ROW, this.label(Material.SKELETON_SKULL, "Gegner", opponentPad, "So viele stellen sich auf das " + Style.PAD_NAMES[opponentPad] + ".").build()); + for(int size = 1; size <= PadLobby.MAX_TEAM_SIZE; size++) { + this.inventory.setItem(OWN_ROW + size, this.sizeItem(size, this.ownPad, size == this.ownSize)); + this.inventory.setItem(OPPONENT_ROW + size, this.sizeItem(size, opponentPad, size == this.opponentSize)); + } + + for(int i = 0; i < MODES.length; i++) { + FightMode mode = MODES[i]; + this.inventory.setItem(MODE_SLOTS[i], this.modeItem(mode, teams, mode == this.mode, mode.isAllowed(this.ownSize, this.opponentSize))); + } + + this.inventory.setItem(DONE_SLOT, ItemBuilder.of(Material.LIME_CONCRETE) + .displayName(Component.text("Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)) + .lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". Jetzt fehlen nur noch die anderen auf den Feldern.") + .build()); + } + + private ItemBuilder label(Material material, String name, int pad, String text) { + return ItemBuilder.of(material) + .displayName(Component.text(name, Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false)) + .lore(text); + } + + private ItemStack sizeItem(int size, int pad, boolean selected) { + ItemBuilder builder = ItemBuilder.of(pad == 0 ? Material.RED_CONCRETE : Material.BLUE_CONCRETE) + .amount(size) + .displayName(Component.text(size + " Spieler", Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false)); + builder.lore(selected ? "Ausgewählt" : "Klicken zum Auswählen", selected ? NamedTextColor.GREEN : NamedTextColor.YELLOW); + if(selected) builder.glint(); + return builder.build(); + } + + private ItemStack modeItem(FightMode mode, boolean teams, boolean selected, boolean allowed) { ItemBuilder builder = switch(mode) { case OWN -> ItemBuilder.of(Material.IRON_SWORD) - .lore("Ihr kämpft beide mit eurem eigenen Inventar."); + .lore("Jeder kämpft mit seinem eigenen Inventar."); case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD) - .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(challenger)) - .lore("Ihr kämpft beide mit deinem Inventar."); - case OPPONENT -> ItemBuilder.of(Material.CHEST) - .lore("Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt."); + .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger)) + .lore(teams ? "Alle kämpfen mit deinem Inventar." : "Ihr kämpft beide mit deinem Inventar."); + case OPPONENT -> ItemBuilder.of(allowed ? Material.CHEST : Material.BARRIER) + .lore(allowed + ? "Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt." + : "Geht nur bei 1 gegen 1."); }; - builder.displayName(Component.text(mode.displayName(), NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); + builder.displayName(Component.text(mode.displayName(teams), allowed ? NamedTextColor.GOLD : NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false)); + if(!allowed) return builder.build(); + builder.appendLore(Component.empty()); if(selected) { - builder.glint().appendLore(Component.empty()) - .appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)); + builder.glint().appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)); } else { - builder.appendLore(Component.empty()) - .appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)); + builder.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)); } return builder.build(); } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java index 8ca40a8..795253e 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java @@ -1,6 +1,5 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena; -import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -14,8 +13,8 @@ class ModeMenuListener extends ApplianceListener { if(!(event.getWhoClicked() instanceof Player player)) return; if(event.getRawSlot() >= event.getView().getTopInventory().getSize()) return; - FightMode mode = menu.modeAt(event.getRawSlot()); + ModeMenu.Click click = menu.clickAt(event.getRawSlot()); Lobby lobby = this.getAppliance().lobby(); - if(mode != null && lobby != null) lobby.chooseMode(player, mode); + if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click); } } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java index 560cdb9..6dc00af 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java @@ -1,36 +1,25 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; /** - * Which inventory the two fighters use. The challenger chooses it. + * Which inventory the fighters use. The challenger chooses it. */ public enum FightMode { - OWN("Jeder sein eigenes"), - CHALLENGER("Beide mit meinem"), - OPPONENT("Beide mit dem des Gegners"); - - public enum Role { - CHALLENGER, - OPPONENT - } - - private final String displayName; - - FightMode(String displayName) { - this.displayName = displayName; - } - - public String displayName() { - return this.displayName; - } + OWN, + CHALLENGER, + OPPONENT; /** - * @return whose inventory the fighter with the given role gets + * With more than one player on a side, "the opponent's inventory" would be ambiguous. */ - public Role kitSource(Role fighter) { + public boolean isAllowed(int ownSize, int opponentSize) { + return this != OPPONENT || (ownSize == 1 && opponentSize == 1); + } + + public String displayName(boolean teams) { return switch(this) { - case OWN -> fighter; - case CHALLENGER -> Role.CHALLENGER; - case OPPONENT -> Role.OPPONENT; + case OWN -> "Jeder sein eigenes"; + case CHALLENGER -> teams ? "Alle mit meinem" : "Beide mit meinem"; + case OPPONENT -> "Beide mit dem des Gegners"; }; } } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java index 9eecd13..782a1c8 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java @@ -2,36 +2,46 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; /** - * State of the two start pads. The first player on a pad becomes the challenger and picks the mode, - * stepping on the other pad means accepting. Once both stand there long enough, the fight starts. + * State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode + * while standing there alone. Everyone else joins a team by stepping on a pad. Once both pads hold exactly + * the chosen number of players long enough, the fight starts. */ public class PadLobby { public static final int PADS = 2; + public static final int MAX_TEAM_SIZE = 4; public interface Listener { - /** The player is now the challenger and should pick a mode. */ - void challengerSelected(UUID challenger); + /** The player is now the challenger. Only when alone on the pads the options can be changed. */ + void challengerSelected(UUID challenger, boolean canConfigure); - /** Both pads are taken: give both the inventories of the chosen mode. */ - void kitsApplied(UUID challenger, UUID opponent, FightMode mode); - - /** The player gets the own inventory back. */ - void kitReverted(UUID player); + /** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */ + void kitAssigned(UUID player, UUID kitOwner); void countdown(int secondsLeft); void countdownCancelled(); - void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad); + /** + * @param teams players per pad + * @param kits whose inventory each player fights with + */ + void start(List> teams, UUID challenger, FightMode mode, Map kits); } private final Listener listener; private final int countdownTicks; - private final UUID[] occupants = new UUID[PADS]; - private int challengerPad = -1; + private final List> pads = List.of(new ArrayList<>(), new ArrayList<>()); + /** Everyone on a pad, in the order they stepped on. The first one is the challenger. */ + private final List order = new ArrayList<>(); + private final Map kits = new HashMap<>(); + private final int[] sizes = {1, 1}; private FightMode mode = FightMode.OWN; private int ticksLeft = -1; @@ -41,22 +51,26 @@ public class PadLobby { } /** - * @return false if the pad is already taken + * @return false if the pad is already full or the player stands on a pad */ public boolean enter(int pad, UUID player) { - if(this.occupants[pad] != null) return false; if(this.padOf(player) != -1) return false; + boolean first = this.order.isEmpty(); + if(!first && this.pads.get(pad).size() >= this.sizes[pad]) return false; - this.occupants[pad] = player; - if(this.challengerPad == -1) { - this.challengerPad = pad; + this.pads.get(pad).add(player); + this.order.add(player); + this.kits.put(player, player); + if(first) { + this.sizes[0] = 1; + this.sizes[1] = 1; this.mode = FightMode.OWN; - this.listener.challengerSelected(player); + this.listener.challengerSelected(player, true); return true; } - this.listener.kitsApplied(this.challenger(), this.opponent(), this.mode); - this.ticksLeft = this.countdownTicks; + this.refreshKits(); + if(this.isFull()) this.ticksLeft = this.countdownTicks; return true; } @@ -64,35 +78,47 @@ public class PadLobby { int pad = this.padOf(player); if(pad == -1) return; - int other = 1 - pad; - boolean bothThere = this.isFull(); - this.occupants[pad] = null; - this.listener.kitReverted(player); - if(bothThere) { + boolean wasFull = this.isFull(); + boolean wasChallenger = player.equals(this.challenger()); + this.pads.get(pad).remove(player); + this.order.remove(player); + this.kits.remove(player); + this.listener.kitAssigned(player, player); + + if(wasFull) { this.ticksLeft = -1; - this.listener.kitReverted(this.occupants[other]); this.listener.countdownCancelled(); } - if(pad != this.challengerPad) return; - if(this.occupants[other] == null) { - this.challengerPad = -1; - return; + this.refreshKits(); + // the longest waiting player takes over; team sizes and mode stay as chosen + if(wasChallenger && !this.order.isEmpty()) { + this.listener.challengerSelected(this.order.getFirst(), this.order.size() == 1); } - this.challengerPad = other; - this.mode = FightMode.OWN; - this.listener.challengerSelected(this.occupants[other]); } /** - * Only the challenger can change the mode, and only while nobody stands on the other pad. + * Only the challenger can change the options, and only while standing alone on the pads. + * + * @param ownSize players on the challenger's pad + * @param opponentSize players on the other pad */ - public boolean setMode(UUID player, FightMode mode) { - if(!player.equals(this.challenger()) || this.isFull()) return false; + public boolean configure(UUID player, int ownSize, int opponentSize, FightMode mode) { + if(!this.canConfigure(player)) return false; + if(ownSize < 1 || ownSize > MAX_TEAM_SIZE || opponentSize < 1 || opponentSize > MAX_TEAM_SIZE) return false; + if(!mode.isAllowed(ownSize, opponentSize)) return false; + int own = this.challengerPad(); + this.sizes[own] = ownSize; + this.sizes[1 - own] = opponentSize; this.mode = mode; + this.refreshKits(); return true; } + public boolean canConfigure(UUID player) { + return player.equals(this.challenger()) && this.order.size() == 1; + } + /** * Called every server tick. The countdown only runs while an arena is free. */ @@ -106,39 +132,67 @@ public class PadLobby { if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20); if(this.ticksLeft-- > 0) return; + List> teams = List.of(List.copyOf(this.pads.get(0)), List.copyOf(this.pads.get(1))); UUID challenger = this.challenger(); - UUID opponent = this.opponent(); FightMode mode = this.mode; - int challengerPad = this.challengerPad; - this.occupants[0] = null; - this.occupants[1] = null; - this.challengerPad = -1; + Map kits = Map.copyOf(this.kits); + this.pads.forEach(List::clear); + this.order.clear(); + this.kits.clear(); + this.sizes[0] = 1; + this.sizes[1] = 1; this.mode = FightMode.OWN; this.ticksLeft = -1; - this.listener.start(challenger, opponent, mode, challengerPad); + this.listener.start(teams, challenger, mode, kits); + } + + /** + * Works out whose inventory everyone should hold and reports only the changes. + */ + private void refreshKits() { + UUID challenger = this.challenger(); + if(challenger == null) return; + int challengerPad = this.challengerPad(); + UUID firstOpponent = this.pads.get(1 - challengerPad).isEmpty() ? null : this.pads.get(1 - challengerPad).getFirst(); + + for(UUID player : this.order) { + UUID wanted = switch(this.mode) { + case OWN -> player; + case CHALLENGER -> challenger; + case OPPONENT -> player.equals(challenger) && firstOpponent != null ? firstOpponent : player; + }; + if(wanted.equals(this.kits.get(player))) continue; + this.kits.put(player, wanted); + this.listener.kitAssigned(player, wanted); + } } public int padOf(UUID player) { for(int i = 0; i < PADS; i++) { - if(player.equals(this.occupants[i])) return i; + if(this.pads.get(i).contains(player)) return i; } return -1; } - public @Nullable UUID occupant(int pad) { - return this.occupants[pad]; + public List players(int pad) { + return List.copyOf(this.pads.get(pad)); + } + + public int size(int pad) { + return this.sizes[pad]; + } + + public boolean isPadFull(int pad) { + return !this.order.isEmpty() && this.pads.get(pad).size() >= this.sizes[pad]; } public @Nullable UUID challenger() { - return this.challengerPad == -1 ? null : this.occupants[this.challengerPad]; - } - - public @Nullable UUID opponent() { - return this.challengerPad == -1 ? null : this.occupants[1 - this.challengerPad]; + return this.order.isEmpty() ? null : this.order.getFirst(); } public int challengerPad() { - return this.challengerPad; + UUID challenger = this.challenger(); + return challenger == null ? -1 : this.padOf(challenger); } public FightMode mode() { @@ -146,7 +200,7 @@ public class PadLobby { } public boolean isFull() { - return this.occupants[0] != null && this.occupants[1] != null; + return this.pads.get(0).size() == this.sizes[0] && this.pads.get(1).size() == this.sizes[1]; } public boolean isCountingDown() { @@ -154,7 +208,7 @@ public class PadLobby { } public boolean isIdle() { - return this.occupants[0] == null && this.occupants[1] == null; + return this.order.isEmpty(); } /** Seconds until the start, rounded up; -1 without countdown. */ diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java index 336a9b6..10037f8 100644 --- a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java @@ -2,26 +2,21 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; import org.junit.jupiter.api.Test; -import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.CHALLENGER; -import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.OPPONENT; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; class FightModeTest { @Test - void ownKeepsEveryonesInventory() { - assertEquals(CHALLENGER, FightMode.OWN.kitSource(CHALLENGER)); - assertEquals(OPPONENT, FightMode.OWN.kitSource(OPPONENT)); + void opponentInventoryOnlyInOneVersusOne() { + assertTrue(FightMode.OPPONENT.isAllowed(1, 1)); + assertFalse(FightMode.OPPONENT.isAllowed(1, 2)); + assertFalse(FightMode.OPPONENT.isAllowed(3, 1)); + assertTrue(FightMode.CHALLENGER.isAllowed(4, 3)); + assertTrue(FightMode.OWN.isAllowed(2, 4)); } @Test - void challengerKitForBoth() { - assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(CHALLENGER)); - assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(OPPONENT)); - } - - @Test - void opponentKitForBoth() { - assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(CHALLENGER)); - assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(OPPONENT)); + void namesForTeams() { + assertEquals("Beide mit meinem", FightMode.CHALLENGER.displayName(false)); + assertEquals("Alle mit meinem", FightMode.CHALLENGER.displayName(true)); } } diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java index 5ed9738..326d3cf 100644 --- a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java @@ -5,7 +5,9 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -13,22 +15,21 @@ class PadLobbyTest { private final UUID a = UUID.randomUUID(); private final UUID b = UUID.randomUUID(); private final UUID c = UUID.randomUUID(); + private final UUID d = UUID.randomUUID(); + private final UUID e = UUID.randomUUID(); private final List events = new ArrayList<>(); + private Map startedKits; private PadLobby lobby; @BeforeEach void setUp() { this.lobby = new PadLobby(new PadLobby.Listener() { - public void challengerSelected(UUID challenger) { - PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger)); + public void challengerSelected(UUID challenger, boolean canConfigure) { + PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (canConfigure ? " configurable" : "")); } - public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { - PadLobbyTest.this.events.add("kits " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode); - } - - public void kitReverted(UUID player) { - PadLobbyTest.this.events.add("revert " + PadLobbyTest.this.name(player)); + public void kitAssigned(UUID player, UUID kitOwner) { + PadLobbyTest.this.events.add("kit " + PadLobbyTest.this.name(player) + "<-" + PadLobbyTest.this.name(kitOwner)); } public void countdown(int secondsLeft) { @@ -39,8 +40,10 @@ class PadLobbyTest { PadLobbyTest.this.events.add("cancelled"); } - public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { - PadLobbyTest.this.events.add("start " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode + " " + challengerPad); + public void start(List> teams, UUID challenger, FightMode mode, Map kits) { + PadLobbyTest.this.startedKits = kits; + PadLobbyTest.this.events.add("start " + PadLobbyTest.this.names(teams.get(0)) + " vs " + PadLobbyTest.this.names(teams.get(1)) + + " by " + PadLobbyTest.this.name(challenger) + " " + mode); } }, 2); } @@ -48,50 +51,99 @@ class PadLobbyTest { private String name(UUID uuid) { if(uuid.equals(this.a)) return "a"; if(uuid.equals(this.b)) return "b"; - return "c"; + if(uuid.equals(this.c)) return "c"; + if(uuid.equals(this.d)) return "d"; + return "e"; + } + + private String names(List uuids) { + return uuids.stream().map(this::name).collect(Collectors.joining()); + } + + private void run(int ticks) { + for(int i = 0; i < ticks; i++) this.lobby.tick(true); } @Test - void firstOnPadBecomesChallenger() { + void firstOnPadBecomesChallengerAndMayConfigure() { assertTrue(this.lobby.enter(1, this.a)); - assertEquals(List.of("challenger a"), this.events); + assertEquals(List.of("challenger a configurable"), this.events); assertEquals(this.a, this.lobby.challenger()); - assertNull(this.lobby.opponent()); + assertTrue(this.lobby.canConfigure(this.a)); assertFalse(this.lobby.isCountingDown()); } @Test - void takenPadIsRefused() { + void oneVersusOneStartsAfterCountdown() { this.lobby.enter(0, this.a); - assertFalse(this.lobby.enter(0, this.b)); - assertFalse(this.lobby.enter(1, this.a), "a player can only take one pad"); - } - - @Test - void secondPlayerAppliesKitsAndStartsAfterCountdown() { - this.lobby.enter(0, this.a); - assertTrue(this.lobby.setMode(this.a, FightMode.CHALLENGER)); + assertTrue(this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER)); this.lobby.enter(1, this.b); - assertFalse(this.lobby.setMode(this.a, FightMode.OWN), "mode is fixed once the opponent stands there"); - - for(int i = 0; i < 41; i++) this.lobby.tick(true); + assertFalse(this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed once someone else joined"); + this.run(41); assertEquals(List.of( - "challenger a", - "kits ab CHALLENGER", + "challenger a configurable", + "kit b<-a", "countdown 2", "countdown 1", - "start ab CHALLENGER 0" + "start a vs b by a CHALLENGER" ), this.events); + assertEquals(Map.of(this.a, this.a, this.b, this.a), this.startedKits); assertTrue(this.lobby.isIdle()); - assertNull(this.lobby.challenger()); } @Test - void onlyChallengerMayChangeMode() { + void fullPadIsRefusedAndPlayerOnlyTakesOnePad() { this.lobby.enter(0, this.a); - assertFalse(this.lobby.setMode(this.b, FightMode.OPPONENT)); - assertEquals(FightMode.OWN, this.lobby.mode()); + this.lobby.enter(1, this.b); + assertFalse(this.lobby.enter(1, this.c), "1v1: the blue pad is full"); + assertFalse(this.lobby.enter(0, this.b), "a player can only stand on one pad"); + } + + @Test + void unevenTeamsStartWhenBothPadsAreExactlyFull() { + this.lobby.enter(0, this.a); + assertTrue(this.lobby.configure(this.a, 2, 3, FightMode.OWN)); + assertTrue(this.lobby.enter(0, this.b)); + assertFalse(this.lobby.enter(0, this.c), "red is full with 2"); + this.lobby.enter(1, this.c); + this.lobby.enter(1, this.d); + assertFalse(this.lobby.isCountingDown()); + this.lobby.enter(1, this.e); + assertTrue(this.lobby.isCountingDown()); + this.run(41); + assertEquals("start ab vs cde by a OWN", this.events.getLast()); + } + + @Test + void challengerKitGoesToEveryone() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER); + this.lobby.enter(0, this.b); + this.lobby.enter(1, this.c); + this.lobby.enter(1, this.d); + assertEquals(List.of("challenger a configurable", "kit b<-a", "kit c<-a", "kit d<-a"), this.events); + } + + @Test + void opponentModeOnlyForOneVersusOne() { + this.lobby.enter(0, this.a); + assertFalse(this.lobby.configure(this.a, 2, 1, FightMode.OPPONENT)); + assertTrue(this.lobby.configure(this.a, 1, 1, FightMode.OPPONENT)); + this.lobby.enter(1, this.b); + assertEquals("kit a<-b", this.events.getLast()); + + this.lobby.leave(this.b); + assertTrue(this.events.contains("kit a<-a"), "challenger gets the own inventory back"); + assertTrue(this.events.contains("cancelled")); + } + + @Test + void onlyChallengerMayConfigure() { + this.lobby.enter(0, this.a); + assertFalse(this.lobby.configure(this.b, 2, 2, FightMode.OWN)); + assertFalse(this.lobby.configure(this.a, 5, 1, FightMode.OWN)); + assertFalse(this.lobby.configure(this.a, 0, 1, FightMode.OWN)); } @Test @@ -101,42 +153,49 @@ class PadLobbyTest { for(int i = 0; i < 100; i++) this.lobby.tick(false); assertFalse(this.events.contains("countdown 2")); assertEquals(2, this.lobby.secondsLeft()); - - for(int i = 0; i < 41; i++) this.lobby.tick(true); - assertTrue(this.events.contains("start ab OWN 0")); + this.run(41); + assertEquals("start a vs b by a OWN", this.events.getLast()); } @Test - void opponentLeavingCancelsAndRevertsBoth() { + void leavingCancelsCountdownAndRevertsKit() { this.lobby.enter(0, this.a); - this.lobby.setMode(this.a, FightMode.OPPONENT); + this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER); this.lobby.enter(1, this.b); this.events.clear(); this.lobby.leave(this.b); - assertEquals(List.of("revert b", "revert a", "cancelled"), this.events); + assertEquals(List.of("kit b<-b", "cancelled"), this.events); assertEquals(this.a, this.lobby.challenger()); - assertEquals(FightMode.OPPONENT, this.lobby.mode(), "challenger keeps the chosen mode"); + assertEquals(FightMode.CHALLENGER, this.lobby.mode()); assertFalse(this.lobby.isCountingDown()); } @Test - void challengerLeavingHandsOverRole() { + void longestWaitingTakesOverAndKeepsTeamSizes() { this.lobby.enter(0, this.a); - this.lobby.setMode(this.a, FightMode.CHALLENGER); + this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER); this.lobby.enter(1, this.b); + this.lobby.enter(0, this.c); this.events.clear(); this.lobby.leave(this.a); - assertEquals(List.of("revert a", "revert b", "cancelled", "challenger b"), this.events); + assertEquals(List.of("kit a<-a", "kit b<-b", "kit c<-b", "challenger b"), this.events); assertEquals(this.b, this.lobby.challenger()); - assertEquals(1, this.lobby.challengerPad()); - assertEquals(FightMode.OWN, this.lobby.mode()); + assertFalse(this.lobby.canConfigure(this.b), "others are still there"); + assertEquals(2, this.lobby.size(0)); + assertEquals(2, this.lobby.size(1)); + } - this.lobby.enter(0, this.c); - assertEquals("kits bc OWN", this.events.getLast()); + @Test + void aloneAgainMeansConfigurableAgain() { + this.lobby.enter(0, this.a); + this.lobby.enter(1, this.b); + this.lobby.leave(this.a); + assertEquals("challenger b configurable", this.events.getLast()); + assertTrue(this.lobby.canConfigure(this.b)); } @Test @@ -146,6 +205,6 @@ class PadLobbyTest { assertTrue(this.lobby.isIdle()); assertNull(this.lobby.challenger()); this.lobby.leave(this.b); - assertEquals(List.of("challenger a", "revert a"), this.events); + assertEquals(List.of("challenger a configurable", "kit a<-a"), this.events); } } From 6bda5b6adb03dc5b84207c32a0f7c56d9fd0784a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 15 Oct 2026 23:25:21 +0200 Subject: [PATCH 7/7] arena options stay changeable until the countdown and reopen with a right click --- .../spawn/arena/appliances/arena/Lobby.java | 54 +++++++++++---- .../appliances/arena/ModeMenuListener.java | 12 ++++ .../appliances/arena/logic/PadLobby.java | 44 +++++++++--- .../appliances/arena/logic/PadLobbyTest.java | 68 ++++++++++++++----- 4 files changed, 135 insertions(+), 43 deletions(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java index 05f6f93..eccb546 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java @@ -101,10 +101,6 @@ final class Lobby implements PadLobby.Listener { } void handleMenuClick(Player player, ModeMenu menu, ModeMenu.Click click) { - if(!this.pads.canConfigure(player.getUniqueId())) { - player.closeInventory(); - return; - } int ownSize = menu.ownSize; int opponentSize = menu.opponentSize; FightMode mode = menu.mode; @@ -116,13 +112,27 @@ final class Lobby implements PadLobby.Listener { InteractSounds.of(player).success(); player.closeInventory(); player.sendActionBar(Component.text(this.pads.size(this.pads.challengerPad()) + " gegen " + this.pads.size(1 - this.pads.challengerPad()) - + ", Modus: " + this.modeName(), NamedTextColor.GOLD)); + + ", Modus: " + this.modeName() + ". Rechtsklick öffnet die Auswahl wieder.", NamedTextColor.GOLD)); return; } } // switching to teams drops the opponent's inventory mode if(!mode.isAllowed(ownSize, opponentSize)) mode = FightMode.OWN; - if(!this.pads.configure(player.getUniqueId(), ownSize, opponentSize, mode)) return; + + int ownPad = this.pads.challengerPad(); + String problem = switch(this.pads.configure(player.getUniqueId(), ownSize, opponentSize, mode)) { + case OK -> null; + case NOT_ALLOWED -> "Der Countdown läuft schon"; + case OWN_TOO_SMALL -> "Auf deinem Feld stehen schon " + this.pads.players(ownPad).size(); + case OPPONENT_TOO_SMALL -> "Auf dem anderen Feld stehen schon " + this.pads.players(1 - ownPad).size(); + case INVALID -> "Das geht so nicht"; + }; + if(problem != null) { + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); + player.sendActionBar(Component.text(problem, NamedTextColor.RED)); + if(!this.pads.canConfigure(player.getUniqueId())) player.closeInventory(); + return; + } menu.ownSize = ownSize; menu.opponentSize = opponentSize; menu.mode = mode; @@ -130,6 +140,17 @@ final class Lobby implements PadLobby.Listener { InteractSounds.of(player).click(); } + /** + * Right click on the pad: the challenger opens the options again. + */ + boolean openMenu(Player player) { + if(!this.pads.canConfigure(player.getUniqueId()) || ModeMenu.isOpen(player)) return false; + int pad = this.pads.challengerPad(); + InteractSounds.of(player).open(); + ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode()); + return true; + } + private boolean isTeamFight() { return this.pads.size(0) > 1 || this.pads.size(1) > 1; } @@ -168,24 +189,29 @@ final class Lobby implements PadLobby.Listener { } @Override - public void challengerSelected(UUID challenger, boolean canConfigure) { + public void challengerSelected(UUID challenger, boolean first) { Player player = Bukkit.getPlayer(challenger); if(player == null) return; - if(!canConfigure) { - player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Teams und Modus bleiben wie gewählt.")); + if(!first) { + player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Rechtsklick auf dem Feld öffnet die Auswahl.")); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f); return; } - player.sendMessage(Style.message("Du forderst heraus. Wähle Teamgröße und Modus. Wer sich auf die Felder stellt, macht mit.")); - InteractSounds.of(player).open(); - int pad = this.pads.challengerPad(); - ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode()); + player.sendMessage(Style.message("Du forderst heraus. Wähle Teamgröße und Modus, Rechtsklick auf dem Feld öffnet die Auswahl wieder. Wer sich auf die Felder stellt, macht mit.")); + this.openMenu(player); + } + + @Override + public void countdownStarted() { + UUID challenger = this.pads.challenger(); + Player player = challenger == null ? null : Bukkit.getPlayer(challenger); + if(player != null && ModeMenu.isOpen(player)) player.closeInventory(); } @Override public void kitAssigned(UUID uuid, UUID kitOwner) { Player player = Bukkit.getPlayer(uuid); if(player == null) return; - if(ModeMenu.isOpen(player)) player.closeInventory(); InventoryStore.apply(player, kitOwner); if(uuid.equals(kitOwner)) return; Player owner = Bukkit.getPlayer(kitOwner); diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java index 795253e..09b2b2d 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java @@ -3,6 +3,9 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.event.inventory.InventoryClickEvent; class ModeMenuListener extends ApplianceListener { @@ -17,4 +20,13 @@ class ModeMenuListener extends ApplianceListener { Lobby lobby = this.getAppliance().lobby(); if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click); } + + @EventHandler + public void onRightClick(PlayerInteractEvent event) { + if(event.getHand() != EquipmentSlot.HAND) return; + if(event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.PAD) return; + Lobby lobby = this.getAppliance().lobby(); + if(lobby != null && lobby.openMenu(event.getPlayer())) event.setCancelled(true); + } } diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java index 782a1c8..f7932a8 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java @@ -18,12 +18,15 @@ public class PadLobby { public static final int MAX_TEAM_SIZE = 4; public interface Listener { - /** The player is now the challenger. Only when alone on the pads the options can be changed. */ - void challengerSelected(UUID challenger, boolean canConfigure); + /** The player is now the challenger and may change the options until the countdown runs. */ + void challengerSelected(UUID challenger, boolean first); /** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */ void kitAssigned(UUID player, UUID kitOwner); + /** Both pads are exactly full, the options are fixed now. */ + void countdownStarted(); + void countdown(int secondsLeft); void countdownCancelled(); @@ -70,7 +73,10 @@ public class PadLobby { } this.refreshKits(); - if(this.isFull()) this.ticksLeft = this.countdownTicks; + if(this.isFull()) { + this.ticksLeft = this.countdownTicks; + this.listener.countdownStarted(); + } return true; } @@ -93,30 +99,46 @@ public class PadLobby { this.refreshKits(); // the longest waiting player takes over; team sizes and mode stay as chosen if(wasChallenger && !this.order.isEmpty()) { - this.listener.challengerSelected(this.order.getFirst(), this.order.size() == 1); + this.listener.challengerSelected(this.order.getFirst(), false); } } + public enum ConfigureResult { + OK, + NOT_ALLOWED, + OWN_TOO_SMALL, + OPPONENT_TOO_SMALL, + INVALID + } + /** - * Only the challenger can change the options, and only while standing alone on the pads. + * Only the challenger can change the options, and only until the countdown runs. + * Teams cannot be smaller than the players already standing there. * * @param ownSize players on the challenger's pad * @param opponentSize players on the other pad */ - public boolean configure(UUID player, int ownSize, int opponentSize, FightMode mode) { - if(!this.canConfigure(player)) return false; - if(ownSize < 1 || ownSize > MAX_TEAM_SIZE || opponentSize < 1 || opponentSize > MAX_TEAM_SIZE) return false; - if(!mode.isAllowed(ownSize, opponentSize)) return false; + public ConfigureResult configure(UUID player, int ownSize, int opponentSize, FightMode mode) { + if(!this.canConfigure(player)) return ConfigureResult.NOT_ALLOWED; + if(ownSize < 1 || ownSize > MAX_TEAM_SIZE || opponentSize < 1 || opponentSize > MAX_TEAM_SIZE) return ConfigureResult.INVALID; + if(!mode.isAllowed(ownSize, opponentSize)) return ConfigureResult.INVALID; int own = this.challengerPad(); + if(ownSize < this.pads.get(own).size()) return ConfigureResult.OWN_TOO_SMALL; + if(opponentSize < this.pads.get(1 - own).size()) return ConfigureResult.OPPONENT_TOO_SMALL; + this.sizes[own] = ownSize; this.sizes[1 - own] = opponentSize; this.mode = mode; this.refreshKits(); - return true; + if(this.isFull()) { + this.ticksLeft = this.countdownTicks; + this.listener.countdownStarted(); + } + return ConfigureResult.OK; } public boolean canConfigure(UUID player) { - return player.equals(this.challenger()) && this.order.size() == 1; + return player.equals(this.challenger()) && !this.isCountingDown(); } /** diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java index 326d3cf..5462985 100644 --- a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java @@ -24,8 +24,12 @@ class PadLobbyTest { @BeforeEach void setUp() { this.lobby = new PadLobby(new PadLobby.Listener() { - public void challengerSelected(UUID challenger, boolean canConfigure) { - PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (canConfigure ? " configurable" : "")); + public void challengerSelected(UUID challenger, boolean first) { + PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (first ? " first" : "")); + } + + public void countdownStarted() { + PadLobbyTest.this.events.add("countdown started"); } public void kitAssigned(UUID player, UUID kitOwner) { @@ -67,7 +71,7 @@ class PadLobbyTest { @Test void firstOnPadBecomesChallengerAndMayConfigure() { assertTrue(this.lobby.enter(1, this.a)); - assertEquals(List.of("challenger a configurable"), this.events); + assertEquals(List.of("challenger a first"), this.events); assertEquals(this.a, this.lobby.challenger()); assertTrue(this.lobby.canConfigure(this.a)); assertFalse(this.lobby.isCountingDown()); @@ -76,14 +80,15 @@ class PadLobbyTest { @Test void oneVersusOneStartsAfterCountdown() { this.lobby.enter(0, this.a); - assertTrue(this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER)); this.lobby.enter(1, this.b); - assertFalse(this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed once someone else joined"); + assertEquals(PadLobby.ConfigureResult.NOT_ALLOWED, this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed during the countdown"); this.run(41); assertEquals(List.of( - "challenger a configurable", + "challenger a first", "kit b<-a", + "countdown started", "countdown 2", "countdown 1", "start a vs b by a CHALLENGER" @@ -103,7 +108,7 @@ class PadLobbyTest { @Test void unevenTeamsStartWhenBothPadsAreExactlyFull() { this.lobby.enter(0, this.a); - assertTrue(this.lobby.configure(this.a, 2, 3, FightMode.OWN)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 3, FightMode.OWN)); assertTrue(this.lobby.enter(0, this.b)); assertFalse(this.lobby.enter(0, this.c), "red is full with 2"); this.lobby.enter(1, this.c); @@ -122,16 +127,16 @@ class PadLobbyTest { this.lobby.enter(0, this.b); this.lobby.enter(1, this.c); this.lobby.enter(1, this.d); - assertEquals(List.of("challenger a configurable", "kit b<-a", "kit c<-a", "kit d<-a"), this.events); + assertEquals(List.of("challenger a first", "kit b<-a", "kit c<-a", "kit d<-a", "countdown started"), this.events); } @Test void opponentModeOnlyForOneVersusOne() { this.lobby.enter(0, this.a); - assertFalse(this.lobby.configure(this.a, 2, 1, FightMode.OPPONENT)); - assertTrue(this.lobby.configure(this.a, 1, 1, FightMode.OPPONENT)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.OPPONENT)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.OPPONENT)); this.lobby.enter(1, this.b); - assertEquals("kit a<-b", this.events.getLast()); + assertTrue(this.events.contains("kit a<-b")); this.lobby.leave(this.b); assertTrue(this.events.contains("kit a<-a"), "challenger gets the own inventory back"); @@ -141,9 +146,9 @@ class PadLobbyTest { @Test void onlyChallengerMayConfigure() { this.lobby.enter(0, this.a); - assertFalse(this.lobby.configure(this.b, 2, 2, FightMode.OWN)); - assertFalse(this.lobby.configure(this.a, 5, 1, FightMode.OWN)); - assertFalse(this.lobby.configure(this.a, 0, 1, FightMode.OWN)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.b, 2, 2, FightMode.OWN)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 5, 1, FightMode.OWN)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 0, 1, FightMode.OWN)); } @Test @@ -184,20 +189,47 @@ class PadLobbyTest { assertEquals(List.of("kit a<-a", "kit b<-b", "kit c<-b", "challenger b"), this.events); assertEquals(this.b, this.lobby.challenger()); - assertFalse(this.lobby.canConfigure(this.b), "others are still there"); + assertTrue(this.lobby.canConfigure(this.b), "the new challenger may change the options"); assertEquals(2, this.lobby.size(0)); assertEquals(2, this.lobby.size(1)); } @Test - void aloneAgainMeansConfigurableAgain() { + void handOverWhenAlone() { this.lobby.enter(0, this.a); this.lobby.enter(1, this.b); this.lobby.leave(this.a); - assertEquals("challenger b configurable", this.events.getLast()); + assertEquals("challenger b", this.events.getLast()); assertTrue(this.lobby.canConfigure(this.b)); } + @Test + void challengerMayChangeOptionsWhileOthersWait() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 2, 2, FightMode.OWN); + this.lobby.enter(0, this.b); + this.lobby.enter(1, this.c); + + assertEquals(PadLobby.ConfigureResult.OWN_TOO_SMALL, this.lobby.configure(this.a, 1, 2, FightMode.OWN), + "two already stand on the own pad"); + assertEquals(2, this.lobby.size(0)); + + this.events.clear(); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.CHALLENGER)); + assertEquals(List.of("kit b<-a", "kit c<-a", "countdown started"), this.events, "shrinking to the players there starts the countdown"); + assertTrue(this.lobby.isCountingDown()); + } + + @Test + void opponentSideCannotShrinkBelowPlayers() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 1, 3, FightMode.OWN); + this.lobby.enter(1, this.b); + this.lobby.enter(1, this.c); + assertEquals(PadLobby.ConfigureResult.OPPONENT_TOO_SMALL, this.lobby.configure(this.a, 1, 1, FightMode.OWN)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 4, FightMode.OWN)); + } + @Test void lastPlayerLeavingResetsLobby() { this.lobby.enter(0, this.a); @@ -205,6 +237,6 @@ class PadLobbyTest { assertTrue(this.lobby.isIdle()); assertNull(this.lobby.challenger()); this.lobby.leave(this.b); - assertEquals(List.of("challenger a configurable", "kit a<-a"), this.events); + assertEquals(List.of("challenger a first", "kit a<-a"), this.events); } }