From 7589b054336bd1e6f1f53f73a4b7d7ffb2398b59 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Mon, 13 Apr 2026 01:23:29 +0200 Subject: [PATCH] Replace TeamManager with PresetTeam GUI system Replace the old dynamic /team invite system with a fixed GUI-based preset team implementation. Removes TeamManager, Team, TeamListener and the TeamCommand, and introduces PresetTeam, PresetTeamManager and GUI listeners/menus (TeamSelectionMenu/TeamSelectionListener). Codepaths that referenced teamManager were migrated to presetTeamManager (GameManager win/compass logic, Lunar rich presence, Oracle perk, scoreboards, tablist, lobby items). Lobby now shows a team wool item when teams are enabled and the tablist/scoreboard display and prefix logic were adapted to reflect preset teams. Configuration supports teams.enabled, teams.preset-count and teams.max-size; scheduled invite cleanup and old team-reset logic were removed accordingly. --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 28 +- .../speedhg/client/LunarClientManager.kt | 4 +- .../mcscrims/speedhg/command/TeamCommand.kt | 232 -------------- .../club/mcscrims/speedhg/game/GameManager.kt | 12 +- .../speedhg/game/modules/LobbyItemManager.kt | 32 +- .../mcscrims/speedhg/perk/impl/OraclePerk.kt | 2 +- .../speedhg/scoreboard/ScoreboardManager.kt | 56 +++- .../speedhg/scoreboard/TablistManager.kt | 91 +++++- .../kotlin/club/mcscrims/speedhg/team/Team.kt | 36 --- .../mcscrims/speedhg/team/TeamListener.kt | 55 ---- .../club/mcscrims/speedhg/team/TeamManager.kt | 294 ------------------ .../mcscrims/speedhg/team/gui/PresetTeam.kt | 41 +++ .../speedhg/team/gui/PresetTeamManager.kt | 181 +++++++++++ .../speedhg/team/gui/TeamSelectionListener.kt | 70 +++++ .../speedhg/team/gui/TeamSelectionMenu.kt | 214 +++++++++++++ src/main/resources/config.yml | 3 +- src/main/resources/languages/en_US.yml | 97 ++++-- src/main/resources/plugin.yml | 5 +- 18 files changed, 759 insertions(+), 694 deletions(-) delete mode 100644 src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt delete mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/Team.kt delete mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt delete mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeam.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeamManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionListener.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionMenu.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 5569495..2950030 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -7,7 +7,6 @@ import club.mcscrims.speedhg.command.KitCommand import club.mcscrims.speedhg.command.LeaderboardCommand import club.mcscrims.speedhg.command.PerksCommand import club.mcscrims.speedhg.command.RankingCommand -import club.mcscrims.speedhg.command.TeamCommand import club.mcscrims.speedhg.command.TimerCommand import club.mcscrims.speedhg.config.CustomGameManager import club.mcscrims.speedhg.config.LanguageManager @@ -48,8 +47,8 @@ import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager import club.mcscrims.speedhg.scoreboard.TablistManager import club.mcscrims.speedhg.scoreboard.VolcanoServerRankProvider -import club.mcscrims.speedhg.team.TeamListener -import club.mcscrims.speedhg.team.TeamManager +import club.mcscrims.speedhg.team.gui.PresetTeamManager +import club.mcscrims.speedhg.team.gui.TeamSelectionListener import club.mcscrims.speedhg.webhook.DiscordWebhookManager import club.mcscrims.speedhg.world.DataPackManager import club.mcscrims.speedhg.world.SurfaceBlockPopulator @@ -115,9 +114,6 @@ class SpeedHG : JavaPlugin() { lateinit var dataPackManager: DataPackManager private set - lateinit var teamManager: TeamManager - private set - lateinit var lunarClientManager: LunarClientManager private set @@ -130,6 +126,9 @@ class SpeedHG : JavaPlugin() { lateinit var lobbyAnnouncer: LobbyAnnouncer private set + lateinit var presetTeamManager: PresetTeamManager + private set + override fun onLoad() { instance = this @@ -189,17 +188,11 @@ class SpeedHG : JavaPlugin() { lunarClientManager = LunarClientManager( this ) lobbyItemManager = LobbyItemManager( this ) tablistManager = TablistManager( this, VolcanoServerRankProvider() ) + presetTeamManager = PresetTeamManager( this ) perkManager = PerkManager( this ) perkManager.initialize() - teamManager = TeamManager( this ) - - // Cleanup-Task für abgelaufene Einladungen (1x/Sekunde) - Bukkit.getScheduler().runTaskTimer( this, { -> - teamManager.cleanExpiredInvites() - }, 20L, 20L ) - disasterManager = DisasterManager( this ) disasterManager.start() @@ -220,7 +213,6 @@ class SpeedHG : JavaPlugin() { podiumManager.cleanup() if ( ::lobbyAnnouncer.isInitialized ) lobbyAnnouncer.stop() if ( ::perkManager.isInitialized ) perkManager.shutdown() - if ( ::teamManager.isInitialized ) teamManager.reset() if ( ::tablistManager.isInitialized ) tablistManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::databaseManager.isInitialized ) databaseManager.disconnect() @@ -288,12 +280,6 @@ class SpeedHG : JavaPlugin() { tabCompleter = rankingCommand } - val teamCommand = TeamCommand() - getCommand( "team" )?.apply { - setExecutor( teamCommand ) - tabCompleter = teamCommand - } - getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) getCommand( "perks" )?.setExecutor( PerksCommand() ) getCommand( "help" )?.setExecutor( HelpCommand() ) @@ -310,10 +296,10 @@ class SpeedHG : JavaPlugin() { pm.registerEvents( StatsListener(), this ) pm.registerEvents( MenuListener(), this ) pm.registerEvents(PerkEventDispatcher( this, perkManager ), this ) - pm.registerEvents( TeamListener(), this ) pm.registerEvents( lobbyItemManager, this ) pm.registerEvents(ChatListener( this, VolcanoServerRankProvider() ), this ) pm.registerEvents(KnockbackListener( this ), this ) + pm.registerEvents(TeamSelectionListener( this ), this ) } private fun registerRecipes() diff --git a/src/main/kotlin/club/mcscrims/speedhg/client/LunarClientManager.kt b/src/main/kotlin/club/mcscrims/speedhg/client/LunarClientManager.kt index b77e700..375b1dd 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/client/LunarClientManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/client/LunarClientManager.kt @@ -48,7 +48,7 @@ class LunarClientManager( private fun setRichPresence( player: ApolloPlayer ) { - val teamSize = plugin.teamManager.getTeam( player.uniqueId )?.size ?: 1 + val teamSize = plugin.presetTeamManager.getTeam( player.uniqueId )?.size ?: 1 val currentState = plugin.gameManager.currentState.name val presence = ServerRichPresence.builder() @@ -57,7 +57,7 @@ class LunarClientManager( .gameVariantName(plugin.config.getString( "lunarclient.variantName" )) .playerState( currentState ) .teamCurrentSize( teamSize ) - .teamMaxSize( plugin.teamManager.maxTeamSize ) + .teamMaxSize( plugin.presetTeamManager.maxTeamSize ) .build() richPresenceModule.overrideServerRichPresence( player, presence ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt deleted file mode 100644 index 6b09085..0000000 --- a/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt +++ /dev/null @@ -1,232 +0,0 @@ -package club.mcscrims.speedhg.command - -import club.mcscrims.speedhg.SpeedHG -import club.mcscrims.speedhg.game.GameState -import club.mcscrims.speedhg.team.TeamManager -import club.mcscrims.speedhg.util.sendMsg -import org.bukkit.command.Command -import org.bukkit.command.CommandExecutor -import org.bukkit.command.CommandSender -import org.bukkit.command.TabCompleter -import org.bukkit.entity.Player - -class TeamCommand : CommandExecutor, TabCompleter { - - private val plugin get() = SpeedHG.instance - - // ── Guard: Teams nur in Lobby-Phasen ändern ────────────────────────────── - - private fun isLobbyPhase(): Boolean = when (plugin.gameManager.currentState) { - GameState.LOBBY, GameState.STARTING -> true - else -> false - } - - // ========================================================================= - // onCommand dispatch - // ========================================================================= - - override fun onCommand( - sender: CommandSender, - command: Command, - label: String, - args: Array - ): Boolean { - val player = sender as? Player ?: run { - sender.sendMessage("§cOnly players can use this command.") - return true - } - - if (!plugin.teamManager.isEnabled) { - player.sendMsg("team.disabled") - return true - } - - when (args.firstOrNull()?.lowercase()) { - "invite" -> handleInvite(player, args) - "accept" -> handleAccept(player, args) - "deny" -> handleDeny(player, args) - "leave" -> handleLeave(player) - "kick" -> handleKick(player, args) - "info" -> handleInfo(player) - else -> player.sendMsg("team.usage") - } - - return true - } - - // ========================================================================= - // Subcommand Handlers - // ========================================================================= - - private fun handleInvite(player: Player, args: Array) { - if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } - - val targetName = args.getOrNull(1) ?: run { player.sendMsg("team.invite.usage"); return } - val target = plugin.server.getPlayerExact(targetName) ?: run { - player.sendMsg("team.player_not_found", "name" to targetName); return - } - - when (plugin.teamManager.invite(player, target)) { - is TeamManager.InviteResult.Success -> { - player.sendMsg("team.invite.sent", "name" to target.name) - target.sendMsg("team.invite.received", - "name" to player.name, - "time" to (TeamManager.INVITE_TTL_MS / 1000).toString() - ) - } - is TeamManager.InviteResult.TeamsDisabled -> player.sendMsg("team.disabled") - is TeamManager.InviteResult.InvitedSelf -> player.sendMsg("team.invite.self") - is TeamManager.InviteResult.AlreadyInSameTeam -> player.sendMsg("team.invite.already_teammate") - is TeamManager.InviteResult.TargetAlreadyInTeam -> player.sendMsg("team.invite.target_has_team", "name" to target.name) - is TeamManager.InviteResult.SenderAlreadyInFullTeam -> player.sendMsg("team.invite.team_full") - is TeamManager.InviteResult.InviteAlreadyPending -> player.sendMsg("team.invite.already_pending", "name" to target.name) - } - } - - private fun handleAccept(player: Player, args: Array) { - if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } - - val inviterName = args.getOrNull(1) ?: run { player.sendMsg("team.accept.usage"); return } - val inviter = plugin.server.getPlayerExact(inviterName) ?: run { - player.sendMsg("team.player_not_found", "name" to inviterName); return - } - - when (val result = plugin.teamManager.accept(player, inviter.uniqueId)) { - is TeamManager.AcceptResult.Success -> { - val memberNames = result.team.members - .mapNotNull { plugin.server.getPlayer(it)?.name } - .joinToString(", ") - - // Alle Teammitglieder benachrichtigen - result.team.members.forEach { uuid -> - plugin.server.getPlayer(uuid)?.sendMsg( - "team.accept.joined", - "name" to player.name, - "members" to memberNames - ) - } - } - is TeamManager.AcceptResult.TeamsDisabled -> player.sendMsg("team.disabled") - is TeamManager.AcceptResult.NoInvite -> player.sendMsg("team.accept.no_invite", "name" to inviterName) - is TeamManager.AcceptResult.WrongInviter -> player.sendMsg("team.accept.no_invite", "name" to inviterName) - is TeamManager.AcceptResult.InviteExpired -> player.sendMsg("team.accept.expired", "name" to inviterName) - is TeamManager.AcceptResult.AlreadyInTeam -> player.sendMsg("team.already_in_team") - is TeamManager.AcceptResult.TeamFull -> player.sendMsg("team.invite.team_full") - is TeamManager.AcceptResult.InviterNotFound -> player.sendMsg("team.player_not_found", "name" to inviterName) - } - } - - private fun handleDeny(player: Player, args: Array) { - if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } - - val inviterName = args.getOrNull(1) ?: run { player.sendMsg("team.deny.usage"); return } - val inviter = plugin.server.getPlayerExact(inviterName) ?: run { - player.sendMsg("team.player_not_found", "name" to inviterName); return - } - - if (plugin.teamManager.deny(player, inviter.uniqueId)) { - player.sendMsg("team.deny.success", "name" to inviterName) - inviter.sendMsg("team.deny.received", "name" to player.name) - } else { - player.sendMsg("team.accept.no_invite", "name" to inviterName) - } - } - - private fun handleLeave(player: Player) { - if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } - - val team = plugin.teamManager.getTeam(player) - - when (plugin.teamManager.leave(player)) { - is TeamManager.LeaveResult.NotInTeam -> { - player.sendMsg("team.not_in_team") - } - is TeamManager.LeaveResult.TeamDisbanded -> { - // Alle Ex-Mitglieder (außer dem Verlassenden) benachrichtigen - team?.members?.forEach { uuid -> - plugin.server.getPlayer(uuid)?.sendMsg("team.leave.disbanded") - } - player.sendMsg("team.leave.disbanded") - } - is TeamManager.LeaveResult.Success -> { - player.sendMsg("team.leave.success") - // Verbleibende Mitglieder informieren - team?.members?.forEach { uuid -> - plugin.server.getPlayer(uuid)?.sendMsg( - "team.leave.member_left", "name" to player.name - ) - } - } - } - } - - private fun handleKick(player: Player, args: Array) { - if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } - - val targetName = args.getOrNull(1) ?: run { player.sendMsg("team.kick.usage"); return } - val target = plugin.server.getPlayerExact(targetName) ?: run { - player.sendMsg("team.player_not_found", "name" to targetName); return - } - - when (plugin.teamManager.kick(player, target)) { - is TeamManager.KickResult.Success -> { - player.sendMsg("team.kick.success", "name" to target.name) - target.sendMsg("team.kick.received", "name" to player.name) - } - is TeamManager.KickResult.NotInTeam -> player.sendMsg("team.not_in_team") - is TeamManager.KickResult.NotLeader -> player.sendMsg("team.kick.not_leader") - is TeamManager.KickResult.TargetNotInTeam -> player.sendMsg("team.kick.not_in_your_team", "name" to targetName) - is TeamManager.KickResult.CannotKickSelf -> player.sendMsg("team.kick.self") - } - } - - private fun handleInfo(player: Player) { - val team = plugin.teamManager.getTeam(player) ?: run { - player.sendMsg("team.not_in_team"); return - } - - player.sendMsg("team.info.header") - team.members.forEachIndexed { i, uuid -> - val name = plugin.server.getPlayer(uuid)?.name ?: uuid.toString().take(8) - val isLeader = team.isLeader(uuid) - player.sendMsg( - "team.info.member", - "index" to (i + 1).toString(), - "name" to name, - "leader" to if (isLeader) " ★" else "" - ) - } - player.sendMsg("team.info.footer", "size" to team.size.toString(), "max" to plugin.teamManager.maxTeamSize.toString()) - } - - // ========================================================================= - // Tab Completion - // ========================================================================= - - override fun onTabComplete( - sender: CommandSender, - command: Command, - label: String, - args: Array - ): List { - if (sender !is Player) return emptyList() - - if (args.size == 1) { - return listOf("invite", "accept", "deny", "leave", "kick", "info") - .filter { it.startsWith(args[0], ignoreCase = true) } - } - - if (args.size == 2) { - return when (args[0].lowercase()) { - "invite", "accept", "deny", "kick" -> - plugin.server.onlinePlayers - .filter { it != sender } - .map { it.name } - .filter { it.startsWith(args[1], ignoreCase = true) } - else -> emptyList() - } - } - - return emptyList() - } -} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index 42e9d79..eccf12c 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -295,7 +295,7 @@ class GameManager( private fun checkWin() { if (currentState != GameState.INGAME && currentState != GameState.INVINCIBILITY) return - val teamManager = plugin.teamManager + val teamManager = plugin.presetTeamManager val roundOver = when { // Nur noch 0 oder 1 Spieler übrig → immer Ende @@ -326,8 +326,8 @@ class GameManager( val winnerUUID = alivePlayers.firstOrNull() - val winnerTeam = if ( plugin.teamManager.isEnabled && winnerUUID != null ) - plugin.teamManager.getTeam( winnerUUID ) else null + val winnerTeam = if ( plugin.presetTeamManager.isEnabled && winnerUUID != null ) + plugin.presetTeamManager.getTeam( winnerUUID ) else null Bukkit.getOnlinePlayers().forEach { p -> val isWinner = winnerTeam?.contains( p.uniqueId ) ?: ( p.uniqueId == winnerUUID ) @@ -364,7 +364,7 @@ class GameManager( private fun buildWinnerName(anyAliveUUID: UUID?): String { anyAliveUUID ?: return "N/A" - val teamManager = plugin.teamManager + val teamManager = plugin.presetTeamManager if (!teamManager.isEnabled) { return Bukkit.getPlayer(anyAliveUUID)?.name ?: "N/A" } @@ -420,8 +420,8 @@ class GameManager( { if ( p == target ) continue - if ( plugin.teamManager.isEnabled && - plugin.teamManager.areInSameTeam( p, target )) + if ( plugin.presetTeamManager.isEnabled && + plugin.presetTeamManager.areInSameTeam( p, target )) continue val dist = p.location.distanceSquared( target.location ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt index 9645a42..036047f 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt @@ -8,6 +8,7 @@ import club.mcscrims.speedhg.gui.menu.KitSelectorMenu import club.mcscrims.speedhg.gui.menu.LeaderboardMenu import club.mcscrims.speedhg.gui.menu.PerkSelectorMenu import club.mcscrims.speedhg.gui.menu.StatsMenu +import club.mcscrims.speedhg.team.gui.TeamSelectionMenu import club.mcscrims.speedhg.util.ItemBuilder import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.minimessage.MiniMessage @@ -61,9 +62,10 @@ class LobbyItemManager( companion object { const val TAG_KITS = "kits" const val TAG_PERKS = "perks" - const val TAG_TUTORIAL = "tutorial" - const val TAG_STATS = "stats" + const val TAG_TUTORIAL = "tutorial" + const val TAG_STATS = "stats" const val TAG_LEADERBOARD = "leaderboard" + const val TAG_TEAMS = "teams" } // ── Item definitions ────────────────────────────────────────────────────── @@ -145,6 +147,17 @@ class LobbyItemManager( .build() } + fun buildTeamItem( + player: Player + ): ItemStack + { + return ItemBuilder( Material.WHITE_WOOL ) + .name( plugin.languageManager.getComponent( player, "game.lobby-items.teams.name", mapOf() ) ) + .lore( listOf( plugin.languageManager.getRawMessage( player, "game.lobby-items.teams.lore" ) ) ) + .pdc( key, PersistentDataType.STRING, TAG_TEAMS ) + .build() + } + // ── Public API ──────────────────────────────────────────────────────────── /** @@ -157,11 +170,15 @@ class LobbyItemManager( player: Player ) { player.inventory.clear() - player.inventory.setItem( 0, buildKitItem( player )) - player.inventory.setItem( 1, buildPerkItem( player )) - player.inventory.setItem( 4, buildTutorialItem( player )) - player.inventory.setItem( 7, buildStatsItem( player )) - player.inventory.setItem( 8, buildLeaderboardItem( player )) + player.inventory.setItem( 0, buildKitItem( player ) ) + player.inventory.setItem( 1, buildPerkItem( player ) ) + + if ( plugin.presetTeamManager.isEnabled ) + player.inventory.setItem( 2, buildTeamItem( player ) ) + + player.inventory.setItem( 4, buildTutorialItem( player ) ) + player.inventory.setItem( 7, buildStatsItem( player ) ) + player.inventory.setItem( 8, buildLeaderboardItem( player ) ) } /** @@ -290,6 +307,7 @@ class LobbyItemManager( { TAG_KITS -> KitSelectorMenu( event.player ).open( event.player ) TAG_PERKS -> PerkSelectorMenu( event.player ).open( event.player ) + TAG_TEAMS -> TeamSelectionMenu( event.player ).open( event.player ) TAG_TUTORIAL -> openTutorialBook( event.player ) TAG_STATS -> StatsMenu( event.player ).open( event.player ) TAG_LEADERBOARD -> LeaderboardMenu( event.player ).open( event.player ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt index 7d3140f..42f819a 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt @@ -74,7 +74,7 @@ class OraclePerk : Perk() { .filter { it != player.uniqueId } .mapNotNull { plugin.server.getPlayer(it) } .filter { !plugin.perkManager.isGhost(it) } - .filter { !plugin.teamManager.areInSameTeam( player, it ) } + .filter { !plugin.presetTeamManager.areInSameTeam( player, it ) } .minByOrNull { it.location.distanceSquared(player.location) } private fun buildTrackerComponent(player: Player, nearest: Player): Component { diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt index 7f89c5f..2c15962 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt @@ -58,21 +58,29 @@ class ScoreboardManager( player: Player, board: FastBoard ) { - val gm = plugin.gameManager + val gm = plugin.gameManager val state = gm.currentState - board.updateTitle(player.trans( "scoreboard.title" )) + board.updateTitle( player.trans( "scoreboard.title" ) ) - val online = Bukkit.getOnlinePlayers().size.toString() - val max = Bukkit.getMaxPlayers().toString() + val online = Bukkit.getOnlinePlayers().size.toString() + val max = Bukkit.getMaxPlayers().toString() val variantName = plugin.config.getString( "lunarclient.variantName" ).toString() - val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) - val styleName = plugin.kitManager.getSelectedPlaystyle( player ).displayName + val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) + val styleName = plugin.kitManager.getSelectedPlaystyle( player ).displayName val stats = plugin.statsManager.getCachedStats( player.uniqueId ) val score = stats?.scrimScore ?: 0 val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 ) + + val teamsEnabled = plugin.presetTeamManager.isEnabled + val presetTeam = if ( teamsEnabled ) plugin.presetTeamManager.getTeam( player ) else null + val teamName = presetTeam?.name ?: "-" + val teamMembers = presetTeam?.members + ?.mapNotNull { Bukkit.getPlayer( it )?.name } + ?.joinToString( ", " ) ?: "-" + val rankComponent = Rank.getFormattedRankName( score, games ) val lines: List @@ -81,22 +89,44 @@ class ScoreboardManager( { val timeString = if ( state == GameState.STARTING ) formatTime( gm.timer ) else "Waiting..." + // Wähle den richtigen Scoreboard-Key je nach Team-Status + val key = if ( teamsEnabled ) "scoreboard.lobby_teams" else "scoreboard.lobby" + lines = plugin.languageManager.getMessageList( - player, "scoreboard.lobby", - mapOf( "online" to online, "max" to max, "time" to timeString, "style" to styleName, "variant" to variantName ), + player, key, + mapOf( + "online" to online, + "max" to max, + "time" to timeString, + "style" to styleName, + "variant" to variantName, + "team" to teamName, + "members" to teamMembers + ), mapOf( "kit" to kitName, "rank" to rankComponent ) ) } else { val timeString = formatTime( gm.timer ) - val alive = gm.alivePlayers.size.toString() - val kills = player.getStatistic( Statistic.PLAYER_KILLS ).toString() - val border = String.format( "%.0f", player.world.worldBorder.size ) + val alive = gm.alivePlayers.size.toString() + val kills = player.getStatistic( Statistic.PLAYER_KILLS ).toString() + val border = String.format( "%.0f", player.world.worldBorder.size ) + + val key = if ( teamsEnabled ) "scoreboard.ingame_teams" else "scoreboard.ingame" lines = plugin.languageManager.getMessageList( - player, "scoreboard.ingame", - mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border, "style" to styleName, "variant" to variantName ), + player, key, + mapOf( + "timer" to timeString, + "alive" to alive, + "kills" to kills, + "border" to border, + "style" to styleName, + "variant" to variantName, + "team" to teamName, + "members" to teamMembers + ), mapOf( "kit" to kitName, "rank" to rankComponent ) ) } diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt index 00b5822..808dd85 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt @@ -2,9 +2,11 @@ package club.mcscrims.speedhg.scoreboard import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.ranking.Rank +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.DyeColor import org.bukkit.entity.Player import org.bukkit.event.EventHandler import org.bukkit.event.EventPriority @@ -166,6 +168,16 @@ class TablistManager( updateHeaderFooter( player ) } + /** + * Muss aufgerufen werden wenn ein Spieler einem [PresetTeam] beitritt oder + * es verlässt, damit Prefix und Sortierung sofort aktualisiert werden. + */ + fun refreshTeamPrefix( + player: Player + ) { + assignToTeam( player ) + } + // ========================================================================= // Bukkit-Events // ========================================================================= @@ -238,7 +250,7 @@ class TablistManager( } // ── Prefix: Server-Rang (z. B. "[Admin]") ───────────────────────── - team.prefix(rankProvider.getRankPrefix( player )) + team.prefix(buildPrefix( player )) // ── playerListName: farbiger Spielername ─────────────────────────── // WICHTIG: KEIN hier. Das machen wir am Anfang des Suffixes! @@ -270,6 +282,73 @@ class TablistManager( mm.deserialize( " [${rankTag}]" ) } + /** + * Gibt den Prefix zurück: + * - Teams aktiv + Spieler in einem Team → `[Team X]` in Teamfarbe + * - Teams aktiv + kein Team → leerer Prefix (kein Rang-Prefix) + * - Teams deaktiviert → Server-Rang-Prefix wie gehabt + */ + private fun buildPrefix( + player: Player + ): Component + { + val teamManager = plugin.presetTeamManager + + if ( teamManager.isEnabled ) + { + val presetTeam = teamManager.getTeam( player ) + + return if ( presetTeam != null ) + { + val colorTag = dyeColorToMiniMessage( presetTeam.color ) + mm.deserialize( "${colorTag}[${presetTeam.name}] " ) + } + else + { + Component.empty() + } + } + + return rankProvider.getRankPrefix( player ) + } + + /** + * Konvertiert [DyeColor] zu einem MiniMessage-Farb-Tag für den Tab-Prefix. + * + * | DyeColor | MiniMessage Tag | + * |----------|-----------------| + * | RED | `` | + * | BLUE | `` | + * | GREEN | `` | + * | YELLOW | `` | + * | ORANGE | `` | + * | PURPLE | `` | + * | CYAN | `` | + * | PINK | ``| + * | (sonst) | `` | + */ + private fun dyeColorToMiniMessage( + color: DyeColor + ): String = when( color ) + { + DyeColor.RED -> "" + DyeColor.BLUE -> "" + DyeColor.GREEN -> "" + DyeColor.LIME -> "" + DyeColor.YELLOW -> "" + DyeColor.ORANGE -> "" + DyeColor.PURPLE -> "" + DyeColor.MAGENTA -> "" + DyeColor.CYAN -> "" + DyeColor.LIGHT_BLUE -> "" + DyeColor.PINK -> "" + DyeColor.WHITE -> "" + DyeColor.GRAY -> "" + DyeColor.LIGHT_GRAY -> "" + DyeColor.BLACK -> "" + DyeColor.BROWN -> "" + } + /** Entfernt das Scoreboard-Team des Spielers vollständig. */ private fun removePlayerTeam( uuid: UUID @@ -337,10 +416,16 @@ class TablistManager( { updateTask = plugin.server.scheduler.runTaskTimer( plugin, { -> Bukkit.getOnlinePlayers().forEach { player -> - // Footer mit aktuellen Ping-Werten neu senden updateHeaderFooter( player ) - // SpeedHG-Suffix synchronisieren (falls Rang sich geändert hat) refreshRankSuffix( player ) + + // Team-Prefix bei jedem Tick aktuell halten (Team-Beitritt live sichtbar) + if ( plugin.presetTeamManager.isEnabled ) + { + val teamName = playerTeams[ player.uniqueId ] ?: return@forEach + val team = scoreboard.getTeam( teamName ) ?: return@forEach + team.prefix( buildPrefix( player ) ) + } } }, UPDATE_INTERVAL_TICKS, UPDATE_INTERVAL_TICKS ) } diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt b/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt deleted file mode 100644 index f527ec0..0000000 --- a/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt +++ /dev/null @@ -1,36 +0,0 @@ -package club.mcscrims.speedhg.team - -import java.util.UUID - -/** - * Repräsentiert ein aktives Team in SpeedHG. - * - * ## Design-Entscheidungen - * - * **Keine DB-Persistenz:** Teams existieren nur für eine Runde im RAM. - * Nach dem Spielende werden alle Teams via [TeamManager.reset] verworfen. - * - * **Leader-Konzept:** Der erste Spieler ([members].first()) ist stets der - * Ersteller. Nur er darf `/team kick` ausführen. Bei [leave] durch den - * Leader wird kein automatisches Promoting durchgeführt — das Team löst - * sich auf, wenn der Leader geht (Vereinfachung, da max. 2 Spieler). - * - * @param id Eindeutige Team-ID (UUID, intern generiert). - * @param leader UUID des Team-Erstellers. - * @param members Geordnete Liste aller Mitglieder (Leader immer an Index 0). - */ -data class Team( - val id: UUID = UUID.randomUUID(), - val leader: UUID, - val members: MutableList = mutableListOf(leader) -) { - - /** Gibt `true` zurück wenn [uuid] Mitglied dieses Teams ist. */ - fun contains(uuid: UUID): Boolean = members.contains(uuid) - - /** Gibt `true` zurück wenn [uuid] der Leader dieses Teams ist. */ - fun isLeader(uuid: UUID): Boolean = uuid == leader - - /** Anzahl der aktuellen Mitglieder. */ - val size: Int get() = members.size -} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt b/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt deleted file mode 100644 index 6dd7015..0000000 --- a/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt +++ /dev/null @@ -1,55 +0,0 @@ -package club.mcscrims.speedhg.team - -import club.mcscrims.speedhg.SpeedHG -import club.mcscrims.speedhg.game.GameState -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.EntityDamageByEntityEvent -import org.bukkit.event.player.PlayerQuitEvent - -/** - * Verhindert Friendly Fire zwischen Teammitgliedern und räumt - * Teams auf wenn Spieler die Runde verlassen. - * - * Friendly-Fire-Check läuft auf LOW-Priority, damit er vor allen anderen - * Damage-Listenern (Kit-, Perk-Dispatcher) gecancelt wird. So sehen - * weder [KitEventDispatcher] noch [PerkEventDispatcher] den Hit überhaupt. - */ -class TeamListener : Listener { - - private val plugin get() = SpeedHG.instance - - // ── Friendly Fire ───────────────────────────────────────────────────────── - - @EventHandler(priority = EventPriority.LOW, ignoreCancelled = false) - fun onDamage(event: EntityDamageByEntityEvent) { - if (!plugin.teamManager.isEnabled) return - if (plugin.gameManager.currentState != GameState.INGAME && - plugin.gameManager.currentState != GameState.INVINCIBILITY) return - - val attacker = event.damager as? Player ?: return - val victim = event.entity as? Player ?: return - - if (plugin.teamManager.areInSameTeam(attacker, victim)) { - event.isCancelled = true - } - } - - // ── Team-Cleanup bei Disconnect ─────────────────────────────────────────── - - /** - * Wenn ein Spieler das Spiel während der Lobby verlässt, wird er aus - * seinem Team entfernt (bzw. das Team aufgelöst wenn er Leader war). - * Während INGAME bleibt das Team bestehen — der Spieler zählt weiterhin - * zur Teamgruppe für die Win-Condition (er ist ja bereits eliminiert). - */ - @EventHandler(priority = EventPriority.MONITOR) - fun onQuit(event: PlayerQuitEvent) { - val state = plugin.gameManager.currentState - if (state == GameState.LOBBY || state == GameState.STARTING) { - plugin.teamManager.leave(event.player) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt b/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt deleted file mode 100644 index ff22de2..0000000 --- a/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt +++ /dev/null @@ -1,294 +0,0 @@ -package club.mcscrims.speedhg.team - -import club.mcscrims.speedhg.SpeedHG -import org.bukkit.entity.Player -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap - -/** - * Verwaltet alle aktiven Teams und offenen Einladungen für die laufende Runde. - * - * ## Datenstruktur - * - * Zwei parallele Maps ermöglichen O(1)-Lookups in beide Richtungen: - * - [teamByPlayer]: PlayerUUID → Team (schnelle Abfrage "In welchem Team bin ich?") - * - [teamsById]: TeamUUID → Team (schnelle Iteration über alle Teams) - * - * Einladungen ([pendingInvites]) sind kurzlebig und verfallen nach [INVITE_TTL_MS]. - * Struktur: InviteeUUID → (InviterUUID, ExpiryTimestamp) - * - * ## Thread-Safety - * Alle Maps sind [ConcurrentHashMap], da Bukkit-Events zwar auf dem Main-Thread - * laufen, der Timer-Cleanup ([cleanExpiredInvites]) aber vom Scheduler aufgerufen wird. - * Mutationen (invite, accept, leave) laufen ausschließlich auf dem Main-Thread. - */ -class TeamManager(private val plugin: SpeedHG) { - - companion object { - /** Einladung verfällt nach dieser Zeit in Millisekunden. */ - const val INVITE_TTL_MS = 60_000L - } - - // ── Konfiguration ───────────────────────────────────────────────────────── - - val isEnabled: Boolean - get() = plugin.config.getBoolean("teams.enabled", true) - - val maxTeamSize: Int - get() = plugin.config.getInt("teams.max-size", 2) - - // ── State ───────────────────────────────────────────────────────────────── - - /** TeamID → Team */ - private val teamsById = ConcurrentHashMap() - - /** PlayerUUID → Team (Reverse-Lookup, O(1)) */ - private val teamByPlayer = ConcurrentHashMap() - - /** - * Offene Einladungen: InviteeUUID → Pair(InviterUUID, ExpiryMs) - * Nur eine offene Einladung pro Spieler gleichzeitig. - */ - private val pendingInvites = ConcurrentHashMap>() - - // ========================================================================= - // Team-Queries (O(1), Main-Thread safe) - // ========================================================================= - - /** Gibt das Team des Spielers zurück, oder `null` wenn er keins hat. */ - fun getTeam(player: Player): Team? = teamByPlayer[player.uniqueId] - - /** Gibt das Team des Spielers zurück, oder `null` wenn er keins hat. */ - fun getTeam(uuid: UUID): Team? = teamByPlayer[uuid] - - /** - * Gibt `true` zurück wenn beide Spieler im selben Team sind. - * Wird verwendet für: - * - Friendly-Fire-Check ([TeamListener]) - * - Kompass-Tracking ([GameManager.updateCompass]) - * - Orakel-Perk ([OraclePerk.findNearestEnemy]) - */ - fun areInSameTeam(a: Player, b: Player): Boolean { - val teamA = teamByPlayer[a.uniqueId] ?: return false - return teamA.contains(b.uniqueId) - } - - fun areInSameTeam(a: UUID, b: UUID): Boolean { - val teamA = teamByPlayer[a] ?: return false - return teamA.contains(b) - } - - /** Gibt alle aktiven Teams zurück (nur lesend). */ - fun getAllTeams(): Collection = teamsById.values - - // ========================================================================= - // Invite-System - // ========================================================================= - - sealed class InviteResult { - object Success : InviteResult() - object TeamsDisabled : InviteResult() - object TargetAlreadyInTeam : InviteResult() - object SenderAlreadyInFullTeam : InviteResult() - object InvitedSelf : InviteResult() - object AlreadyInSameTeam : InviteResult() - object InviteAlreadyPending : InviteResult() - } - - /** - * Versendet eine Einladung von [sender] an [target]. - * Erstellt bei Bedarf ein neues Team für [sender]. - */ - fun invite(sender: Player, target: Player): InviteResult { - if (!isEnabled) return InviteResult.TeamsDisabled - if (sender.uniqueId == target.uniqueId) return InviteResult.InvitedSelf - - val senderTeam = teamByPlayer[sender.uniqueId] - val targetTeam = teamByPlayer[target.uniqueId] - - if (senderTeam != null && senderTeam.contains(target.uniqueId)) - return InviteResult.AlreadyInSameTeam - - if (targetTeam != null) - return InviteResult.TargetAlreadyInTeam - - if (senderTeam != null && senderTeam.size >= maxTeamSize) - return InviteResult.SenderAlreadyInFullTeam - - if (pendingInvites.containsKey(target.uniqueId)) - return InviteResult.InviteAlreadyPending - - pendingInvites[target.uniqueId] = Pair(sender.uniqueId, System.currentTimeMillis() + INVITE_TTL_MS) - return InviteResult.Success - } - - sealed class AcceptResult { - data class Success(val team: Team) : AcceptResult() - object TeamsDisabled : AcceptResult() - object NoInvite : AcceptResult() - object InviteExpired : AcceptResult() - object AlreadyInTeam : AcceptResult() - object TeamFull : AcceptResult() - object InviterNotFound : AcceptResult() - object WrongInviter : AcceptResult() - } - - /** - * Nimmt eine Einladung von [inviterName] an. - * Wenn [inviterName] null ist, nimmt es die einzige vorhandene Einladung an. - */ - fun accept(invitee: Player, inviterUUID: UUID): AcceptResult { - if (!isEnabled) return AcceptResult.TeamsDisabled - if (teamByPlayer.containsKey(invitee.uniqueId)) return AcceptResult.AlreadyInTeam - - val (storedInviterUUID, expiry) = pendingInvites[invitee.uniqueId] - ?: return AcceptResult.NoInvite - - if (storedInviterUUID != inviterUUID) return AcceptResult.WrongInviter - - if (System.currentTimeMillis() > expiry) { - pendingInvites.remove(invitee.uniqueId) - return AcceptResult.InviteExpired - } - - pendingInvites.remove(invitee.uniqueId) - - // Team des Inviters abrufen oder neu erstellen - val team = teamByPlayer.getOrPut(inviterUUID) { - val newTeam = Team(leader = inviterUUID) - teamsById[newTeam.id] = newTeam - newTeam - } - - if (team.size >= maxTeamSize) return AcceptResult.TeamFull - - team.members.add(invitee.uniqueId) - teamByPlayer[invitee.uniqueId] = team - - return AcceptResult.Success(team) - } - - fun deny(invitee: Player, inviterUUID: UUID): Boolean { - val invite = pendingInvites[invitee.uniqueId] ?: return false - if (invite.first != inviterUUID) return false - pendingInvites.remove(invitee.uniqueId) - return true - } - - // ========================================================================= - // Team-Mutations - // ========================================================================= - - sealed class LeaveResult { - object Success : LeaveResult() - object NotInTeam : LeaveResult() - /** Leader hat das Team verlassen → Team aufgelöst */ - object TeamDisbanded : LeaveResult() - } - - fun leave(player: Player): LeaveResult { - val team = teamByPlayer[player.uniqueId] ?: return LeaveResult.NotInTeam - - val wasLeader = team.isLeader(player.uniqueId) - - team.members.remove(player.uniqueId) - teamByPlayer.remove(player.uniqueId) - - return if (wasLeader || team.size == 0) { - // Leader geht → Team auflösen (alle verbleibenden Mitglieder rausnehmen) - disbandTeam(team) - LeaveResult.TeamDisbanded - } else { - if (team.size == 0) teamsById.remove(team.id) - LeaveResult.Success - } - } - - sealed class KickResult { - object Success : KickResult() - object NotInTeam : KickResult() - object NotLeader : KickResult() - object TargetNotInTeam : KickResult() - object CannotKickSelf : KickResult() - } - - fun kick(kicker: Player, target: Player): KickResult { - val team = teamByPlayer[kicker.uniqueId] ?: return KickResult.NotInTeam - if (!team.isLeader(kicker.uniqueId)) return KickResult.NotLeader - if (kicker.uniqueId == target.uniqueId) return KickResult.CannotKickSelf - if (!team.contains(target.uniqueId)) return KickResult.TargetNotInTeam - - team.members.remove(target.uniqueId) - teamByPlayer.remove(target.uniqueId) - - if (team.size == 0) { - teamsById.remove(team.id) - teamByPlayer.remove(kicker.uniqueId) - } - - return KickResult.Success - } - - // ========================================================================= - // Win-Condition Helper - // ========================================================================= - - /** - * Prüft ob alle [aliveUUIDs] im selben Team sind. - * Gibt `true` zurück wenn: - * - Teams deaktiviert → niemals (nur 1 Spieler = Sieg, Standardlogik) - * - Alle Überlebenden exakt dasselbe [Team]-Objekt teilen - * - * Aufruf in [GameManager.checkWin]: - * ```kotlin - * if (alivePlayers.size <= 1 || plugin.teamManager.allAliveInSameTeam(alivePlayers)) - * endGame(...) - * ``` - */ - fun allAliveInSameTeam(aliveUUIDs: Set): Boolean { - if (!isEnabled || aliveUUIDs.size <= 1) return false - - val firstTeam = teamByPlayer[aliveUUIDs.first()] ?: return false - return aliveUUIDs.all { uuid -> - val team = teamByPlayer[uuid] ?: return false - team.id == firstTeam.id - } - } - - // ========================================================================= - // Lifecycle - // ========================================================================= - - /** Räumt abgelaufene Einladungen auf. Einmal pro Sekunde aufrufen. */ - fun cleanExpiredInvites() { - val now = System.currentTimeMillis() - pendingInvites.entries.removeIf { (_, pair) -> now > pair.second } - } - - /** - * Setzt den gesamten Team-State zurück. - * In [GameManager.startGame] aufrufen (vor Spielstart). - */ - fun reset() { - teamsById.clear() - teamByPlayer.clear() - pendingInvites.clear() - plugin.logger.info("[TeamManager] Teams zurückgesetzt.") - } - - // ── Pending-Invite Queries ──────────────────────────────────────────────── - - /** Gibt die UUID des Inviters zurück, falls eine Einladung für [invitee] vorliegt. */ - fun getInviterFor(invitee: Player): UUID? { - val entry = pendingInvites[invitee.uniqueId] ?: return null - return if (System.currentTimeMillis() < entry.second) entry.first else null - } - - // ── Private Helpers ─────────────────────────────────────────────────────── - - private fun disbandTeam(team: Team) { - team.members.forEach { teamByPlayer.remove(it) } - team.members.clear() - teamsById.remove(team.id) - } -} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeam.kt b/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeam.kt new file mode 100644 index 0000000..101e2b3 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeam.kt @@ -0,0 +1,41 @@ +package club.mcscrims.speedhg.team.gui + +import org.bukkit.DyeColor +import java.util.UUID + +/** + * Repräsentiert einen festen Team-Slot im GUI-System. + * + * ## Unterschied zu [Team] + * + * [Team] wird dynamisch durch `/team invite` erstellt und hat keine + * feste Identität. [PresetTeam] hingegen existiert **immer** — es + * ist ein nummerierter Slot mit Farbe und max. Kapazität, der im + * [TeamSelectionMenu] als Wolle-Icon sichtbar ist. + * + * ## Lebenszyklus + * + * | Phase | Zustand | + * |---------|------------------------------------------------| + * | Lobby | Alle Slots leer, Spieler können beitreten | + * | Running | Slots gesperrt, Änderungen werden blockiert | + * | Reset | `members` wird geleert via [PresetTeamManager] | + * + * @param index Slot-Nummer (0-basiert), für den GUI-Slot-Index. + * @param name Angezeigter Name im GUI (z. B. `"Team 1"`). + * @param color Wolle-Farbe für das Icon. + * @param maxSize Maximale Spieleranzahl. + */ +data class PresetTeam( + val index: Int, + val name: String, + val color: DyeColor, + val maxSize: Int, + val members: MutableList = mutableListOf() +) { + val isFull: Boolean get() = members.size >= maxSize + val isEmpty: Boolean get() = members.isEmpty() + val size: Int get() = members.size + + fun contains( uuid: UUID ): Boolean = members.contains( uuid ) +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeamManager.kt b/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeamManager.kt new file mode 100644 index 0000000..bbae030 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/gui/PresetTeamManager.kt @@ -0,0 +1,181 @@ +package club.mcscrims.speedhg.team.gui + +import club.mcscrims.speedhg.SpeedHG +import org.bukkit.DyeColor +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Verwaltet die festen GUI-Team-Slots für das GommeHD-style Team-System. + * + * ## Konfiguration (`config.yml`) + * + * ```yaml + * teams: + * enabled: true + * preset-count: 8 + * max-size: 2 + * ``` + * + * Wenn `teams.enabled: false`, werden alle Mutations ([join], [leave]) sofort + * mit [JoinResult.TeamsDisabled] abgewiesen und [giveTeamItem] gibt `null` zurück. + * + * ## Datenstruktur + * + * ``` + * presetTeams: List — geordnete Slot-Liste (Index = GUI-Position) + * teamByPlayer: UUID → PresetTeam — O(1) Reverse-Lookup + * ``` + */ +class PresetTeamManager( + private val plugin: SpeedHG +) { + + companion object { + private val TEAM_COLORS = listOf( + DyeColor.RED, + DyeColor.BLUE, + DyeColor.GREEN, + DyeColor.YELLOW, + DyeColor.ORANGE, + DyeColor.PURPLE, + DyeColor.CYAN, + DyeColor.PINK, + DyeColor.WHITE, + DyeColor.BLACK + ) + } + + // ── Konfiguration ────────────────────────────────────────────────────────── + + val isEnabled: Boolean + get() = plugin.config.getBoolean( "teams.enabled", true ) + + val maxTeamSize: Int + get() = plugin.config.getInt( "teams.max-size", 2 ) + + // ── State ────────────────────────────────────────────────────────────────── + + val presetTeams: List + + /** Reverse-Lookup: PlayerUUID → PresetTeam. Thread-safe für Scheduler. */ + private val teamByPlayer = ConcurrentHashMap() + + init { + val count = plugin.config.getInt( "teams.preset-count", 8 ) + + presetTeams = ( 0 until count ).map { i -> + PresetTeam( + index = i, + name = "Team ${i + 1}", + color = TEAM_COLORS[ i % TEAM_COLORS.size ], + maxSize = maxTeamSize + ) + } + } + + // ── Queries ──────────────────────────────────────────────────────────────── + + fun getTeam( player: Player ): PresetTeam? = teamByPlayer[player.uniqueId] + fun getTeam( uuid: UUID ): PresetTeam? = teamByPlayer[uuid] + + // ── Mutations ────────────────────────────────────────────────────────────── + + sealed class JoinResult { + object Success : JoinResult() + object TeamsDisabled : JoinResult() + object TeamFull : JoinResult() + /** Spieler war bereits in diesem Team. */ + object AlreadyHere : JoinResult() + } + + /** + * Gibt `true` zurück wenn beide Spieler im selben Team sind. + * Wird verwendet für: + * - Friendly-Fire-Check ([TeamSelectionListener]) + * - Kompass-Tracking ([GameManager.updateCompass]) + * - Orakel-Perk ([OraclePerk.findNearestEnemy]) + */ + fun areInSameTeam( + a: Player, + b: Player + ): Boolean + { + val teamA = teamByPlayer[ a.uniqueId ] ?: return false + return teamA.contains( b.uniqueId ) + } + + fun areInSameTeam( + a: UUID, + b: UUID + ): Boolean + { + val teamA = teamByPlayer[ a ] ?: return false + return teamA.contains( b ) + } + + /** + * Prüft ob alle [aliveUUIDs] im selben Team sind. + * Gibt `true` zurück wenn: + * - Teams deaktiviert → niemals (nur 1 Spieler = Sieg, Standardlogik) + * - Alle Überlebenden exakt dasselbe [Team]-Objekt teilen + * + * Aufruf in [GameManager.checkWin]: + * ```kotlin + * if (alivePlayers.size <= 1 || plugin.teamManager.allAliveInSameTeam(alivePlayers)) + * endGame(...) + * ``` + */ + fun allAliveInSameTeam(aliveUUIDs: Set): Boolean { + if (!isEnabled || aliveUUIDs.size <= 1) return false + + val firstTeam = teamByPlayer[aliveUUIDs.first()] ?: return false + return aliveUUIDs.all { uuid -> + val team = teamByPlayer[uuid] ?: return false + team.index == firstTeam.index + } + } + + /** + * Lässt [player] dem [team]-Slot beitreten. + * Verlässt automatisch das alte Team, falls vorhanden. + */ + fun join( + player: Player, + team: PresetTeam + ): JoinResult + { + if ( !isEnabled ) return JoinResult.TeamsDisabled + if ( team.contains( player.uniqueId ) ) return JoinResult.AlreadyHere + if ( team.isFull ) return JoinResult.TeamFull + + teamByPlayer[player.uniqueId]?.members?.remove( player.uniqueId ) + team.members.add( player.uniqueId ) + teamByPlayer[player.uniqueId] = team + + // Tab-Prefix sofort aktualisieren + plugin.tablistManager.refreshTeamPrefix( player ) + + return JoinResult.Success + } + + fun leave( + player: Player + ) { + val team = teamByPlayer.remove( player.uniqueId ) ?: return + team.members.remove( player.uniqueId ) + + // Tab-Prefix zurücksetzen + plugin.tablistManager.refreshTeamPrefix( player ) + } + + /** Setzt alle Team-Slots zurück (Spielstart / Runden-Reset). */ + fun reset() + { + presetTeams.forEach { it.members.clear() } + teamByPlayer.clear() + plugin.logger.info( "[PresetTeamManager] Alle Team-Slots zurückgesetzt." ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionListener.kt b/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionListener.kt new file mode 100644 index 0000000..d73c167 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionListener.kt @@ -0,0 +1,70 @@ +package club.mcscrims.speedhg.team.gui + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.gui.menu.MenuHolder +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.EntityDamageByEntityEvent +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Fängt alle Inventory-Events ab und delegiert sie an das zugehörige [Menu]. + * + * Das Routing funktioniert über den [MenuHolder]-Pattern: + * Jedes via [Menu.createInventory] erstellte Inventar hat einen [MenuHolder] + * als Holder. Ist der Holder kein [MenuHolder], wird das Event ignoriert — + * so werden fremde Inventare (Kisten, Workbenches etc.) nie berührt. + */ +class TeamSelectionListener( + private val plugin: SpeedHG +) : Listener { + + @EventHandler( priority = EventPriority.LOW, ignoreCancelled = false ) + fun onDamage( + event: EntityDamageByEntityEvent + ) { + if ( !plugin.presetTeamManager.isEnabled ) return + if ( plugin.gameManager.currentState != GameState.INGAME && + plugin.gameManager.currentState != GameState.INVINCIBILITY ) return + + val attacker = event.damager as? Player ?: return + val victim = event.entity as? Player ?: return + + if (plugin.presetTeamManager.areInSameTeam( attacker, victim )) + event.isCancelled = true + } + + @EventHandler( priority = EventPriority.MONITOR ) + fun onQuit( + event: PlayerQuitEvent + ) { + val state = plugin.gameManager.currentState + if ( state == GameState.LOBBY || state == GameState.STARTING ) + plugin.presetTeamManager.leave( event.player ) + } + + @EventHandler + fun onInventoryClick( + event: InventoryClickEvent + ) { + val menu = ( event.inventory.holder as? MenuHolder )?.menu + as? TeamSelectionMenu ?: return + + event.isCancelled = true + + val player = event.whoClicked as? org.bukkit.entity.Player ?: return + menu.onClick( event, player ) + } + + @EventHandler + fun onInventoryClose( + event: InventoryCloseEvent + ) { + // Kein State-Cleanup nötig — PresetTeamManager ist unabhängig vom Inventar + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionMenu.kt new file mode 100644 index 0000000..2018d2d --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/gui/TeamSelectionMenu.kt @@ -0,0 +1,214 @@ +package club.mcscrims.speedhg.team.gui + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.gui.menu.Menu +import club.mcscrims.speedhg.util.trans +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.TextDecoration +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.enchantments.Enchantment +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack + +/** + * GUI-basiertes Team-Auswahl-Menü im GommeHD-Stil. + * + * ## Layout (4 Reihen, 36 Slots) + * + * ``` + * [F][F][F][F][F][F][F][F][F] ← Reihe 0: Filler + * [F][T1][T2][T3][T4][T5][T6][T7][F] + * [F][T8][..][..][..][..][..][..][F] + * [F][F][F][F][F][F][F][F][F] ← Reihe 3: Filler + * ``` + * + * ## Wolle-Farb-Logik + * + * | Zustand | Material | + * |----------------------|--------------------------| + * | Platz frei | `LIME_WOOL` | + * | Team voll | `RED_WOOL` | + * | Spieler selbst drin | Teamfarbe + Glanz-Effekt | + * + * @param viewer Spieler, der das Menü öffnet. + */ +class TeamSelectionMenu( + private val viewer: Player +) : Menu( + rows = 4, + title = viewer.trans( "gui.team_menu.title" ) +) { + + private val plugin get() = SpeedHG.instance + + companion object { + private val TEAM_SLOTS = listOf( + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25 + ) + + private val FILLER_MATERIAL = Material.GRAY_STAINED_GLASS_PANE + } + + // ── Build ────────────────────────────────────────────────────────────────── + + override fun build(): Inventory = createInventory( title ).also { populate( it ) } + + private fun populate( + inv: Inventory + ) { + inv.clear() + + val filler = buildFiller() + repeat( size ) { inv.setItem( it, filler ) } + + plugin.presetTeamManager.presetTeams.forEachIndexed { i, team -> + val slot = TEAM_SLOTS.getOrNull( i ) ?: return@forEachIndexed + inv.setItem( slot, buildTeamItem( team ) ) + } + } + + // ── Click-Handling ───────────────────────────────────────────────────────── + + override fun onClick( + event: InventoryClickEvent, + player: Player + ) { + val slot = event.rawSlot + if ( slot !in 0 until size ) return + + val teamIndex = TEAM_SLOTS.indexOf( slot ) + if ( teamIndex == -1 ) return + + val team = plugin.presetTeamManager.presetTeams.getOrNull( teamIndex ) ?: return + + when ( plugin.presetTeamManager.join( player, team ) ) + { + is PresetTeamManager.JoinResult.Success -> { + player.sendActionBar( player.trans( "gui.team_menu.joined", "team" to team.name ) ) + refresh( player ) + } + + is PresetTeamManager.JoinResult.TeamFull -> { + player.sendActionBar( player.trans( "gui.team_menu.full" ) ) + } + + is PresetTeamManager.JoinResult.AlreadyHere -> { + player.sendActionBar( player.trans( "gui.team_menu.already_here" ) ) + } + + is PresetTeamManager.JoinResult.TeamsDisabled -> { + player.sendActionBar( player.trans( "gui.team_menu.disabled" ) ) + } + } + } + + // ── Refresh ──────────────────────────────────────────────────────────────── + + /** + * Aktualisiert das Inventar für alle Spieler, die dieses Menü gerade offen haben. + * Wird nach jeder Beitritts-Aktion aufgerufen, damit alle + * offenen Instanzen die neue Teamgröße sofort sehen. + */ + private fun refresh( + triggeringPlayer: Player + ) { + populate( inventory ) + + Bukkit.getOnlinePlayers() + .filter { it != triggeringPlayer } + .forEach { other -> + val holder = other.openInventory.topInventory.holder + if ( holder is club.mcscrims.speedhg.gui.menu.MenuHolder && + holder.menu is TeamSelectionMenu ) + { + populate( other.openInventory.topInventory ) + } + } + } + + // ── Item-Builder ─────────────────────────────────────────────────────────── + + private fun buildTeamItem( + team: PresetTeam + ): ItemStack { + val isSelf = team.contains( viewer.uniqueId ) + val woolKey = "${team.color.name}_WOOL" + + val material = when + { + isSelf -> Material.getMaterial( woolKey ) ?: Material.LIME_WOOL + team.isFull -> Material.RED_WOOL + else -> Material.LIME_WOOL + } + + val item = ItemStack( material ) + + item.editMeta { meta -> + meta.displayName( + viewer.trans( "gui.team_menu.item.name", "team" to team.name ) + .decoration( TextDecoration.ITALIC, false ) + ) + + val memberNames = team.members + .mapNotNull { Bukkit.getPlayer( it )?.name } + + val lore = mutableListOf() + lore += Component.empty() + + lore += viewer.trans( + "gui.team_menu.item.size", + "current" to team.size.toString(), + "max" to team.maxSize.toString() + ).decoration( TextDecoration.ITALIC, false ) + + lore += Component.empty() + + if ( memberNames.isEmpty() ) + { + lore += viewer.trans( "gui.team_menu.item.empty" ) + .decoration( TextDecoration.ITALIC, false ) + } + else + { + memberNames.forEach { name -> + lore += viewer.trans( "gui.team_menu.item.member", "player" to name ) + .decoration( TextDecoration.ITALIC, false ) + } + } + + lore += Component.empty() + + lore += if ( team.isFull ) + viewer.trans( "gui.team_menu.item.full_hint" ).decoration( TextDecoration.ITALIC, false ) + else + viewer.trans( "gui.team_menu.item.join_hint" ).decoration( TextDecoration.ITALIC, false ) + + lore += Component.empty() + meta.lore( lore ) + + if ( isSelf ) { + meta.addEnchant( Enchantment.UNBREAKING, 1, true ) + meta.addItemFlags( ItemFlag.HIDE_ENCHANTS ) + } + + meta.addItemFlags( ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP ) + } + + return item + } + + private fun buildFiller(): ItemStack { + val item = ItemStack( FILLER_MATERIAL ) + item.editMeta { meta -> + meta.displayName( + Component.text( " " ).decoration( TextDecoration.ITALIC, false ) + ) + } + return item + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 891b0c8..248f73f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -29,7 +29,8 @@ lunarclient: teams: enabled: false - max-size: 2 + preset-count: 10 # Anzahl der Team-Slots + max-size: 2 # Spieler pro Team recraftNerf: enabled: false diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 2c21784..5a8712c 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -32,6 +32,9 @@ game: leaderboard: name: 'Top 10 Leaderboard' lore: 'Who are the best players?' + teams: + name: 'Teams' + lore: 'Click to choose your team.' ranking: placement_progress: 'Placement / — Placed # · Kill(s)' @@ -175,27 +178,70 @@ commands: scoreboard: title: 'SpeedHG' + + # ── Ohne Teams ──────────────────────────────────────────────────────────── + lobby: - - " " - - " " - - "Players: /" - - "Kit: " - - "Style: