Compare commits

...
56 Commits
Author SHA1 Message Date
stijn 7dce9d9f30 Integrate TabListService to update player colors during the game 2026-07-12 21:41:26 +02:00
stijn 95219c17bc Add Multiverse-Core as a soft dependency to ensure teleport locations are found 2026-07-11 22:22:09 +02:00
stijn d412812dcb Add /hg list command to display players and spectators in the game 2026-07-11 22:20:09 +02:00
stijn 21f12904ac Update stateChange to handle ROUND_STATE.ENDED instead of ROUND_STATE.FINALE. 2026-07-11 22:20:03 +02:00
stijn 416c44a8f3 show participant status in tab using colors
announce killer if present
show everyone the bossbar
drop items on death
give players participation price permission
2026-07-11 22:13:52 +02:00
stijn fc7b5c76a6 Handle ROUND_STATE.ENDED in stateChange to clear resources and reset loot. 2026-07-11 21:53:40 +02:00
stijn 9198af6c29 Register online players without auto-join-bypass permission during ROUND_STATE.PLAYER_REGISTRATION or ROUND_STATE.ENDED. 2026-07-11 19:38:59 +02:00
stijn d5266b659b Register online players without auto-join-bypass permission during ROUND_STATE.PLAYER_REGISTRATION or ROUND_STATE.ENDED. 2026-07-11 19:38:26 +02:00
stijn e9e7c1cc80 Allow round to start from ROUND_STATE.ENDED and update related state handling 2026-07-11 19:35:33 +02:00
stijn 40f52a4a45 Update PlayerService to adjust PLAYER_STATE during ROUND_STATE.ENDED phase 2026-07-11 19:14:37 +02:00
stijn 3906bd747b Handle asynchronous player teleportation in PlayerTeleporterService with error handling and logging. 2026-07-11 17:14:32 +02:00
stijn 0d757aa2ab Remove redundant teleportation logging in PlayerService during finale phase. 2026-07-11 17:14:25 +02:00
stijn 5f35866e7a Make finale border size configurable via Config and apply it dynamically in RoundService. 2026-07-11 17:14:07 +02:00
stijn 491e6a6050 Make finale border size configurable via Config and apply it dynamically in RoundService. 2026-07-11 16:52:15 +02:00
stijn 244f8f6df7 Add logging to PlayerService for teleports, adjust ROUND_STATE handling, and use @Slf4j annotation for consistency. Update countdown logic in Round and refactor configuration keys for finale timing. 2026-07-11 16:51:04 +02:00
stijn fe10d57978 Show title messages during broadcasts to enhance player visibility. 2026-07-11 16:28:03 +02:00
stijn 8d05658214 Introduce LootItem record and refactor loot handling logic to support material-specific max amounts 2026-07-06 00:04:53 +02:00
stijn 7324177250 Change player game mode from SURVIVAL to ADVENTURE during round reset in RoundService 2026-07-05 23:59:49 +02:00
stijn c7161f707c Refactor Round phase transitions and countdown logic; consolidate messages, adjust state handling, and add configurable durations for safe and kill phases. 2026-07-05 23:42:20 +02:00
stijn 5ce26782f2 Add CommandListener to enforce command whitelist and block restricted commands 2026-07-05 23:19:49 +02:00
stijn a137ef4cc6 Handle empty stage list in Round and adjust state transitions in RoundService 2026-06-28 02:19:28 +02:00
stijn 62c9661c82 Adjust finale border size to 60 in RoundService 2026-06-28 00:35:39 +02:00
stijn 936cabfd42 Update boss bar messages to reflect accurate border shrinking phases and align config keys 2026-06-28 00:32:32 +02:00
stijn 38f8ffd4a3 Prioritize finale center when searching for safe locations in /stuck command 2026-06-27 23:58:54 +02:00
stijn 1a9f4cbd2d Refactor respawn handling: streamline spectator teleportation, reset player experience, adjust finale border size, and improve game stage countdown logic. 2026-06-27 23:57:07 +02:00
stijn 99bff5446d Adjust Round logic to handle last game stage countdown gracefully 2026-06-27 23:00:13 +02:00
stijn f9e00b04cc Update BORDER_TRANSITION_SECONDS in WorldBorderUtil to 45 2026-06-27 23:00:05 +02:00
stijn a7ce376abb Add ItemSpawnListener to control item despawn delays based on round phases 2026-06-27 22:55:09 +02:00
stijn 897c8ab14d Refactor world border handling: centralize logic in WorldBorderUtil and integrate into RoundService and GameStageHandler. Add logging for player teleportation and improve player reset process. 2026-06-27 22:32:43 +02:00
stijn a3085ad4b4 Prevent duplicate game end announcements and update RoundService initialization to include PlayerTeleporterService. 2026-06-27 22:17:50 +02:00
stijn 29c46ba485 Fix typo in getHumanReadableName across commands and services 2026-06-27 22:04:17 +02:00
stijn ca7b6f1152 Enhance death handling and round logic: suppress death messages, clear player inventories, reset health and food levels, and teleport players to spectator area or prepare them for countdown phase. Integrate PlayerTeleporterService into RoundService. 2026-06-27 22:02:05 +02:00
stijn 4bf6ca9dc4 Improve RoundState command to handle null ROUND_STATE values gracefully 2026-06-22 02:26:37 +02:00
stijn f0d3a20283 Add /hg nextphase and /hg end commands to manage game phases and rounds 2026-06-22 02:22:02 +02:00
stijn 233cffd2df Implement BossBarService for countdowns and finale updates, integrating dynamic boss bar messages and real-time player progress tracking. 2026-06-22 02:02:42 +02:00
stijn dd060bce48 Adjust teleportation logic to set correct direction for player-facing location 2026-06-21 23:56:16 +02:00
stijn 10a6103f32 Add LootItems configuration support and reload handling 2026-06-21 23:56:12 +02:00
stijn 646b9a2a35 Refactor STATS_FORMAT to use text block for improved readability 2026-06-15 23:29:28 +02:00
stijn 09b48fbafe Teleport spectating players to designated spectator area 2026-06-15 23:29:24 +02:00
stijn 6cd5315d28 Broadcast player death messages with remaining player count and suppress default death messages 2026-06-15 23:26:04 +02:00
stijn cf011ae72f Add winner declaration message and endRound logic with proper message broadcasting and error handling 2026-06-15 23:17:47 +02:00
stijn c8e85f186a Add player stats tracking with StatService and /hg stats command integration 2026-06-15 22:56:27 +02:00
stijn 2b6480c880 Add warmup period for /hg stuck with movement and damage cancellation 2026-06-15 22:45:06 +02:00
stijn 4cf4361286 Update /hg stuck cooldown handling with Duration for improved time management 2026-06-15 22:41:45 +02:00
stijn 016ab17ef2 Add /hg stuck command for teleporting players to the nearest safe location with configurable cooldown and effects 2026-06-15 22:37:16 +02:00
stijn 4c1fd4d228 Refactor decideRarity method for improved readability with consistent formatting 2026-06-15 22:23:36 +02:00
stijn 8ba514c9c2 Fix null-check for loot items and adjust rarity handling in generateLoot method 2026-06-15 22:15:45 +02:00
stijn 25c95dd46a Introduce LootService for chest loot handling and ChestListener for in-game chest interactions 2026-06-15 22:13:51 +02:00
stijn 58d0b7fa19 Add loot items configuration and RARITY enum for item rarity handling 2026-06-15 22:04:52 +02:00
stijn e97a6ade73 Add player movement freezing during countdown and winner declaration logic 2026-06-15 21:00:43 +02:00
stijn d7160b86cd Update message formatting for Hunger Games and improve configuration validation methods 2026-05-31 17:59:27 +02:00
stijn 02228f570d Add initial border size configuration, round warmup messages, and start-round validation logic 2026-05-31 16:40:16 +02:00
stijn c2d83492d1 Fix missing angle brackets in ROUND_STATE message placeholders 2026-05-30 20:02:59 +02:00
stijn 161344b30c Add /hg reload command to refresh configs and messages 2026-05-30 19:57:02 +02:00
stijn c969873e0a Refactor PlayerService to include Round dependency and update singleton initialization logic 2026-05-30 19:56:50 +02:00
stijn 9db0e70425 Introduce round management improvements, tab completion for /hg register, and plugin.yml configuration 2026-05-30 18:37:48 +02:00
40 changed files with 1923 additions and 93 deletions
+3
View File
@@ -41,3 +41,6 @@ bin/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
### Ignore bat files ###
*bat
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
+6 -5
View File
@@ -139,11 +139,12 @@ Track each registered player in one of these states:
## 🧭 Commands ## 🧭 Commands
### `/hg stuck` ### `/hg stuck`
- [ ] Teleport player to the nearest safe grass block - [x] Teleport player to the nearest safe grass block
- [ ] Require minimum height threshold to prevent abuse (configurable) - [x] Require minimum height threshold to prevent abuse (configurable)
- [ ] Launch fireworks at the destination on use - [x] Launch fireworks at the destination on use
- [ ] Configure firework count/type - [x] Configure firework count/type
- [ ] Optional cooldown to prevent spam - [x] Optional cooldown to prevent spam
- [x] Warmup period (configurable) that cancels on movement or damage
### `/hg stats [player]` ### `/hg stats [player]`
- [ ] Show overall stats for the specified player (or self if no argument) - [ ] Show overall stats for the specified player (or self if no argument)
+26 -13
View File
@@ -2,14 +2,10 @@ package com.alttd.hunger_games;
import com.alttd.hunger_games.commands.BaseCommand; import com.alttd.hunger_games.commands.BaseCommand;
import com.alttd.hunger_games.config.Config; import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.config.LootItems;
import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.event_listeners.PlayerDamageListener; import com.alttd.hunger_games.event_listeners.*;
import com.alttd.hunger_games.event_listeners.PlayerDisconnectListener; import com.alttd.hunger_games.services.*;
import com.alttd.hunger_games.event_listeners.PlayerJoinListener;
import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.PlayerTeleporterService;
import com.alttd.hunger_games.services.Round;
import com.alttd.hunger_games.services.RoundService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -17,9 +13,14 @@ import org.bukkit.plugin.java.JavaPlugin;
@Slf4j @Slf4j
public final class Main extends JavaPlugin { public final class Main extends JavaPlugin {
private Round round;
private RoundService roundService; private RoundService roundService;
private PlayerService playerService; private PlayerService playerService;
private PlayerTeleporterService playerTeleporterService; private PlayerTeleporterService playerTeleporterService;
private LootService lootService;
private StatService statService;
private BossBarService bossBarService;
private TabListService tabListService;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -32,10 +33,18 @@ public final class Main extends JavaPlugin {
} }
private void registerServices() { private void registerServices() {
Round round = Round.createSingletonInstance(); round = Round.createSingletonInstance();
roundService = RoundService.createSingletonInstance(round); lootService = new LootService();
statService = new StatService(this, round);
playerTeleporterService = PlayerTeleporterService.createSingletonInstance(); playerTeleporterService = PlayerTeleporterService.createSingletonInstance();
playerService = PlayerService.createSingletonInstance(roundService, playerTeleporterService); roundService = RoundService.createSingletonInstance(round, this, lootService, statService, playerTeleporterService);
tabListService = new TabListService(round, roundService);
roundService.setTabListService(tabListService);
playerService = PlayerService.createSingletonInstance(round, roundService, playerTeleporterService);
bossBarService = BossBarService.createSingletonInstance(round, roundService);
round.setBossBarService(bossBarService);
roundService.setBossBarService(bossBarService);
} }
@Override @Override
@@ -44,19 +53,23 @@ public final class Main extends JavaPlugin {
} }
private void registerCommands() { private void registerCommands() {
BaseCommand command = new BaseCommand(this, roundService, playerService); BaseCommand command = new BaseCommand(this, roundService, playerService, round, statService, tabListService);
} }
private void registerEvents() { private void registerEvents() {
PluginManager pluginManager = getServer().getPluginManager(); PluginManager pluginManager = getServer().getPluginManager();
pluginManager.registerEvents(new PlayerDisconnectListener(playerService), this); pluginManager.registerEvents(new PlayerDisconnectListener(playerService), this);
pluginManager.registerEvents(new PlayerJoinListener(playerService), this); pluginManager.registerEvents(new PlayerJoinListener(playerService, tabListService), this);
pluginManager.registerEvents(new PlayerDamageListener(roundService, playerService), this); pluginManager.registerEvents(new PlayerDamageListener(roundService, playerService, statService), this);
pluginManager.registerEvents(new ChestListener(roundService, lootService), this);
pluginManager.registerEvents(new ItemSpawnListener(roundService), this);
pluginManager.registerEvents(new CommandListener(), this);
} }
public void reloadConfigs() { public void reloadConfigs() {
Config.reload(); Config.reload();
Messages.reload(); Messages.reload();
LootItems.reload();
} }
private void registerSchedulers() { private void registerSchedulers() {
@@ -1,11 +1,9 @@
package com.alttd.hunger_games.commands; package com.alttd.hunger_games.commands;
import com.alttd.hunger_games.Main; import com.alttd.hunger_games.Main;
import com.alttd.hunger_games.commands.subcommands.Register; import com.alttd.hunger_games.commands.subcommands.*;
import com.alttd.hunger_games.commands.subcommands.RoundState;
import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.*;
import com.alttd.hunger_games.services.RoundService;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
@@ -17,11 +15,12 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Getter @Slf4j
@Getter
public class BaseCommand implements CommandExecutor, TabExecutor { public class BaseCommand implements CommandExecutor, TabExecutor {
private final List<SubCommand> subCommands; private final List<SubCommand> subCommands;
public BaseCommand(Main main, RoundService roundService, PlayerService playerService) { public BaseCommand(Main main, RoundService roundService, PlayerService playerService, Round round, StatService statService, TabListService tablistService) {
PluginCommand command = main.getCommand("hungergames"); PluginCommand command = main.getCommand("hungergames");
if (command == null) { if (command == null) {
subCommands = null; subCommands = null;
@@ -33,18 +32,29 @@ public class BaseCommand implements CommandExecutor, TabExecutor {
command.setAliases(List.of("hg")); command.setAliases(List.of("hg"));
subCommands = new ArrayList<>(List.of( subCommands = new ArrayList<>(List.of(
new Reload(main),
new RoundState(roundService), new RoundState(roundService),
new Register(playerService) new Register(playerService, tablistService),
new StartRound(round, roundService, playerService),
new NextPhase(round),
new EndRound(round),
new Stuck(main),
new Stats(statService),
new ListPlayers(roundService)
)); ));
} }
@Override @Override
public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String cmd, @NotNull String[] args) { public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String cmd, @NotNull String[] args) {
if (args.length == 0) { if (args.length == 0) {
commandSender.sendRichMessage(Messages.HELP.HELP_MESSAGE_WRAPPER.replaceAll("<commands>", subCommands.stream() commandSender.sendRichMessage(Messages.HELP.HELP_MESSAGE_WRAPPER.replaceAll("<commands>",
.filter(subCommand -> commandSender.hasPermission(subCommand.getPermission())) subCommands.stream()
.filter(subCommand -> commandSender.hasPermission(
subCommand.getPermission()))
.map(SubCommand::getHelpMessage) .map(SubCommand::getHelpMessage)
.collect(Collectors.joining("\n")))); .collect(Collectors.joining(
"\n"))
));
return true; return true;
} }
@@ -54,7 +64,9 @@ public class BaseCommand implements CommandExecutor, TabExecutor {
} }
if (!commandSender.hasPermission(subCommand.getPermission())) { if (!commandSender.hasPermission(subCommand.getPermission())) {
commandSender.sendRichMessage(Messages.GENERIC.NO_PERMISSION, Placeholder.parsed("permission", subCommand.getPermission())); commandSender.sendRichMessage(Messages.GENERIC.NO_PERMISSION,
Placeholder.parsed("permission", subCommand.getPermission())
);
return true; return true;
} }
@@ -0,0 +1,37 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.services.Round;
import lombok.RequiredArgsConstructor;
import org.bukkit.command.CommandSender;
import java.util.List;
@RequiredArgsConstructor
public class EndRound extends SubCommand {
private final Round round;
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
round.endRound();
commandSender.sendRichMessage("<green>Ending the round.</green>");
return true;
}
@Override
public String getName() {
return "end";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.END_ROUND;
}
}
@@ -0,0 +1,71 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.services.RoundService;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.*;
public class ListPlayers extends SubCommand {
private final RoundService roundService;
public ListPlayers(RoundService roundService) {
this.roundService = roundService;
}
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
Set<UUID> registeredUuids = roundService.getPlayers(PLAYER_STATE.REGISTERED);
Set<UUID> spectatingUuids = roundService.getPlayers(PLAYER_STATE.SPECTATING);
List<Component> registeredNames = registeredUuids.stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.map(Player::name)
.toList();
List<Component> spectatingNames = spectatingUuids.stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.map(Player::name)
.toList();
Component inGamePlayers = Component.join(JoinConfiguration.commas(true), registeredNames);
Component spectators = Component.join(JoinConfiguration.commas(true), spectatingNames);
int inGameCount = registeredNames.size();
int totalCount = inGameCount + spectatingNames.size();
commandSender.sendRichMessage(Messages.COMMANDS.LIST_PLAYERS,
Placeholder.unparsed("in_game", String.valueOf(inGameCount)),
Placeholder.unparsed("total", String.valueOf(totalCount)),
Placeholder.component("in_game_players", inGamePlayers),
Placeholder.component("spectators", spectators)
);
return true;
}
@Override
public String getName() {
return "list";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return Collections.emptyList();
}
@Override
public String getHelpMessage() {
return Messages.HELP.LIST;
}
}
@@ -0,0 +1,37 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.services.Round;
import lombok.RequiredArgsConstructor;
import org.bukkit.command.CommandSender;
import java.util.List;
@RequiredArgsConstructor
public class NextPhase extends SubCommand {
private final Round round;
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
round.forceNextPhase();
commandSender.sendRichMessage(Messages.COMMANDS.NEXT_PHASE_SUCCESS);
return true;
}
@Override
public String getName() {
return "nextphase";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.NEXT_PHASE;
}
}
@@ -5,8 +5,11 @@ import com.alttd.hunger_games.commands.shared_parsers.OnlinePlayerParser;
import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.PLAYER_STATE; import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.TabListService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -14,16 +17,14 @@ import javax.annotation.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@RequiredArgsConstructor
@Slf4j @Slf4j
public class Register extends SubCommand { public class Register extends SubCommand {
private final static OnlinePlayerParser ONLINE_PLAYER_PARSER = new OnlinePlayerParser(); private final static OnlinePlayerParser ONLINE_PLAYER_PARSER = new OnlinePlayerParser();
private final static int PLAYER_ARG = 1; private final static int PLAYER_ARG = 1;
private final PlayerService playerService; private final PlayerService playerService;
private final TabListService tabListService;
public Register(PlayerService playerService) {
this.playerService = playerService;
}
@Override @Override
public boolean onCommand(CommandSender commandSender, String[] args) { public boolean onCommand(CommandSender commandSender, String[] args) {
@@ -43,6 +44,7 @@ public class Register extends SubCommand {
playerToRegister.sendRichMessage(Messages.REGISTER.FAILED_TO_REGISTER_YOU); playerToRegister.sendRichMessage(Messages.REGISTER.FAILED_TO_REGISTER_YOU);
} else { } else {
notifyRegisteredPlayer(playerToRegister, nullablePlayerState); notifyRegisteredPlayer(playerToRegister, nullablePlayerState);
tabListService.updatePlayerColor(playerToRegister);
} }
if (commandSender instanceof Player commandPlayer) { if (commandSender instanceof Player commandPlayer) {
@@ -97,6 +99,9 @@ public class Register extends SubCommand {
@Override @Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) { public List<String> getTabComplete(CommandSender commandSender, String[] args) {
if (args.length == 2) {
return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList();
}
return List.of(); return List.of();
} }
@@ -0,0 +1,39 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.Main;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages;
import org.bukkit.command.CommandSender;
import java.util.List;
public class Reload extends SubCommand {
private final Main main;
public Reload(Main main) {
this.main = main;
}
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
main.reloadConfigs();
commandSender.sendRichMessage(Messages.GENERIC.RELOAD_SUCCESS);
return true;
}
@Override
public String getName() {
return "reload";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.RELOAD;
}
}
@@ -2,6 +2,7 @@ package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand; import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.RoundService; import com.alttd.hunger_games.services.RoundService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
@@ -16,9 +17,10 @@ public class RoundState extends SubCommand {
@Override @Override
public boolean onCommand(CommandSender commandSender, String[] args) { public boolean onCommand(CommandSender commandSender, String[] args) {
ROUND_STATE roundState = roundService.getRoundState();
commandSender.sendRichMessage(Messages.ROUND_STATE.ROUND_STATE, commandSender.sendRichMessage(Messages.ROUND_STATE.ROUND_STATE,
Placeholder.parsed("round_state", Placeholder.parsed("round_state",
roundService.getRoundState().getHumandReadableName())); roundState == null ? "null" : roundState.getHumanReadableName()));
return true; return true;
} }
@@ -2,8 +2,13 @@ package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand; import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages; import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.Round; import com.alttd.hunger_games.services.Round;
import com.alttd.hunger_games.services.RoundService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import java.util.List; import java.util.List;
@@ -12,10 +17,21 @@ import java.util.List;
public class StartRound extends SubCommand { public class StartRound extends SubCommand {
private final Round round; private final Round round;
private final RoundService roundService;
private final PlayerService playerService;
@Override @Override
public boolean onCommand(CommandSender commandSender, String[] args) { public boolean onCommand(CommandSender commandSender, String[] args) {
ROUND_STATE roundState = roundService.getRoundState();
if (roundState == ROUND_STATE.PLAYER_REGISTRATION || roundState == ROUND_STATE.ENDED) {
Bukkit.getOnlinePlayers().stream()
.filter(player -> !player.hasPermission("hungergames.auto-join-bypass"))
.forEach(playerService::registerPlayer);
round.startRound(); round.startRound();
} else {
commandSender.sendRichMessage(Messages.START_ROUND.CAN_NOT_START_ROUND,
Placeholder.parsed("round_state", roundState.getHumanReadableName()));
}
return true; return true;
} }
@@ -0,0 +1,78 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.LifetimeStats;
import com.alttd.hunger_games.services.StatService;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class Stats extends SubCommand {
private final StatService statService;
public Stats(StatService statService) {
this.statService = statService;
}
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
OfflinePlayer target;
if (args.length < 2) {
if (!(commandSender instanceof Player player)) {
commandSender.sendRichMessage(Messages.GENERIC.PLAYER_ONLY);
return true;
}
target = player;
} else {
target = Bukkit.getOfflinePlayerIfCached(args[1]);
if (target == null) {
target = Bukkit.getPlayer(args[1]);
}
if (target == null) {
commandSender.sendRichMessage(Messages.GENERIC.PLAYER_NOT_FOUND.replaceAll("<player>", args[1]));
return true;
}
}
LifetimeStats stats = statService.getOrCreateLifetimeStats(target.getUniqueId());
commandSender.sendRichMessage(Messages.STATS.STATS_FORMAT,
Placeholder.parsed("player", target.getName() != null ? target.getName() : "Unknown"),
Placeholder.parsed("kills", String.valueOf(stats.getTotalKills())),
Placeholder.parsed("deaths", String.valueOf(stats.getTotalDeaths())),
Placeholder.parsed("damage_dealt", String.format("%.2f", stats.getTotalDamageDealt())),
Placeholder.parsed("damage_taken", String.format("%.2f", stats.getTotalDamageTaken())),
Placeholder.parsed("wins", String.valueOf(stats.getTotalWins())),
Placeholder.parsed("rounds_played", String.valueOf(stats.getTotalRoundsPlayed())),
Placeholder.parsed("kd_ratio", String.format("%.2f", stats.getKDRatio()))
);
return true;
}
@Override
public String getName() {
return "stats";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
if (args.length == 2) {
return Bukkit.getOnlinePlayers().stream().map(Player::getName).collect(Collectors.toList());
}
return Collections.emptyList();
}
@Override
public String getHelpMessage() {
return Messages.HELP.STATS;
}
}
@@ -0,0 +1,233 @@
package com.alttd.hunger_games.commands.subcommands;
import com.alttd.hunger_games.Main;
import com.alttd.hunger_games.commands.SubCommand;
import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.config.Messages;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Color;
import org.bukkit.FireworkEffect;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.scheduler.BukkitTask;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
public class Stuck extends SubCommand implements Listener {
private final Main main;
private final Map<UUID, Instant> cooldowns = new HashMap<>();
private final Map<UUID, Warmup> warmups = new HashMap<>();
public Stuck(Main main) {
this.main = main;
main.getServer().getPluginManager().registerEvents(this, main);
}
private record Warmup(Location location, BukkitTask task) { }
@Override
public boolean onCommand(CommandSender commandSender, String[] args) {
if (!(commandSender instanceof Player player)) {
commandSender.sendRichMessage(Messages.GENERIC.PLAYER_ONLY);
return true;
}
if (warmups.containsKey(player.getUniqueId())) {
player.sendRichMessage(Messages.STUCK.ALREADY_WARMING_UP);
return true;
}
if (player.getLocation().getY() < Config.DESTINATION.STUCK_MIN_HEIGHT) {
player.sendRichMessage(Messages.STUCK.TOO_LOW,
Placeholder.parsed("height", String.valueOf(Config.DESTINATION.STUCK_MIN_HEIGHT))
);
return true;
}
if (isOnCooldown(player)) {
Duration remaining = getRemainingCooldown(player);
player.sendRichMessage(Messages.STUCK.ON_COOLDOWN,
Placeholder.parsed("time", remaining.toSeconds() + "s")
);
return true;
}
Optional<Location> safeLocation = findSafeLocation(player.getLocation());
if (safeLocation.isEmpty()) {
player.sendRichMessage(Messages.STUCK.NO_SAFE_LOCATION);
return true;
}
startWarmup(player, safeLocation.get());
return true;
}
private void startWarmup(Player player, Location safeLocation) {
Duration warmupTime = Config.DESTINATION.STUCK_WARMUP;
if (warmupTime.isZero() || warmupTime.isNegative()) {
teleport(player, safeLocation);
return;
}
player.sendRichMessage(Messages.STUCK.WARMUP_STARTED,
Placeholder.parsed("time", warmupTime.toSeconds() + "s")
);
BukkitTask task = main.getServer().getScheduler().runTaskLater(main, () -> {
warmups.remove(player.getUniqueId());
teleport(player, safeLocation);
}, warmupTime.toSeconds() * 20L);
warmups.put(player.getUniqueId(), new Warmup(player.getLocation(), task));
}
private void teleport(Player player, Location safeLocation) {
player.teleport(safeLocation.add(0.5, 1, 0.5));
spawnFireworks(player.getLocation());
player.sendRichMessage(Messages.STUCK.TELEPORTED);
cooldowns.put(player.getUniqueId(), Instant.now());
}
@EventHandler
public void onPlayerMove(PlayerMoveEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
Warmup warmup = warmups.get(uuid);
if (warmup == null) {
return;
}
Location from = warmup.location();
Location to = event.getTo();
if (from.getWorld() != to.getWorld() || from.distanceSquared(to) > 0.25) {
warmup.task().cancel();
warmups.remove(uuid);
event.getPlayer().sendRichMessage(Messages.STUCK.WARMUP_CANCELLED_MOVEMENT);
}
}
@EventHandler
public void onPlayerDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
Warmup warmup = warmups.get(player.getUniqueId());
if (warmup == null) {
return;
}
warmup.task().cancel();
warmups.remove(player.getUniqueId());
player.sendRichMessage(Messages.STUCK.WARMUP_CANCELLED_DAMAGE);
}
private boolean isOnCooldown(Player player) {
Instant lastUse = cooldowns.get(player.getUniqueId());
if (lastUse == null) {
return false;
}
return Duration.between(lastUse, Instant.now()).compareTo(Config.DESTINATION.STUCK_COOLDOWN) < 0;
}
private Duration getRemainingCooldown(Player player) {
Instant lastUse = cooldowns.get(player.getUniqueId());
if (lastUse == null) {
return Duration.ZERO;
}
Duration elapsed = Duration.between(lastUse, Instant.now());
Duration remaining = Config.DESTINATION.STUCK_COOLDOWN.minus(elapsed);
return remaining.isNegative() ? Duration.ZERO : remaining;
}
private Optional<Location> findSafeLocation(Location start) {
int radius = 10;
Location finaleCenter = Config.DESTINATION.FINALE_CENTER;
boolean prioritizeFinale = finaleCenter != null && finaleCenter.getWorld().equals(start.getWorld());
for (int r = 0; r <= radius; r++) {
List<Location> candidates = new ArrayList<>();
for (int x = -r; x <= r; x++) {
for (int z = -r; z <= r; z++) {
if (Math.abs(x) != r && Math.abs(z) != r) {
continue;
}
candidates.add(start.clone().add(x, 0, z));
}
}
if (prioritizeFinale) {
candidates.sort(Comparator.comparingDouble(loc -> loc.distanceSquared(finaleCenter)));
}
for (Location baseLocation : candidates) {
Optional<Location> safeY = findSafeY(baseLocation);
if (safeY.isPresent()) {
return safeY;
}
}
}
return Optional.empty();
}
private Optional<Location> findSafeY(Location base) {
// Search up and down a bit from current Y
for (int yOffset = -5; yOffset <= 5; yOffset++) {
Block target = base.getBlock().getRelative(0, yOffset, 0);
if (isSafe(target)) {
return Optional.of(target.getLocation());
}
}
return Optional.empty();
}
private boolean isSafe(Block block) {
return block.getType() == Material.GRASS_BLOCK &&
block.getRelative(BlockFace.UP).getType().isAir() &&
block.getRelative(BlockFace.UP).getRelative(BlockFace.UP).getType().isAir();
}
private void spawnFireworks(Location location) {
for (int i = 0; i < Config.DESTINATION.STUCK_FIREWORK_COUNT; i++) {
Firework firework = (Firework) location.getWorld().spawnEntity(location, EntityType.FIREWORK_ROCKET);
FireworkMeta meta = firework.getFireworkMeta();
meta.addEffect(FireworkEffect.builder()
.withColor(Color.RED, Color.ORANGE, Color.YELLOW)
.withFade(Color.ORANGE)
.with(FireworkEffect.Type.BALL_LARGE)
.trail(true)
.flicker(true)
.build());
meta.setPower(1);
firework.setFireworkMeta(meta);
}
}
@Override
public String getName() {
return "stuck";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.STUCK;
}
}
@@ -111,19 +111,25 @@ abstract class AbstractConfig {
return yaml.getDouble(path, yaml.getDouble(path)); return yaml.getDouble(path, yaml.getDouble(path));
} }
boolean contains(String prefix, String path) { boolean isValidSection(String prefix, String path) {
return getConfigurationSection(prefix + path) != null;
}
boolean isEmpty(String prefix, String path) {
path = prefix + path; path = prefix + path;
return !yaml.contains(path); return yaml.get(path) == null;
} }
Optional<Location> getLocation(String prefix, String path) { Optional<Location> getLocation(String prefix, String path) {
if (contains(prefix, path)) { if (!isValidSection(prefix, path)) {
log.warn("No location configuration for {}", prefix + path);
return Optional.empty(); return Optional.empty();
} }
//destination.start-center.world
String rootPath = prefix + path + "."; String rootPath = prefix + path + ".";
if (contains(rootPath, "world") || contains(rootPath, path + "x") || contains(rootPath, path + "y") || contains(rootPath, path + "z")) { if (isEmpty(rootPath, "world") || isEmpty(rootPath, "x") || isEmpty(rootPath, "y") || isEmpty(rootPath, "z")) {
log.error("Invalid location configuration for {}", path); ConfigurationSection configurationSection = getConfigurationSection(prefix + path);
log.error("Invalid location configuration for:\n\t{}", configurationSection.getKeys(false).stream().map(key -> rootPath + key).collect(Collectors.joining("\n\t")));
return Optional.empty(); return Optional.empty();
} }
@@ -180,4 +186,8 @@ abstract class AbstractConfig {
ConfigurationSection getConfigurationSection(String path) { ConfigurationSection getConfigurationSection(String path) {
return yaml.getConfigurationSection(path); return yaml.getConfigurationSection(path);
} }
ConfigurationSection createSection(String path) {
return yaml.createSection(path);
}
} }
@@ -2,16 +2,12 @@ package com.alttd.hunger_games.config;
import com.alttd.hunger_games.data_objects.GameStage; import com.alttd.hunger_games.data_objects.GameStage;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import java.io.File; import java.io.File;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.*;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Slf4j @Slf4j
public class Config extends AbstractConfig { public class Config extends AbstractConfig {
@@ -44,7 +40,13 @@ public class Config extends AbstractConfig {
public static class ROUND { public static class ROUND {
private static final String prefix = "round."; private static final String prefix = "round.";
public static String HG_WORLD = "world";
public static Duration COUNTDOWN = Duration.ofSeconds(10); public static Duration COUNTDOWN = Duration.ofSeconds(10);
public static Duration SAFE_PHASE = Duration.ofSeconds(30);
public static Duration TIME_TO_FINALE = Duration.ofMinutes(5);
public static int INITIAL_BORDER_SIZE = 5000;
public static int FINALE_BORDER_SIZE = 48;
public static int DEFAULT_BORDER_TRANSITION_SECONDS = 45;
public static List<GameStage> STAGES = List.of( public static List<GameStage> STAGES = List.of(
GameStage.builder().duration(Duration.ofMinutes(5)).worldBorderSize(1000).build(), GameStage.builder().duration(Duration.ofMinutes(5)).worldBorderSize(1000).build(),
GameStage.builder().duration(Duration.ofMinutes(5)).worldBorderSize(750).build(), GameStage.builder().duration(Duration.ofMinutes(5)).worldBorderSize(750).build(),
@@ -53,10 +55,21 @@ public class Config extends AbstractConfig {
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
HG_WORLD = config.getString(prefix, "hg-world", HG_WORLD);
int countdownSeconds = config.getInt(prefix, "countdown-seconds", Math.toIntExact(COUNTDOWN.toSeconds())); int countdownSeconds = config.getInt(prefix, "countdown-seconds", Math.toIntExact(COUNTDOWN.toSeconds()));
COUNTDOWN = Duration.ofSeconds(countdownSeconds); COUNTDOWN = Duration.ofSeconds(countdownSeconds);
int safePhaseSeconds = config.getInt(prefix, "safe-phase-seconds", Math.toIntExact(SAFE_PHASE.toSeconds()));
SAFE_PHASE = Duration.ofSeconds(safePhaseSeconds);
int killPhaseSeconds = config.getInt(prefix, "time-to-finale-seconds", Math.toIntExact(TIME_TO_FINALE.toSeconds()));
TIME_TO_FINALE = Duration.ofSeconds(killPhaseSeconds);
INITIAL_BORDER_SIZE = config.getInt(prefix, "initial-border-size", INITIAL_BORDER_SIZE);
FINALE_BORDER_SIZE = config.getInt(prefix, "finale-border-size", FINALE_BORDER_SIZE);
DEFAULT_BORDER_TRANSITION_SECONDS = config.getInt(prefix, "default-border-transition-seconds", DEFAULT_BORDER_TRANSITION_SECONDS);
ConfigurationSection configurationSection = config.getConfigurationSection(prefix + "stages"); ConfigurationSection configurationSection = config.getConfigurationSection(prefix + "stages");
if (configurationSection == null) {
configurationSection = config.createSection(prefix + "stages");
}
Set<String> keys = configurationSection.getKeys(false); Set<String> keys = configurationSection.getKeys(false);
STAGES = getGameStages(keys, configurationSection); STAGES = getGameStages(keys, configurationSection);
} }
@@ -105,6 +118,11 @@ public class Config extends AbstractConfig {
public static int FINALE_RADIUS = 10; public static int FINALE_RADIUS = 10;
public static Location SPECTATOR_AREA = null; public static Location SPECTATOR_AREA = null;
public static int STUCK_MIN_HEIGHT = 64;
public static int STUCK_FIREWORK_COUNT = 3;
public static Duration STUCK_COOLDOWN = Duration.ofMinutes(2);
public static Duration STUCK_WARMUP = Duration.ofSeconds(5);
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
Config.DESTINATION.START_RADIUS = config.getInt(prefix, "start-radius", START_RADIUS); Config.DESTINATION.START_RADIUS = config.getInt(prefix, "start-radius", START_RADIUS);
@@ -117,6 +135,24 @@ public class Config extends AbstractConfig {
DESTINATION.FINALE_RADIUS = config.getInt(prefix, "finale-radius", FINALE_RADIUS); DESTINATION.FINALE_RADIUS = config.getInt(prefix, "finale-radius", FINALE_RADIUS);
config.getLocation(prefix, "finale-center") config.getLocation(prefix, "finale-center")
.ifPresent(location -> DESTINATION.FINALE_CENTER = location); .ifPresent(location -> DESTINATION.FINALE_CENTER = location);
STUCK_MIN_HEIGHT = config.getInt(prefix, "stuck-min-height", STUCK_MIN_HEIGHT);
STUCK_FIREWORK_COUNT = config.getInt(prefix, "stuck-firework-count", STUCK_FIREWORK_COUNT);
STUCK_COOLDOWN = Duration.ofSeconds(config.getInt(prefix, "stuck-cooldown-seconds", (int) STUCK_COOLDOWN.toSeconds()));
STUCK_WARMUP = Duration.ofSeconds(config.getInt(prefix, "stuck-warmup-seconds", (int) STUCK_WARMUP.toSeconds()));
}
}
public static class COMMAND_WHITELIST {
private static final String prefix = "command-whitelist";
public static final List<String> PREFIXES = new ArrayList<>(List.of("msg", "ac", "gac", "tell", "r", "server", "lobby", "hub", "bayou", "creative", "hg", "lp"));
@SuppressWarnings("unused")
private static void load() {
List<String> stringList = config.getStringList(prefix, "prefix-list", PREFIXES);
PREFIXES.clear();
PREFIXES.addAll(stringList);
} }
} }
} }
@@ -0,0 +1,71 @@
package com.alttd.hunger_games.config;
import com.alttd.hunger_games.data_objects.LootItem;
import com.alttd.hunger_games.data_objects.RARITY;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Material;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@Slf4j
public class LootItems extends AbstractConfig {
static LootItems config;
LootItems() {
super(
new File(File.separator
+ "mnt" + File.separator
+ "configs" + File.separator
+ "HungerGames"),
"loot-items.yml");
}
public static void reload() {
config = new LootItems();
config.readConfig(LootItems.class, null);
}
public static class ITEMS {
private static final String prefix = "items.";
private static final HashMap<RARITY, List<LootItem>> ITEMS = new HashMap<>();
public static List<LootItem> get(RARITY rarity) {
return ITEMS.get(rarity);
}
@SuppressWarnings("unused")
private static void load() {
for (RARITY rarity : RARITY.values()) {
List<LootItem> currentItems = ITEMS.getOrDefault(rarity, List.of());
List<String> materialNameList = currentItems.stream()
.map(item -> item.material().name() + (item.maxAmount() > 1 ? ":" + item.maxAmount() : ""))
.toList();
List<String> materialList = config.getList(prefix, rarity.getConfigName(), materialNameList);
List<LootItem> lootItems = new ArrayList<>();
for (String entry : materialList) {
String[] split = entry.split(":");
Material material = Material.getMaterial(split[0].toUpperCase());
if (material == null) {
log.warn("Invalid material found in loot-items.yml: {}", split[0]);
continue;
}
int maxAmount = 1;
if (split.length > 1) {
try {
maxAmount = Integer.parseInt(split[1]);
} catch (NumberFormatException ignored) {
log.error("Invalid max amount found in loot-items.yml: {}", split[1]);
}
}
lootItems.add(new LootItem(material, maxAmount));
}
ITEMS.put(rarity, lootItems);
}
}
}
}
@@ -29,6 +29,12 @@ public class Messages extends AbstractConfig {
public static String ROUND_STATE = "<green>Show the current round state: <gold>/hg roundstate</gold></green>"; public static String ROUND_STATE = "<green>Show the current round state: <gold>/hg roundstate</gold></green>";
public static String REGISTER = "<green>Register a player for the game: <gold>/hg register <player></gold></green>"; public static String REGISTER = "<green>Register a player for the game: <gold>/hg register <player></gold></green>";
public static String START_ROUND = "<green>Start the game: <gold>/hg start</gold></green>"; public static String START_ROUND = "<green>Start the game: <gold>/hg start</gold></green>";
public static String NEXT_PHASE = "<green>Skip to the next phase/stage: <gold>/hg nextphase</gold></green>";
public static String RELOAD = "<green>Reload config and messages: <gold>/hg reload</gold></green>";
public static String STUCK = "<green>Teleport to safety if stuck: <gold>/hg stuck</gold></green>";
public static String STATS = "<green>Show player stats: <gold>/hg stats [player]</gold></green>";
public static String LIST = "<green>List all players in the game and spectators: <gold>/hg list</gold></green>";
public static String END_ROUND = "<green>End the game: <gold>/hg end</gold></green>";
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
@@ -37,6 +43,12 @@ public class Messages extends AbstractConfig {
ROUND_STATE = config.getString(prefix, "round-state", ROUND_STATE); ROUND_STATE = config.getString(prefix, "round-state", ROUND_STATE);
REGISTER = config.getString(prefix, "register", REGISTER); REGISTER = config.getString(prefix, "register", REGISTER);
START_ROUND = config.getString(prefix, "start", START_ROUND); START_ROUND = config.getString(prefix, "start", START_ROUND);
NEXT_PHASE = config.getString(prefix, "next-phase", NEXT_PHASE);
RELOAD = config.getString(prefix, "reload", RELOAD);
STUCK = config.getString(prefix, "stuck", STUCK);
STATS = config.getString(prefix, "stats", STATS);
LIST = config.getString(prefix, "list", LIST);
END_ROUND = config.getString(prefix, "end-round", END_ROUND);
} }
} }
@@ -46,19 +58,23 @@ public class Messages extends AbstractConfig {
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 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_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>"; public static String PLAYER_NOT_FOUND = "<red>Unable to find online player <player></red>";
public static String RELOAD_SUCCESS = "<green>Config and messages have been reloaded.</green>";
public static String COMMAND_BLOCKED = "<red>You cannot use that command here!</red>";
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
NO_PERMISSION = config.getString(prefix, "no-permission", NO_PERMISSION); NO_PERMISSION = config.getString(prefix, "no-permission", NO_PERMISSION);
PLAYER_ONLY = config.getString(prefix, "player-only", PLAYER_ONLY); PLAYER_ONLY = config.getString(prefix, "player-only", PLAYER_ONLY);
PLAYER_NOT_FOUND = config.getString(prefix, "player-only", PLAYER_NOT_FOUND); PLAYER_NOT_FOUND = config.getString(prefix, "player-only", PLAYER_NOT_FOUND);
RELOAD_SUCCESS = config.getString(prefix, "reload-success", RELOAD_SUCCESS);
COMMAND_BLOCKED = config.getString(prefix, "command-blocked", COMMAND_BLOCKED);
} }
} }
public static class ROUND_STATE { public static class ROUND_STATE {
private static final String prefix = "round-state."; private static final String prefix = "round-state.";
public static String ROUND_STATE = "<green>Current round state is: <gold>round_state</gold></green>"; public static String ROUND_STATE = "<green>Current round state is: <gold><round_state></gold></green>";
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static void load() { private static void load() {
@@ -66,6 +82,56 @@ public class Messages extends AbstractConfig {
} }
} }
public static class GAME {
private static final String prefix = "game.";
public static String WARMUP = "<gold>The Hunger Games are starting soon! Get ready!</gold>";
public static String STARTED = "<green><bold>The Hunger Games has begun! Good luck!</bold></green>";
public static String BORDER_SHRINK = "<red>The border is shrinking to <size> blocks!</red>";
public static String CHEST_EMPTY = "<red>This chest is empty!</red>";
public static String WINNER = "<gold><player> has won the Hunger Games!</gold>";
public static String PLAYER_DEATH = "<red><player> has died! <gold><remaining></gold> players remaining.</red>";
public static String PLAYER_KILLED = "<red><victim> was killed by <killer>! <gold><remaining></gold> players remaining.</red>";
public static String BOSSBAR_COUNTDOWN = "<gold>Loot phase in <time></gold>";
public static String BOSSBAR_PVP_COUNTDOWN = "<green>PVP starts in <time></green>";
public static String BOSSBAR_SAFE_PHASE = "<green>Border shrinking in <time></green>";
public static String BOSSBAR_KILL_PHASE = "<red>Border shrinking... <time></red>";
public static String BOSSBAR_FINALE = "<gold>Remaining Players: <remaining></gold>";
public static String NEXT_STATE = "<gold>Starting <round>!</gold>";
@SuppressWarnings("unused")
private static void load() {
WARMUP = config.getString(prefix, "warmup", WARMUP);
STARTED = config.getString(prefix, "started", STARTED);
BORDER_SHRINK = config.getString(prefix, "border-shrink", BORDER_SHRINK);
CHEST_EMPTY = config.getString(prefix, "chest-empty", CHEST_EMPTY);
WINNER = config.getString(prefix, "winner", WINNER);
PLAYER_DEATH = config.getString(prefix, "player-death", PLAYER_DEATH);
PLAYER_KILLED = config.getString(prefix, "player-killed", PLAYER_KILLED);
BOSSBAR_COUNTDOWN = config.getString(prefix, "bossbar-countdown", BOSSBAR_COUNTDOWN);
BOSSBAR_PVP_COUNTDOWN = config.getString(prefix, "bossbar-pvp-countdown", BOSSBAR_PVP_COUNTDOWN);
BOSSBAR_SAFE_PHASE = config.getString(prefix, "bossbar-border-shrinking-in", BOSSBAR_SAFE_PHASE);
BOSSBAR_KILL_PHASE = config.getString(prefix, "bossbar-border-shrinking", BOSSBAR_KILL_PHASE);
BOSSBAR_FINALE = config.getString(prefix, "bossbar-finale", BOSSBAR_FINALE);
NEXT_STATE = config.getString(prefix, "next-state", NEXT_STATE);
}
}
public static class START_ROUND {
private static final String prefix = "start-round.";
public static String CAN_NOT_START_ROUND = "<red>Unable to start the round, current state is <round_state></red>";
@SuppressWarnings("unused")
private static void load() {
CAN_NOT_START_ROUND = config.getString(prefix, "can-not-start", CAN_NOT_START_ROUND);
}
}
public static class RECONNECT { public static class RECONNECT {
private static final String prefix = "reconnect."; private static final String prefix = "reconnect.";
@@ -97,4 +163,64 @@ public class Messages extends AbstractConfig {
GAME_RUNNING = config.getString(prefix, "game-running", GAME_RUNNING); GAME_RUNNING = config.getString(prefix, "game-running", GAME_RUNNING);
} }
} }
public static class STUCK {
private static final String prefix = "stuck.";
public static String TELEPORTED = "<green>You have been teleported to safety!</green>";
public static String TOO_LOW = "<red>You are too low to use this command (minimum height: <height>). If you are truly stuck here dm a staff member for help</red>";
public static String NO_SAFE_LOCATION = "<red>Unable to find a safe grass block nearby</red>";
public static String ON_COOLDOWN = "<red>You must wait <time> before using this command again</red>";
public static String WARMUP_STARTED = "<green>Teleporting in <time>. Do not move or take damage!</green>";
public static String WARMUP_CANCELLED_MOVEMENT = "<red>Teleportation cancelled due to movement.</red>";
public static String WARMUP_CANCELLED_DAMAGE = "<red>Teleportation cancelled due to taking damage.</red>";
public static String ALREADY_WARMING_UP = "<red>You are already warming up!</red>";
@SuppressWarnings("unused")
private static void load() {
TELEPORTED = config.getString(prefix, "teleported", TELEPORTED);
TOO_LOW = config.getString(prefix, "too-low", TOO_LOW);
NO_SAFE_LOCATION = config.getString(prefix, "no-safe-location", NO_SAFE_LOCATION);
ON_COOLDOWN = config.getString(prefix, "on-cooldown", ON_COOLDOWN);
WARMUP_STARTED = config.getString(prefix, "warmup-started", WARMUP_STARTED);
WARMUP_CANCELLED_MOVEMENT = config.getString(prefix, "warmup-cancelled-movement", WARMUP_CANCELLED_MOVEMENT);
WARMUP_CANCELLED_DAMAGE = config.getString(prefix, "warmup-cancelled-damage", WARMUP_CANCELLED_DAMAGE);
ALREADY_WARMING_UP = config.getString(prefix, "already-warming-up", ALREADY_WARMING_UP);
}
}
public static class STATS {
private static final String prefix = "stats.";
public static String STATS_FORMAT = """
<gold>Stats for <player>:</gold>
<green>Kills: <gold><kills></gold></green>
<green>Deaths: <gold><deaths></gold></green>
<green>Damage Dealt: <gold><damage_dealt></gold></green>
<green>Damage Taken: <gold><damage_taken></gold></green>
<green>Wins: <gold><wins></gold></green>
<green>Rounds Played: <gold><rounds_played></gold></green>
<green>K/D Ratio: <gold><kd_ratio></gold></green>""";
@SuppressWarnings("unused")
private static void load() {
STATS_FORMAT = config.getString(prefix, "format", STATS_FORMAT);
}
}
public static class COMMANDS {
private static final String prefix = "commands.";
public static String NEXT_PHASE_SUCCESS = "<green>Skipping to the next phase/stage.</green>";
public static String LIST_PLAYERS = """
<gold>HungerGames Players (<in_game>/<total>):</gold>
<green>In Game: <white><in_game_players></white></green>
<yellow>Spectators: <white><spectators></white></yellow>""";
@SuppressWarnings("unused")
private static void load() {
NEXT_PHASE_SUCCESS = config.getString(prefix + "next-phase", "success", NEXT_PHASE_SUCCESS);
LIST_PLAYERS = config.getString(prefix, "list-players", LIST_PLAYERS);
}
}
} }
@@ -0,0 +1,36 @@
package com.alttd.hunger_games.data_objects;
import lombok.Builder;
import lombok.Data;
import java.util.UUID;
@Data
@Builder
public class LifetimeStats {
private final UUID uuid;
private int totalKills;
private int totalDeaths;
private double totalDamageDealt;
private double totalDamageTaken;
private int totalWins;
private int totalRoundsPlayed;
public void addRoundStats(RoundStats roundStats) {
totalKills += roundStats.getKills();
totalDeaths += roundStats.getDeaths();
totalDamageDealt += roundStats.getDamageDealt();
totalDamageTaken += roundStats.getDamageTaken();
if (roundStats.isWon()) {
totalWins++;
}
totalRoundsPlayed++;
}
public double getKDRatio() {
if (totalDeaths == 0) {
return totalKills;
}
return (double) totalKills / totalDeaths;
}
}
@@ -0,0 +1,6 @@
package com.alttd.hunger_games.data_objects;
import org.bukkit.Material;
public record LootItem(Material material, int maxAmount) {
}
@@ -0,0 +1,15 @@
package com.alttd.hunger_games.data_objects;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Getter
public enum RARITY {
COMMON("common"),
UNCOMMON("uncommon"),
RARE("rare"),
EPIC("epic");
private final String configName;
}
@@ -17,7 +17,7 @@ public enum ROUND_STATE {
return Optional.of(values()[ordinal() + 1]); return Optional.of(values()[ordinal() + 1]);
} }
public String getHumandReadableName() { public String getHumanReadableName() {
return switch (this) { return switch (this) {
case PLAYER_REGISTRATION -> "Player Registration"; case PLAYER_REGISTRATION -> "Player Registration";
case COUNTDOWN -> "Countdown"; case COUNTDOWN -> "Countdown";
@@ -0,0 +1,34 @@
package com.alttd.hunger_games.data_objects;
import lombok.Builder;
import lombok.Data;
import java.util.UUID;
@Data
@Builder
public class RoundStats {
private final UUID uuid;
private int kills;
private int deaths;
private double damageDealt;
private double damageTaken;
private int placement;
private boolean won;
public void addKill() {
kills++;
}
public void addDeath() {
deaths++;
}
public void addDamageDealt(double damage) {
damageDealt += damage;
}
public void addDamageTaken(double damage) {
damageTaken += damage;
}
}
@@ -0,0 +1,54 @@
package com.alttd.hunger_games.event_listeners;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.LootService;
import com.alttd.hunger_games.services.RoundService;
import lombok.RequiredArgsConstructor;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.Inventory;
@RequiredArgsConstructor
public class ChestListener implements Listener {
private final RoundService roundService;
private final LootService lootService;
@EventHandler
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
Block block = event.getClickedBlock();
if (block == null || block.getType() != Material.CHEST) {
return;
}
ROUND_STATE roundState = roundService.getRoundState();
if (roundState != ROUND_STATE.SAFE_PHASE && roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) {
return;
}
Player player = event.getPlayer();
if (roundService.getPlayerState(player.getUniqueId()).orElse(null) != PLAYER_STATE.REGISTERED) {
return;
}
event.setCancelled(true);
Inventory inventory = lootService.getChestInventory(block.getLocation());
if (inventory.isEmpty()) {
player.sendRichMessage(Messages.GAME.CHEST_EMPTY);
} else {
player.openInventory(inventory);
}
}
}
@@ -0,0 +1,34 @@
package com.alttd.hunger_games.event_listeners;
import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.config.Messages;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import java.util.Optional;
public class CommandListener implements Listener {
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
Player player = event.getPlayer();
if (player.hasPermission("hg.bypass")) {
return;
}
// Remove leading slash
String message = event.getMessage().substring(1).toLowerCase();
String command = message.split(" ")[0];
boolean whitelisted = Config.COMMAND_WHITELIST.PREFIXES.stream()
.anyMatch(prefix -> command.startsWith(prefix.toLowerCase()));
if (!whitelisted) {
event.setCancelled(true);
player.sendRichMessage(Messages.GENERIC.COMMAND_BLOCKED);
}
}
}
@@ -0,0 +1,59 @@
package com.alttd.hunger_games.event_listeners;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.RoundService;
import lombok.RequiredArgsConstructor;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.ItemSpawnEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@RequiredArgsConstructor
public class ItemSpawnListener implements Listener {
private final RoundService roundService;
private final Set<UUID> deathDrops = new HashSet<>();
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onItemSpawn(ItemSpawnEvent event) {
if (deathDrops.remove(event.getEntity().getUniqueId())) {
event.getEntity().setTicksLived(5400); // 300 seconds total - 5400 ticks = 30 seconds left
return;
}
setDespawnDelay(event.getEntity());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerDeath(EntityDeathEvent event) {
if (!(event.getEntity() instanceof Player)) {
return;
}
ROUND_STATE roundState = roundService.getRoundState();
if (roundState == ROUND_STATE.SAFE_PHASE || roundState == ROUND_STATE.KILL_PHASE || roundState == ROUND_STATE.FINALE) {
event.getDrops().forEach(itemStack -> {
Item item = event.getEntity().getWorld().dropItemNaturally(event.getEntity().getLocation(), itemStack);
deathDrops.add(item.getUniqueId());
});
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerDropItem(PlayerDropItemEvent event) {
setDespawnDelay(event.getItemDrop());
}
private void setDespawnDelay(Item item) {
ROUND_STATE roundState = roundService.getRoundState();
if (roundState == ROUND_STATE.SAFE_PHASE || roundState == ROUND_STATE.KILL_PHASE || roundState == ROUND_STATE.FINALE) {
item.setTicksLived(5800);
}
}
}
@@ -3,10 +3,13 @@ package com.alttd.hunger_games.event_listeners;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.RoundService; import com.alttd.hunger_games.services.RoundService;
import com.alttd.hunger_games.services.StatService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
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.PlayerRespawnEvent; import org.bukkit.event.player.PlayerRespawnEvent;
@@ -16,25 +19,43 @@ public class PlayerDamageListener implements Listener {
private final RoundService roundService; private final RoundService roundService;
private final PlayerService playerService; private final PlayerService playerService;
private final StatService statService;
@EventHandler @EventHandler
public void onEntityDamage(EntityDamageEvent event) { public void onEntityDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player)) { if (!(event.getEntity() instanceof Player player)) {
return; return;
} }
ROUND_STATE roundState = roundService.getRoundState(); ROUND_STATE roundState = roundService.getRoundState();
if (roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) { if (roundState != ROUND_STATE.KILL_PHASE && roundState != ROUND_STATE.FINALE) {
event.setCancelled(true); event.setCancelled(true);
return;
}
double damage = Math.min(event.getFinalDamage(), player.getHealth());
statService.addDamageTaken(player.getUniqueId(), damage);
if (event instanceof EntityDamageByEntityEvent damageByEntityEvent && damageByEntityEvent.getDamager() instanceof Player attacker) {
statService.addDamageDealt(attacker.getUniqueId(), damage);
} }
} }
@EventHandler @EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerDeath(PlayerDeathEvent event) { public void onPlayerDeath(PlayerDeathEvent event) {
playerService.handlePlayerDeath(event.getPlayer()); Player player = event.getPlayer();
event.setShowDeathMessages(false);
event.deathMessage(null);
playerService.handlePlayerDeath(player);
statService.addDeath(player.getUniqueId());
Player killer = player.getKiller();
if (killer != null) {
statService.addKill(killer.getUniqueId());
}
} }
@EventHandler @EventHandler
public void onPlayerRespawn(PlayerRespawnEvent event) { public void onPlayerRespawn(PlayerRespawnEvent event) {
playerService.handlePlayerRespawn(event.getPlayer()); playerService.handlePlayerRespawn(event);
} }
} }
@@ -1,18 +1,33 @@
package com.alttd.hunger_games.event_listeners; package com.alttd.hunger_games.event_listeners;
import com.alttd.hunger_games.services.PlayerService; import com.alttd.hunger_games.services.PlayerService;
import com.alttd.hunger_games.services.TabListService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandException;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class PlayerJoinListener implements Listener { public class PlayerJoinListener implements Listener {
private final PlayerService playerService; private final PlayerService playerService;
private final TabListService tabListService;
@EventHandler @EventHandler
public void onPlayerJoin(PlayerJoinEvent event) { public void onPlayerJoin(PlayerJoinEvent event) {
playerService.handleJoin(event.getPlayer()); Player player = event.getPlayer();
playerService.handleJoin(player);
tabListService.updatePlayerColor(player);
try {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(),
String.format("lp user %s permission settemp cmi.kit.hunger-games true 7d", player.getName()));
} catch (CommandException e) {
log.error("Failed to set participant permission for {}", player.getName(), e);
}
} }
} }
@@ -0,0 +1,29 @@
package com.alttd.hunger_games.event_listeners;
import lombok.RequiredArgsConstructor;
import org.bukkit.Location;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import java.util.Set;
import java.util.UUID;
@RequiredArgsConstructor
public class PlayerMovementListener implements Listener {
private final Set<UUID> uuidSet;
@EventHandler
public void onPlayerMove(PlayerMoveEvent event) {
if (!uuidSet.contains(event.getPlayer().getUniqueId())) {
return;
}
Location from = event.getFrom();
Location to = event.getTo();
//Only freeze movement not rotation or jumping
if (from.getBlockX() != to.getBlockX()
|| from.getBlockZ() != to.getBlockZ()) {
event.setTo(from);
}
}
}
@@ -1,19 +1,43 @@
package com.alttd.hunger_games.game; package com.alttd.hunger_games.game;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.utils.WorldBorderUtil;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import java.time.Duration;
@UtilityClass @UtilityClass
public class GameStageHandler { public class GameStageHandler {
public static void handleStageChange(int worldBorderSize) { public static void handleStageChange(int worldBorderSize) {
//TODO change world border size and handle stage change WorldBorderUtil.setSize(worldBorderSize);
String message = Messages.GAME.BORDER_SHRINK
.replace("<size>", String.valueOf(worldBorderSize));
broadcast(message);
}
public static void broadcastMessage(String message) {
broadcast(message);
} }
public static void handleCountdownEnd() { public static void handleCountdownEnd() {
//TODO free players and handle stage change broadcast(Messages.GAME.STARTED);
} }
public static void handleWarmup() { public static void handleWarmup() {
//TODO tp players to start area etc (might be handled by state change already, so maybe just messages) broadcast(Messages.GAME.WARMUP);
}
private static void broadcast(String message) {
Component component = MiniMessage.miniMessage().deserialize(message);
Bukkit.broadcast(component);
Title.Times times = Title.Times.times(Duration.ofSeconds(1), Duration.ofSeconds(5), Duration.ofSeconds(1));
Title title = Title.title(Component.empty(), component, times);
Bukkit.getOnlinePlayers().forEach(player -> player.showTitle(title));
} }
} }
@@ -0,0 +1,146 @@
package com.alttd.hunger_games.services;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.time.Duration;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
public class BossBarService implements RoundListener {
private static BossBarService instance = null;
private final RoundService roundService;
private final BossBar bossBar;
private final Set<UUID> viewers = new HashSet<>();
private int maxPlayers = 0;
public BossBarService(Round round, RoundService roundService) {
this.roundService = roundService;
this.bossBar = BossBar.bossBar(Component.empty(), 1.0f, BossBar.Color.YELLOW, BossBar.Overlay.PROGRESS);
round.register(this);
}
public static BossBarService createSingletonInstance(Round round, RoundService roundService) {
if (instance != null) {
throw new IllegalStateException("BossBarService is already initialized.");
}
instance = new BossBarService(round, roundService);
return instance;
}
public void updateCountdown(Duration timeLeft, Duration totalTime, ROUND_STATE state) {
String message = switch (state) {
case COUNTDOWN -> Messages.GAME.BOSSBAR_COUNTDOWN;
case SAFE_PHASE -> Messages.GAME.BOSSBAR_PVP_COUNTDOWN;
case KILL_PHASE -> Messages.GAME.BOSSBAR_SAFE_PHASE;
default -> {
if (state.ordinal() > ROUND_STATE.KILL_PHASE.ordinal()) {
yield Messages.GAME.BOSSBAR_KILL_PHASE;
}
throw new IllegalArgumentException("Invalid round state: " + state);
}
};
if (message.isEmpty()) {
bossBar.name(Component.empty());
bossBar.progress(0.0f);
return;
}
bossBar.name(MiniMessage.miniMessage().deserialize(message, Placeholder.unparsed("time", formatTime(timeLeft))));
bossBar.progress(Math.clamp((float) timeLeft.toMillis() / totalTime.toMillis(), 0.0f, 1.0f));
updateBossBarColor(state);
updateViewers();
}
private void updateBossBarColor(ROUND_STATE state) {
if (state == ROUND_STATE.ENDED || state == ROUND_STATE.PLAYER_REGISTRATION) {
hideAll();
return;
}
switch (state) {
case COUNTDOWN -> bossBar.color(BossBar.Color.YELLOW);
case SAFE_PHASE -> bossBar.color(BossBar.Color.GREEN);
case KILL_PHASE -> bossBar.color(BossBar.Color.RED);
case FINALE -> bossBar.color(BossBar.Color.PURPLE);
default -> bossBar.color(BossBar.Color.WHITE);
}
}
public void updateFinale(int remainingPlayers) {
if (maxPlayers == 0) {
maxPlayers = remainingPlayers;
}
if (remainingPlayers <= 1) {
hideAll();
return;
}
bossBar.name(MiniMessage.miniMessage().deserialize(Messages.GAME.BOSSBAR_FINALE, Placeholder.unparsed("remaining", String.valueOf(remainingPlayers))));
bossBar.progress(Math.clamp((float) remainingPlayers / maxPlayers, 0.0f, 1.0f));
bossBar.color(BossBar.Color.PURPLE);
updateViewers();
}
private void updateViewers() {
if (roundService.getRoundState() == ROUND_STATE.ENDED || roundService.getRoundState() == ROUND_STATE.PLAYER_REGISTRATION) {
hideAll();
return;
}
Bukkit.getOnlinePlayers().forEach(player -> {
if (viewers.add(player.getUniqueId())) {
player.showBossBar(bossBar);
}
});
viewers.removeIf(uuid -> {
Player player = Bukkit.getPlayer(uuid);
return player == null || !player.isOnline();
});
}
private void hideAll() {
viewers.stream()
.map(Bukkit::getPlayer)
.filter(Objects::nonNull)
.forEach(player -> player.hideBossBar(bossBar));
viewers.clear();
}
private String formatTime(Duration duration) {
if (duration.isNegative()) {
return "0s";
}
long minutes = duration.toMinutesPart();
int seconds = duration.toSecondsPart();
if (minutes > 0) {
return "%dm %ds".formatted(minutes, seconds);
}
return "%ds".formatted(seconds);
}
@Override
public void stateChange(ROUND_STATE roundState) {
if (roundState == ROUND_STATE.PLAYER_REGISTRATION || roundState == ROUND_STATE.ENDED) {
hideAll();
maxPlayers = 0;
} else if (roundState == ROUND_STATE.FINALE) {
updateFinale(roundService.getPlayers(PLAYER_STATE.REGISTERED).size());
}
}
}
@@ -0,0 +1,82 @@
package com.alttd.hunger_games.services;
import com.alttd.hunger_games.config.LootItems;
import com.alttd.hunger_games.data_objects.LootItem;
import com.alttd.hunger_games.data_objects.RARITY;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
@Slf4j
public class LootService {
private final HashMap<Location, Inventory> chestInventories = new HashMap<>();
private final Random random = ThreadLocalRandom.current();
public Inventory getChestInventory(Location location) {
if (chestInventories.containsKey(location)) {
return chestInventories.get(location);
}
Inventory inventory = generateLoot();
chestInventories.put(location, inventory);
return inventory;
}
private Inventory generateLoot() {
Inventory inventory = Bukkit.createInventory(null, 27, Component.text("Chest"));
int itemCount = random.nextInt(3, 7);
for (int i = 0; i < itemCount; i++) {
int slot = random.nextInt(27);
RARITY rarity = decideRarity();
List<LootItem> possibleItems = LootItems.ITEMS.get(rarity);
if (possibleItems == null || possibleItems.isEmpty()) {
log.warn("No items found for rarity: {}", rarity);
continue;
}
LootItem lootItem = possibleItems.get(random.nextInt(possibleItems.size()));
if (lootItem == null || lootItem.material() == null) {
log.warn("LootItem or material is null for rarity: {}", rarity);
continue;
}
int amount = lootItem.maxAmount() > 1 ? random.nextInt(lootItem.maxAmount()) + 1 : 1;
inventory.setItem(slot, new ItemStack(lootItem.material(), amount));
}
return inventory;
}
private RARITY decideRarity() {
double roll = random.nextDouble();
// 5%
if (roll < 0.05) {
return RARITY.EPIC;
}
// 10%
if (roll < 0.15) {
return RARITY.RARE;
}
// 25%
if (roll < 0.40) {
return RARITY.UNCOMMON;
}
// 60%
return RARITY.COMMON;
}
public void clear() {
chestInventories.clear();
}
}
@@ -4,13 +4,17 @@ import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.DESTINATION; import com.alttd.hunger_games.data_objects.DESTINATION;
import com.alttd.hunger_games.data_objects.PLAYER_STATE; import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
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 org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerRespawnEvent;
import java.util.*; import java.util.*;
@RequiredArgsConstructor @Slf4j
public class PlayerService implements RoundListener { public class PlayerService implements RoundListener {
private static PlayerService instance = null; private static PlayerService instance = null;
@@ -19,17 +23,24 @@ public class PlayerService implements RoundListener {
private final PlayerTeleporterService playerTeleporterService; private final PlayerTeleporterService playerTeleporterService;
private ROUND_STATE roundState; private ROUND_STATE roundState;
public static PlayerService createSingletonInstance(RoundService roundService, PlayerTeleporterService playerTeleporterService) { public PlayerService(Round round, RoundService roundService, PlayerTeleporterService playerTeleporterService) {
this.roundService = roundService;
this.playerTeleporterService = playerTeleporterService;
this.roundState = round.register(this);
}
public static PlayerService createSingletonInstance(Round round, RoundService roundService, PlayerTeleporterService playerTeleporterService) {
if (instance != null) { if (instance != null) {
throw new IllegalStateException("PlayerService is already initialized."); throw new IllegalStateException("PlayerService is already initialized.");
} }
instance = new PlayerService(roundService, playerTeleporterService); instance = new PlayerService(round, roundService, playerTeleporterService);
return instance; return instance;
} }
@Override @Override
public void stateChange(ROUND_STATE roundState) { public void stateChange(ROUND_STATE roundState) {
this.roundState = roundState; this.roundState = roundState;
playerTeleporterService.reset();
switch (roundState) { switch (roundState) {
case PLAYER_REGISTRATION, KILL_PHASE, SAFE_PHASE -> { case PLAYER_REGISTRATION, KILL_PHASE, SAFE_PHASE -> {
//Nothing //Nothing
@@ -49,7 +60,7 @@ public class PlayerService implements RoundListener {
} }
case ENDED -> { case ENDED -> {
roundService.clear(); roundService.clear();
Bukkit.getOnlinePlayers().forEach(player -> playerTeleporterService.teleportPlayer(player, DESTINATION.START_AREA)); Bukkit.getOnlinePlayers().forEach(player -> playerTeleporterService.teleportPlayer(player, DESTINATION.SPECTATOR_AREA));
} }
} }
} }
@@ -72,7 +83,7 @@ public class PlayerService implements RoundListener {
PLAYER_STATE playerState = optionalPlayerState.get(); PLAYER_STATE playerState = optionalPlayerState.get();
if (playerState == PLAYER_STATE.SPECTATING) { if (playerState == PLAYER_STATE.SPECTATING) {
//TODO: teleport player to spectator location playerTeleporterService.teleportPlayer(player, DESTINATION.SPECTATOR_AREA);
} }
roundService.setPlayerState(player.getUniqueId(), playerState); roundService.setPlayerState(player.getUniqueId(), playerState);
@@ -80,6 +91,7 @@ public class PlayerService implements RoundListener {
} }
public void handleDisconnect(Player player) { public void handleDisconnect(Player player) {
player.playerListName(null);
UUID uuid = player.getUniqueId(); UUID uuid = player.getUniqueId();
roundService.setPlayerState(uuid, PLAYER_STATE.DISCONNECTED); roundService.setPlayerState(uuid, PLAYER_STATE.DISCONNECTED);
} }
@@ -100,13 +112,24 @@ public class PlayerService implements RoundListener {
public void handlePlayerDeath(Player player) { public void handlePlayerDeath(Player player) {
UUID uuid = player.getUniqueId(); UUID uuid = player.getUniqueId();
roundService.setPlayerState(uuid, PLAYER_STATE.SPECTATING); roundService.setPlayerState(uuid, PLAYER_STATE.SPECTATING);
// TODO: send a message when a player is killed
int remaining = roundService.getPlayers(PLAYER_STATE.REGISTERED).size();
Player killer = player.getKiller();
if (killer != null) {
Bukkit.broadcast(MiniMessage.miniMessage().deserialize(Messages.GAME.PLAYER_KILLED,
Placeholder.component("victim", player.name()),
Placeholder.component("killer", killer.name()),
Placeholder.unparsed("remaining", String.valueOf(remaining))));
} else {
Bukkit.broadcast(MiniMessage.miniMessage().deserialize(Messages.GAME.PLAYER_DEATH,
Placeholder.component("player", player.name()),
Placeholder.unparsed("remaining", String.valueOf(remaining))));
}
} }
public void handlePlayerRespawn(Player player) { public void handlePlayerRespawn(PlayerRespawnEvent event) {
roundService.getPlayerState(player.getUniqueId()) Location spectatorArea = playerTeleporterService.getLocationFromDestination(DESTINATION.SPECTATOR_AREA);
.filter(state -> state == PLAYER_STATE.SPECTATING) event.setRespawnLocation(spectatorArea);
.ifPresent(state -> playerTeleporterService.teleportPlayer(player, DESTINATION.SPECTATOR_AREA));
} }
private Optional<PLAYER_STATE> determinePlayerState(UUID playerUuid) { private Optional<PLAYER_STATE> determinePlayerState(UUID playerUuid) {
@@ -117,13 +140,18 @@ public class PlayerService implements RoundListener {
if (optionalPlayerState.isPresent()) { if (optionalPlayerState.isPresent()) {
PLAYER_STATE playerState = optionalPlayerState.get(); PLAYER_STATE playerState = optionalPlayerState.get();
return Optional.of(switch (playerState) { return Optional.of(switch (playerState) {
case DISCONNECTED, SPECTATING -> PLAYER_STATE.SPECTATING; case DISCONNECTED, SPECTATING -> {
if (roundState.equals(ROUND_STATE.ENDED)) {
yield PLAYER_STATE.REGISTERED;
}
yield PLAYER_STATE.SPECTATING;
}
case REGISTERED -> PLAYER_STATE.REGISTERED; case REGISTERED -> PLAYER_STATE.REGISTERED;
}); });
} }
return switch (roundState) { return switch (roundState) {
case PLAYER_REGISTRATION, COUNTDOWN -> Optional.of(PLAYER_STATE.REGISTERED); case PLAYER_REGISTRATION, COUNTDOWN, ENDED -> Optional.of(PLAYER_STATE.REGISTERED);
case KILL_PHASE, FINALE, ENDED, SAFE_PHASE -> Optional.of(PLAYER_STATE.SPECTATING); case KILL_PHASE, FINALE, SAFE_PHASE -> Optional.of(PLAYER_STATE.SPECTATING);
}; };
} }
} }
@@ -4,6 +4,7 @@ import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.data_objects.DESTINATION; import com.alttd.hunger_games.data_objects.DESTINATION;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -12,6 +13,7 @@ import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@Slf4j
@NoArgsConstructor @NoArgsConstructor
public class PlayerTeleporterService implements RoundListener { public class PlayerTeleporterService implements RoundListener {
@@ -29,10 +31,20 @@ public class PlayerTeleporterService implements RoundListener {
} }
public void teleportPlayer(Player player, DESTINATION destination) { public void teleportPlayer(Player player, DESTINATION destination) {
player.teleport(getLocationFromDestination(destination)); Location locationFromDestination = getLocationFromDestination(destination);
try {
player.teleportAsync(getLocationFromDestination(destination));
} catch (Exception e) {
log.error("Failed to teleport player {} to destination {} ({})", player.getName(), destination.name(), locationFromDestination, e);
}
} }
private Location getLocationFromDestination(DESTINATION destination) { public void reset() {
placementCount.clear();
usedLocations.clear();
}
public Location getLocationFromDestination(DESTINATION destination) {
return switch (destination) { return switch (destination) {
case START_AREA -> { case START_AREA -> {
Location startCenter = Config.DESTINATION.START_CENTER; Location startCenter = Config.DESTINATION.START_CENTER;
@@ -66,7 +78,10 @@ public class PlayerTeleporterService implements RoundListener {
double angleRadians = Math.toRadians(angleDegrees); double angleRadians = Math.toRadians(angleDegrees);
double x = center.getX() + radius * Math.cos(angleRadians); double x = center.getX() + radius * Math.cos(angleRadians);
double z = center.getZ() + radius * Math.sin(angleRadians); double z = center.getZ() + radius * Math.sin(angleRadians);
Location location = new Location(center.getWorld(), x, center.getY(), z); Location location = new Location(center.getWorld(), x, center.getY(), z);
location.setDirection(center.toVector().subtract(location.toVector()));
usedLocations.computeIfAbsent(destination, k -> new HashSet<>()).add(location); usedLocations.computeIfAbsent(destination, k -> new HashSet<>()).add(location);
return location; return location;
} }
@@ -1,16 +1,20 @@
package com.alttd.hunger_games.services; package com.alttd.hunger_games.services;
import com.alttd.hunger_games.config.Config; import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.GameStage; import com.alttd.hunger_games.data_objects.GameStage;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.game.GameStageHandler; import com.alttd.hunger_games.game.GameStageHandler;
import lombok.Setter;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class Round { public class Round {
@@ -19,6 +23,10 @@ public class Round {
private final List<RoundListener> listeners = new ArrayList<>(); private final List<RoundListener> listeners = new ArrayList<>();
private ROUND_STATE roundState = ROUND_STATE.PLAYER_REGISTRATION; private ROUND_STATE roundState = ROUND_STATE.PLAYER_REGISTRATION;
private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture<?> countdownFuture = null;
private Runnable onEnd = null;
@Setter
private BossBarService bossBarService;
private Round() { private Round() {
@@ -42,10 +50,21 @@ public class Round {
} }
public void stop() { public void stop() {
if (countdownFuture != null) {
countdownFuture.cancel(true);
}
roundState = ROUND_STATE.PLAYER_REGISTRATION; roundState = ROUND_STATE.PLAYER_REGISTRATION;
listeners.forEach(roundListener -> roundListener.stateChange(roundState)); listeners.forEach(roundListener -> roundListener.stateChange(roundState));
} }
public void endRound() {
if (countdownFuture != null) {
countdownFuture.cancel(true);
}
roundState = ROUND_STATE.ENDED;
listeners.forEach(roundListener -> roundListener.stateChange(roundState));
}
private void nextStage() { private void nextStage() {
Optional<ROUND_STATE> optionalNextRoundState = roundState.next(); Optional<ROUND_STATE> optionalNextRoundState = roundState.next();
if (optionalNextRoundState.isEmpty()) { if (optionalNextRoundState.isEmpty()) {
@@ -58,32 +77,76 @@ public class Round {
} }
public void startRound() { public void startRound() {
if (roundState.equals(ROUND_STATE.PLAYER_REGISTRATION)) { if (!roundState.equals(ROUND_STATE.PLAYER_REGISTRATION) && !roundState.equals(ROUND_STATE.ENDED)) {
throw new IllegalStateException("Round can not be started before player registration."); throw new IllegalStateException("Round can not be started before player registration.");
} }
roundState = ROUND_STATE.COUNTDOWN; roundState = ROUND_STATE.COUNTDOWN;
listeners.forEach(roundListener -> roundListener.stateChange(roundState)); listeners.forEach(roundListener -> roundListener.stateChange(roundState));
GameStageHandler.handleWarmup(); GameStageHandler.handleWarmup();
Duration warmupDuration = Config.ROUND.COUNTDOWN; Duration warmupDuration = Config.ROUND.COUNTDOWN;
scheduledExecutorService.schedule(() -> {
startCountdown(warmupDuration, () -> {
GameStageHandler.handleCountdownEnd(); GameStageHandler.handleCountdownEnd();
nextStage(); nextStage(); // COUNTDOWN -> SAFE_PHASE
startCountdown(Config.ROUND.SAFE_PHASE, () -> {
nextStage(); // SAFE_PHASE -> KILL_PHASE
GameStageHandler.broadcastMessage(Messages.GAME.NEXT_STATE.replace("<round>", roundState.getHumanReadableName()));
scheduleNextStage(0); scheduleNextStage(0);
}, warmupDuration.toSeconds(), TimeUnit.SECONDS); });
});
}
private void startCountdown(Duration duration, Runnable onEnd) {
if (countdownFuture != null) {
countdownFuture.cancel(true);
}
this.onEnd = onEnd;
final Instant endTime = Instant.now().plus(duration);
countdownFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
if (roundState.equals(ROUND_STATE.FINALE)) {
if (countdownFuture != null) {
countdownFuture.cancel(false);
countdownFuture = null;
}
return;
}
Duration timeLeft = Duration.between(Instant.now(), endTime);
if (bossBarService != null) {
bossBarService.updateCountdown(timeLeft, duration, roundState);
}
if (timeLeft.isZero() || timeLeft.isNegative()) {
onEnd.run();
}
}, 0, 1, TimeUnit.SECONDS);
} }
private void scheduleNextStage(int index) { private void scheduleNextStage(int index) {
List<GameStage> gameStageList = Config.ROUND.STAGES; List<GameStage> gameStageList = Config.ROUND.STAGES;
if (gameStageList.isEmpty()) {
return;
}
if (gameStageList.size() <= index) { if (gameStageList.size() <= index) {
nextStage(); startCountdown(Config.ROUND.TIME_TO_FINALE, this::nextStage);
return; return;
} }
GameStage gameStage = gameStageList.get(index); GameStage gameStage = gameStageList.get(index);
Duration duration = gameStage.getDuration(); Duration duration = gameStage.getDuration();
scheduledExecutorService.schedule(() -> {
startCountdown(duration, () -> {
GameStageHandler.handleStageChange(gameStage.getWorldBorderSize()); GameStageHandler.handleStageChange(gameStage.getWorldBorderSize());
scheduleNextStage(index + 1); scheduleNextStage(index + 1);
}, duration.toSeconds(), TimeUnit.SECONDS); });
} }
public void forceNextPhase() {
if (countdownFuture == null || onEnd == null) {
return;
}
countdownFuture.cancel(true);
onEnd.run();
}
} }
@@ -1,30 +1,63 @@
package com.alttd.hunger_games.services; package com.alttd.hunger_games.services;
import com.alttd.hunger_games.Main;
import com.alttd.hunger_games.config.Config;
import com.alttd.hunger_games.config.Messages;
import com.alttd.hunger_games.data_objects.DESTINATION;
import com.alttd.hunger_games.data_objects.PLAYER_STATE; import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE; import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.event_listeners.PlayerMovementListener;
import com.alttd.hunger_games.utils.WorldBorderUtil;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
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.GameMode;
import org.bukkit.World;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j
public class RoundService implements RoundListener { public class RoundService implements RoundListener {
private static RoundService instance = null; private static RoundService instance = null;
private final PlayerTeleporterService playerTeleporterService;
private final Main main;
private final LootService lootService;
private final StatService statService;
private PlayerMovementListener playerMovementListener;
@Getter @Getter
private ROUND_STATE roundState; private ROUND_STATE roundState;
private final HashMap<UUID, PLAYER_STATE> players = new HashMap<>(); private final HashMap<UUID, PLAYER_STATE> players = new HashMap<>();
public static RoundService createSingletonInstance(Round round) { private final Round round;
@Setter
private BossBarService bossBarService;
@Setter
private TabListService tabListService;
public static RoundService createSingletonInstance(Round round, Main main, LootService lootService, StatService statService, PlayerTeleporterService playerTeleporterService) {
if (instance != null) { if (instance != null) {
throw new IllegalStateException("RoundService is already initialized."); throw new IllegalStateException("RoundService is already initialized.");
} }
instance = new RoundService(round); instance = new RoundService(round, main, lootService, statService, playerTeleporterService);
return instance; return instance;
} }
private RoundService(Round round) { private RoundService(Round round, Main main, LootService lootService, StatService statService, PlayerTeleporterService playerTeleporterService) {
this.round = round;
this.roundState = round.register(this); this.roundState = round.register(this);
this.main = main;
this.lootService = lootService;
this.statService = statService;
this.playerTeleporterService = playerTeleporterService;
} }
public Set<UUID> getPlayers(PLAYER_STATE playerState) { public Set<UUID> getPlayers(PLAYER_STATE playerState) {
@@ -35,7 +68,52 @@ public class RoundService implements RoundListener {
} }
protected void setPlayerState(UUID uuid, PLAYER_STATE playerState) { protected void setPlayerState(UUID uuid, PLAYER_STATE playerState) {
players.put(uuid, playerState); PLAYER_STATE oldState = players.put(uuid, playerState);
if (playerState == PLAYER_STATE.SPECTATING && oldState == PLAYER_STATE.REGISTERED) {
int remaining = getPlayers(PLAYER_STATE.REGISTERED).size();
statService.setPlacement(uuid, remaining + 1);
if (roundState == ROUND_STATE.FINALE && bossBarService != null) {
bossBarService.updateFinale(remaining);
}
}
if (tabListService != null) {
Player player = Bukkit.getPlayer(uuid);
if (player != null) {
tabListService.updatePlayerColor(player);
}
}
if (!roundState.equals(ROUND_STATE.KILL_PHASE) && !roundState.equals(ROUND_STATE.FINALE)) {
return;
}
Set<UUID> uuidSet = getPlayers(PLAYER_STATE.REGISTERED);
if (uuidSet.size() != 1) {
return;
}
declareWinner(uuidSet.iterator().next());
}
private void declareWinner(UUID winnerUUID) {
Player player = Bukkit.getPlayer(winnerUUID);
if (player == null) {
log.error("Winner {} not found", winnerUUID);
Bukkit.broadcast(MiniMessage.miniMessage().deserialize("<red>Winner not found</red>"));
return;
}
statService.setPlacement(winnerUUID, 1);
statService.setWon(winnerUUID, true);
Bukkit.broadcast(MiniMessage.miniMessage().deserialize(Messages.GAME.WINNER, Placeholder.component("player", player.name())));
playerTeleporterService.teleportPlayer(player, DESTINATION.SPECTATOR_AREA);
player.setHealth(20);
player.setFoodLevel(20);
player.getInventory().clear();
player.setTotalExperience(0);
player.setExp(0.0f);
player.setLevel(0);
round.endRound();
} }
protected void clear() { protected void clear() {
@@ -49,8 +127,59 @@ public class RoundService implements RoundListener {
@Override @Override
public void stateChange(ROUND_STATE roundState) { public void stateChange(ROUND_STATE roundState) {
this.roundState = roundState; this.roundState = roundState;
if (roundState.equals(ROUND_STATE.PLAYER_REGISTRATION)) { stopFreeze();
if (roundState.equals(ROUND_STATE.PLAYER_REGISTRATION) || roundState.equals(ROUND_STATE.ENDED)) {
if (roundState.equals(ROUND_STATE.ENDED)) {
cleanupItems();
}
clear(); clear();
lootService.clear();
}
if (roundState.equals(ROUND_STATE.COUNTDOWN)) {
Bukkit.getOnlinePlayers().stream()
.filter(player -> PLAYER_STATE.REGISTERED.equals(getPlayerState(player.getUniqueId()).orElse(null)))
.forEach(player -> {
player.setHealth(20);
player.setFoodLevel(20);
player.setGameMode(GameMode.ADVENTURE);
player.getInventory().clear();
player.setTotalExperience(0);
player.setExp(0.0f);
player.setLevel(0);
});
startFreeze();
WorldBorderUtil.resetBorder();
}
if (roundState.equals(ROUND_STATE.FINALE)) {
WorldBorderUtil.setSize(Config.ROUND.FINALE_BORDER_SIZE, 1);
}
if (!roundState.equals(ROUND_STATE.ENDED) && !roundState.equals(ROUND_STATE.PLAYER_REGISTRATION)) {
Bukkit.broadcast(MiniMessage.miniMessage().deserialize(Messages.GAME.NEXT_STATE, Placeholder.parsed("round", roundState.getHumanReadableName())));
} }
} }
private void stopFreeze() {
if (playerMovementListener == null) {
return;
}
HandlerList.unregisterAll(playerMovementListener);
playerMovementListener = null;
}
private void startFreeze() {
stopFreeze();
Set<UUID> uuidSet = getPlayers(PLAYER_STATE.REGISTERED);
playerMovementListener = new PlayerMovementListener(uuidSet);
main.getServer().getPluginManager().registerEvents(playerMovementListener, main);
}
private void cleanupItems() {
World world = Bukkit.getWorld(Config.ROUND.HG_WORLD);
if (world == null) {
log.error("Unable to cleanup items, world {} not found", Config.ROUND.HG_WORLD);
return;
}
world.getEntitiesByClass(Item.class).forEach(Item::remove);
}
} }
@@ -0,0 +1,123 @@
package com.alttd.hunger_games.services;
import com.alttd.hunger_games.Main;
import com.alttd.hunger_games.data_objects.LifetimeStats;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import com.alttd.hunger_games.data_objects.RoundStats;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class StatService implements RoundListener {
private final Main main;
private final Map<UUID, RoundStats> currentRoundStats = new ConcurrentHashMap<>();
private final Map<UUID, LifetimeStats> lifetimeStatsCache = new ConcurrentHashMap<>();
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final File statsDirectory;
private final File lifetimeStatsFile;
public StatService(Main main, Round round) {
this.main = main;
this.statsDirectory = new File(main.getDataFolder(), "stats");
if (!statsDirectory.exists()) {
statsDirectory.mkdirs();
}
this.lifetimeStatsFile = new File(statsDirectory, "lifetime.json");
round.register(this);
loadLifetimeStats();
}
public RoundStats getOrCreateRoundStats(UUID uuid) {
return currentRoundStats.computeIfAbsent(uuid, k -> RoundStats.builder().uuid(k).build());
}
public LifetimeStats getOrCreateLifetimeStats(UUID uuid) {
return lifetimeStatsCache.computeIfAbsent(uuid, k -> LifetimeStats.builder().uuid(k).build());
}
@Override
public void stateChange(ROUND_STATE roundState) {
if (roundState.equals(ROUND_STATE.ENDED)) {
saveRoundStats();
}
if (roundState.equals(ROUND_STATE.PLAYER_REGISTRATION)) {
currentRoundStats.clear();
}
}
private void saveRoundStats() {
if (currentRoundStats.isEmpty()) {
return;
}
long timestamp = System.currentTimeMillis();
File roundFile = new File(statsDirectory, "round_" + timestamp + ".json");
try (FileWriter writer = new FileWriter(roundFile)) {
gson.toJson(currentRoundStats.values(), writer);
} catch (IOException e) {
main.getLogger().severe("Could not save round stats: " + e.getMessage());
}
for (RoundStats roundStats : currentRoundStats.values()) {
LifetimeStats lifetime = getOrCreateLifetimeStats(roundStats.getUuid());
lifetime.addRoundStats(roundStats);
}
saveLifetimeStats();
}
private void loadLifetimeStats() {
if (!lifetimeStatsFile.exists()) {
return;
}
try (FileReader reader = new FileReader(lifetimeStatsFile)) {
LifetimeStats[] stats = gson.fromJson(reader, LifetimeStats[].class);
if (stats != null) {
for (LifetimeStats s : stats) {
lifetimeStatsCache.put(s.getUuid(), s);
}
}
} catch (IOException e) {
main.getLogger().severe("Could not load lifetime stats: " + e.getMessage());
}
}
private void saveLifetimeStats() {
try (FileWriter writer = new FileWriter(lifetimeStatsFile)) {
gson.toJson(lifetimeStatsCache.values(), writer);
} catch (IOException e) {
main.getLogger().severe("Could not save lifetime stats: " + e.getMessage());
}
}
public void addKill(UUID uuid) {
getOrCreateRoundStats(uuid).addKill();
}
public void addDeath(UUID uuid) {
getOrCreateRoundStats(uuid).addDeath();
}
public void addDamageDealt(UUID uuid, double damage) {
getOrCreateRoundStats(uuid).addDamageDealt(damage);
}
public void addDamageTaken(UUID uuid, double damage) {
getOrCreateRoundStats(uuid).addDamageTaken(damage);
}
public void setPlacement(UUID uuid, int placement) {
getOrCreateRoundStats(uuid).setPlacement(placement);
}
public void setWon(UUID uuid, boolean won) {
getOrCreateRoundStats(uuid).setWon(won);
}
}
@@ -0,0 +1,46 @@
package com.alttd.hunger_games.services;
import com.alttd.hunger_games.data_objects.PLAYER_STATE;
import com.alttd.hunger_games.data_objects.ROUND_STATE;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.UUID;
public class TabListService implements RoundListener {
private final RoundService roundService;
private ROUND_STATE roundState;
public TabListService(Round round, RoundService roundService) {
this.roundService = roundService;
this.roundState = round.register(this);
}
public void updatePlayerColor(Player player) {
if (!player.isOnline()) {
return;
}
if (roundState == null || roundState == ROUND_STATE.ENDED || roundState == ROUND_STATE.PLAYER_REGISTRATION) {
player.playerListName(null);
return;
}
PLAYER_STATE state = roundService.getPlayerState(player.getUniqueId()).orElse(PLAYER_STATE.SPECTATING);
switch (state) {
case REGISTERED -> player.playerListName(player.name().color(NamedTextColor.GREEN));
case SPECTATING, DISCONNECTED -> player.playerListName(player.name().color(NamedTextColor.RED));
}
}
public void updateAllPlayers() {
Bukkit.getOnlinePlayers().forEach(this::updatePlayerColor);
}
@Override
public void stateChange(ROUND_STATE roundState) {
this.roundState = roundState;
updateAllPlayers();
}
}
@@ -0,0 +1,44 @@
package com.alttd.hunger_games.utils;
import com.alttd.hunger_games.config.Config;
import lombok.experimental.UtilityClass;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.WorldBorder;
import java.util.Optional;
@UtilityClass
public class WorldBorderUtil {
public static void setSize(int size) {
setSize(size, Config.ROUND.DEFAULT_BORDER_TRANSITION_SECONDS);
}
public static void setSize(int size, int seconds) {
getWorld().ifPresent(world -> {
WorldBorder border = world.getWorldBorder();
border.setSize(size, seconds);
});
}
public static void resetBorder() {
Location center = Config.DESTINATION.START_CENTER;
if (center == null || center.getWorld() == null) {
return;
}
WorldBorder border = center.getWorld().getWorldBorder();
border.setCenter(center.getX(), center.getZ());
border.setSize(Config.ROUND.INITIAL_BORDER_SIZE);
}
private static Optional<World> getWorld() {
Location center = Config.DESTINATION.START_CENTER;
if (center != null && center.getWorld() != null) {
return Optional.ofNullable(center.getWorld());
}
return Bukkit.getWorlds().isEmpty() ? Optional.empty() : Optional.of(Bukkit.getWorlds().getFirst());
}
}
+32
View File
@@ -0,0 +1,32 @@
name: HungerGames
version: ${version}
main: com.alttd.hunger_games.Main
api-version: "1.21"
# Load after Multiverse-Core to be able to find the teleport locations
softdepend:
- Multiverse-Core
commands:
hungergames:
description: Main HungerGames command
aliases: [hg]
usage: /<command> <subcommand>
permissions:
hungergames.register:
description: Register a player for the game
default: op
hungergames.roundstate:
description: Check the current round state
default: true
hungergames.start:
description: Start a HungerGames round
default: op
hungergames.*:
description: Wildcard permission for all HungerGames commands
default: op
children:
hungergames.register: true
hungergames.roundstate: true
hungergames.start: true