diff --git a/build.gradle.kts b/build.gradle.kts index f68b1d6..e3f3e77 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ +import java.util.Properties + plugins { id("java") } group = "com.alttd.ctf" -version = "1.0-SNAPSHOT" dependencies { compileOnly("com.alttd:Galaxy-API:1.21-R0.1-SNAPSHOT") { @@ -38,4 +39,41 @@ tasks.test { tasks.jar { archiveFileName.set("CaptureTheFlag.jar") +} + +val versionPropsFile = file("version.properties") +val versionProps = Properties().apply { + if (versionPropsFile.exists()) { + load(versionPropsFile.inputStream()) + } else { + throw GradleException("version.properties file not found!") + } +} + +val majorVersion: String = versionProps["version"] as String +var buildNumber: Int = (versionProps["buildNumber"] as String).toInt() + +version = "$majorVersion.$buildNumber" + +val incrementBuildNumber = tasks.register("incrementBuildNumber") { + doLast { + buildNumber++ + versionProps["buildNumber"] = buildNumber.toString() + versionProps.store(versionPropsFile.outputStream(), null) + println("Build number incremented to $buildNumber") + } +} + +tasks.named("build") { + dependsOn(incrementBuildNumber) +} + +tasks.withType { + manifest { + attributes( + "Implementation-Title" to project.name, + "Implementation-Version" to version, + "Build-Number" to buildNumber.toString() + ) + } } \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/Main.java b/src/main/java/com/alttd/ctf/Main.java index 3777ffc..821c51e 100644 --- a/src/main/java/com/alttd/ctf/Main.java +++ b/src/main/java/com/alttd/ctf/Main.java @@ -4,12 +4,19 @@ import com.alttd.ctf.commands.CommandManager; import com.alttd.ctf.config.Config; import com.alttd.ctf.config.GameConfig; import com.alttd.ctf.config.Messages; +import com.alttd.ctf.events.OnPlayerDeath; +import com.alttd.ctf.events.OnPlayerJoin; import com.alttd.ctf.events.OnSnowballHit; +import com.alttd.ctf.flag.Flag; +import com.alttd.ctf.flag.FlagTryCaptureEvent; import com.alttd.ctf.game.GameManager; import com.alttd.ctf.json_config.JacksonConfig; import com.alttd.ctf.json_config.JsonConfigManager; import com.alttd.ctf.team.Team; import lombok.extern.slf4j.Slf4j; +import org.bukkit.Bukkit; +import org.bukkit.GameRule; +import org.bukkit.World; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; @@ -22,15 +29,28 @@ import java.util.stream.Collectors; public class Main extends JavaPlugin { private GameManager gameManager = null; + private Flag flag; @Override public void onEnable() { - log.info("Plugin enabled!"); + Package pkg = Main.class.getPackage(); + String version = pkg.getImplementationVersion(); + log.info("Plugin enabled, version {}", version); + reloadConfigs(); this.gameManager = new GameManager(); registerTeams(); //Skipped in reloadConfig if gameManager is not created yet - CommandManager commandManager = new CommandManager(this, gameManager); - registerEvents(); + flag = new Flag(this, gameManager); + CommandManager commandManager = new CommandManager(this, gameManager, flag); + //Ensuring immediate respawn is on in all worlds + log.info("Enabling immediate respawn for {}.", Config.FLAG.world); + World world = Bukkit.getWorld(Config.FLAG.world); + if (world != null) { + world.setGameRule(GameRule.DO_IMMEDIATE_RESPAWN, true); + } else { + log.error("No valid flag world defined, unable to modify game rules"); + } + registerEvents(flag); } public void reloadConfigs() { @@ -42,9 +62,13 @@ public class Main extends JavaPlugin { } } - private void registerEvents() { + private void registerEvents(Flag flag) { PluginManager pluginManager = getServer().getPluginManager(); + //TODO add event for player joining and clear their inv pluginManager.registerEvents(new OnSnowballHit(gameManager), this); + pluginManager.registerEvents(new FlagTryCaptureEvent(flag), this); + pluginManager.registerEvents(new OnPlayerDeath(gameManager), this); + pluginManager.registerEvents(new OnPlayerJoin(gameManager, flag), this); } private void registerTeams() { diff --git a/src/main/java/com/alttd/ctf/commands/CommandManager.java b/src/main/java/com/alttd/ctf/commands/CommandManager.java index 4877529..2bbd093 100644 --- a/src/main/java/com/alttd/ctf/commands/CommandManager.java +++ b/src/main/java/com/alttd/ctf/commands/CommandManager.java @@ -6,6 +6,7 @@ import com.alttd.ctf.commands.subcommands.CreateTeam; import com.alttd.ctf.commands.subcommands.Reload; import com.alttd.ctf.commands.subcommands.Start; import com.alttd.ctf.config.Messages; +import com.alttd.ctf.flag.Flag; import com.alttd.ctf.game.GameManager; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -24,7 +25,7 @@ import java.util.stream.Collectors; public class CommandManager implements CommandExecutor, TabExecutor { private final List subCommands; - public CommandManager(Main main, GameManager gameManager) { + public CommandManager(Main main, GameManager gameManager, Flag flag) { PluginCommand command = main.getCommand("ctf"); if (command == null) { subCommands = null; @@ -36,7 +37,7 @@ public class CommandManager implements CommandExecutor, TabExecutor { subCommands = Arrays.asList( new ChangeTeam(gameManager), - new Start(gameManager), + new Start(gameManager, flag), new CreateTeam(main, gameManager), new Reload(main) ); diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java b/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java index 3329b32..e0f93a3 100644 --- a/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java +++ b/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java @@ -49,7 +49,7 @@ public class ChangeTeam extends SubCommand { commandSender.sendRichMessage(String.format("Please enter a valid integer, %s is not a valid integer", args[2])); return 2; } - Optional optionalTeam = gameManager.getTeams().stream().filter(team -> team.getId() == teamId).findFirst(); + Optional optionalTeam = gameManager.getTeam(teamId); if (optionalTeam.isEmpty()) { commandSender.sendRichMessage(String.format("Please provide a valid team id %d is not a valid team id", teamId)); return 3; diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java b/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java index f34534f..0a50ee7 100644 --- a/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java +++ b/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java @@ -73,10 +73,7 @@ public class CreateTeam extends SubCommand { Color decodedColor = Color.decode(color); TeamColor teamColor = new TeamColor(decodedColor.getRed(), decodedColor.getGreen(), decodedColor.getBlue(), color); - int highestId = gameManager.getTeams().stream() - .mapToInt(Team::getId) - .max() - .orElse(0); + int highestId = gameManager.getMaxTeamId(); Team team = new Team(MiniMessage.miniMessage().deserialize(String.format("%s", color, name)), highestId + 1, player.getLocation(), player.getLocation(), teamColor); diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/Start.java b/src/main/java/com/alttd/ctf/commands/subcommands/Start.java index b475ac9..692e3c1 100644 --- a/src/main/java/com/alttd/ctf/commands/subcommands/Start.java +++ b/src/main/java/com/alttd/ctf/commands/subcommands/Start.java @@ -2,6 +2,7 @@ package com.alttd.ctf.commands.subcommands; import com.alttd.ctf.commands.SubCommand; import com.alttd.ctf.config.Messages; +import com.alttd.ctf.flag.Flag; import com.alttd.ctf.game.GameManager; import org.bukkit.command.CommandSender; @@ -11,9 +12,11 @@ import java.util.List; public class Start extends SubCommand { private final GameManager gameManager; + private final Flag flag; - public Start(GameManager gameManager) { + public Start(GameManager gameManager, Flag flag) { this.gameManager = gameManager; + this.flag = flag; } @FunctionalInterface @@ -24,7 +27,7 @@ public class Start extends SubCommand { @Override public int onCommand(CommandSender commandSender, String[] args) { return handle(commandSender, args, combatTime -> { - gameManager.start(combatTime); + gameManager.start(combatTime, flag); return 0; }); } @@ -35,7 +38,7 @@ public class Start extends SubCommand { } Duration combatTime; try { - combatTime = Duration.ofSeconds(Integer.parseInt(args[1])); + combatTime = Duration.ofMinutes(Integer.parseInt(args[1])); } catch (NumberFormatException e) { commandSender.sendRichMessage("Please enter a valid integer"); return 1; diff --git a/src/main/java/com/alttd/ctf/config/Config.java b/src/main/java/com/alttd/ctf/config/Config.java index 7958991..9f6220a 100644 --- a/src/main/java/com/alttd/ctf/config/Config.java +++ b/src/main/java/com/alttd/ctf/config/Config.java @@ -41,4 +41,21 @@ public class Config extends AbstractConfig{ } } + public static class FLAG { + private static final String prefix = "flag."; + + public static String world = "world"; + public static double x = 0; + public static double y = 0; + public static double z = 0; + + @SuppressWarnings("unused") + private static void load() { + world = config.getString(prefix, "world", world); + x = config.getDouble(prefix, "x", x); + y = config.getDouble(prefix, "y", y); + z = config.getDouble(prefix, "z", z); + } + } + } diff --git a/src/main/java/com/alttd/ctf/events/OnPlayerDeath.java b/src/main/java/com/alttd/ctf/events/OnPlayerDeath.java new file mode 100644 index 0000000..1a974cc --- /dev/null +++ b/src/main/java/com/alttd/ctf/events/OnPlayerDeath.java @@ -0,0 +1,29 @@ +package com.alttd.ctf.events; + +import com.alttd.ctf.game.GameManager; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; + +public class OnPlayerDeath implements Listener { + + private final GameManager gameManager; + + public OnPlayerDeath(GameManager gameManager) { + this.gameManager = gameManager; + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + if (gameManager.getGamePhase().isEmpty()) { + return; + } + event.deathMessage(null); + event.setShouldDropExperience(false); + Player player = event.getPlayer(); + player.getInventory().clear(); + player.updateInventory(); + } + +} diff --git a/src/main/java/com/alttd/ctf/events/OnPlayerJoin.java b/src/main/java/com/alttd/ctf/events/OnPlayerJoin.java new file mode 100644 index 0000000..795d1e6 --- /dev/null +++ b/src/main/java/com/alttd/ctf/events/OnPlayerJoin.java @@ -0,0 +1,41 @@ +package com.alttd.ctf.events; + +import com.alttd.ctf.flag.Flag; +import com.alttd.ctf.game.GameManager; +import lombok.extern.slf4j.Slf4j; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +@Slf4j +public class OnPlayerJoin implements Listener { + + private final GameManager gameManager; + private final Flag flag; + + public OnPlayerJoin(GameManager gameManager, Flag flag) {//TODO remove player from team when they leave the game + this.gameManager = gameManager; + this.flag = flag; + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + AttributeInstance maxHealthAttribute = player.getAttribute(Attribute.GENERIC_MAX_HEALTH); + if (maxHealthAttribute == null) { + log.error("Player does not have max health attribute"); + return; + } + maxHealthAttribute.setBaseValue(20); + player.setHealth(20); + flag.addPlayer(player); + if (gameManager.getGamePhase().isEmpty()) { + return; + } + //TODO other stuff based on game state (like adding them to a team etc) + } + +} diff --git a/src/main/java/com/alttd/ctf/flag/Flag.java b/src/main/java/com/alttd/ctf/flag/Flag.java new file mode 100644 index 0000000..4b27740 --- /dev/null +++ b/src/main/java/com/alttd/ctf/flag/Flag.java @@ -0,0 +1,279 @@ +package com.alttd.ctf.flag; + +import com.alttd.ctf.Main; +import com.alttd.ctf.config.Config; +import com.alttd.ctf.game.GameManager; +import com.alttd.ctf.team.Team; +import com.alttd.ctf.team.TeamPlayer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.*; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.boss.KeyedBossBar; +import org.bukkit.entity.Player; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitScheduler; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +public class Flag implements Runnable { + + private final HashMap teamFlagPointCount = new HashMap<>(); + private final ItemStack flagItem = new ItemStack(Material.CYAN_BANNER); + private final BossBar bossBar = createBossBar(); + private final HashMap wins = new HashMap<>(); + private int lastWinningTeamId = -1; + private Location flagLocation; + private Team winningTeam; + private Player flagCarrier; //TODO check for player disconnects? + + private final Main main; + private final GameManager gameManager; + + private BossBar createBossBar() { + NamespacedKey namespacedKey = NamespacedKey.fromString("ctf_flag", main); + if (namespacedKey == null) { + throw new IllegalStateException("No NamespaceKey could be created for the bossbar"); + } + if (Bukkit.getBossBar(namespacedKey) != null) { + Bukkit.removeBossBar(namespacedKey); + } + KeyedBossBar captureProgress = Bukkit.createBossBar(namespacedKey, "Capture progress", BarColor.GREEN, BarStyle.SEGMENTED_20); + captureProgress.removeAll(); + captureProgress.setProgress(0); + captureProgress.setVisible(false); + return captureProgress; + } + + protected Location getFlagLocation() { + return flagLocation; + } + + public void addPlayer(Player player) { + bossBar.addPlayer(player); + } + + public synchronized void capture(TeamPlayer teamPlayer, Player player) { + if (flagCarrier != null) { + return; + } + //TODO knockback enemies from flag location to create space for person who captured mayb short speed boost and heal? + //TODO add de-buffs and enable buffs for others? + player.getInventory().setItem(EquipmentSlot.HEAD, flagItem); + Bukkit.getScheduler().runTask(main, () -> flagLocation.getBlock().setType(Material.AIR)); + Bukkit.broadcast(MiniMessage.miniMessage().deserialize(" from captured the flag!", TagResolver.resolver( + Placeholder.component("player", player.displayName()), + Placeholder.component("team", teamPlayer.getTeam().getName()) + ))); + flagCarrier = player; + } + + public void spawnFlag() { + //TODO disable flag capture for a minute or so, and maybe have it slowly drop down? + World world = Bukkit.getWorld(Config.FLAG.world); + if (world == null) { + throw new IllegalStateException(String.format("Tried to spawn flag in world [%s] that doesn't exist", Config.FLAG.world)); + } + this.flagLocation = new Location(world, Config.FLAG.x, Config.FLAG.y, Config.FLAG.z); + //Place block on main thread + Bukkit.getScheduler().runTask(main, () -> flagLocation.getBlock().setType(flagItem.getType())); + } + + @Override + public void run() { + if (flagCarrier != null) { + checkFlagCarrier(); + return; + } + if (flagLocation == null) { + log.warn("Tried to run Flag without a flag location, spawn it first"); + return; + } + if (!updateScoreBasedOnNearbyPlayers().join()) { + return; //Score didn't change + } + if (teamFlagPointCount.isEmpty()) { + return; + } + Optional optionalTeam = winnerExists(); + if (optionalTeam.isEmpty()) { + updateDisplay(); + } else { + winningTeam = optionalTeam.get(); + bossBar.setVisible(false); + bossBar.setProgress(0); + //TODO stop capture and let ppl know they can now capture the flag + } + } + + private void checkFlagCarrier() { + if (flagCarrier.isDead()) { + flagCarrier = null; + spawnFlag(); + return; + } + double distance = winningTeam.getSpawnLocation().distance(flagCarrier.getLocation()); + if (distance > 5) { + //TODO spawn some particles or something so a trail is made for specific classes to follow? + return; + } + //TODO better message? mayb with a text thing on the screen? + Bukkit.broadcast(MiniMessage.miniMessage().deserialize(" captured the flag for !", + Placeholder.component("player", flagCarrier.displayName()), + Placeholder.component("team", winningTeam.getName()))); + spawnFlag(); + wins.merge(winningTeam.getId(), 1, Integer::sum); + winningTeam = null; + Optional optionalTeamPlayer = gameManager.getTeamPlayer(flagCarrier.getUniqueId()); + if (optionalTeamPlayer.isEmpty()) { + flagCarrier.getInventory().setItem(EquipmentSlot.HEAD, null); + } else { + TeamPlayer teamPlayer = optionalTeamPlayer.get(); + teamPlayer.getGameClass().setArmor(flagCarrier, teamPlayer); + flagCarrier.getInventory().setItem(EquipmentSlot.HEAD, null); + } + teamFlagPointCount.clear(); + flagCarrier = null; + } + + private Optional winnerExists() { + Optional> max = teamFlagPointCount.entrySet().stream() + .max(Map.Entry.comparingByValue()); + if (max.isEmpty()) { + return Optional.empty(); + } + if (max.get().getValue() < 100) { + return Optional.empty(); + } + return gameManager.getTeam(max.get().getKey()); + } + + /** + * Updates the score based on nearby players within a specified radius of the flag's location. + * This method utilizes asynchronous and synchronous tasks to handle operations efficiently. + * + * @return A CompletableFuture that resolves to a Boolean indicating whether the score was successfully updated. + */ + private CompletableFuture updateScoreBasedOnNearbyPlayers() { + CompletableFuture future = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(main, () -> { + Collection nearbyPlayers = flagLocation.getNearbyPlayers(10); + Bukkit.getScheduler().runTaskAsynchronously(main, () -> { + boolean result = updateScoreBasedOnNearbyPlayers(nearbyPlayers); + future.complete(result); + }); + }); + return future; + } + + /** + * Updates the score of teams based on the nearby players within a specified range. + * This method identifies nearby players around the current location within a 10-block radius, + * determines their respective teams, and calculates the team with the maximum number of players + * in proximity. If there is a tie for the maximum count, the method exits without updating scores. + * If a single team has the highest number of nearby players, scores are updated accordingly: + * - Increment the score for the team with the majority presence. + * - Decrement the scores for other teams. + * + * @return true if a single team has the largest number of nearby players and scores were updated, + * false if there is a tie or no players are nearby. + */ + private boolean updateScoreBasedOnNearbyPlayers(Collection players) { + List nearbyPlayers = players.stream() + .map(player -> gameManager.getTeamPlayer(player.getUniqueId())) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + if (nearbyPlayers.isEmpty()) { + teamFlagPointCount.forEach((teamId, count) -> teamFlagPointCount.put(teamId, Math.max(0, count - 1))); + return true; //No players in the area, decrease score + } + + Map teamCounts = nearbyPlayers.stream() + .collect(Collectors.groupingBy(TeamPlayer::getTeam, Collectors.counting())); + + Optional> maxEntry = teamCounts.entrySet() + .stream() + .max(Map.Entry.comparingByValue()); + + if (maxEntry.isEmpty()) { + return false; //No players in the area + } + Map.Entry teamLongEntry = maxEntry.get(); + long maxCount = teamLongEntry.getValue(); + + long maxCountTeams = teamCounts.values().stream().filter(count -> count == maxCount).count(); + + if (maxCountTeams != 1) { + return false; //There are multiple teams that have the most players in the area + } + Team winningTeam = teamLongEntry.getKey(); + + teamCounts.forEach((team, count) -> { + teamFlagPointCount.merge(team.getId(), team.equals(winningTeam) ? 1 : -1, (oldValue, delta) -> { + int updatedValue = oldValue + delta; + log.debug("Set count to {} for team {}", updatedValue, team.getId()); + return Math.max(updatedValue, 0); + }); + }); + return true; + } + + private void updateDisplay() { + Integer highestKey = teamFlagPointCount.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + if (highestKey == null) { + throw new IllegalStateException("Updating display without any teams existing in the score"); + } + Optional team = gameManager.getTeam(highestKey); + if (team.isEmpty()) { + throw new IllegalStateException(String.format("Team %s in point list doesnt exist", highestKey)); + } + if (lastWinningTeamId != highestKey) { + bossBar.setTitle(String.format("Team %s is capturing the flag", PlainTextComponentSerializer.plainText().serialize(team.get().getName()))); + lastWinningTeamId = highestKey; + } + bossBar.setProgress(Math.min(100, teamFlagPointCount.get(highestKey)) / 100.0); + bossBar.setVisible(teamFlagPointCount.get(highestKey) > 0); + } + + protected Optional getWinningTeam() { + return winningTeam == null ? Optional.empty() : Optional.of(winningTeam); + } + + public HashMap getWins() { + HashMap winsByTeam = new HashMap<>(); + wins.keySet().stream() + .map(gameManager::getTeam) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach(team -> winsByTeam.put(team, wins.get(team.getId()))); + return winsByTeam; + } + + public void reset() { + bossBar.setVisible(false); + teamFlagPointCount.clear(); + bossBar.setProgress(0); + winningTeam = null; + flagCarrier = null; + wins.clear(); + lastWinningTeamId = -1; + } +} diff --git a/src/main/java/com/alttd/ctf/flag/FlagTryCaptureEvent.java b/src/main/java/com/alttd/ctf/flag/FlagTryCaptureEvent.java new file mode 100644 index 0000000..f89da71 --- /dev/null +++ b/src/main/java/com/alttd/ctf/flag/FlagTryCaptureEvent.java @@ -0,0 +1,47 @@ +package com.alttd.ctf.flag; + +import com.alttd.ctf.team.Team; +import com.alttd.ctf.team.TeamPlayer; +import lombok.AllArgsConstructor; +import org.bukkit.Tag; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; + +import java.util.Optional; + +@AllArgsConstructor +public class FlagTryCaptureEvent implements Listener { + + private final Flag flag; + + @EventHandler + public void onPlayerRightClick(PlayerInteractEvent event) { + if (!event.getAction().isRightClick()) { + return; + } + Optional optionalTeam = flag.getWinningTeam(); + if (optionalTeam.isEmpty()) { + return; + } + Team winningTeam = optionalTeam.get(); + Block clickedBlock = event.getClickedBlock(); + + if (clickedBlock == null || !Tag.BANNERS.isTagged(clickedBlock.getType())) { + return; + } + if (!(clickedBlock.getLocation().distance(flag.getFlagLocation()) < 1)) { + return; + } + Player player = event.getPlayer(); + Optional teamPlayer = winningTeam.getPlayer(player.getUniqueId()); + if (teamPlayer.isEmpty()) { + return; + } + flag.capture(teamPlayer.get(), player); + } + +} diff --git a/src/main/java/com/alttd/ctf/game/GameManager.java b/src/main/java/com/alttd/ctf/game/GameManager.java index 4b8dfbb..1616b8b 100644 --- a/src/main/java/com/alttd/ctf/game/GameManager.java +++ b/src/main/java/com/alttd/ctf/game/GameManager.java @@ -1,15 +1,14 @@ package com.alttd.ctf.game; +import com.alttd.ctf.flag.Flag; import com.alttd.ctf.game.phases.ClassSelectionPhase; -import com.alttd.ctf.game_class.GameClass; +import com.alttd.ctf.game.phases.CombatPhase; +import com.alttd.ctf.game.phases.EndedPhase; +import com.alttd.ctf.game.phases.GatheringPhase; import com.alttd.ctf.game_class.creation.FighterCreator; -import com.alttd.ctf.game_class.implementations.Fighter; import com.alttd.ctf.team.Team; -import com.alttd.ctf.team.TeamColor; import com.alttd.ctf.team.TeamPlayer; -import org.bukkit.Material; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -28,6 +27,9 @@ public class GameManager { public GameManager() { phases = new HashMap<>(); phases.put(GamePhase.CLASS_SELECTION, new ClassSelectionPhase(this, (FighterCreator::createFighter))); + phases.put(GamePhase.GATHERING, new GatheringPhase()); + phases.put(GamePhase.COMBAT, new CombatPhase()); + phases.put(GamePhase.ENDED, new EndedPhase()); } public Optional getGamePhase() { @@ -55,6 +57,10 @@ public class GameManager { return getTeams().stream().filter(filterTeam -> filterTeam.getPlayer(uuid).isPresent()).findAny(); } + public Optional getTeam(int teamId) { + return getTeams().stream().filter(filterTeam -> filterTeam.getId() == teamId).findAny(); + } + public Optional getTeamPlayer(@NotNull UUID uuid) { return getTeams().stream() .map(team -> team.getPlayer(uuid)) @@ -63,13 +69,14 @@ public class GameManager { .orElseGet(Optional::empty); } - public void start(Duration duration) { + public void start(Duration duration, Flag flag) { + //TODO assign players to teams and if they are discord link give them a team role so they can be in vc with each other if (runningGame != null) { runningGame.end(); executorService.shutdown(); executorService = Executors.newSingleThreadScheduledExecutor(); } - runningGame = new RunningGame(this, duration); + runningGame = new RunningGame(this, duration, flag); executorService.scheduleAtFixedRate(runningGame, 0, 1, TimeUnit.SECONDS); } @@ -84,4 +91,11 @@ public class GameManager { public void clearTeams() { teams.clear(); } + + public int getMaxTeamId() { + return getTeams().stream() + .mapToInt(Team::getId) + .max() + .orElse(0); + } } diff --git a/src/main/java/com/alttd/ctf/game/GamePhaseExecutor.java b/src/main/java/com/alttd/ctf/game/GamePhaseExecutor.java index 3f88c62..50a66b1 100644 --- a/src/main/java/com/alttd/ctf/game/GamePhaseExecutor.java +++ b/src/main/java/com/alttd/ctf/game/GamePhaseExecutor.java @@ -1,8 +1,10 @@ package com.alttd.ctf.game; +import com.alttd.ctf.flag.Flag; + public interface GamePhaseExecutor { - void start(); + void start(Flag flag); void end(); } diff --git a/src/main/java/com/alttd/ctf/game/RunningGame.java b/src/main/java/com/alttd/ctf/game/RunningGame.java index 959bc8b..9f62613 100644 --- a/src/main/java/com/alttd/ctf/game/RunningGame.java +++ b/src/main/java/com/alttd/ctf/game/RunningGame.java @@ -1,7 +1,9 @@ package com.alttd.ctf.game; import com.alttd.ctf.config.GameConfig; +import com.alttd.ctf.flag.Flag; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; @@ -12,39 +14,47 @@ import java.time.Duration; import java.time.Instant; import java.util.HashMap; +@Slf4j public class RunningGame implements Runnable { private final HashMap phaseDurations = GameConfig.PHASES.getGAME_PHASE_DURATION(); private final GameManager gameManager; + private final Flag flag; @Getter private GamePhase currentPhase = GamePhase.values()[0]; private Instant phaseStartTime = null; + private int lastMinuteBroadcast = 0; - public RunningGame(GameManager gameManager, Duration gameDuration) { + public RunningGame(GameManager gameManager, Duration gameDuration, Flag flag) { this.gameManager = gameManager; + this.flag = flag; phaseDurations.put(GamePhase.COMBAT, gameDuration); } @Override public void run() { + GamePhase nextPhase = GamePhase.values().length < currentPhase.ordinal() ? null : GamePhase.values()[currentPhase.ordinal() + 1]; if (phaseStartTime == null) { phaseStartTime = Instant.now(); - GamePhase nextPhase = GamePhase.values()[currentPhase.ordinal() + 1]; - nextPhaseActions(currentPhase, nextPhase); + nextPhaseActions(null, currentPhase, nextPhase); } if (Duration.between(phaseStartTime, Instant.now()).compareTo(phaseDurations.get(currentPhase)) >= 0) { - GamePhase nextPhase = GamePhase.values()[currentPhase.ordinal() + 1]; - nextPhaseActions(currentPhase, nextPhase); - currentPhase = nextPhase; + GamePhase previousPhase = currentPhase; + currentPhase = GamePhase.values()[currentPhase.ordinal() + 1]; + nextPhaseActions(previousPhase, currentPhase, nextPhase); phaseStartTime = Instant.now(); + } else if (nextPhase != null) { + broadcastNextPhaseStartTime(currentPhase, nextPhase); } } - private void nextPhaseActions(@NotNull GamePhase phase, @Nullable GamePhase nextPhase) { - //TODO class/functions for each phase that they run at the start of that phase - // These should notify the player of what the phase is and that it started as well - gameManager.getPhaseExecutor(phase).start(); + private void nextPhaseActions(@Nullable GamePhase previousPhase, @NotNull GamePhase phase, @Nullable GamePhase nextPhase) { + //TODO command to go to next phase + if (previousPhase != null) { + gameManager.getPhaseExecutor(previousPhase).end(); + } + gameManager.getPhaseExecutor(phase).start(flag); if (nextPhase != null) { broadcastNextPhaseStartTime(phase, nextPhase); } @@ -53,19 +63,27 @@ public class RunningGame implements Runnable { private void broadcastNextPhaseStartTime(GamePhase currentPhase, GamePhase nextPhase) {//TODO check how this works/what it should do //Remaining time for this phase Duration duration = phaseDurations.get(currentPhase).minus(Duration.between(phaseStartTime, Instant.now())); - if (duration.toMinutes() > 5 && duration.toMinutes() % 15 == 0) { + log.debug(duration.toString());//TODO remove debug + if ((duration.toMinutes() > 1 && (duration.toMinutes() % 15 == 0 || duration.toMinutes() <= 5)) && duration.toSecondsPart() < 2) { + if (lastMinuteBroadcast == duration.toMinutes()) { + return; + } + lastMinuteBroadcast = (int) duration.toMinutes(); Bukkit.broadcast(MiniMessage.miniMessage().deserialize( "The will start in " + duration.toMinutes() + " minutes", Placeholder.component("phase", nextPhase.getDisplayName()) )); - } else if (duration.toMinutes() < 5) { - //TODO start minute countdown -> second countdown + } else if (duration.toMinutes() < 1 && duration.toSeconds() % 15 == 0) { + Bukkit.broadcast(MiniMessage.miniMessage().deserialize( + "The will start in " + duration.toSeconds() + " seconds", + Placeholder.component("phase", nextPhase.getDisplayName()) + )); } } public void end() { //TODO say the phase ended early? currentPhase = GamePhase.ENDED; - nextPhaseActions(currentPhase, null); + nextPhaseActions(null, currentPhase, null); } } diff --git a/src/main/java/com/alttd/ctf/game/phases/ClassSelectionPhase.java b/src/main/java/com/alttd/ctf/game/phases/ClassSelectionPhase.java index ebc65aa..2477be5 100644 --- a/src/main/java/com/alttd/ctf/game/phases/ClassSelectionPhase.java +++ b/src/main/java/com/alttd/ctf/game/phases/ClassSelectionPhase.java @@ -1,17 +1,22 @@ package com.alttd.ctf.game.phases; +import com.alttd.ctf.flag.Flag; import com.alttd.ctf.game.GameManager; import com.alttd.ctf.game.GamePhaseExecutor; import com.alttd.ctf.game_class.GameClass; import com.alttd.ctf.team.Team; import com.alttd.ctf.team.TeamColor; +import com.alttd.ctf.util.CircularIterator; +import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; import org.bukkit.Location; import org.jetbrains.annotations.NotNull; import java.util.Optional; +@Slf4j public class ClassSelectionPhase implements GamePhaseExecutor { private final GameManager gameManager; @@ -28,9 +33,21 @@ public class ClassSelectionPhase implements GamePhaseExecutor { } @Override - public void start() { + public void start(Flag flag) { teleportPlayersToStartingZone(); Bukkit.broadcast(MiniMessage.miniMessage().deserialize("Select your class")); + CircularIterator teamCircularIterator = new CircularIterator<>(gameManager.getTeams()); + if (teamCircularIterator.hasNext()) { + Bukkit.getOnlinePlayers().stream() + .filter(player -> gameManager.getTeamPlayer(player.getUniqueId()).isEmpty()) + .forEach(player -> { + Team team = teamCircularIterator.next(); + team.addPlayer(player.getUniqueId()); + player.sendRichMessage("You joined !", Placeholder.component("team", team.getName())); + }); + } else { + log.warn("No teams to add players to"); + } //TODO let players select classes // They should always be able to do this when in their starting zone // They should be locked into their starting zone until the next phase starts @@ -59,5 +76,6 @@ public class ClassSelectionPhase implements GamePhaseExecutor { defaultClass.apply(player); }); }); + //TODO expand world border so ppl can gather things but not get near the flag yet } } diff --git a/src/main/java/com/alttd/ctf/game/phases/CombatPhase.java b/src/main/java/com/alttd/ctf/game/phases/CombatPhase.java new file mode 100644 index 0000000..8a40f84 --- /dev/null +++ b/src/main/java/com/alttd/ctf/game/phases/CombatPhase.java @@ -0,0 +1,34 @@ +package com.alttd.ctf.game.phases; + +import com.alttd.ctf.flag.Flag; +import com.alttd.ctf.game.GamePhaseExecutor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class CombatPhase implements GamePhaseExecutor { + + private ScheduledExecutorService executorService = null; + + @Override + public void start(Flag flag) { + Bukkit.broadcast(MiniMessage.miniMessage().deserialize("CAPTURE THE FLAG")); + flag.spawnFlag(); + if (executorService == null || !executorService.isShutdown()) { + if (executorService != null) { + executorService.shutdown(); + } + executorService = Executors.newSingleThreadScheduledExecutor(); + } + executorService.scheduleAtFixedRate(flag, 0, 1, TimeUnit.SECONDS); + // TODO Add players to bossbar list for capture progress(or maybe only when any progress is made) + } + + @Override + public void end() { + executorService.shutdown(); + } +} diff --git a/src/main/java/com/alttd/ctf/game/phases/EndedPhase.java b/src/main/java/com/alttd/ctf/game/phases/EndedPhase.java new file mode 100644 index 0000000..6bc0da3 --- /dev/null +++ b/src/main/java/com/alttd/ctf/game/phases/EndedPhase.java @@ -0,0 +1,98 @@ +package com.alttd.ctf.game.phases; + +import com.alttd.ctf.config.Config; +import com.alttd.ctf.flag.Flag; +import com.alttd.ctf.game.GamePhaseExecutor; +import com.alttd.ctf.team.Team; +import lombok.extern.slf4j.Slf4j; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +public class EndedPhase implements GamePhaseExecutor { + + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + + @Override + public void start(Flag flag) { + Bukkit.broadcast(MiniMessage.miniMessage().deserialize("Capture the flag has ended!")); + HashMap wins = flag.getWins(); + Bukkit.broadcast(Component.join(JoinConfiguration.separator(Component.newline()), getWinnerMessages(wins))); + new Thread(() -> { + World world = Bukkit.getWorld(Config.FLAG.world); + if (world == null) { + log.error("Invalid flag world defined"); + return; + } + Location spawnLocation = world.getSpawnLocation(); + Bukkit.getOnlinePlayers().forEach(player -> { + player.getInventory().clear(); + player.updateInventory(); + player.teleportAsync(spawnLocation); + }); + flag.reset(); + }).start(); + // TODO reset world (coreprotect) to prep for next round + } + + private List getWinnerMessages(HashMap wins) { + List messages = new ArrayList<>(); + int highestScore = wins.values().stream() + .max(Integer::compareTo) + .orElse(0); + + List topTeams = wins.entrySet().stream() + .filter(entry -> entry.getValue() == highestScore) + .map(Map.Entry::getKey) + .toList(); + + if (highestScore <= 0) { // No one captured the flag, no winners + messages.add(miniMessage.deserialize("No one captured the flag, it's a draw!")); + return messages; + } else if (topTeams.size() > 1) { // Draw scenario, multiple teams have the same top score + messages.add(miniMessage.deserialize("It's a draw! Top teams:")); + topTeams.forEach(team -> { + messages.add(miniMessage.deserialize(" had captures.", + Placeholder.component("team", team.getName()), + Placeholder.parsed("score", String.valueOf(highestScore)))); + }); + addOtherTeamsScore(wins, highestScore, messages); + return messages; + } else { // Single winner + Team winner = topTeams.getFirst(); + messages.add(miniMessage.deserialize(" has won with captures!", + Placeholder.component("team", winner.getName()), + Placeholder.parsed("score", String.valueOf(highestScore)))); + if (wins.size() <= 1) { + return messages; + } + messages.add(miniMessage.deserialize("Other teams:")); + addOtherTeamsScore(wins, highestScore, messages); + return messages; + } + } + + private void addOtherTeamsScore(HashMap wins, int winningScore, List messages) { + messages.add(miniMessage.deserialize("Other teams:")); + wins.entrySet().stream() + .filter(entry -> entry.getValue() < winningScore) + .forEach(entry -> messages.add(miniMessage.deserialize(" had captures.", + Placeholder.component("team", entry.getKey().getName()), + Placeholder.parsed("score", String.valueOf(entry.getValue()))))); + } + + @Override + public void end() { + + } +} diff --git a/src/main/java/com/alttd/ctf/game/phases/GatheringPhase.java b/src/main/java/com/alttd/ctf/game/phases/GatheringPhase.java new file mode 100644 index 0000000..24e904a --- /dev/null +++ b/src/main/java/com/alttd/ctf/game/phases/GatheringPhase.java @@ -0,0 +1,20 @@ +package com.alttd.ctf.game.phases; + +import com.alttd.ctf.flag.Flag; +import com.alttd.ctf.game.GamePhaseExecutor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; + +public class GatheringPhase implements GamePhaseExecutor { + @Override + public void start(Flag flag) { + Bukkit.broadcast(MiniMessage.miniMessage().deserialize("Gather materials and prepare for combat!")); + //TODO give everyone haste or something so they can mine faster till this game phase ends + // Let them store things at base during this phase, after only one class can do this? + } + + @Override + public void end() { + //TODO Remove team area barrier + } +} diff --git a/src/main/java/com/alttd/ctf/game_class/GameClass.java b/src/main/java/com/alttd/ctf/game_class/GameClass.java index 9f933d5..470c3f7 100644 --- a/src/main/java/com/alttd/ctf/game_class/GameClass.java +++ b/src/main/java/com/alttd/ctf/game_class/GameClass.java @@ -31,6 +31,9 @@ public abstract class GameClass { private final int damage; protected GameClass(List armor, List tools, ItemStack displayItem, double health, int throwTickSpeed, int damage) { + if (armor.size() != 4) { + throw new IllegalArgumentException("Armor has to have 4 entries"); + } this.armor = armor; this.tools = tools; this.displayItem = displayItem; @@ -50,6 +53,8 @@ public abstract class GameClass { log.error("Player does not have max health attribute"); return; } + maxHealthAttribute.setBaseValue(health); + player.setHealth(health); //Always reset the player inventory since other classes might have had them get items player.getInventory().clear(); @@ -58,7 +63,6 @@ public abstract class GameClass { setArmor(player, color.r(), color.g(), color.b()); player.updateInventory(); - maxHealthAttribute.setBaseValue(health); teamPlayer.setGameClass(this); } @@ -70,7 +74,7 @@ public abstract class GameClass { leatherArmorMeta.setColor(Color.fromBGR(r, g, b)); itemStack.setItemMeta(leatherArmorMeta); } - return new ItemStack(material); + return itemStack; }).toArray(ItemStack[]::new)); } @@ -78,4 +82,8 @@ public abstract class GameClass { return displayItem; } + public void setArmor(Player player, TeamPlayer teamPlayer) { + TeamColor color = teamPlayer.getTeam().getColor(); + setArmor(player, color.r(), color.g(), color.b()); + } } diff --git a/src/main/java/com/alttd/ctf/game_class/creation/FighterCreator.java b/src/main/java/com/alttd/ctf/game_class/creation/FighterCreator.java index cc033f8..6ec00d4 100644 --- a/src/main/java/com/alttd/ctf/game_class/creation/FighterCreator.java +++ b/src/main/java/com/alttd/ctf/game_class/creation/FighterCreator.java @@ -19,7 +19,7 @@ public class FighterCreator { } private static List getArmor() { - return (List.of(Material.LEATHER_CHESTPLATE)); + return (List.of(Material.AIR, Material.AIR, Material.LEATHER_CHESTPLATE, Material.AIR)); } private static List getTools() { diff --git a/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java b/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java index 6910870..3d84a4a 100644 --- a/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java +++ b/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java @@ -29,9 +29,6 @@ public class JsonConfigManager { for (File file : files) { T config = objectMapper.readValue(file, clazz); - - ValidationUtil.validate(config); - configs.add(config); } diff --git a/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java b/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java deleted file mode 100644 index 01f0625..0000000 --- a/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.alttd.ctf.json_config; - -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validation; -import jakarta.validation.Validator; -import jakarta.validation.ValidatorFactory; - -import java.util.Set; - -public class ValidationUtil { - - private static final ValidatorFactory VALIDATOR_FACTORY = Validation.buildDefaultValidatorFactory(); - private static final Validator VALIDATOR = VALIDATOR_FACTORY.getValidator(); - - public static void validate(T object) { - Set> violations = VALIDATOR.validate(object); - - if (!violations.isEmpty()) { - StringBuilder errorMessage = new StringBuilder("Validation errors:\n"); - for (ConstraintViolation violation : violations) { - errorMessage.append("- ").append(violation.getPropertyPath()).append(": ").append(violation.getMessage()).append("\n"); - } - throw new IllegalArgumentException(errorMessage.toString()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/team/Team.java b/src/main/java/com/alttd/ctf/team/Team.java index 7e0c6e2..0d28d35 100644 --- a/src/main/java/com/alttd/ctf/team/Team.java +++ b/src/main/java/com/alttd/ctf/team/Team.java @@ -1,8 +1,10 @@ package com.alttd.ctf.team; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.Component; import org.bukkit.Location; @@ -11,9 +13,11 @@ import org.jetbrains.annotations.NotNull; import java.util.*; @Slf4j +@NoArgsConstructor @AllArgsConstructor public class Team { + @JsonIgnore private final HashMap players = new HashMap<>(); @JsonProperty("name") @NotNull @@ -21,7 +25,7 @@ public class Team { private Component name; @JsonProperty("id") @Getter - private final int id; + private int id; @JsonProperty("spawnLocation") @NotNull @Getter diff --git a/src/main/java/com/alttd/ctf/team/TeamColor.java b/src/main/java/com/alttd/ctf/team/TeamColor.java index f60c24d..d4fd900 100644 --- a/src/main/java/com/alttd/ctf/team/TeamColor.java +++ b/src/main/java/com/alttd/ctf/team/TeamColor.java @@ -1,13 +1,11 @@ package com.alttd.ctf.team; import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; public record TeamColor( - @JsonProperty("red") @NotNull int r, - @JsonProperty("green") @NotNull int g, - @JsonProperty("blue") @NotNull int b, - @JsonProperty("hex") @NotNull @Pattern(regexp = "^#[A-Fa-f0-9]{6}$") String hex + @JsonProperty("red") int r, + @JsonProperty("green") int g, + @JsonProperty("blue") int b, + @JsonProperty("hex")String hex ) { } diff --git a/src/main/java/com/alttd/ctf/util/CircularIterator.java b/src/main/java/com/alttd/ctf/util/CircularIterator.java new file mode 100644 index 0000000..eeeb044 --- /dev/null +++ b/src/main/java/com/alttd/ctf/util/CircularIterator.java @@ -0,0 +1,40 @@ +package com.alttd.ctf.util; + +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.NoSuchElementException; + +public class CircularIterator implements Iterator { + + private final LinkedList list; + private int index = 0; + + public CircularIterator(Collection list) { + this.list = new LinkedList<>(list); + } + + @Override + public boolean hasNext() { + return !list.isEmpty(); + } + + @Override + public E next() { + if (!hasNext()) { + throw new NoSuchElementException("No elements to iterate."); + } + if (index >= list.size()) { + index = 0; + } + return list.get(index++); + } + + @Override + public void remove() { + if (list.isEmpty()) { + throw new IllegalStateException("Cannot remove elements from an empty list."); + } + list.remove(index); + } +} diff --git a/version.properties b/version.properties new file mode 100644 index 0000000..c164c2f --- /dev/null +++ b/version.properties @@ -0,0 +1,3 @@ +#Fri Feb 07 22:07:29 CET 2025 +buildNumber=5 +version=0.1