Add initial Capture the Flag plugin implementation

Implemented the core structure for a Capture the Flag (CTF) plugin. This includes team management, game phases, player classes, command handling, and configuration support. The project is set up with Gradle for dependency management and provides placeholders for future feature expansion.
This commit is contained in:
2025-01-24 17:49:17 +01:00
commit 6e38d42f2d
29 changed files with 1625 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
package com.alttd.ctf;
import com.alttd.ctf.commands.CommandManager;
import com.alttd.ctf.config.Config;
import com.alttd.ctf.events.OnSnowballHit;
import com.alttd.ctf.game.GameManager;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
@Slf4j
public class Main extends JavaPlugin {
@Override
public void onEnable() {
log.info("Plugin enabled!");
reloadConfigs();
GameManager gameManager = new GameManager();
CommandManager commandManager = new CommandManager(this, gameManager);
registerEvents(gameManager);
}
public void reloadConfigs() {
Config.reload(this);
}
private void registerEvents(GameManager gameManager) {
PluginManager pluginManager = getServer().getPluginManager();
pluginManager.registerEvents(new OnSnowballHit(gameManager), this);
}
}
@@ -0,0 +1,118 @@
package com.alttd.ctf.commands;
import com.alttd.ctf.Main;
import com.alttd.ctf.commands.subcommands.ChangeTeam;
import com.alttd.ctf.commands.subcommands.Start;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.command.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Getter
@Slf4j
public class CommandManager implements CommandExecutor, TabExecutor {
private final List<SubCommand> subCommands;
public CommandManager(Main main, GameManager gameManager) {
PluginCommand command = main.getCommand("ctf");
if (command == null) {
subCommands = null;
log.error("Unable to find transfer command.");
return;
}
command.setExecutor(this);
command.setTabCompleter(this);
subCommands = Arrays.asList(
new ChangeTeam(gameManager),
new Start(gameManager)
);
}
@Override
public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String cmd, @NotNull String[] args) {
if (args.length == 0) {
commandSender.sendRichMessage(Messages.HELP.HELP_MESSAGE_WRAPPER.replaceAll("<commands>", subCommands.stream()
.filter(subCommand -> commandSender.hasPermission(subCommand.getPermission()))
.map(SubCommand::getHelpMessage)
.collect(Collectors.joining("\n"))));
return true;
}
SubCommand subCommand = getSubCommand(args[0]);
if (subCommand == null)
return false;
if (!commandSender.hasPermission(subCommand.getPermission())) {
commandSender.sendRichMessage(Messages.GENERIC.NO_PERMISSION, Placeholder.parsed("permission", subCommand.getPermission()));
return true;
}
int failedPos = subCommand.onCommand(commandSender, args);
if (failedPos > 0) {
commandSender.sendRichMessage(String.format("<hover:show_text:'%s'>%s</hover>",
getHoverText(command.getName(), args, failedPos),
subCommand.getHelpMessage()));
} else if (failedPos < 0) {
commandSender.sendRichMessage(subCommand.getHelpMessage());
}
return true;
}
private String getHoverText(String commandName, String[] args, int failedPos) {
StringBuilder hoverText = new StringBuilder();
hoverText.append("<green>/").append(commandName);
for (int i = 0; i < failedPos; i++) {
hoverText.append(" ").append(args[i]);
}
hoverText.append("</green>");
if (failedPos < args.length) {
hoverText.append(" <red>").append(args[failedPos]).append("</red>");
}
for (int i = failedPos + 1; i < args.length; i++) {
hoverText.append(" <yellow>").append(args[i]).append("</yellow>");
}
return hoverText.toString();
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String cmd, @NotNull String[] args) {
List<String> res = new ArrayList<>();
if (args.length <= 1) {
res.addAll(subCommands.stream()
.filter(subCommand -> commandSender.hasPermission(subCommand.getPermission()))
.map(SubCommand::getName)
.filter(name -> args.length == 0 || name.startsWith(args[0]))
.toList()
);
} else {
SubCommand subCommand = getSubCommand(args[0]);
if (subCommand != null && commandSender.hasPermission(subCommand.getPermission()))
res.addAll(subCommand.getTabComplete(commandSender, args).stream()
.filter(str -> str.toLowerCase().startsWith(args[args.length - 1].toLowerCase()))
.toList());
}
return res;
}
private SubCommand getSubCommand(String cmdName) {
return subCommands.stream()
.filter(subCommand -> subCommand.getName().equals(cmdName))
.findFirst()
.orElse(null);
}
}
@@ -0,0 +1,25 @@
package com.alttd.ctf.commands;
import org.bukkit.command.CommandSender;
import java.util.List;
public abstract class SubCommand {
public SubCommand() {}
//A return value other than 0 means the command failed
// If the value is more than 0 it should be the position in the array of the argument it failed on
// If the value is less than 0 it means something else failed (like the amount of arguments)
public abstract int onCommand(CommandSender commandSender, String[] args);
public abstract String getName();
public String getPermission() {
return "ctf." + getName();
}
public abstract List<String> getTabComplete(CommandSender commandSender, String[] args);
public abstract String getHelpMessage();
}
@@ -0,0 +1,109 @@
package com.alttd.ctf.commands.subcommands;
import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.team.Team;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Optional;
public class ChangeTeam extends SubCommand {
private final GameManager gameManager;
public ChangeTeam(GameManager gameManager) {
this.gameManager = gameManager;
}
@FunctionalInterface
private interface ChangeTeamConsumer {
int apply(Player player, Team team);
}
@Override
public int onCommand(CommandSender commandSender, String[] args) {
return handle(commandSender, args, (player, team) -> {
changeTeam(commandSender, player, team);
return 0;
});
}
private int handle(CommandSender commandSender, String[] args, ChangeTeamConsumer consumer) {
if (args.length != 3) {
return -1;
}
Player player = Bukkit.getPlayer(args[1]);
if (player == null) {
commandSender.sendRichMessage("<red>Please provide a valid player</red>");
return 1;
}
int teamId;
try {
teamId = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
commandSender.sendRichMessage(String.format("<red>Please enter a valid integer, %s is not a valid integer</red>", args[2]));
return 2;
}
Optional<Team> optionalTeam = gameManager.getTeams().stream().filter(team -> team.getId() == teamId).findFirst();
if (optionalTeam.isEmpty()) {
commandSender.sendRichMessage(String.format("<red>Please provide a valid team id %d is not a valid team id</red>", teamId));
return 3;
}
return consumer.apply(player, optionalTeam.get());
}
private void changeTeam(CommandSender commandSender, Player player, Team team) {
Optional<Team> optionalOldTeam = gameManager.getTeam(player.getUniqueId());
if (optionalOldTeam.isPresent()) {
moveBetweenTeams(commandSender, player, team, optionalOldTeam.get());
return;
}
gameManager.registerPlayer(team, player);
commandSender.sendRichMessage("<green><player> has been placed in<team>.</green>",
TagResolver.resolver(
Placeholder.component("player", player.displayName()),
Placeholder.component("team", team.getName())));
}
private void moveBetweenTeams(CommandSender commandSender, Player player, Team newTeam, Team oldTeam) {
if (oldTeam.equals(newTeam)) {
commandSender.sendRichMessage("<green><player> was already in <team>, nothing has changed.</green>",
TagResolver.resolver(
Placeholder.component("player", player.displayName()),
Placeholder.component("team", newTeam.getName())));
return;
}
gameManager.registerPlayer(newTeam, player);
commandSender.sendRichMessage("<green><player> has been moved from <old_team> to <new_team>.</green>",
TagResolver.resolver(
Placeholder.component("player", player.displayName()),
Placeholder.component("old_team", oldTeam.getName())),
Placeholder.component("new_team", newTeam.getName()));
}
@Override
public String getName() {
return "changeteam";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return switch (args.length) {
case 2 -> Bukkit.getOnlinePlayers().stream().map(Player::getName).toList();
case 3 -> gameManager.getTeams().stream().map(Team::getId).map(Object::toString).toList();
default -> List.of();
};
}
@Override
public String getHelpMessage() {
return Messages.HELP.CHANGE_TEAM;
}
}
@@ -0,0 +1,64 @@
package com.alttd.ctf.commands.subcommands;
import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import org.bukkit.command.CommandSender;
import java.time.Duration;
import java.util.List;
public class Start extends SubCommand {
private final GameManager gameManager;
public Start(GameManager gameManager) {
this.gameManager = gameManager;
}
@FunctionalInterface
private interface StartConsumer {
int apply(Duration combatTime);
}
@Override
public int onCommand(CommandSender commandSender, String[] args) {
return handle(commandSender, args, combatTime -> {
gameManager.start(combatTime);
return 0;
});
}
private int handle(CommandSender commandSender, String[] args, StartConsumer consumer) {
if (args.length != 2) {
return -1;
}
Duration combatTime;
try {
combatTime = Duration.ofSeconds(Integer.parseInt(args[1]));
} catch (NumberFormatException e) {
commandSender.sendRichMessage("<red>Please enter a valid integer</red>");
return 1;
}
return consumer.apply(combatTime);
}
@Override
public String getName() {
return "start";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
//noinspection SwitchStatementWithTooFewBranches
return switch (args.length) {
case 2 -> List.of("30", "45", "60");
default -> List.of();
};
}
@Override
public String getHelpMessage() {
return Messages.HELP.START;
}
}
@@ -0,0 +1,148 @@
package com.alttd.ctf.config;
import com.alttd.ctf.Main;
import com.google.common.collect.ImmutableMap;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@SuppressWarnings({"unused", "SameParameterValue"})
abstract class AbstractConfig {
File file;
YamlConfiguration yaml;
AbstractConfig(Main main, String filename) {
init(new File(main.getDataFolder(), filename), filename);
}
AbstractConfig(File file, String filename) {
init(new File(file.getPath() + File.separator + filename), filename);
}
private void init(File file, String filename) {
this.file = file;
this.yaml = new YamlConfiguration();
try {
yaml.load(file);
} catch (IOException ignore) {
} catch (InvalidConfigurationException ex) {
log.error("Could not load {}, please correct your syntax errors", filename, ex);
throw new RuntimeException(ex);
}
yaml.options().copyDefaults(true);
}
void readConfig(Class<?> clazz, Object instance) {
for (Class<?> declaredClass : clazz.getDeclaredClasses()) {
for (Method method : declaredClass.getDeclaredMethods()) {
if (!Modifier.isPrivate(method.getModifiers())) {
continue;
}
if (method.getParameterTypes().length != 0 || method.getReturnType() != Void.TYPE) {
continue;
}
try {
method.setAccessible(true);
method.invoke(instance);
} catch (InvocationTargetException ex) {
throw new RuntimeException(ex.getCause());
} catch (Exception ex) {
log.error("Error invoking {}.", method, ex);
}
}
}
save();
}
private void save() {
try {
yaml.save(file);
} catch (IOException ex) {
log.error("Could not save {}.", file.toString(), ex);
}
}
void set(String prefix, String path, Object val) {
path = prefix + path;
yaml.addDefault(path, val);
yaml.set(path, val);
save();
}
String getString(String prefix, String path, String def) {
path = prefix + path;
yaml.addDefault(path, def);
return yaml.getString(path, yaml.getString(path));
}
boolean getBoolean(String prefix, String path, boolean def) {
path = prefix + path;
yaml.addDefault(path, def);
return yaml.getBoolean(path, yaml.getBoolean(path));
}
int getInt(String prefix, String path, int def) {
path = prefix + path;
yaml.addDefault(path, def);
return yaml.getInt(path, yaml.getInt(path));
}
double getDouble(String prefix, String path, double def) {
path = prefix + path;
yaml.addDefault(path, def);
return yaml.getDouble(path, yaml.getDouble(path));
}
<T> List<String> getList(String prefix, String path, T def) {
path = prefix + path;
yaml.addDefault(path, def);
List<?> list = yaml.getList(path, yaml.getList(path));
return list == null ? null : list.stream().map(Object::toString).collect(Collectors.toList());
}
List<String> getStringList(String prefix, String path, List<String> def) {
path = prefix + path;
yaml.addDefault(path, def);
return yaml.getStringList(path);
}
@NonNull
<T> Map<String, T> getMap(String prefix, @NonNull String path, final @Nullable Map<String, T> def) {
path = prefix + path;
final ImmutableMap.Builder<String, T> builder = ImmutableMap.builder();
if (def != null && yaml.getConfigurationSection(path) == null) {
yaml.addDefault(path, def.isEmpty() ? new HashMap<>() : def);
return def;
}
final ConfigurationSection section = yaml.getConfigurationSection(path);
if (section != null) {
for (String key : section.getKeys(false)) {
@SuppressWarnings("unchecked")
final T val = (T) section.get(key);
if (val != null) {
builder.put(key, val);
}
}
}
return builder.build();
}
ConfigurationSection getConfigurationSection(String path) {
return yaml.getConfigurationSection(path);
}
}
@@ -0,0 +1,44 @@
package com.alttd.ctf.config;
import com.alttd.ctf.Main;
import lombok.extern.slf4j.Slf4j;
import java.util.logging.Level;
@Slf4j
public class Config extends AbstractConfig{
static Config config;
private final Main main;
Config(Main main) {
super(main, "config.yml");
this.main = main;
}
public static void reload(Main main) {
log.info("Reloading config");
config = new Config(main);
config.readConfig(Config.class, null);
}
@SuppressWarnings("unused")
public static class SETTINGS {
private static final String prefix = "settings.";
@SuppressWarnings("unused")
private static void load() {
if (config.getBoolean(prefix, "debug", false)) {
config.main.getLogger().setLevel(Level.FINE);
} else if (config.getBoolean(prefix, "warnings", true)) {
config.main.getLogger().setLevel(Level.WARNING);
} else {
config.main.getLogger().setLevel(Level.INFO);
}
log.debug("Debug logging is enabled");
log.warn("Warning logging is enabled");
log.info("Info logging is enabled");
}
}
}
@@ -0,0 +1,48 @@
package com.alttd.ctf.config;
import com.alttd.ctf.Main;
import com.alttd.ctf.game.GamePhase;
import lombok.extern.slf4j.Slf4j;
import java.time.Duration;
import java.util.HashMap;
@Slf4j
public class GameConfig extends AbstractConfig{
static GameConfig config;
GameConfig(Main main) {
super(main, "game-config.yml");
}
public static void reload(Main main) {
log.info("Reloading config");
config = new GameConfig(main);
config.readConfig(GameConfig.class, null);
}
@SuppressWarnings("unused")
public static class PHASES {
private static final String prefix = "phases.";
private static final HashMap<GamePhase, Duration> GAME_PHASE_DURATION = new HashMap<>();
public static HashMap<GamePhase, Duration> getGAME_PHASE_DURATION() {
return new HashMap<>(GAME_PHASE_DURATION);
}
@SuppressWarnings("unused")
private static void load() {
GAME_PHASE_DURATION.clear();
for (GamePhase phase : GamePhase.values()) {
if (phase.equals(GamePhase.COMBAT))
continue;
int phaseDurationInMinutes = config.getInt(prefix, phase.name().toLowerCase(), 5);
GAME_PHASE_DURATION.put(phase, Duration.ofMinutes(phaseDurationInMinutes));
log.debug("Set {} phase duration to {} minutes", phase.name(), phaseDurationInMinutes);
}
}
}
}
@@ -0,0 +1,52 @@
package com.alttd.ctf.config;
import com.alttd.ctf.Main;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Messages extends AbstractConfig {
static Messages config;
Messages(Main main) {
super(main, "config.yml");
}
public static void reload(Main main) {
log.info("Reloading messages config");
config = new Messages(main);
config.readConfig(Messages.class, null);
}
public static class HELP {
private static final String prefix = "help.";
public static String HELP_MESSAGE_WRAPPER = "<gold>Main help:\n<commands></gold>";
public static String HELP_MESSAGE = "<green>Show this menu: <gold>/ctf help</gold></green>";
public static String CHANGE_TEAM = "<green>Change a players team: <gold>/ctf changeteam <player> <team></gold></green>";
public static String START = "<green>Start a new game: <gold>/ctf start <time_in_minutes></gold></green>";
@SuppressWarnings("unused")
private static void load() {
HELP_MESSAGE_WRAPPER = config.getString(prefix, "help-wrapper", HELP_MESSAGE_WRAPPER);
HELP_MESSAGE = config.getString(prefix, "help", HELP_MESSAGE);
CHANGE_TEAM = config.getString(prefix, "change-team", CHANGE_TEAM);
START = config.getString(prefix, "start", START);
}
}
public static class GENERIC {
private static final String prefix = "generic.";
public static String NO_PERMISSION = "<red><hover:show_text:'<red><permission></red>'>You don't have permission for this command</hover></red>";
public static String PLAYER_ONLY = "<red>This command can only be executed as a player</red>";
public static String PLAYER_NOT_FOUND = "<red>Unable to find online player <player></red>";
@SuppressWarnings("unused")
private static void load() {
NO_PERMISSION = config.getString(prefix, "no-permission", NO_PERMISSION);
PLAYER_ONLY = config.getString(prefix, "player-only", PLAYER_ONLY);
PLAYER_NOT_FOUND = config.getString(prefix, "player-only", PLAYER_NOT_FOUND);
}
}
}
@@ -0,0 +1,78 @@
package com.alttd.ctf.events;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.game.GamePhase;
import com.alttd.ctf.game_class.GameClass;
import com.alttd.ctf.team.TeamPlayer;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import java.util.Optional;
@Slf4j
public class OnSnowballHit implements Listener {
private final GameManager gameManager;
public OnSnowballHit(GameManager gameManager) {
this.gameManager = gameManager;
}
@FunctionalInterface
private interface SnowballHitConsumer {
void apply(Player hitPlayer, Player shooter, TeamPlayer shooterTeamPlayer);
}
@EventHandler
public void onSnowballHit(EntityDamageByEntityEvent event) {
handle(event, (hitPlayer, shooter, shooterTeamPlayer) -> {
GameClass shooterClass = shooterTeamPlayer.getGameClass();
shooter.setCooldown(Material.SNOWBALL, shooterClass.getThrowTickSpeed());
double newHealth = hitPlayer.getHealth() - shooterClass.getDamage();
hitPlayer.setHealth(Math.max(newHealth, 0));
log.debug("{} health was set to {} because of a snowball thrown by {}",
hitPlayer.getName(), Math.max(newHealth, 0), shooter.getName());
});
}
private void handle(EntityDamageByEntityEvent event, SnowballHitConsumer consumer) {
Optional<GamePhase> optionalGamePhase = gameManager.getGamePhase();
if (optionalGamePhase.isEmpty()) {
log.debug("No game is running but player was hit by snowball");
return;
}
GamePhase gamePhase = optionalGamePhase.get();
if (!gamePhase.equals(GamePhase.COMBAT)) {
log.debug("Not in combat phase but player was hit by snowball");
return;
}
if (!(event.getEntity() instanceof Player hitPlayer)) {
log.debug("An entity other than a player was hit by a snowball");
return;
}
if (!(event.getDamager() instanceof org.bukkit.entity.Snowball snowball)) {
log.debug("The player was hit by something other than a snowball");
return;
}
if (!(snowball.getShooter() instanceof Player shooter)) {
log.debug("The shooter that hit a player with a snowball was not a player");
return;
}
Optional<TeamPlayer> teamPlayer = gameManager.getTeamPlayer(shooter.getUniqueId());
if (teamPlayer.isEmpty()) {
log.debug("The shooter that hit a player with a snowball was not a team player");
return;
}
consumer.apply(hitPlayer, shooter, teamPlayer.get());
}
}
@@ -0,0 +1,77 @@
package com.alttd.ctf.game;
import com.alttd.ctf.game.phases.ClassSelectionPhase;
import com.alttd.ctf.game_class.implementations.Fighter;
import com.alttd.ctf.team.Team;
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;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class GameManager {
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
private final HashMap<GamePhase, GamePhaseExecutor> phases;
private RunningGame runningGame;
private final HashMap<Integer, Team> teams = new HashMap<>();
public GameManager() {
phases = new HashMap<>();
//TODO initialize this somewhere else (maybe load it from a json file?)
phases.put(GamePhase.CLASS_SELECTION, new ClassSelectionPhase(this, new Fighter(List.of(Material.LEATHER_CHESTPLATE), List.of(), new ItemStack(Material.STONE), 15, 3, 5)));
}
public Optional<GamePhase> getGamePhase() {
return runningGame == null ? Optional.empty() : Optional.of(runningGame.getCurrentPhase());
}
public void registerPlayer(Team team, Player player) {
unregisterPlayer(player);
teams.get(team.getId()).addPlayer(player.getUniqueId());
}
public void unregisterPlayer(Player player) {
teams.values().forEach(team -> team.removePlayer(player.getUniqueId()));
}
public Collection<Team> getTeams() {
return teams.values();
}
public Optional<Team> getTeam(@NotNull UUID uuid) {
return getTeams().stream().filter(filterTeam -> filterTeam.getPlayer(uuid).isPresent()).findAny();
}
public Optional<TeamPlayer> getTeamPlayer(@NotNull UUID uuid) {
return getTeams().stream()
.map(team -> team.getPlayer(uuid))
.filter(Optional::isPresent)
.findFirst()
.orElseGet(Optional::empty);
}
public void start(Duration duration) {
if (runningGame != null) {
runningGame.end();
executorService.shutdown();
executorService = Executors.newSingleThreadScheduledExecutor();
}
runningGame = new RunningGame(this, duration);
executorService.scheduleAtFixedRate(runningGame, 0, 1, TimeUnit.SECONDS);
}
protected GamePhaseExecutor getPhaseExecutor(GamePhase gamePhase) {
GamePhaseExecutor gamePhaseExecutor = phases.get(gamePhase);
if (gamePhaseExecutor == null) {
throw new IllegalArgumentException("No phase executor found for phase " + gamePhase);
}
return gamePhaseExecutor;
}
}
@@ -0,0 +1,19 @@
package com.alttd.ctf.game;
import lombok.Getter;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
@Getter
public enum GamePhase {
CLASS_SELECTION(MiniMessage.miniMessage().deserialize("<green>Class selection phase</green>")),
GATHERING(MiniMessage.miniMessage().deserialize("<green>Gathering phase</green>")),
COMBAT(MiniMessage.miniMessage().deserialize("<green>Combat phase</green>")),
ENDED(MiniMessage.miniMessage().deserialize("<green>Game end phase</green>"));
private final Component displayName;
GamePhase(Component displayName) {
this.displayName = displayName;
}
}
@@ -0,0 +1,8 @@
package com.alttd.ctf.game;
public interface GamePhaseExecutor {
void start();
void end();
}
@@ -0,0 +1,71 @@
package com.alttd.ctf.game;
import com.alttd.ctf.config.GameConfig;
import lombok.Getter;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
public class RunningGame implements Runnable {
private final HashMap<GamePhase, Duration> phaseDurations = GameConfig.PHASES.getGAME_PHASE_DURATION();
private final GameManager gameManager;
@Getter
private GamePhase currentPhase = GamePhase.values()[0];
private Instant phaseStartTime = null;
public RunningGame(GameManager gameManager, Duration gameDuration) {
this.gameManager = gameManager;
phaseDurations.put(GamePhase.COMBAT, gameDuration);
}
@Override
public void run() {
if (phaseStartTime == null) {
phaseStartTime = Instant.now();
GamePhase nextPhase = GamePhase.values()[currentPhase.ordinal() + 1];
nextPhaseActions(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;
phaseStartTime = Instant.now();
}
}
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();
if (nextPhase != null) {
broadcastNextPhaseStartTime(phase, nextPhase);
}
}
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) {
Bukkit.broadcast(MiniMessage.miniMessage().deserialize(
"<green>The <phase> will start in <bold><red>" + duration.toMinutes() + "</red> minutes</bold></green>",
Placeholder.component("phase", nextPhase.getDisplayName())
));
} else if (duration.toMinutes() < 5) {
//TODO start minute countdown -> second countdown
}
}
public void end() {
//TODO say the phase ended early?
currentPhase = GamePhase.ENDED;
nextPhaseActions(currentPhase, null);
}
}
@@ -0,0 +1,54 @@
package com.alttd.ctf.game.phases;
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 net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
public class ClassSelectionPhase implements GamePhaseExecutor {
private final GameManager gameManager;
private final GameClass defaultClass;
public ClassSelectionPhase(@NotNull GameManager gameManager, @NotNull GameClass defaultClass) {
this.gameManager = gameManager;
this.defaultClass = defaultClass;
}
@Override
public void start() {
teleportPlayersToStartingZone();
Bukkit.broadcast(MiniMessage.miniMessage().deserialize("<green>Select your class</green>"));
//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
// That phase should handle opening the zone
}
private void teleportPlayersToStartingZone() {
Bukkit.getOnlinePlayers().forEach(player -> {
Optional<Team> team = gameManager.getTeam(player.getUniqueId());
if (team.isEmpty()) {
return;
}
Location spawnLocation = team.get().getSpawnLocation();
player.teleportAsync(spawnLocation);
});
}
@Override
public void end() {
gameManager.getTeams().forEach(team -> team.getPlayers().forEach(player -> {
if (player.getGameClass() != null) {
return;
}
defaultClass.apply(player);
}));
}
}
@@ -0,0 +1,81 @@
package com.alttd.ctf.game_class;
import com.alttd.ctf.team.TeamColor;
import com.alttd.ctf.team.TeamPlayer;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Bukkit;
import org.bukkit.Color;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta;
import java.util.List;
@Slf4j
public abstract class GameClass {
private final List<Material> armor;
private final List<ItemStack> tools;
private final ItemStack displayItem;
private final double health;
//Delay in ticks between throws (prevents auto clickers)
@Getter
private final int throwTickSpeed;
//Damage done when hitting a player with a snowball
@Getter
private final int damage;
protected GameClass(List<Material> armor, List<ItemStack> tools, ItemStack displayItem, double health, int throwTickSpeed, int damage) {
this.armor = armor;
this.tools = tools;
this.displayItem = displayItem;
this.health = health;
this.throwTickSpeed = throwTickSpeed;
this.damage = damage;
}
public void apply(TeamPlayer teamPlayer) {
Player player = Bukkit.getPlayer(teamPlayer.getUuid());
if (player == null || !player.isOnline()) {
log.warn("Tried to give class to offline player {}", player == null ? teamPlayer.getUuid() : player.getName());
return;
}
AttributeInstance maxHealthAttribute = player.getAttribute(Attribute.GENERIC_MAX_HEALTH);
if (maxHealthAttribute == null) {
log.error("Player does not have max health attribute");
return;
}
//Always reset the player inventory since other classes might have had them get items
player.getInventory().clear();
player.getInventory().setContents(tools.toArray(ItemStack[]::new));
TeamColor color = teamPlayer.getTeam().getColor();
setArmor(player, color.r(), color.g(), color.b());
player.updateInventory();
maxHealthAttribute.setBaseValue(health);
teamPlayer.setGameClass(this);
}
private void setArmor(Player player, int r, int g, int b) {
player.getInventory().setArmorContents(armor.stream().map(material -> {
ItemStack itemStack = new ItemStack(material);
ItemMeta itemMeta = itemStack.getItemMeta();
if (itemMeta instanceof LeatherArmorMeta leatherArmorMeta) {
leatherArmorMeta.setColor(Color.fromBGR(r, g, b));
itemStack.setItemMeta(leatherArmorMeta);
}
return new ItemStack(material);
}).toArray(ItemStack[]::new));
}
ItemStack getDisplayItem() {
return displayItem;
}
}
@@ -0,0 +1,17 @@
package com.alttd.ctf.game_class.implementations;
import com.alttd.ctf.game_class.GameClass;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import java.util.List;
@Slf4j
public class Fighter extends GameClass {
public Fighter(List<Material> armor, List<ItemStack> tools, ItemStack displayItem, double health, int tickThrowSpeed, int damage) {
super(armor, tools, displayItem, health, tickThrowSpeed, damage);
}
}
@@ -0,0 +1,74 @@
package com.alttd.ctf.team;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import java.util.*;
@Slf4j
public class Team {
private final HashMap<UUID, TeamPlayer> players = new HashMap<>();
@Getter
private Component name;
@Getter
private final int id;
@Getter
private Location spawnLocation;
@Getter
private Location worldBorderCenter; //TODO https://github.com/yannicklamprecht/WorldBorderAPI/blob/main/how-to-use.md
//TODO store team color to be used for kits and chat colors (in rgb?)
@Getter
private TeamColor color;
public Team(int id) {
this.id = id;
reloadTeamData();
}
private void reloadTeamData() {
this.color = new TeamColor(255, 0, 0, "#FF0000");
this.name = MiniMessage.miniMessage().deserialize(String.format("<color:#%s>Test Team</color>", color.hex()));
this.spawnLocation = Bukkit.getWorld("world").getSpawnLocation();
//TODO load team data from config
}
public void addPlayer(UUID uuid) {
players.put(uuid, new TeamPlayer(uuid, this));
log.debug("Added player with uuid {} to team with id {}", uuid, id);
}
public Optional<TeamPlayer> getPlayer(@NotNull UUID uuid) {
if (!players.containsKey(uuid))
return Optional.empty();
return Optional.of(players.get(uuid));
}
public Collection<TeamPlayer> getPlayers() {
return players.values();
}
public void removePlayer(@NotNull UUID uuid) {
TeamPlayer remove = players.remove(uuid);
if (remove != null) {
log.debug("Removed player with uuid {} from team with id {}", uuid, id);
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Team other = (Team) obj;
return Objects.equals(id, other.getId());
}
}
@@ -0,0 +1,4 @@
package com.alttd.ctf.team;
public record TeamColor(int r, int g, int b, String hex) {
}
@@ -0,0 +1,39 @@
package com.alttd.ctf.team;
import com.alttd.ctf.game_class.GameClass;
import lombok.Getter;
import lombok.Setter;
import java.util.Objects;
import java.util.UUID;
@Getter
public class TeamPlayer {
private final UUID uuid;
private final Team team;
@Setter
private GameClass gameClass;
protected TeamPlayer(UUID uuid, Team team) {
this.uuid = uuid;
this.team = team;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
TeamPlayer other = (TeamPlayer) obj;
return Objects.equals(uuid, other.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
}
+9
View File
@@ -0,0 +1,9 @@
name: CTF
version: '${version}'
main: com.alttd.ctf.Main
api-version: '1.21'
commands:
ctf:
description: Base command for capture the flag
permission: ctf.use
usage: /ctf