Author SHA1 Message Date
besiece bc7ce154bd Add recommendations for using WanderingTrades plugin and configuration tasks 2026-11-08 16:25:55 +01:00
MineTec d18ddc7b07 Remove outdated outlawed PvP system and related configurations 2026-10-29 19:51:36 +01:00
MineTec b06ba40d16 Merge pull request 'added combined info bar for TPS, MSPT and online player count' (#17) from feature/combinedInfoBar into master
Reviewed-on: #17
Reviewed-by: Elias Müller <elias@elias-mueller.com>
2026-10-28 11:57:53 +00:00
jannis 3eca590c6f added combined info bar for TPS, MSPT and online player count 2026-10-18 23:31:10 +02:00
MineTec f6ac150a8f arena: challenger idling 1 min in the options gets sent back to the tribune 2026-10-18 17:53:13 +02:00
MineTec 92a655e107 arena: full pads wait for the challenger to close the options (max 20 s) instead of closing them 2026-10-18 17:50:16 +02:00
MineTec 77a740edb7 arena: leaving the box during a fight (e.g. ender pearl glitch) counts as knocked out 2026-10-18 17:06:14 +02:00
MineTec 4a87bb3fad chat settings are synced with velocity again (join/leave, mentions) 2026-10-17 22:57:42 +02:00
MineTec ca0e364d85 chat moves to the velocity plugin chat-bridge: backend only silences chat and sends rank prefixes 2026-10-17 22:41:48 +02:00
MineTec fb88856925 Merge branch 'feature/arena' 2026-10-15 23:40:25 +02:00
MineTec 3882f64289 Merge remote-tracking branch 'origin/master' 2026-10-15 23:40:25 +02:00
MineTec 6bda5b6adb arena options stay changeable until the countdown and reopen with a right click 2026-10-15 23:25:21 +02:00
MineTec ae3bde626f added team fights up to 4 vs 4 to the arena 2026-10-15 23:21:20 +02:00
MineTec 4add446940 doubled the size of arena status displays 2026-10-15 23:08:19 +02:00
jannis d03a21d460 changed way to unlock Quarz Recipe 2026-10-15 23:07:26 +02:00
MineTec a91f550153 fixed duplicated display villagers when entities were not loaded yet 2026-10-15 22:55:54 +02:00
MineTec b8ba85f8c5 widened arena spectator range 2026-10-15 22:52:07 +02:00
MineTec 65c48eae45 fixed arena transfer with empty inventory slots and blocked advancements in the arena 2026-10-15 22:42:03 +02:00
46 changed files with 1365 additions and 1238 deletions

No files matched your search

+8
View File
@@ -1,3 +1,11 @@
# Chatcoloring # Chatcoloring
Der Spieler benötigt das Recht "chatcolor" damit dessen Namen blau im Chat angezeigt wird. Der Spieler benötigt das Recht "chatcolor" damit dessen Namen blau im Chat angezeigt wird.
# Wandering Trader
Ich würde vorschlagen, das Plugin WanderingTrades zu verwenden, da es uns ermöglicht, eigene Handelsangebote festzulegen und gleichzeitig die Köpfe von Spielern unseres Servers zu verkaufen.
Da WanderingTrades auch Mini-Blöcke unterstützt, sollten wir überlegen, ob wir das Plugin MiniBlocks nicht vollständig weglassen. Was das Plugin nicht kann ist Player Heads zu droppen, wenn ein Spieler einen anderen tötet, dass müssten wir dann noch selber schreiben.
Zu tun wäre dann noch:
Die Config des Plugins um Sniffing Potion und Heart of the Seas hinzuzufügen und Preis für Player Heads hinzufügen.
@@ -20,6 +20,7 @@ import org.bukkit.Material;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.attribute.Attribute; import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.entity.Villager; import org.bukkit.entity.Villager;
import org.bukkit.event.Listener; 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"); 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.syncRegions().forEach(region -> this.worlds.keepLoaded(region, Main.instance()));
this.removeStrayVillagers();
this.placeStarterPlatform(); this.placeStarterPlatform();
this.syncAll(); this.syncAll();
@@ -178,6 +180,25 @@ public class Arena extends Appliance {
.forEach(this::toTribune); .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. * A fresh, empty world gets a small platform at the tribune spawn, so the first visitors do not fall into the void.
*/ */
@@ -204,14 +225,13 @@ public class Arena extends Appliance {
return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE); return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE);
} }
void startFight(Player red, Player blue, UUID challenger, FightMode mode) { void startFight(List<List<Player>> teams, FightMode mode, Map<UUID, UUID> kits) {
ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null); ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null);
if(slot == null) { if(slot == null) {
this.toTribune(red); teams.forEach(team -> team.forEach(this::toTribune));
this.toTribune(blue);
return; return;
} }
new Fight(this, slot, red, blue, challenger, mode).start(); new Fight(this, slot, teams, mode, kits).start();
} }
void resetSlot(ArenaSlot slot) { void resetSlot(ArenaSlot slot) {
@@ -293,7 +313,7 @@ public class Arena extends Appliance {
public void handleQuit(Player player) { public void handleQuit(Player player) {
Fight fight = this.fightOf(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); if(this.lobby != null) this.lobby.leave(player);
this.teams.leave(player); this.teams.leave(player);
this.states.remove(player.getUniqueId()); this.states.remove(player.getUniqueId());
@@ -327,7 +347,7 @@ public class Arena extends Appliance {
public @Nullable Region arenaRegionOf(Player player) { public @Nullable Region arenaRegionOf(Player player) {
Fight fight = this.fightOf(player); Fight fight = this.fightOf(player);
return fight == null ? null : fight.slot().settings.region(); return fight == null ? null : fight.region();
} }
public @Nullable Lobby lobby() { public @Nullable Lobby lobby() {
@@ -373,6 +393,7 @@ public class Arena extends Appliance {
new FightListener(), new FightListener(),
new WorldProtectionListener(), new WorldProtectionListener(),
new ModeMenuListener(), new ModeMenuListener(),
new NoProgressListener(),
new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())), new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())),
new DismissInventoryOpenFromHolder(villager) new DismissInventoryOpenFromHolder(villager)
); );
@@ -23,7 +23,8 @@ final class ArenaSlot {
ArenaSlot(ArenaSettings.Slot settings, World world) { ArenaSlot(ArenaSettings.Slot settings, World world) {
this.settings = settings; 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() { void updateDisplay() {
@@ -39,6 +39,7 @@ final class ArenaWorlds {
world.setGameRule(GameRules.SPAWN_WARDENS, false); world.setGameRule(GameRules.SPAWN_WARDENS, false);
world.setGameRule(GameRules.RAIDS, false); world.setGameRule(GameRules.RAIDS, false);
world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false); world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false);
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
world.setStorm(false); world.setStorm(false);
world.setThundering(false); world.setThundering(false);
world.setTime(6000); world.setTime(6000);
@@ -5,6 +5,7 @@ import org.bukkit.Color;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.entity.Display; import org.bukkit.entity.Display;
import org.bukkit.entity.TextDisplay; 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. * 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) { 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 // displays are not saved, they would vanish with an unloaded chunk
location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance()); location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance());
return location.getWorld().spawn(location, TextDisplay.class, display -> { return location.getWorld().spawn(location, TextDisplay.class, display -> {
@@ -26,6 +31,11 @@ final class Displays {
display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0)); display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0));
display.setLineWidth(220); display.setLineWidth(220);
display.addScoreboardTag(MANAGED_TAG); display.addScoreboardTag(MANAGED_TAG);
if(scale != 1f) {
Transformation transformation = display.getTransformation();
transformation.getScale().set(scale);
display.setTransformation(transformation);
}
}); });
} }
} }
@@ -1,9 +1,11 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena; 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.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title; import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -18,37 +20,44 @@ import org.jetbrains.annotations.Nullable;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; 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 { final class Fight {
private static final int CELEBRATION_TICKS = 60; private static final int CELEBRATION_TICKS = 60;
private static final double SPECTATOR_RANGE = 24; private static final double SPECTATOR_RANGE = 32;
private static final double SPAWN_SPREAD = 2;
private final Arena arena; private final Arena arena;
private final ArenaSlot slot; private final ArenaSlot slot;
private final UUID[] fighters; private final List<List<UUID>> teams;
private final String[] names; private final Map<UUID, String> names = new HashMap<>();
private final UUID challenger; private final List<Set<UUID>> alive;
private final FightMode mode; private final FightMode mode;
private final Map<UUID, UUID> kits;
private final BossBar bossBar; private final BossBar bossBar;
private final Set<UUID> bossBarViewers = new HashSet<>(); private final Set<UUID> bossBarViewers = new HashSet<>();
private int secondsLeft; private int secondsLeft;
private boolean over; private boolean over;
private @Nullable BukkitTask timer; private @Nullable BukkitTask timer;
Fight(Arena arena, ArenaSlot slot, Player red, Player blue, UUID challenger, FightMode mode) { Fight(Arena arena, ArenaSlot slot, List<List<Player>> teams, FightMode mode, Map<UUID, UUID> kits) {
this.arena = arena; this.arena = arena;
this.slot = slot; this.slot = slot;
this.fighters = new UUID[]{red.getUniqueId(), blue.getUniqueId()}; this.teams = teams.stream().map(team -> team.stream().map(Player::getUniqueId).toList()).toList();
this.names = new String[]{red.getName(), blue.getName()}; teams.forEach(team -> team.forEach(player -> this.names.put(player.getUniqueId(), player.getName())));
this.challenger = challenger; this.alive = this.teams.stream().map(team -> (Set<UUID>) new LinkedHashSet<>(team)).toList();
this.mode = mode; this.mode = mode;
this.kits = kits;
this.secondsLeft = arena.settings().fightSeconds(); this.secondsLeft = arena.settings().fightSeconds();
this.bossBar = BossBar.bossBar(Component.empty(), 1f, BossBar.Color.RED, BossBar.Overlay.NOTCHED_10); 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.state = ArenaSlot.State.FIGHT;
this.slot.fight = this; this.slot.fight = this;
FightMode.Role[] roles = new FightMode.Role[2]; for(int team = 0; team < 2; team++) {
for(int i = 0; i < 2; i++) { List<UUID> members = this.teams.get(team);
roles[i] = this.fighters[i].equals(this.challenger) ? FightMode.Role.CHALLENGER : FightMode.Role.OPPONENT; for(int i = 0; i < members.size(); i++) {
} Player player = Bukkit.getPlayer(members.get(i));
UUID challengerId = this.challenger; if(player == null) continue;
UUID opponentId = this.fighters[0].equals(challengerId) ? this.fighters[1] : this.fighters[0]; this.arena.setState(player, Arena.PlayerState.FIGHT);
this.arena.teams().join(player, Teams.fight(team));
for(int i = 0; i < 2; i++) { player.closeInventory();
Player player = Bukkit.getPlayer(this.fighters[i]); player.teleport(this.spawnFor(team, i, members.size()));
if(player == null) continue; Arena.restore(player, GameMode.SURVIVAL);
UUID kitOwner = this.mode.kitSource(roles[i]) == FightMode.Role.CHALLENGER ? challengerId : opponentId; InventoryStore.apply(player, this.kits.getOrDefault(player.getUniqueId(), player.getUniqueId()));
this.arena.setState(player, Arena.PlayerState.FIGHT); player.showTitle(Title.title(
this.arena.teams().join(player, Teams.fight(i)); Component.text("Kampf!", NamedTextColor.GOLD),
player.closeInventory(); Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(1 - team)),
player.teleport(this.slot.settings.spawns()[i]); Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500))
Arena.restore(player, GameMode.SURVIVAL); ));
InventoryStore.apply(player, kitOwner); Style.play(player, Sound.EVENT_RAID_HORN, 1.2f);
}
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);
} }
// 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( 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.updateBossBar();
this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20); 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() { 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(" 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() { int secondsLeft() {
@@ -106,7 +143,20 @@ final class Fight {
} }
boolean involves(UUID player) { 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() { ArenaSlot slot() {
@@ -116,11 +166,11 @@ final class Fight {
private void tickSecond() { private void tickSecond() {
if(this.over) return; if(this.over) return;
if(--this.secondsLeft <= 0) { if(--this.secondsLeft <= 0) {
this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY)); this.timeUp();
return; return;
} }
if(this.secondsLeft == 60 || this.secondsLeft == 10) { 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)); player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f); Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f);
}); });
@@ -128,13 +178,40 @@ final class Fight {
this.updateBossBar(); 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() { private void updateBossBar() {
this.bossBar.name(this.headline().append(Component.text(" " + Style.time(this.secondsLeft), NamedTextColor.WHITE))); 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)); this.bossBar.progress(Math.clamp((float) this.secondsLeft / this.arena.settings().fightSeconds(), 0f, 1f));
Set<UUID> viewers = new HashSet<>(); Set<UUID> viewers = new HashSet<>();
for(Player player : this.arena.worlds().play().getPlayers()) { 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) { for(UUID uuid : this.bossBarViewers) {
Player player = Bukkit.getPlayer(uuid); Player player = Bukkit.getPlayer(uuid);
@@ -150,32 +227,72 @@ final class Fight {
private boolean isSpectating(Player player) { private boolean isSpectating(Player player) {
if(this.arena.state(player) != Arena.PlayerState.TRIBUNE && this.arena.state(player) != Arena.PlayerState.PAD) return false; 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 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))); double dz = Math.max(0, Math.max(region.minZ() - player.getZ(), player.getZ() - (region.maxZ() + 1)));
return Math.hypot(dx, dz) <= SPECTATOR_RANGE; 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) { void defeated(Player player) {
int index = this.indexOf(loser.getUniqueId()); this.knockOut(player, Component.empty(), "ist ausgeschieden");
if(index == -1 || this.over) return; }
Player winner = Bukkit.getPlayer(this.fighters[1 - index]);
Component detail = winner == null /**
? Component.empty() * The player got out of the box, for example by glitching through a wall with an ender pearl.
: Component.text("(" + this.mode.displayName() + ", " + Style.hearts(winner.getHealth() + winner.getAbsorptionAmount()) + " ❤ übrig)", NamedTextColor.GRAY); */
this.finish(1 - index, detail); void leftBox(Player player) {
this.knockOut(player, Component.text("Du hast die Arena verlassen", NamedTextColor.GRAY), "hat die Arena verlassen");
}
private void knockOut(Player player, Component subtitle, String what) {
if(this.over || !this.eliminate(player.getUniqueId())) return;
player.showTitle(Title.title(Component.text("Ausgeschieden", NamedTextColor.RED), subtitle));
Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(player);
this.announceElimination(player.getUniqueId(), what);
this.checkEnd();
} }
/** /**
* The player left the server during the fight. * The player left the server during the fight.
*/ */
void forfeit(UUID quitter) { void forfeit(UUID quitter) {
int index = this.indexOf(quitter); if(this.over || !this.eliminate(quitter)) return;
if(index == -1 || this.over) return; this.announceElimination(quitter, "hat die Arena verlassen");
this.finish(1 - index, Component.text("(" + this.names[index] + " hat die Arena verlassen)", NamedTextColor.GRAY)); 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 +303,20 @@ final class Fight {
this.over = true; this.over = true;
this.stopTimer(); this.stopTimer();
this.hideBossBar(); this.hideBossBar();
this.fighterPlayers().forEach(this.arena::toTribune); this.alivePlayers().forEach(this.arena::toTribune);
this.slot.fight = null; this.slot.fight = null;
} }
private void finish(int winnerIndex, Component detail) { private void finish(int winner, Component detail) {
this.over = true; this.over = true;
this.stopTimer(); this.stopTimer();
if(winnerIndex == -1) { if(winner == -1) {
Bukkit.broadcast(Style.message( Bukkit.broadcast(Style.message(
Component.text(this.names[0], Style.PAD_TEXT_COLORS[0]) this.teamNames(0).append(Component.text(" und ", NamedTextColor.GRAY)).append(this.teamNames(1))
.append(Component.text(" und ", NamedTextColor.GRAY)) .append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)).append(detail)
.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 -> { this.alivePlayers().forEach(player -> {
player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail)); player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail));
Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f); Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(player); this.arena.toTribune(player);
@@ -211,33 +325,31 @@ final class Fight {
return; return;
} }
int loserIndex = 1 - winnerIndex; int loser = 1 - winner;
boolean plural = this.teams.get(winner).size() > 1;
Bukkit.broadcast(Style.message( Bukkit.broadcast(Style.message(
Component.text(this.names[winnerIndex], Style.PAD_TEXT_COLORS[winnerIndex]) this.teamNames(winner)
.append(Component.text(" hat gegen ", NamedTextColor.GRAY)) .append(Component.text(plural ? " gewinnen gegen " : " hat gegen ", NamedTextColor.GRAY))
.append(Component.text(this.names[loserIndex], Style.PAD_TEXT_COLORS[loserIndex])) .append(this.teamNames(loser))
.append(Component.text(" gewonnen ", NamedTextColor.GRAY)) .append(Component.text(plural ? " " : " gewonnen ", NamedTextColor.GRAY))
.append(detail) .append(detail)
)); ));
Player loser = Bukkit.getPlayer(this.fighters[loserIndex]); for(UUID uuid : this.teams.get(loser)) {
if(loser != null) { Player player = Bukkit.getPlayer(uuid);
loser.showTitle(Title.title( if(player == null) continue;
Component.text("Verloren", NamedTextColor.RED), player.showTitle(Title.title(Component.text("Verloren", NamedTextColor.RED), Component.empty()));
Component.text(this.names[winnerIndex] + " hat gewonnen", NamedTextColor.GRAY) if(this.alive.get(loser).contains(uuid)) this.arena.toTribune(player);
));
Style.play(loser, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(loser);
} }
for(UUID uuid : this.teams.get(winner)) {
Player winner = Bukkit.getPlayer(this.fighters[winnerIndex]); Player player = Bukkit.getPlayer(uuid);
if(winner != null) { if(player == null) continue;
winner.showTitle(Title.title( player.showTitle(Title.title(
Component.text("Gewonnen!", NamedTextColor.GOLD), 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); Style.play(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f);
this.celebrate(winner.getLocation(), Style.PAD_PARTICLE_COLORS[winnerIndex]); if(this.alive.get(winner).contains(uuid)) this.celebrate(player.getLocation(), Style.PAD_PARTICLE_COLORS[winner]);
} }
this.afterCelebration(); this.afterCelebration();
} }
@@ -261,9 +373,8 @@ final class Fight {
private void afterCelebration() { private void afterCelebration() {
this.hideBossBar(); this.hideBossBar();
Bukkit.getScheduler().runTaskLater(Main.instance(), () -> { Bukkit.getScheduler().runTaskLater(Main.instance(), () -> {
for(UUID uuid : this.fighters) { for(Player player : this.alivePlayers()) {
Player player = Bukkit.getPlayer(uuid); if(this.arena.fightOf(player) == this && this.arena.state(player) == Arena.PlayerState.FIGHT) this.arena.toTribune(player);
if(player != null && this.arena.fightOf(player) == this) this.arena.toTribune(player);
} }
this.slot.fight = null; this.slot.fight = null;
this.arena.resetSlot(this.slot); this.arena.resetSlot(this.slot);
@@ -275,17 +386,19 @@ final class Fight {
this.timer = null; this.timer = null;
} }
private int indexOf(UUID player) { private int teamOf(UUID player) {
if(this.fighters[0].equals(player)) return 0; if(this.teams.get(0).contains(player)) return 0;
if(this.fighters[1].equals(player)) return 1; if(this.teams.get(1).contains(player)) return 1;
return -1; return -1;
} }
private List<Player> fighterPlayers() { private List<Player> alivePlayers() {
List<Player> players = new ArrayList<>(); List<Player> players = new ArrayList<>();
for(UUID uuid : this.fighters) { for(Set<UUID> team : this.alive) {
Player player = Bukkit.getPlayer(uuid); for(UUID uuid : team) {
if(player != null) players.add(player); Player player = Bukkit.getPlayer(uuid);
if(player != null) players.add(player);
}
} }
return players; return players;
} }
@@ -12,6 +12,7 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
@@ -22,19 +23,23 @@ import org.bukkit.inventory.ItemStack;
class FightListener extends ApplianceListener<Arena> { class FightListener extends ApplianceListener<Arena> {
private boolean isFighting(Player player) { private boolean isFighting(Player player) {
Fight fight = this.getAppliance().fightOf(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) @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onDamageByPlayer(EntityDamageByEntityEvent event) { public void onDamageByPlayer(EntityDamageByEntityEvent event) {
if(!(event.getEntity() instanceof Player)) return; if(!(event.getEntity() instanceof Player victim)) return;
Player attacker = switch(event.getDamager()) { Player attacker = switch(event.getDamager()) {
case Player player -> player; case Player player -> player;
case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter; case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter;
default -> null; default -> null;
}; };
if(attacker == null || attacker == event.getEntity()) return; if(attacker == null || attacker == victim) return;
if(!this.isFighting(attacker)) event.setCancelled(true); 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) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -90,4 +95,17 @@ class FightListener extends ApplianceListener<Arena> {
event.setCancelled(true); event.setCancelled(true);
} }
} }
/**
* Whoever ends up outside the box anyway (ender pearl through a wall or corner) loses.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if(!event.hasChangedBlock()) return;
Player player = event.getPlayer();
if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT || !this.isFighting(player)) return;
Fight fight = this.getAppliance().fightOf(player);
if(event.getTo().getWorld() == this.getAppliance().worlds().play() && fight.region().contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) return;
fight.leftBox(player);
}
} }
@@ -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.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds; import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title; import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -19,7 +20,9 @@ import org.jetbrains.annotations.Nullable;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -29,6 +32,10 @@ import java.util.UUID;
final class Lobby implements PadLobby.Listener { final class Lobby implements PadLobby.Listener {
/** How far around a taken pad other players get pushed away. */ /** How far around a taken pad other players get pushed away. */
private static final double PUSH_MARGIN = 0.4; private static final double PUSH_MARGIN = 0.4;
/** How long a full lobby waits for the challenger to close the options. */
private static final int CHOOSING_SECONDS = 20;
/** Whoever keeps the options open this long without clicking anything gets sent off the pad. */
private static final int MENU_IDLE_TICKS = 60 * 20;
private final Arena arena; private final Arena arena;
private final Region[] regions; private final Region[] regions;
@@ -36,11 +43,13 @@ final class Lobby implements PadLobby.Listener {
private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS]; private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS];
private final Map<UUID, Long> lastPushSound = new HashMap<>(); private final Map<UUID, Long> lastPushSound = new HashMap<>();
private long ticks; private long ticks;
private @Nullable UUID menuViewer;
private int menuIdleTicks;
Lobby(Arena arena, Region[] regions) { Lobby(Arena arena, Region[] regions) {
this.arena = arena; this.arena = arena;
this.regions = regions; this.regions = regions;
this.pads = new PadLobby(this, arena.settings().countdownSeconds()); this.pads = new PadLobby(this, arena.settings().countdownSeconds(), CHOOSING_SECONDS);
for(int i = 0; i < PadLobby.PADS; i++) { for(int i = 0; i < PadLobby.PADS; i++) {
Region region = regions[i]; Region region = regions[i];
this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ())); this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ()));
@@ -55,12 +64,23 @@ final class Lobby implements PadLobby.Listener {
else if(state == Arena.PlayerState.TRIBUNE) this.checkEnter(player); else if(state == Arena.PlayerState.TRIBUNE) this.checkEnter(player);
} }
this.checkMenuIdle();
this.pads.tick(this.arena.hasFreeSlot()); this.pads.tick(this.arena.hasFreeSlot());
if(this.ticks % 3 == 0) this.drawParticles(); if(this.ticks % 3 == 0) this.drawParticles();
if(this.ticks % 5 == 0) this.updateDisplays(); if(this.ticks % 5 == 0) this.updateDisplays();
} }
private void checkMenuIdle() {
if(this.menuViewer == null || ++this.menuIdleTicks < MENU_IDLE_TICKS) return;
Player player = Bukkit.getPlayer(this.menuViewer);
this.menuViewer = null;
if(player == null || !ModeMenu.isOpen(player)) return;
this.arena.toTribune(player);
player.sendMessage(Style.message("Du hast eine Minute lang nichts ausgewählt und wurdest vom Feld geschickt."));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f);
}
private void checkLeave(Player player) { private void checkLeave(Player player) {
int pad = this.pads.padOf(player.getUniqueId()); int pad = this.pads.padOf(player.getUniqueId());
if(pad != -1 && this.isOn(player, pad, 0)) return; if(pad != -1 && this.isOn(player, pad, 0)) return;
@@ -69,7 +89,7 @@ final class Lobby implements PadLobby.Listener {
private void checkEnter(Player player) { private void checkEnter(Player player) {
for(int pad = 0; pad < PadLobby.PADS; pad++) { 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); if(this.isOn(player, pad, PUSH_MARGIN)) this.pushAway(player, pad);
continue; continue;
} }
@@ -79,6 +99,12 @@ final class Lobby implements PadLobby.Listener {
this.arena.setState(player, Arena.PlayerState.PAD); this.arena.setState(player, Arena.PlayerState.PAD);
this.arena.teams().join(player, Teams.pad(pad)); this.arena.teams().join(player, Teams.pad(pad));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f); Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f);
if(this.pads.isWaitingForChallenger()) {
player.sendActionBar(this.choosingText());
} else 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; return;
} }
} }
@@ -94,14 +120,81 @@ final class Lobby implements PadLobby.Listener {
this.arena.setState(player, Arena.PlayerState.TRIBUNE); this.arena.setState(player, Arena.PlayerState.TRIBUNE);
} }
void chooseMode(Player player, FightMode mode) { void handleMenuClick(Player player, ModeMenu menu, ModeMenu.Click click) {
if(!this.pads.setMode(player.getUniqueId(), mode)) { int ownSize = menu.ownSize;
player.closeInventory(); int opponentSize = menu.opponentSize;
FightMode mode = menu.mode;
this.menuIdleTicks = 0;
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() + ". 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;
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; return;
} }
menu.ownSize = ownSize;
menu.opponentSize = opponentSize;
menu.mode = mode;
menu.draw();
InteractSounds.of(player).click(); InteractSounds.of(player).click();
player.closeInventory(); }
player.sendActionBar(Component.text("Modus: " + mode.displayName(), NamedTextColor.GOLD));
/**
* 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());
this.menuViewer = player.getUniqueId();
this.menuIdleTicks = 0;
this.pads.setChoosing(player.getUniqueId(), true);
return true;
}
/**
* The challenger closed the options: a full lobby starts counting down now.
*/
void menuClosed(Player player) {
if(player.getUniqueId().equals(this.menuViewer)) this.menuViewer = null;
this.pads.setChoosing(player.getUniqueId(), false);
}
private Component choosingText() {
Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger());
String name = challenger == null ? "Der Herausforderer" : challenger.getName();
return Component.text(name + " wählt noch Teams und Modus", NamedTextColor.YELLOW);
}
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() { boolean isIdle() {
@@ -109,11 +202,7 @@ final class Lobby implements PadLobby.Listener {
} }
void shutdown() { void shutdown() {
for(int pad = 0; pad < PadLobby.PADS; pad++) { for(int pad = 0; pad < PadLobby.PADS; pad++) this.playersOn(pad).forEach(this::leave);
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(); for(TextDisplay display : this.displays) display.remove();
} }
@@ -134,121 +223,143 @@ final class Lobby implements PadLobby.Listener {
if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return; if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return;
this.lastPushSound.put(player.getUniqueId(), now); this.lastPushSound.put(player.getUniqueId(), now);
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); 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 @Override
public void challengerSelected(UUID challenger) { public void challengerSelected(UUID challenger, boolean first) {
Player player = Bukkit.getPlayer(challenger); Player player = Bukkit.getPlayer(challenger);
if(player == null) return; if(player == null) return;
player.sendMessage(Style.message("Du forderst heraus. Wähle den Modus. Wer sich auf das andere Feld stellt, nimmt an.")); if(!first) {
InteractSounds.of(player).open(); player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Rechtsklick auf dem Feld öffnet die Auswahl."));
ModeMenu.open(player, this.pads.mode()); Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f);
return;
}
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 @Override
public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { public void waitingForChallenger() {
Player challengerPlayer = Bukkit.getPlayer(challenger); UUID challenger = this.pads.challenger();
Player opponentPlayer = Bukkit.getPlayer(opponent); Player player = challenger == null ? null : Bukkit.getPlayer(challenger);
if(challengerPlayer == null || opponentPlayer == null) return; if(player != null && player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu menu) {
if(ModeMenu.isOpen(challengerPlayer)) challengerPlayer.closeInventory(); menu.everyoneThere = true;
menu.draw();
InventoryStore.apply(challengerPlayer, mode.kitSource(FightMode.Role.CHALLENGER) == FightMode.Role.CHALLENGER ? challenger : opponent); Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f);
InventoryStore.apply(opponentPlayer, mode.kitSource(FightMode.Role.OPPONENT) == FightMode.Role.CHALLENGER ? challenger : opponent); }
for(int pad = 0; pad < PadLobby.PADS; pad++) {
String kitText = switch(mode) { this.playersOn(pad).stream()
case OWN -> "Jeder kämpft mit seinem eigenen Inventar."; .filter(occupant -> !occupant.getUniqueId().equals(challenger))
case CHALLENGER -> "Ihr kämpft beide mit dem Inventar von " + challengerPlayer.getName() + "."; .forEach(occupant -> occupant.sendActionBar(this.choosingText()));
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 @Override
public void kitReverted(UUID uuid) { 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); Player player = Bukkit.getPlayer(uuid);
if(player != null) InventoryStore.apply(player, uuid); if(player == null) return;
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 @Override
public void countdown(int secondsLeft) { public void countdown(int secondsLeft) {
for(int pad = 0; pad < PadLobby.PADS; pad++) { for(int pad = 0; pad < PadLobby.PADS; pad++) {
Player player = this.occupantPlayer(pad); Component opponents = this.namesOn(1 - pad);
Player other = this.occupantPlayer(1 - pad); for(Player player : this.playersOn(pad)) {
if(player == null || other == null) continue; player.showTitle(Title.title(
player.showTitle(Title.title( Component.text(secondsLeft, NamedTextColor.GOLD),
Component.text(secondsLeft, NamedTextColor.GOLD), Component.text("Kampf gegen ", NamedTextColor.GRAY).append(opponents),
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)
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);
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f); }
} }
} }
@Override @Override
public void countdownCancelled() { public void countdownCancelled() {
for(int pad = 0; pad < PadLobby.PADS; pad++) { for(int pad = 0; pad < PadLobby.PADS; pad++) {
Player player = this.occupantPlayer(pad); for(Player player : this.playersOn(pad)) {
if(player == null) continue; player.clearTitle();
player.clearTitle(); player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED));
player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED)); Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f);
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f); }
} }
} }
@Override @Override
public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { public void start(List<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits) {
Player challengerPlayer = Bukkit.getPlayer(challenger); List<List<Player>> players = teams.stream()
Player opponentPlayer = Bukkit.getPlayer(opponent); .map(team -> team.stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList())
if(challengerPlayer == null || opponentPlayer == null) { .toList();
if(challengerPlayer != null) this.arena.toTribune(challengerPlayer); if(players.get(0).isEmpty() || players.get(1).isEmpty()) {
if(opponentPlayer != null) this.arena.toTribune(opponentPlayer); players.forEach(team -> team.forEach(this.arena::toTribune));
return; return;
} }
Player red = challengerPad == 0 ? challengerPlayer : opponentPlayer; this.arena.startFight(players, mode, kits);
Player blue = challengerPad == 0 ? opponentPlayer : challengerPlayer;
this.arena.startFight(red, blue, challenger, mode);
} }
private @Nullable Player occupantPlayer(int pad) { private List<Player> playersOn(int pad) {
UUID occupant = this.pads.occupant(pad); return this.pads.players(pad).stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList();
return occupant == null ? null : Bukkit.getPlayer(occupant); }
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() { private void updateDisplays() {
boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot(); boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot();
Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); 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++) { for(int pad = 0; pad < PadLobby.PADS; pad++) {
List<Player> players = this.playersOn(pad);
Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[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; Component body;
if(waiting) { if(waiting) {
body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() body = this.namesOn(pad).appendNewline()
.append(Component.text("Gleich geht es los", NamedTextColor.WHITE)); .append(Component.text("Alle Arenen belegt, gleich geht es los", NamedTextColor.GRAY));
} else if(this.pads.isWaitingForChallenger() && challenger != null) {
body = this.namesOn(pad).appendNewline()
.append(Component.text(challenger.getName() + " wählt noch", NamedTextColor.YELLOW));
} else if(this.pads.isCountingDown()) { } 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)); .append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD));
} else if(occupant != null) { } else if(challenger == null) {
body = Component.text(occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline() body = !this.arena.hasFreeSlot()
.append(Component.text("fordert heraus", NamedTextColor.GRAY)).appendNewline() ? 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)); .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 { } else {
body = Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline() Component who = players.isEmpty()
.append(Component.text("um herauszufordern", NamedTextColor.WHITE)); ? 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)); this.displays[pad].text(title.appendNewline().append(body));
} }
@@ -260,18 +371,18 @@ final class Lobby implements PadLobby.Listener {
for(int pad = 0; pad < PadLobby.PADS; pad++) { for(int pad = 0; pad < PadLobby.PADS; pad++) {
Region region = this.regions[pad]; Region region = this.regions[pad];
Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad]; Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad];
boolean taken = this.pads.occupant(pad) != null; boolean full = this.pads.isPadFull(pad);
double spacing = countdown ? 0.25 : taken ? 0.4 : 0.6; double spacing = countdown ? 0.25 : full ? 0.4 : 0.6;
this.drawOutline(region, new Particle.DustOptions(color, taken ? 1.2f : 0.9f), spacing); 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; double speed = countdown ? 0.6 : 0.3;
for(int strand = 0; strand < 2; strand++) { for(Player occupant : this.playersOn(pad)) {
double angle = this.ticks * speed + strand * Math.PI; for(int strand = 0; strand < 2; strand++) {
double height = (this.ticks % 40) / 20.0; double angle = this.ticks * speed + strand * Math.PI;
Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7); double height = (this.ticks % 40) / 20.0;
Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn(); 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();
}
} }
} }
} }
@@ -1,6 +1,7 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena; 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.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.ItemBuilder;
import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems; import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
@@ -14,55 +15,130 @@ import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.inventory.meta.SkullMeta;
import org.jetbrains.annotations.NotNull; 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 { 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 FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT};
private static final int DONE_SLOT = 40;
private final Inventory inventory; sealed interface Click {
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) { record OwnSize(int size) implements Click {
challenger.openInventory(new ModeMenu(challenger, current).getInventory()); }
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;
/** Both pads are full, only the challenger closing the menu is missing. */
boolean everyoneThere;
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) { static boolean isOpen(Player player) {
return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu; return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu;
} }
@Nullable FightMode modeAt(int slot) { Click clickAt(int slot) {
for(int i = 0; i < SLOTS.length; i++) { if(slot > OWN_ROW && slot <= OWN_ROW + PadLobby.MAX_TEAM_SIZE) return new OwnSize(slot - OWN_ROW);
if(SLOTS[i] == slot) return MODES[i]; 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; 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)));
}
ItemBuilder done = ItemBuilder.of(Material.LIME_CONCRETE)
.displayName(Component.text(this.everyoneThere ? "Fertig, Kampf starten" : "Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false))
.lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". "
+ (this.everyoneThere ? "Alle sind da, der Countdown startet, sobald du fertig bist." : "Jetzt fehlen nur noch die anderen auf den Feldern."));
if(this.everyoneThere) done.glint();
this.inventory.setItem(DONE_SLOT, done.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) { ItemBuilder builder = switch(mode) {
case OWN -> ItemBuilder.of(Material.IRON_SWORD) 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) case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD)
.withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(challenger)) .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger))
.lore("Ihr kämpft beide mit deinem Inventar."); .lore(teams ? "Alle kämpfen mit deinem Inventar." : "Ihr kämpft beide mit deinem Inventar.");
case OPPONENT -> ItemBuilder.of(Material.CHEST) case OPPONENT -> ItemBuilder.of(allowed ? Material.CHEST : Material.BARRIER)
.lore("Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt."); .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) { if(selected) {
builder.glint().appendLore(Component.empty()) builder.glint().appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
.appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
} else { } else {
builder.appendLore(Component.empty()) builder.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
} }
return builder.build(); return builder.build();
} }
@@ -1,10 +1,13 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena; 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 eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; 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; import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
class ModeMenuListener extends ApplianceListener<Arena> { class ModeMenuListener extends ApplianceListener<Arena> {
@EventHandler @EventHandler
@@ -14,8 +17,25 @@ class ModeMenuListener extends ApplianceListener<Arena> {
if(!(event.getWhoClicked() instanceof Player player)) return; if(!(event.getWhoClicked() instanceof Player player)) return;
if(event.getRawSlot() >= event.getView().getTopInventory().getSize()) 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(); Lobby lobby = this.getAppliance().lobby();
if(mode != null && lobby != null) lobby.chooseMode(player, mode); if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click);
}
@EventHandler
public void onClose(InventoryCloseEvent event) {
if(!(event.getInventory().getHolder(false) instanceof ModeMenu)) return;
if(!(event.getPlayer() instanceof Player player)) return;
Lobby lobby = this.getAppliance().lobby();
if(lobby != null) lobby.menuClosed(player);
}
@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);
} }
} }
@@ -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<Arena> {
@EventHandler
public void onCriterion(PlayerAdvancementCriterionGrantEvent event) {
event.setCancelled(true);
}
@EventHandler
public void onRecipe(PlayerRecipeDiscoverEvent event) {
event.setCancelled(true);
}
}
@@ -1,36 +1,25 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; 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 { public enum FightMode {
OWN("Jeder sein eigenes"), OWN,
CHALLENGER("Beide mit meinem"), CHALLENGER,
OPPONENT("Beide mit dem des Gegners"); OPPONENT;
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 * 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) { return switch(this) {
case OWN -> fighter; case OWN -> "Jeder sein eigenes";
case CHALLENGER -> Role.CHALLENGER; case CHALLENGER -> teams ? "Alle mit meinem" : "Beide mit meinem";
case OPPONENT -> Role.OPPONENT; case OPPONENT -> "Beide mit dem des Gegners";
}; };
} }
} }
@@ -2,61 +2,89 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.jetbrains.annotations.Nullable; 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; import java.util.UUID;
/** /**
* State of the two start pads. The first player on a pad becomes the challenger and picks the mode, * State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode.
* stepping on the other pad means accepting. Once both stand there long enough, the fight starts. * Everyone else joins a team by stepping on a pad. Once both pads hold exactly the chosen number of players
* and the challenger is done choosing (or took too long), the countdown runs and then the fight starts.
*/ */
public class PadLobby { public class PadLobby {
public static final int PADS = 2; public static final int PADS = 2;
public static final int MAX_TEAM_SIZE = 4;
public interface Listener { public interface Listener {
/** The player is now the challenger and should pick a mode. */ /** The player is now the challenger and may change the options until the countdown runs. */
void challengerSelected(UUID challenger); void challengerSelected(UUID challenger, boolean first);
/** Both pads are taken: give both the inventories of the chosen mode. */ /** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */
void kitsApplied(UUID challenger, UUID opponent, FightMode mode); void kitAssigned(UUID player, UUID kitOwner);
/** The player gets the own inventory back. */ /** Both pads are exactly full, but the challenger still has the options open. */
void kitReverted(UUID player); void waitingForChallenger();
/** Both pads are exactly full, the options are fixed now. */
void countdownStarted();
void countdown(int secondsLeft); void countdown(int secondsLeft);
void countdownCancelled(); 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<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits);
} }
private final Listener listener; private final Listener listener;
private final int countdownTicks; private final int countdownTicks;
private final UUID[] occupants = new UUID[PADS]; private final int choosingTicks;
private int challengerPad = -1; private final List<List<UUID>> 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<UUID> order = new ArrayList<>();
private final Map<UUID, UUID> kits = new HashMap<>();
private final int[] sizes = {1, 1};
private FightMode mode = FightMode.OWN; private FightMode mode = FightMode.OWN;
private int ticksLeft = -1; private int ticksLeft = -1;
private boolean choosing;
/** How much longer everyone waits for the challenger to finish choosing; -1 while nobody waits. */
private int choosingTicksLeft = -1;
public PadLobby(Listener listener, int countdownSeconds) { /**
* @param choosingSeconds how long a full lobby waits for the challenger to close the options
*/
public PadLobby(Listener listener, int countdownSeconds, int choosingSeconds) {
this.listener = listener; this.listener = listener;
this.countdownTicks = countdownSeconds * 20; this.countdownTicks = countdownSeconds * 20;
this.choosingTicks = choosingSeconds * 20;
} }
/** /**
* @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) { public boolean enter(int pad, UUID player) {
if(this.occupants[pad] != null) return false;
if(this.padOf(player) != -1) 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; this.pads.get(pad).add(player);
if(this.challengerPad == -1) { this.order.add(player);
this.challengerPad = pad; this.kits.put(player, player);
if(first) {
this.sizes[0] = 1;
this.sizes[1] = 1;
this.mode = FightMode.OWN; this.mode = FightMode.OWN;
this.listener.challengerSelected(player); this.listener.challengerSelected(player, true);
return true; return true;
} }
this.listener.kitsApplied(this.challenger(), this.opponent(), this.mode); this.refreshKits();
this.ticksLeft = this.countdownTicks; this.startCountdownIfReady();
return true; return true;
} }
@@ -64,39 +92,100 @@ public class PadLobby {
int pad = this.padOf(player); int pad = this.padOf(player);
if(pad == -1) return; if(pad == -1) return;
int other = 1 - pad; boolean wasFull = this.isFull();
boolean bothThere = this.isFull(); boolean wasChallenger = player.equals(this.challenger());
this.occupants[pad] = null; this.pads.get(pad).remove(player);
this.listener.kitReverted(player); this.order.remove(player);
if(bothThere) { this.kits.remove(player);
this.ticksLeft = -1; this.listener.kitAssigned(player, player);
this.listener.kitReverted(this.occupants[other]);
this.listener.countdownCancelled();
}
if(pad != this.challengerPad) return; if(wasFull) {
if(this.occupants[other] == null) { if(this.isCountingDown()) this.listener.countdownCancelled();
this.challengerPad = -1; this.ticksLeft = -1;
return; this.choosingTicksLeft = -1;
} }
this.challengerPad = other; if(wasChallenger) this.choosing = false;
this.mode = FightMode.OWN;
this.listener.challengerSelected(this.occupants[other]); 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(), false);
}
}
public enum ConfigureResult {
OK,
NOT_ALLOWED,
OWN_TOO_SMALL,
OPPONENT_TOO_SMALL,
INVALID
} }
/** /**
* 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 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 setMode(UUID player, FightMode mode) { public ConfigureResult configure(UUID player, int ownSize, int opponentSize, FightMode mode) {
if(!player.equals(this.challenger()) || this.isFull()) return false; 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.mode = mode;
return true; this.refreshKits();
this.startCountdownIfReady();
return ConfigureResult.OK;
}
/**
* The challenger opened or closed the options. While they are open, a full lobby waits instead of counting down.
*/
public void setChoosing(UUID player, boolean choosing) {
if(!this.canConfigure(player) || this.choosing == choosing) return;
this.choosing = choosing;
this.startCountdownIfReady();
}
public boolean isWaitingForChallenger() {
return this.choosingTicksLeft >= 0;
}
private void startCountdownIfReady() {
if(this.isCountingDown()) return;
if(!this.isFull()) {
this.choosingTicksLeft = -1;
return;
}
if(this.choosing) {
if(this.choosingTicksLeft >= 0) return;
this.choosingTicksLeft = this.choosingTicks;
this.listener.waitingForChallenger();
return;
}
this.choosingTicksLeft = -1;
this.ticksLeft = this.countdownTicks;
this.listener.countdownStarted();
}
public boolean canConfigure(UUID player) {
return player.equals(this.challenger()) && !this.isCountingDown();
} }
/** /**
* Called every server tick. The countdown only runs while an arena is free. * Called every server tick. The countdown only runs while an arena is free.
*/ */
public void tick(boolean arenaAvailable) { public void tick(boolean arenaAvailable) {
if(this.choosingTicksLeft >= 0 && this.choosingTicksLeft-- == 0) {
this.choosing = false;
this.startCountdownIfReady();
}
if(this.ticksLeft < 0) return; if(this.ticksLeft < 0) return;
if(!arenaAvailable) { if(!arenaAvailable) {
this.ticksLeft = this.countdownTicks; this.ticksLeft = this.countdownTicks;
@@ -106,39 +195,68 @@ public class PadLobby {
if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20); if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20);
if(this.ticksLeft-- > 0) return; if(this.ticksLeft-- > 0) return;
List<List<UUID>> teams = List.of(List.copyOf(this.pads.get(0)), List.copyOf(this.pads.get(1)));
UUID challenger = this.challenger(); UUID challenger = this.challenger();
UUID opponent = this.opponent();
FightMode mode = this.mode; FightMode mode = this.mode;
int challengerPad = this.challengerPad; Map<UUID, UUID> kits = Map.copyOf(this.kits);
this.occupants[0] = null; this.pads.forEach(List::clear);
this.occupants[1] = null; this.order.clear();
this.challengerPad = -1; this.kits.clear();
this.sizes[0] = 1;
this.sizes[1] = 1;
this.mode = FightMode.OWN; this.mode = FightMode.OWN;
this.ticksLeft = -1; this.ticksLeft = -1;
this.listener.start(challenger, opponent, mode, challengerPad); this.choosing = false;
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) { public int padOf(UUID player) {
for(int i = 0; i < PADS; i++) { 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; return -1;
} }
public @Nullable UUID occupant(int pad) { public List<UUID> players(int pad) {
return this.occupants[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() { public @Nullable UUID challenger() {
return this.challengerPad == -1 ? null : this.occupants[this.challengerPad]; return this.order.isEmpty() ? null : this.order.getFirst();
}
public @Nullable UUID opponent() {
return this.challengerPad == -1 ? null : this.occupants[1 - this.challengerPad];
} }
public int challengerPad() { public int challengerPad() {
return this.challengerPad; UUID challenger = this.challenger();
return challenger == null ? -1 : this.padOf(challenger);
} }
public FightMode mode() { public FightMode mode() {
@@ -146,7 +264,7 @@ public class PadLobby {
} }
public boolean isFull() { 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() { public boolean isCountingDown() {
@@ -154,7 +272,7 @@ public class PadLobby {
} }
public boolean isIdle() { 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. */ /** Seconds until the start, rounded up; -1 without countdown. */
@@ -2,26 +2,21 @@ package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.CHALLENGER; import static org.junit.jupiter.api.Assertions.*;
import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.OPPONENT;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FightModeTest { class FightModeTest {
@Test @Test
void ownKeepsEveryonesInventory() { void opponentInventoryOnlyInOneVersusOne() {
assertEquals(CHALLENGER, FightMode.OWN.kitSource(CHALLENGER)); assertTrue(FightMode.OPPONENT.isAllowed(1, 1));
assertEquals(OPPONENT, FightMode.OWN.kitSource(OPPONENT)); 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 @Test
void challengerKitForBoth() { void namesForTeams() {
assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(CHALLENGER)); assertEquals("Beide mit meinem", FightMode.CHALLENGER.displayName(false));
assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(OPPONENT)); assertEquals("Alle mit meinem", FightMode.CHALLENGER.displayName(true));
}
@Test
void opponentKitForBoth() {
assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(CHALLENGER));
assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(OPPONENT));
} }
} }
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -13,22 +15,29 @@ class PadLobbyTest {
private final UUID a = UUID.randomUUID(); private final UUID a = UUID.randomUUID();
private final UUID b = UUID.randomUUID(); private final UUID b = UUID.randomUUID();
private final UUID c = UUID.randomUUID(); private final UUID c = UUID.randomUUID();
private final UUID d = UUID.randomUUID();
private final UUID e = UUID.randomUUID();
private final List<String> events = new ArrayList<>(); private final List<String> events = new ArrayList<>();
private Map<UUID, UUID> startedKits;
private PadLobby lobby; private PadLobby lobby;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
this.lobby = new PadLobby(new PadLobby.Listener() { this.lobby = new PadLobby(new PadLobby.Listener() {
public void challengerSelected(UUID challenger) { public void challengerSelected(UUID challenger, boolean first) {
PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger)); PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (first ? " first" : ""));
} }
public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) { public void waitingForChallenger() {
PadLobbyTest.this.events.add("kits " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode); PadLobbyTest.this.events.add("waiting");
} }
public void kitReverted(UUID player) { public void countdownStarted() {
PadLobbyTest.this.events.add("revert " + PadLobbyTest.this.name(player)); PadLobbyTest.this.events.add("countdown started");
}
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) { public void countdown(int secondsLeft) {
@@ -39,59 +48,111 @@ class PadLobbyTest {
PadLobbyTest.this.events.add("cancelled"); PadLobbyTest.this.events.add("cancelled");
} }
public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) { public void start(List<List<UUID>> teams, UUID challenger, FightMode mode, Map<UUID, UUID> kits) {
PadLobbyTest.this.events.add("start " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode + " " + challengerPad); 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); }, 2, 3);
} }
private String name(UUID uuid) { private String name(UUID uuid) {
if(uuid.equals(this.a)) return "a"; if(uuid.equals(this.a)) return "a";
if(uuid.equals(this.b)) return "b"; 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<UUID> 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 @Test
void firstOnPadBecomesChallenger() { void firstOnPadBecomesChallengerAndMayConfigure() {
assertTrue(this.lobby.enter(1, this.a)); assertTrue(this.lobby.enter(1, this.a));
assertEquals(List.of("challenger a"), this.events); assertEquals(List.of("challenger a first"), this.events);
assertEquals(this.a, this.lobby.challenger()); assertEquals(this.a, this.lobby.challenger());
assertNull(this.lobby.opponent()); assertTrue(this.lobby.canConfigure(this.a));
assertFalse(this.lobby.isCountingDown()); assertFalse(this.lobby.isCountingDown());
} }
@Test @Test
void takenPadIsRefused() { void oneVersusOneStartsAfterCountdown() {
this.lobby.enter(0, this.a); this.lobby.enter(0, this.a);
assertFalse(this.lobby.enter(0, this.b)); assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER));
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); this.lobby.enter(1, this.b);
assertFalse(this.lobby.setMode(this.a, FightMode.OWN), "mode is fixed once the opponent stands there"); assertEquals(PadLobby.ConfigureResult.NOT_ALLOWED, this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed during the countdown");
this.run(41);
for(int i = 0; i < 41; i++) this.lobby.tick(true);
assertEquals(List.of( assertEquals(List.of(
"challenger a", "challenger a first",
"kits ab CHALLENGER", "kit b<-a",
"countdown started",
"countdown 2", "countdown 2",
"countdown 1", "countdown 1",
"start ab CHALLENGER 0" "start a vs b by a CHALLENGER"
), this.events); ), this.events);
assertEquals(Map.of(this.a, this.a, this.b, this.a), this.startedKits);
assertTrue(this.lobby.isIdle()); assertTrue(this.lobby.isIdle());
assertNull(this.lobby.challenger());
} }
@Test @Test
void onlyChallengerMayChangeMode() { void fullPadIsRefusedAndPlayerOnlyTakesOnePad() {
this.lobby.enter(0, this.a); this.lobby.enter(0, this.a);
assertFalse(this.lobby.setMode(this.b, FightMode.OPPONENT)); this.lobby.enter(1, this.b);
assertEquals(FightMode.OWN, this.lobby.mode()); 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);
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);
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 first", "kit b<-a", "kit c<-a", "kit d<-a", "countdown started"), this.events);
}
@Test
void opponentModeOnlyForOneVersusOne() {
this.lobby.enter(0, this.a);
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);
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");
assertTrue(this.events.contains("cancelled"));
}
@Test
void onlyChallengerMayConfigure() {
this.lobby.enter(0, this.a);
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 @Test
@@ -101,42 +162,76 @@ class PadLobbyTest {
for(int i = 0; i < 100; i++) this.lobby.tick(false); for(int i = 0; i < 100; i++) this.lobby.tick(false);
assertFalse(this.events.contains("countdown 2")); assertFalse(this.events.contains("countdown 2"));
assertEquals(2, this.lobby.secondsLeft()); assertEquals(2, this.lobby.secondsLeft());
this.run(41);
for(int i = 0; i < 41; i++) this.lobby.tick(true); assertEquals("start a vs b by a OWN", this.events.getLast());
assertTrue(this.events.contains("start ab OWN 0"));
} }
@Test @Test
void opponentLeavingCancelsAndRevertsBoth() { void leavingCancelsCountdownAndRevertsKit() {
this.lobby.enter(0, this.a); 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.lobby.enter(1, this.b);
this.events.clear(); this.events.clear();
this.lobby.leave(this.b); 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(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()); assertFalse(this.lobby.isCountingDown());
} }
@Test @Test
void challengerLeavingHandsOverRole() { void longestWaitingTakesOverAndKeepsTeamSizes() {
this.lobby.enter(0, this.a); 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(1, this.b);
this.lobby.enter(0, this.c);
this.events.clear(); this.events.clear();
this.lobby.leave(this.a); 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(this.b, this.lobby.challenger());
assertEquals(1, this.lobby.challengerPad()); assertTrue(this.lobby.canConfigure(this.b), "the new challenger may change the options");
assertEquals(FightMode.OWN, this.lobby.mode()); assertEquals(2, this.lobby.size(0));
assertEquals(2, this.lobby.size(1));
}
this.lobby.enter(0, this.c); @Test
assertEquals("kits bc OWN", this.events.getLast()); void handOverWhenAlone() {
this.lobby.enter(0, this.a);
this.lobby.enter(1, this.b);
this.lobby.leave(this.a);
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 @Test
@@ -146,6 +241,62 @@ class PadLobbyTest {
assertTrue(this.lobby.isIdle()); assertTrue(this.lobby.isIdle());
assertNull(this.lobby.challenger()); assertNull(this.lobby.challenger());
this.lobby.leave(this.b); this.lobby.leave(this.b);
assertEquals(List.of("challenger a", "revert a"), this.events); assertEquals(List.of("challenger a first", "kit a<-a"), this.events);
}
@Test
void fullLobbyWaitsWhileChallengerChooses() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
assertEquals("waiting", this.events.getLast());
assertFalse(this.lobby.isCountingDown());
assertTrue(this.lobby.isWaitingForChallenger());
assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER), "the menu stays usable");
this.lobby.setChoosing(this.a, false);
assertEquals("countdown started", this.events.getLast());
assertFalse(this.lobby.isWaitingForChallenger());
}
@Test
void choosingTimesOut() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.run(60);
assertFalse(this.lobby.isCountingDown());
this.run(1);
assertTrue(this.lobby.isCountingDown());
this.lobby.setChoosing(this.a, false);
assertEquals(1, this.events.stream().filter("countdown started"::equals).count());
}
@Test
void onlyChallengerChoosingBlocks() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.b, true);
this.lobby.enter(1, this.b);
assertTrue(this.lobby.isCountingDown());
}
@Test
void opponentLeavingWhileWaitingIsQuiet() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.lobby.leave(this.b);
assertFalse(this.events.contains("cancelled"));
assertFalse(this.lobby.isWaitingForChallenger());
}
@Test
void newChallengerIsNotChoosing() {
this.lobby.enter(0, this.a);
this.lobby.setChoosing(this.a, true);
this.lobby.enter(1, this.b);
this.lobby.leave(this.a);
this.lobby.enter(0, this.c);
assertTrue(this.lobby.isCountingDown());
} }
} }
@@ -1,66 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import net.kyori.adventure.sound.Sound;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
public class ChatMention extends Appliance {
private static List<String> playerNames;
public String formatPlayer(String name) {
return "@" + name;
}
public List<String> getPlayerNames() {
return playerNames;
}
public void refreshPlayers() {
Bukkit.getScheduler().runTaskAsynchronously(
Main.instance(),
() -> playerNames = Arrays.stream(Bukkit.getOfflinePlayers())
.map(OfflinePlayer::getName)
.filter(Objects::nonNull)
.toList()
);
}
public void notifyPlayers(List<String> playerNames) {
playerNames.stream()
.distinct()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.filter(player -> Settings.instance()
.getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class)
.notifyOnMention()
)
.forEach(player -> player.playSound(
Sound.sound(
org.bukkit.Sound.ENTITY_EXPERIENCE_ORB_PICKUP,
Sound.Source.PLAYER,
1.0f,
1.0f
)
));
}
@Override
public void onEnable() {
Settings.instance().declareSetting(ChatMentionSetting.class);
this.refreshPlayers();
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new ChatMentionListener());
}
}
@@ -1,56 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil;
import io.papermc.paper.event.player.AsyncChatDecorateEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import java.util.ArrayList;
import java.util.List;
class ChatMentionListener extends ApplianceListener<ChatMention> {
@SuppressWarnings("UnstableApiUsage")
@EventHandler
public void coloringEvent(AsyncChatDecorateEvent event) {
String message = PlainTextComponentSerializer.plainText().serialize(event.result());
List<String> words = List.of(message.split(" "));
List<String> mentioned = new ArrayList<>();
ChatMentionSetting.ChatMentionConfig config = Settings.instance()
.getSetting(event.player(), Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class);
ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class);
Component result = words.stream()
.map(word -> {
String wordWithoutAnnotation = word.replace("@", "");
boolean isPlayer = this.getAppliance().getPlayerNames().contains(wordWithoutAnnotation);
if(isPlayer && config.applyMentions()) {
mentioned.add(wordWithoutAnnotation);
Component mention = Component.text(
this.getAppliance().formatPlayer(wordWithoutAnnotation),
NamedTextColor.GOLD
);
return chatMessages.addReportActions(mention, wordWithoutAnnotation);
} else {
return Component.text(word);
}
})
.reduce(ComponentUtil::appendWithSpace)
.orElseThrow();
this.getAppliance().notifyPlayers(mentioned);
event.result(result);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().refreshPlayers();
}
}
@@ -1,36 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class ChatMessages extends Appliance {
@Override
public void onEnable() {
Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class);
}
public Component getReportablePlayerName(Player player) {
return this.addReportActions(player.displayName(), player.getName());
}
public Component addReportActions(Component message, String username) {
return message
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesen Spieler zu reporten").color(NamedTextColor.GOLD)))
.clickEvent(ClickEvent.suggestCommand(String.format("/report %s ", username)));
}
@Override
@NotNull
protected List<Listener> listeners() {
return List.of(new ChatMessagesListener());
}
}
@@ -1,74 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.util.IteratorUtil;
import io.papermc.paper.event.player.AsyncChatEvent;
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.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.Optional;
class ChatMessagesListener extends ApplianceListener<ChatMessages> {
@EventHandler
public void onPlayerChatEvent(AsyncChatEvent event) {
event.renderer(
(source, sourceDisplayName, message, viewer) ->
Component.text("")
.append(this.getAppliance().getReportablePlayerName(source))
.append(Component.text(" > ").color(TextColor.color(Color.GRAY.asRGB())))
.append(message).color(TextColor.color(Color.SILVER.asRGB()))
);
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerJoin(PlayerJoinEvent event) {
boolean wasHidden = event.joinMessage() == null;
event.joinMessage(null);
if(wasHidden) return;
IteratorUtil.onlinePlayers(player -> {
if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return;
player.sendMessage(
Component
.text(">>> ").color(NamedTextColor.GREEN)
.append(this.getAppliance().getReportablePlayerName(event.getPlayer()))
);
});
}
@EventHandler
public void onPlayerLeave(PlayerQuitEvent event) {
boolean wasHidden = event.quitMessage() == null;
event.quitMessage(null);
if(wasHidden) return;
IteratorUtil.onlinePlayers(player -> {
if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return;
player.sendMessage(
Component
.text("<<< ").color(NamedTextColor.RED)
.append(this.getAppliance().getReportablePlayerName(event.getPlayer()))
);
});
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.deathMessage(
Component
.text("☠ ")
.append(
Optional
.ofNullable(event.deathMessage())
.orElse(Component.text(event.getPlayer().getName()))
)
.color(TextColor.color(Color.SILVER.asRGB()))
);
}
}
@@ -2,10 +2,12 @@ package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName;
import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat.NetworkChat;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate; import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil; import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentBuilder; import net.kyori.adventure.text.ComponentBuilder;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
@@ -49,15 +51,27 @@ public class DisplayName extends Appliance {
.map(appliance -> (Prefixed) appliance) .map(appliance -> (Prefixed) appliance)
.toList(); .toList();
ComponentBuilder<TextComponent, TextComponent.Builder> playerName = Component.text(); List<Component> prefixComponents = prefixes.stream()
prefixes.stream()
.map(prefixed -> prefixed.getNamePrefix(player)) .map(prefixed -> prefixed.getNamePrefix(player))
.filter(Objects::nonNull) .filter(Objects::nonNull)
.forEach(prefix -> playerName .toList();
.append(prefix)
.append(ComponentUtil.clearedSpace()) ComponentBuilder<TextComponent, TextComponent.Builder> playerName = Component.text();
prefixComponents.forEach(prefix -> playerName
.append(prefix)
.append(ComponentUtil.clearedSpace())
); );
// Den Chat-Namen baut Velocity selbst (Farbe, Bedrock), es braucht von hier nur die Prefixe
Main.instance().getAppliances().stream()
.filter(NetworkChat.class::isInstance)
.map(NetworkChat.class::cast)
.findFirst()
.ifPresent(networkChat -> networkChat.setPrefix(
player,
prefixComponents.isEmpty() ? null : Component.join(JoinConfiguration.separator(ComponentUtil.clearedSpace()), prefixComponents)
));
if(Floodgate.isBedrock(player)) { if(Floodgate.isBedrock(player)) {
playerName playerName
.append( .append(
@@ -19,7 +19,8 @@ public class InfoBarSetting extends MultiBoolSetting<InfoBarSetting.InfoBarConfi
public record InfoBarConfiguration( public record InfoBarConfiguration(
@DisplayName("Millisekunden pro Tick") boolean mspt, @DisplayName("Millisekunden pro Tick") boolean mspt,
@DisplayName("Spieler online") boolean playerCounter, @DisplayName("Spieler online") boolean playerCounter,
@DisplayName("Ticks pro Sekunde") boolean tps @DisplayName("Ticks pro Sekunde") boolean tps,
@DisplayName("TPS, MSPT & Spieler") boolean tpsMsptPlayerCounter
) {} ) {}
@Override @Override
@@ -39,7 +40,7 @@ public class InfoBarSetting extends MultiBoolSetting<InfoBarSetting.InfoBarConfi
@Override @Override
protected InfoBarConfiguration defaultValue() { protected InfoBarConfiguration defaultValue() {
return new InfoBarConfiguration(false, false, false); return new InfoBarConfiguration(false, false, false, false);
} }
@Override @Override
@@ -1,5 +1,6 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars; package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars.TpsMsptPlayerCounterBar;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
@@ -16,7 +17,8 @@ public class InfoBars extends Appliance {
private final List<Bar> infoBars = List.of( private final List<Bar> infoBars = List.of(
new TpsBar(), new TpsBar(),
new MsptBar(), new MsptBar(),
new PlayerCounterBar() new PlayerCounterBar(),
new TpsMsptPlayerCounterBar()
); );
public void showAllEnabled(Player player) { public void showAllEnabled(Player player) {
@@ -24,6 +26,7 @@ public class InfoBars extends Appliance {
this.setVisible(player, MsptBar.name, config.mspt()); this.setVisible(player, MsptBar.name, config.mspt());
this.setVisible(player, PlayerCounterBar.name, config.playerCounter()); this.setVisible(player, PlayerCounterBar.name, config.playerCounter());
this.setVisible(player, TpsBar.name, config.tps()); this.setVisible(player, TpsBar.name, config.tps());
this.setVisible(player, TpsMsptPlayerCounterBar.name, config.tpsMsptPlayerCounter());
} }
public void setVisible(Player player, String bar, boolean visible) { public void setVisible(Player player, String bar, boolean visible) {
@@ -0,0 +1,103 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.Bar;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.playerlimit.PlayerLimit;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.util.statistics.ServerMonitor;
import eu.mhsl.craftattack.spawn.core.util.text.ColorUtil;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import java.time.Duration;
public class TpsMsptPlayerCounterBar extends Bar {
public static String name = "serverStats";
@Override
protected Duration refresh() {
return Duration.ofSeconds(3);
}
@Override
protected String name() {
return name;
}
@Override
protected Component title() {
float tps = this.currentTps();
float mspt = this.currentMspt();
int players = this.currentPlayerCount();
Component separator = Component.text(" | ", NamedTextColor.DARK_GRAY);
TextColor color = ColorUtil.mapGreenToRed(players, 0, this.getMaxPlayerCount(), true);
return Component.text()
.append(Component.text("TPS: ", NamedTextColor.GRAY))
.append(Component.text(String.format("%.2f", tps), ColorUtil.tpsColor(tps)))
.append(separator)
.append(Component.text("MSPT: ", NamedTextColor.GRAY))
.append(Component.text(String.format("%.2f", mspt), ColorUtil.msptColor(mspt)))
.append(separator)
.append(Component.text("Spieler: ", NamedTextColor.GRAY))
.append(Component.text(players, color))
.build();
}
@Override
protected float progress() {
return Math.clamp(this.currentTps() / 20f, 0f, 1f);
}
@Override
protected BossBar.Color color() {
int severity = Math.max(
this.tpsSeverity(),
Math.max(this.msptSeverity(), this.playerSeverity())
);
return switch (severity) {
case 2 -> BossBar.Color.RED;
case 1 -> BossBar.Color.YELLOW;
default -> BossBar.Color.GREEN;
};
}
@Override
protected BossBar.Overlay overlay() {
return BossBar.Overlay.PROGRESS;
}
private int tpsSeverity() {
float tps = this.currentTps();
return tps >= 18 ? 0 : tps >= 15 ? 1 : 2;
}
private int msptSeverity() {
return this.currentMspt() <= 50 ? 0 : 2;
}
private int playerSeverity() {
int freeSlots = this.getMaxPlayerCount() - this.currentPlayerCount();
return freeSlots <= 0 ? 2 : freeSlots < 5 ? 1 : 0;
}
private float currentTps() {
return (float) Bukkit.getTPS()[0];
}
private float currentMspt() {
return ServerMonitor.getServerMSPT();
}
private int currentPlayerCount() {
return Bukkit.getOnlinePlayers().size();
}
private int getMaxPlayerCount() {
return Main.instance().getAppliance(PlayerLimit.class).getLimit();
}
}
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention; package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory;
@@ -29,7 +29,7 @@ public class ChatMentionSetting extends MultiBoolSetting<ChatMentionSetting.Chat
@Override @Override
protected String description() { protected String description() {
return "Erwähnungen werden automatisch im Chat angewandt und der Empfänger erhält einen Signalton"; return "Wirst du im Chat erwähnt, wird dein Name hervorgehoben und du hörst einen Signalton";
} }
@Override @Override
@@ -0,0 +1,101 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Chat läuft über Velocity (Plugin chat-bridge): Der öffentliche Chat, /msg, /r, /adminchat, /mute und
* Join/Leave-Meldungen gelten im ganzen Netzwerk. Hier wird der Chat nur verworfen, Velocity bekommt
* die Rang-Prefixe für den Namen, und die Chat-Einstellungen werden mit Velocity abgeglichen.
* <p>
* Kanal {@value #channel}, Nachrichten (UTF-Typ, dann Daten):
* <ul>
* <li>→ Proxy {@code prefix}: Adventure-JSON oder leer</li>
* <li>→ Proxy {@code settings-hello} beim Join, {@code settings-set} nach einer Änderung: Join/Leave,
* Erwähnungen hervorheben, Erwähnungston (je boolean). Velocity speichert sie; kennt es schon
* Werte, antwortet es auf das Hello mit {@code settings}.</li>
* <li>← Proxy {@code settings}: gespeicherte Werte, werden hier übernommen</li>
* </ul>
*/
public class NetworkChat extends Appliance {
public static final String channel = "craftattack:chat";
private final Map<UUID, Component> prefixes = new ConcurrentHashMap<>();
@Override
public void onEnable() {
Bukkit.getMessenger().registerOutgoingPluginChannel(Main.instance(), channel);
Bukkit.getMessenger().registerIncomingPluginChannel(Main.instance(), channel, (channel, player, message) -> this.receive(player, message));
Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class);
Settings.instance().declareSetting(ChatMentionSetting.class);
Settings.instance().addChangeListener(ShowJoinAndLeaveMessagesSetting.class, player -> this.sendSettings(player, "settings-set"));
Settings.instance().addChangeListener(ChatMentionSetting.class, player -> this.sendSettings(player, "settings-set"));
}
public void setPrefix(Player player, @Nullable Component prefix) {
if(prefix == null) this.prefixes.remove(player.getUniqueId());
else this.prefixes.put(player.getUniqueId(), prefix);
this.sendPrefix(player);
}
void sendPrefix(Player player) {
Component prefix = this.prefixes.get(player.getUniqueId());
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF("prefix");
output.writeUTF(prefix == null ? "" : GsonComponentSerializer.gson().serialize(prefix));
this.send(player, output);
}
void sendSettings(Player player, String type) {
ChatMentionSetting.ChatMentionConfig mentions = Settings.instance()
.getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class);
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF(type);
output.writeBoolean(Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class));
output.writeBoolean(mentions.applyMentions());
output.writeBoolean(mentions.notifyOnMention());
this.send(player, output);
}
private void receive(Player player, byte[] message) {
ByteArrayDataInput input = ByteStreams.newDataInput(message);
if(!input.readUTF().equals("settings")) return;
boolean joinAndLeave = input.readBoolean();
boolean applyMentions = input.readBoolean();
boolean notifyOnMention = input.readBoolean();
Settings.instance().setSetting(player, Settings.Key.ShowJoinAndLeaveMessages, joinAndLeave);
Settings.instance().setSetting(player, Settings.Key.ChatMentions, new ChatMentionSetting.ChatMentionConfig(applyMentions, notifyOnMention));
}
private void send(Player player, ByteArrayDataOutput output) {
// Velocity meldet den Kanal erst kurz nach dem Join an, dann folgt PlayerRegisterChannelEvent
if(!player.getListeningPluginChannels().contains(channel)) return;
player.sendPluginMessage(Main.instance(), channel, output.toByteArray());
}
void forget(Player player) {
this.prefixes.remove(player.getUniqueId());
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NetworkChatListener());
}
}
@@ -0,0 +1,74 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.event.player.AsyncChatEvent;
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.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRegisterChannelEvent;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
class NetworkChatListener extends ApplianceListener<NetworkChat> {
// Vanilla-Befehle, die am Proxy vorbei in den lokalen Chat schreiben; /msg, /tell, /w fängt Velocity ab
private static final Set<String> localChatCommands = Set.of(
"me", "minecraft:me", "teammsg", "tm", "minecraft:teammsg", "minecraft:tm",
"minecraft:msg", "minecraft:tell", "minecraft:w"
);
@EventHandler(priority = EventPriority.HIGHEST)
public void onChat(AsyncChatEvent event) {
// Velocity hat die Nachricht schon an alle verteilt
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onCommand(PlayerCommandPreprocessEvent event) {
String label = event.getMessage().substring(1).split(" ", 2)[0].toLowerCase(Locale.ROOT);
if(!localChatCommands.contains(label)) return;
event.setCancelled(true);
event.getPlayer().sendMessage(Component.text("Dieser Befehl ist hier nicht verfügbar.", NamedTextColor.RED));
}
@EventHandler
public void onRegisterChannel(PlayerRegisterChannelEvent event) {
if(!event.getChannel().equals(NetworkChat.channel)) return;
this.getAppliance().sendPrefix(event.getPlayer());
this.getAppliance().sendSettings(event.getPlayer(), "settings-hello");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onJoin(PlayerJoinEvent event) {
// Join- und Leave-Meldungen schreibt Velocity, beim Serverwechsel gar keine
event.joinMessage(null);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onQuit(PlayerQuitEvent event) {
event.quitMessage(null);
this.getAppliance().forget(event.getPlayer());
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.deathMessage(
Component
.text("☠ ")
.append(
Optional
.ofNullable(event.deathMessage())
.orElse(Component.text(event.getPlayer().getName()))
)
.color(TextColor.color(Color.SILVER.asRGB()))
);
}
}
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages; package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory;
@@ -1,194 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateMessageCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateReplyCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentBuilder;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.stream.Collectors;
public class PrivateMessage extends Appliance {
public final int targetChangeTimeoutSeconds = 30;
public final int conversationTimeoutMinutes = 30;
private record Conversation(UUID target, Long lastSet) {
}
private final Map<Player, List<Conversation>> replyMapping = new WeakHashMap<>();
public void reply(Player sender, String message) {
this.replyMapping.computeIfAbsent(sender, player -> new ArrayList<>());
List<Conversation> replyList = this.replyMapping.get(sender);
List<Conversation> tooOldConversations = replyList.stream()
.filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.conversationTimeoutMinutes * 60 * 1000))
.toList();
replyList.removeAll(tooOldConversations);
if(replyList.isEmpty()) throw new ApplianceCommand.Error("Du führst aktuell keine Konversation.");
Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE);
Conversation youngestEntry = replyList.stream()
.max(Comparator.comparingLong(o -> o.lastSet))
.orElse(replyList.getLast());
if(message.isBlank()) {
Player currentTargetPlayer = Bukkit.getPlayer(youngestEntry.target());
Component currentTargetComponent = currentTargetPlayer != null
? Main.instance().getAppliance(ChatMessages.class).getReportablePlayerName(currentTargetPlayer)
: Component.text("niemandem.");
sender.sendMessage(
privatePrefix
.append(Component.text("Du schreibst aktuell mit ", NamedTextColor.GRAY))
.append(currentTargetComponent)
);
return;
}
List<Conversation> oldConversations = replyList.stream()
.filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.targetChangeTimeoutSeconds * 1000))
.toList();
if(oldConversations.contains(youngestEntry) || replyList.size() == 1) {
Player target = Bukkit.getPlayer(youngestEntry.target());
if(target == null)
throw new ApplianceCommand.Error("Der Spieler " + Bukkit.getOfflinePlayer(youngestEntry.target()).getName() + " ist nicht mehr verfügbar.");
replyList.clear();
this.sendWhisper(sender, new ResolvedPmUserArguments(target, message));
return;
}
ComponentBuilder<TextComponent, TextComponent.Builder> component = Component.text();
component.append(
Component.newline()
.append(privatePrefix)
.append(Component.text("Das Ziel für /r hat sich bei dir in den letzten ", NamedTextColor.RED))
.append(Component.text(String.valueOf(this.targetChangeTimeoutSeconds), NamedTextColor.RED))
.append(Component.text(" Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED))
.appendNewline()
.appendNewline()
);
if(!oldConversations.isEmpty()) {
Conversation youngestOldConversation = oldConversations.stream()
.max(Comparator.comparingLong(o -> o.lastSet))
.orElse(oldConversations.getLast());
replyList.removeAll(oldConversations);
replyList.add(youngestOldConversation);
}
List<String> playerNames = replyList.stream()
.map(conversation -> Bukkit.getOfflinePlayer(conversation.target()).getName())
.distinct()
.toList();
playerNames.forEach(playerName -> component.append(
Component.text("[")
.append(Component.text(playerName, NamedTextColor.GOLD))
.append(Component.text("]"))
.clickEvent(ClickEvent.runCommand(String.format("/msg %s %s", playerName, message)))
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.").color(NamedTextColor.GOLD))))
.append(Component.text(" "))
);
component.appendNewline();
sender.sendMessage(component.build());
}
public void sendWhisper(Player sender, ResolvedPmUserArguments userArguments) {
Conversation newReceiverConversation = new Conversation(
sender.getUniqueId(),
System.currentTimeMillis()
);
if(this.replyMapping.get(userArguments.receiver) != null) {
List<Conversation> oldEntries = this.replyMapping.get(userArguments.receiver).stream()
.filter(conversation -> conversation.target() == sender.getUniqueId())
.toList();
this.replyMapping.get(userArguments.receiver).removeAll(oldEntries);
} else {
this.replyMapping.put(
userArguments.receiver,
new ArrayList<>()
);
}
this.replyMapping.get(userArguments.receiver).add(newReceiverConversation);
List<Conversation> senderConversationList = new ArrayList<>();
senderConversationList.add(
new Conversation(
userArguments.receiver.getUniqueId(),
System.currentTimeMillis()
)
);
this.replyMapping.put(
sender,
senderConversationList
);
ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class);
Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE);
sender.sendMessage(
Component.text()
.append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", userArguments.receiver.getName()))))
.append(sender.displayName())
.append(Component.text(" zu ", NamedTextColor.GRAY))
.append(chatMessages.getReportablePlayerName(userArguments.receiver))
.append(Component.text(" > ", NamedTextColor.GRAY))
.append(Component.text(userArguments.message))
);
userArguments.receiver.sendMessage(
Component.text()
.append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", sender.getName()))))
.append(chatMessages.getReportablePlayerName(sender))
.append(Component.text(" zu ", NamedTextColor.GRAY))
.append(userArguments.receiver.displayName())
.append(Component.text(" > ", NamedTextColor.GRAY))
.append(Component.text(userArguments.message))
);
}
public record ResolvedPmUserArguments(Player receiver, String message) {
}
public ResolvedPmUserArguments resolveImplicit(String[] args) {
if(args.length < 2)
throw new ApplianceCommand.Error("Es muss ein Spieler sowie eine Nachricht angegeben werden.");
List<String> arguments = List.of(args);
Player targetPlayer = Bukkit.getPlayer(arguments.getFirst());
if(targetPlayer == null)
throw new ApplianceCommand.Error(String.format("Der Spieler %s konnte nicht gefunden werden.", arguments.getFirst()));
String message = arguments.stream().skip(1).collect(Collectors.joining(" "));
return new ResolvedPmUserArguments(targetPlayer, message);
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(
new PrivateMessageCommand(),
new PrivateReplyCommand()
);
}
}
@@ -1,18 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
public class PrivateMessageCommand extends ApplianceCommand.PlayerChecked<PrivateMessage> {
public PrivateMessageCommand() {
super("msg");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
this.getAppliance().sendWhisper(this.getPlayer(), this.getAppliance().resolveImplicit(args));
}
}
@@ -1,18 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
public class PrivateReplyCommand extends ApplianceCommand.PlayerChecked<PrivateMessage> {
public PrivateReplyCommand() {
super("r");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
this.getAppliance().reply(this.getPlayer(), String.join(" ", args));
}
}
@@ -112,6 +112,18 @@ public class Settings extends Appliance {
return clazz.cast(setting.state()); return clazz.cast(setting.state());
} }
/** Setzt einen Wert von außen (z. B. vom Proxy), ohne die Change-Listener auszulösen. */
public <T> void setSetting(Player player, Key key, T value) {
Setting<?> setting = this.getSettings(player).stream()
.filter(s -> Objects.equals(s.getKey(), key))
.findFirst()
.orElseThrow();
if(!setting.dataType().isInstance(value))
throw new IllegalStateException("Tried to set Setting with Datatype " + value.getClass().getSimpleName() + " but expected " + setting.dataType().getSimpleName());
setting.restore(player, value);
}
public void openSettings(Player player) { public void openSettings(Player player) {
List<Setting<?>> settings = this.getSettings(player); List<Setting<?>> settings = this.getSettings(player);
Inventory inventory = Bukkit.createInventory(null, this.calculateInvSize(settings), Component.text("Einstellungen")); Inventory inventory = Bukkit.createInventory(null, this.calculateInvSize(settings), Component.text("Einstellungen"));
@@ -62,6 +62,12 @@ public abstract class Setting<TDataType> {
Settings.instance().invokeChangeListener(p, this.getClass()); Settings.instance().invokeChangeListener(p, this.getClass());
} }
@SuppressWarnings("unchecked")
public void restore(Player p, Object value) {
this.state = (TDataType) value;
this.toStorage(p.getPersistentDataContainer(), this.state);
}
public ItemStack buildItem() { public ItemStack buildItem() {
ItemStack stack = new ItemStack(this.icon(), 1); ItemStack stack = new ItemStack(this.icon(), 1);
stack.setItemMeta(this.buildMeta(stack.getItemMeta())); stack.setItemMeta(this.buildMeta(stack.getItemMeta()));
@@ -1,37 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class AdminChat extends Appliance {
public void sendMessage(Player sender, String message) {
Component privatePrefix = Component
.text("[Admin] ", NamedTextColor.LIGHT_PURPLE)
.clickEvent(ClickEvent.suggestCommand(AdminChatCommand.commandName));
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission("admin"))
.forEach(player -> {
Component formattedMessage = Component.text()
.append(privatePrefix)
.append(sender.displayName())
.append(Component.text(" > ", NamedTextColor.DARK_GRAY))
.append(Component.text(message))
.build();
player.sendMessage(formattedMessage);
});
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new AdminChatCommand());
}
}
@@ -1,22 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class AdminChatCommand extends ApplianceCommand.PlayerChecked<AdminChat> {
public static final String commandName = "adminchat";
public AdminChatCommand() {
super(commandName);
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(!sender.hasPermission("admin")) return;
String message = String.join(" ", args);
this.getAppliance().sendMessage(this.getPlayer(), message);
}
}
@@ -1,44 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public class ChatMute extends Appliance {
private static final String namespace = ChatMute.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey mutedUntilKey = new NamespacedKey(namespace, "mutedUntilMillis".toLowerCase());
public void mutePlayer(Player player, int durationHours) {
PersistentDataContainer container = player.getPersistentDataContainer();
long mutedUntil = System.currentTimeMillis() + (long) durationHours * 60 * 60 * 1000;
container.set(ChatMute.mutedUntilKey, PersistentDataType.LONG, mutedUntil);
}
public @Nullable Long muteStatus(Player player) {
PersistentDataContainer container = player.getPersistentDataContainer();
if(!container.has(mutedUntilKey)) return null;
long mutedUntil = Objects.requireNonNull(container.get(mutedUntilKey, PersistentDataType.LONG));
if(mutedUntil < System.currentTimeMillis()) return null;
return mutedUntil;
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new MuteCommand());
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new ChatMuteListener());
}
}
@@ -1,25 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.util.text.DataSizeConverter;
import io.papermc.paper.event.player.AsyncChatEvent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.event.EventHandler;
import org.jetbrains.annotations.Nullable;
class ChatMuteListener extends ApplianceListener<ChatMute> {
@EventHandler
public void onChat(AsyncChatEvent event) {
@Nullable Long muteDuration = this.getAppliance().muteStatus(event.getPlayer());
if(muteDuration == null) return;
event.setCancelled(true);
event.getPlayer().sendMessage(Component.text(
String.format(
"Du bist für %s gestummt!",
DataSizeConverter.formatSecondsToHumanReadable((int) ((muteDuration - System.currentTimeMillis()) / 1000))
),
NamedTextColor.RED
));
}
}
@@ -1,35 +0,0 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
class MuteCommand extends ApplianceCommand<ChatMute> {
public MuteCommand() {
super("mute");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length < 2) throw new Error("Syntax: mute <player> <duration in hours>");
Player player = Objects.requireNonNull(Bukkit.getPlayer(args[0]));
int durationInHours = Integer.parseInt(args[1]);
this.getAppliance().mutePlayer(player, durationInHours);
sender.sendMessage(String.format("%s wurde für %d Stunden gestummt!", player.getName(), durationInHours));
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length == 2) {
return List.of("1", "2", "4", "8", "24", "48");
}
return null;
}
}
@@ -8,6 +8,7 @@ import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.EntityType; import org.bukkit.entity.EntityType;
import org.bukkit.entity.Villager; import org.bukkit.entity.Villager;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -19,11 +20,14 @@ public class DisplayVillager {
public DisplayVillager(UUID uuid, Location location, Consumer<Villager> villagerCreator) { public DisplayVillager(UUID uuid, Location location, Consumer<Villager> villagerCreator) {
this.location = location; this.location = location;
try { // getEntities() waits for the chunk's entities; getEntity(uuid) misses them while they are still loading
ChunkUtils.loadChunkAtLocation(this.location); this.villager = Arrays.stream(ChunkUtils.loadChunkAtLocation(this.location).getEntities())
this.villager = (Villager) this.location.getWorld().getEntity(uuid); .filter(entity -> entity.getUniqueId().equals(uuid))
Objects.requireNonNull(this.villager); .filter(Villager.class::isInstance)
} catch(NullPointerException | IllegalArgumentException e) { .map(Villager.class::cast)
.findFirst()
.orElse(null);
if(this.villager == null) {
this.villager = this.getBaseVillager(); this.villager = this.getBaseVillager();
villagerCreator.accept(this.villager); villagerCreator.accept(this.villager);
} }
-3
View File
@@ -110,9 +110,6 @@ tablist:
projectTitle: "Title" projectTitle: "Title"
interface: eth0 interface: eth0
outlawed:
voluntarily: []
packselect: packselect:
packs: packs:
- somepack: - somepack:
@@ -1,7 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
class OutlawChangeNotPermitted extends Exception {
public OutlawChangeNotPermitted(String message) {
super(message);
}
}
@@ -1,165 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName.DisplayName;
import eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.whitelist.Whitelist;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.util.text.DisconnectInfo;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.*;
public class Outlawed extends Appliance implements DisplayName.Prefixed {
public final int timeoutInMs = 1000 * 60 * 60 * 6;
private final Map<UUID, Long> timeouts = new HashMap<>();
public enum Status {
DISABLED,
VOLUNTARILY,
FORCED
}
private final Map<Player, Status> playerStatusMap = new WeakHashMap<>();
private final String voluntarilyEntry = "voluntarily";
public Outlawed() {
super("outlawed");
Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(),
() -> this.playerStatusMap.forEach((player, status) -> {
if(!player.isOnline()) return;
if(status != Status.FORCED) return;
try {
this.queryAppliance(Whitelist.class).fullIntegrityCheck(player);
} catch(DisconnectInfo.Throwable e) {
e.getDisconnectScreen().applyKick(player);
}
}),
20 * 60,
20 * 60 * 5
);
}
void askForConfirmation(Player player) {
Component confirmationMessage = switch(this.getLawStatus(player)) {
case DISABLED -> Component.text("Wenn du den PVP-Modus aktivierst, darfst du von allen anderen PVP-Spielern grundlos angegriffen werden." +
" Du kannst den PVP-Modus erst wieder nach " + this.timeoutInMs / 1000 / 60 / 60 + " Stunden deaktivieren!");
case VOLUNTARILY -> Component.text("Wenn du den PVP-Modus deaktivierst, darfst du nicht mehr grundlos von anderen Spielern angegriffen werden.");
case FORCED -> Component.text("Du darfst zurzeit deinen PVP-Modus nicht ändern, da die˝ser als Strafe auferlegt wurde!");
};
String command = String.format("/%s confirm", OutlawedCommand.commandName);
Component changeText = Component.text(
String.format(
"Zum ändern deines PVP-Status klicke auf diese Nachricht oder tippe '%s'",
command
),
NamedTextColor.GOLD
).clickEvent(ClickEvent.suggestCommand(command));
player.sendMessage(
Component.text()
.append(confirmationMessage.color(NamedTextColor.RED))
.appendNewline()
.append(changeText)
);
}
void switchLawStatus(Player player) throws OutlawChangeNotPermitted {
if(this.getLawStatus(player).equals(Status.FORCED)) {
throw new OutlawChangeNotPermitted("Dein PVP-Status wurde als Strafe auferlegt und kann daher nicht verändert werden.");
}
if(this.isTimeout(player)) {
throw new OutlawChangeNotPermitted("Du kannst deinen PVP-Status nicht so schnell wechseln. Bitte warte einige Stunden bevor du umschaltest!");
}
this.setLawStatus(player, this.isOutlawed(player) ? Status.DISABLED : Status.VOLUNTARILY);
this.setTimeout(player);
}
public void updateForcedStatus(Player player, boolean forced) {
this.setLawStatus(player, forced ? Status.FORCED : this.getLawStatus(player) == Status.FORCED ? Status.DISABLED : this.getLawStatus(player));
}
public Status getLawStatus(Player player) {
return this.playerStatusMap.computeIfAbsent(player, p -> {
if(this.localConfig().getStringList(this.voluntarilyEntry).contains(p.getUniqueId().toString()))
return Status.VOLUNTARILY;
return Status.DISABLED;
});
}
private void setLawStatus(Player player, Status status) {
this.playerStatusMap.put(player, status);
this.queryAppliance(DisplayName.class).update(player);
List<String> newList = this.localConfig().getStringList(this.voluntarilyEntry);
if(status.equals(Status.VOLUNTARILY)) {
newList.add(player.getUniqueId().toString());
} else {
newList.remove(player.getUniqueId().toString());
}
this.localConfig().set(this.voluntarilyEntry, newList.stream().distinct().toList());
Configuration.saveChanges();
}
public boolean isOutlawed(Player player) {
return this.getLawStatus(player) != Status.DISABLED;
}
private boolean isTimeout(Player player) {
return this.timeouts.getOrDefault(player.getUniqueId(), 0L) > System.currentTimeMillis() - this.timeoutInMs;
}
private void setTimeout(Player player) {
this.timeouts.put(player.getUniqueId(), System.currentTimeMillis());
}
public Component getStatusDescription(Status status) {
return switch(status) {
case DISABLED -> Component.text("PVP-Modus inaktiv: ", NamedTextColor.GREEN)
.append(Component.text("Es gelten die normalen Regeln!", NamedTextColor.GOLD));
case VOLUNTARILY, FORCED -> Component.text("PVP-Modus aktiv: ", NamedTextColor.RED)
.append(Component.text(
"Du darfst von allen anderen PVP-Spielern angegriffen und getötet werden!\n" +
"Wenn du getötet wirst, müssen andere Spieler deine Items *nicht* zurückerstatten!",
NamedTextColor.GOLD
));
};
}
@Override
public Component getNamePrefix(Player player) {
if(this.isOutlawed(player)) {
return Component.text("[☠]", NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(Component.text(
"PVP-Modus Spieler dürfen von anderen vogelfreien Spielern ohne Grund angegriffen werden!"
)));
}
return null;
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new OutlawedCommand());
}
@Override
@NotNull
protected List<Listener> listeners() {
return List.of(new OutlawedReminderListener());
}
}
@@ -1,30 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
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;
class OutlawedCommand extends ApplianceCommand.PlayerChecked<Outlawed> {
public static final String commandName = "pvp";
public OutlawedCommand() {
super(commandName);
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length == 1 && args[0].equals("confirm")) {
try {
this.getAppliance().switchLawStatus(this.getPlayer());
sender.sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(this.getPlayer())));
} catch(OutlawChangeNotPermitted e) {
sender.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED));
}
} else {
this.getAppliance().askForConfirmation(this.getPlayer());
}
}
}
@@ -1,14 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
class OutlawedReminderListener extends ApplianceListener<Outlawed> {
@EventHandler
public void onJoin(PlayerJoinEvent e) {
if(this.getAppliance().isOutlawed(e.getPlayer())) {
e.getPlayer().sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(e.getPlayer())));
}
}
}
@@ -14,7 +14,7 @@ import org.jetbrains.annotations.NotNull;
import java.util.List; import java.util.List;
public class QuarzRecipe extends Appliance { public class QuarzRecipe extends Appliance {
private NamespacedKey recipeKey; protected NamespacedKey recipeKey;
@Override @Override
public void onEnable() { public void onEnable() {
this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz"); this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz");
@@ -26,11 +26,6 @@ public class QuarzRecipe extends Appliance {
Bukkit.getOnlinePlayers().forEach(this::unlock); Bukkit.getOnlinePlayers().forEach(this::unlock);
} }
@Override
public void onDisable() {
Bukkit.removeRecipe(this.recipeKey);
}
public void unlock(Player player) { public void unlock(Player player) {
player.discoverRecipe(this.recipeKey); player.discoverRecipe(this.recipeKey);
} }
@@ -1,12 +1,19 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe; package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.inventory.ItemStack;
class QuarzRecipeListener extends ApplianceListener<QuarzRecipe> { class QuarzRecipeListener extends ApplianceListener<QuarzRecipe> {
@EventHandler @EventHandler
public void onPlayerJoin(PlayerJoinEvent event) { public void shouldDiscover(PlayerInventorySlotChangeEvent event) {
this.getAppliance().unlock(event.getPlayer()); ItemStack clickedItem = event.getNewItemStack();
Player player = event.getPlayer();
if(clickedItem.getType() != Material.QUARTZ_BLOCK) return;
if(player.hasDiscoveredRecipe(this.getAppliance().recipeKey)) return;
this.getAppliance().unlock(player);
} }
} }
@@ -20,7 +20,6 @@ import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -75,8 +74,7 @@ public class ArenaPortal extends Appliance {
if(!this.pendingTransfers.add(player.getUniqueId())) return; if(!this.pendingTransfers.add(player.getUniqueId())) return;
player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY)); player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY));
List<ItemStack> contents = new ArrayList<>(List.of(player.getInventory().getContents())); String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(player.getInventory().getContents()));
String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(contents));
ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items); ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items);
Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> { Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> {