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.
This commit is contained in:
TDSTOS
2026-04-13 01:23:29 +02:00
parent 77560a7486
commit 7589b05433
18 changed files with 759 additions and 694 deletions

View File

@@ -7,7 +7,6 @@ import club.mcscrims.speedhg.command.KitCommand
import club.mcscrims.speedhg.command.LeaderboardCommand import club.mcscrims.speedhg.command.LeaderboardCommand
import club.mcscrims.speedhg.command.PerksCommand import club.mcscrims.speedhg.command.PerksCommand
import club.mcscrims.speedhg.command.RankingCommand import club.mcscrims.speedhg.command.RankingCommand
import club.mcscrims.speedhg.command.TeamCommand
import club.mcscrims.speedhg.command.TimerCommand import club.mcscrims.speedhg.command.TimerCommand
import club.mcscrims.speedhg.config.CustomGameManager import club.mcscrims.speedhg.config.CustomGameManager
import club.mcscrims.speedhg.config.LanguageManager 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.ScoreboardManager
import club.mcscrims.speedhg.scoreboard.TablistManager import club.mcscrims.speedhg.scoreboard.TablistManager
import club.mcscrims.speedhg.scoreboard.VolcanoServerRankProvider import club.mcscrims.speedhg.scoreboard.VolcanoServerRankProvider
import club.mcscrims.speedhg.team.TeamListener import club.mcscrims.speedhg.team.gui.PresetTeamManager
import club.mcscrims.speedhg.team.TeamManager import club.mcscrims.speedhg.team.gui.TeamSelectionListener
import club.mcscrims.speedhg.webhook.DiscordWebhookManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager
import club.mcscrims.speedhg.world.DataPackManager import club.mcscrims.speedhg.world.DataPackManager
import club.mcscrims.speedhg.world.SurfaceBlockPopulator import club.mcscrims.speedhg.world.SurfaceBlockPopulator
@@ -115,9 +114,6 @@ class SpeedHG : JavaPlugin() {
lateinit var dataPackManager: DataPackManager lateinit var dataPackManager: DataPackManager
private set private set
lateinit var teamManager: TeamManager
private set
lateinit var lunarClientManager: LunarClientManager lateinit var lunarClientManager: LunarClientManager
private set private set
@@ -130,6 +126,9 @@ class SpeedHG : JavaPlugin() {
lateinit var lobbyAnnouncer: LobbyAnnouncer lateinit var lobbyAnnouncer: LobbyAnnouncer
private set private set
lateinit var presetTeamManager: PresetTeamManager
private set
override fun onLoad() override fun onLoad()
{ {
instance = this instance = this
@@ -189,17 +188,11 @@ class SpeedHG : JavaPlugin() {
lunarClientManager = LunarClientManager( this ) lunarClientManager = LunarClientManager( this )
lobbyItemManager = LobbyItemManager( this ) lobbyItemManager = LobbyItemManager( this )
tablistManager = TablistManager( this, VolcanoServerRankProvider() ) tablistManager = TablistManager( this, VolcanoServerRankProvider() )
presetTeamManager = PresetTeamManager( this )
perkManager = PerkManager( this ) perkManager = PerkManager( this )
perkManager.initialize() 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 = DisasterManager( this )
disasterManager.start() disasterManager.start()
@@ -220,7 +213,6 @@ class SpeedHG : JavaPlugin() {
podiumManager.cleanup() podiumManager.cleanup()
if ( ::lobbyAnnouncer.isInitialized ) lobbyAnnouncer.stop() if ( ::lobbyAnnouncer.isInitialized ) lobbyAnnouncer.stop()
if ( ::perkManager.isInitialized ) perkManager.shutdown() if ( ::perkManager.isInitialized ) perkManager.shutdown()
if ( ::teamManager.isInitialized ) teamManager.reset()
if ( ::tablistManager.isInitialized ) tablistManager.shutdown() if ( ::tablistManager.isInitialized ) tablistManager.shutdown()
if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown()
if ( ::databaseManager.isInitialized ) databaseManager.disconnect() if ( ::databaseManager.isInitialized ) databaseManager.disconnect()
@@ -288,12 +280,6 @@ class SpeedHG : JavaPlugin() {
tabCompleter = rankingCommand tabCompleter = rankingCommand
} }
val teamCommand = TeamCommand()
getCommand( "team" )?.apply {
setExecutor( teamCommand )
tabCompleter = teamCommand
}
getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() )
getCommand( "perks" )?.setExecutor( PerksCommand() ) getCommand( "perks" )?.setExecutor( PerksCommand() )
getCommand( "help" )?.setExecutor( HelpCommand() ) getCommand( "help" )?.setExecutor( HelpCommand() )
@@ -310,10 +296,10 @@ class SpeedHG : JavaPlugin() {
pm.registerEvents( StatsListener(), this ) pm.registerEvents( StatsListener(), this )
pm.registerEvents( MenuListener(), this ) pm.registerEvents( MenuListener(), this )
pm.registerEvents(PerkEventDispatcher( this, perkManager ), this ) pm.registerEvents(PerkEventDispatcher( this, perkManager ), this )
pm.registerEvents( TeamListener(), this )
pm.registerEvents( lobbyItemManager, this ) pm.registerEvents( lobbyItemManager, this )
pm.registerEvents(ChatListener( this, VolcanoServerRankProvider() ), this ) pm.registerEvents(ChatListener( this, VolcanoServerRankProvider() ), this )
pm.registerEvents(KnockbackListener( this ), this ) pm.registerEvents(KnockbackListener( this ), this )
pm.registerEvents(TeamSelectionListener( this ), this )
} }
private fun registerRecipes() private fun registerRecipes()

View File

@@ -48,7 +48,7 @@ class LunarClientManager(
private fun setRichPresence( private fun setRichPresence(
player: ApolloPlayer 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 currentState = plugin.gameManager.currentState.name
val presence = ServerRichPresence.builder() val presence = ServerRichPresence.builder()
@@ -57,7 +57,7 @@ class LunarClientManager(
.gameVariantName(plugin.config.getString( "lunarclient.variantName" )) .gameVariantName(plugin.config.getString( "lunarclient.variantName" ))
.playerState( currentState ) .playerState( currentState )
.teamCurrentSize( teamSize ) .teamCurrentSize( teamSize )
.teamMaxSize( plugin.teamManager.maxTeamSize ) .teamMaxSize( plugin.presetTeamManager.maxTeamSize )
.build() .build()
richPresenceModule.overrideServerRichPresence( player, presence ) richPresenceModule.overrideServerRichPresence( player, presence )

View File

@@ -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<out String>
): 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<out String>) {
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<out String>) {
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<out String>) {
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<out String>) {
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<out String>
): List<String> {
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()
}
}

View File

@@ -295,7 +295,7 @@ class GameManager(
private fun checkWin() { private fun checkWin() {
if (currentState != GameState.INGAME && currentState != GameState.INVINCIBILITY) return if (currentState != GameState.INGAME && currentState != GameState.INVINCIBILITY) return
val teamManager = plugin.teamManager val teamManager = plugin.presetTeamManager
val roundOver = when { val roundOver = when {
// Nur noch 0 oder 1 Spieler übrig → immer Ende // Nur noch 0 oder 1 Spieler übrig → immer Ende
@@ -326,8 +326,8 @@ class GameManager(
val winnerUUID = alivePlayers.firstOrNull() val winnerUUID = alivePlayers.firstOrNull()
val winnerTeam = if ( plugin.teamManager.isEnabled && winnerUUID != null ) val winnerTeam = if ( plugin.presetTeamManager.isEnabled && winnerUUID != null )
plugin.teamManager.getTeam( winnerUUID ) else null plugin.presetTeamManager.getTeam( winnerUUID ) else null
Bukkit.getOnlinePlayers().forEach { p -> Bukkit.getOnlinePlayers().forEach { p ->
val isWinner = winnerTeam?.contains( p.uniqueId ) ?: ( p.uniqueId == winnerUUID ) val isWinner = winnerTeam?.contains( p.uniqueId ) ?: ( p.uniqueId == winnerUUID )
@@ -364,7 +364,7 @@ class GameManager(
private fun buildWinnerName(anyAliveUUID: UUID?): String { private fun buildWinnerName(anyAliveUUID: UUID?): String {
anyAliveUUID ?: return "N/A" anyAliveUUID ?: return "N/A"
val teamManager = plugin.teamManager val teamManager = plugin.presetTeamManager
if (!teamManager.isEnabled) { if (!teamManager.isEnabled) {
return Bukkit.getPlayer(anyAliveUUID)?.name ?: "N/A" return Bukkit.getPlayer(anyAliveUUID)?.name ?: "N/A"
} }
@@ -420,8 +420,8 @@ class GameManager(
{ {
if ( p == target ) continue if ( p == target ) continue
if ( plugin.teamManager.isEnabled && if ( plugin.presetTeamManager.isEnabled &&
plugin.teamManager.areInSameTeam( p, target )) plugin.presetTeamManager.areInSameTeam( p, target ))
continue continue
val dist = p.location.distanceSquared( target.location ) val dist = p.location.distanceSquared( target.location )

View File

@@ -8,6 +8,7 @@ import club.mcscrims.speedhg.gui.menu.KitSelectorMenu
import club.mcscrims.speedhg.gui.menu.LeaderboardMenu import club.mcscrims.speedhg.gui.menu.LeaderboardMenu
import club.mcscrims.speedhg.gui.menu.PerkSelectorMenu import club.mcscrims.speedhg.gui.menu.PerkSelectorMenu
import club.mcscrims.speedhg.gui.menu.StatsMenu import club.mcscrims.speedhg.gui.menu.StatsMenu
import club.mcscrims.speedhg.team.gui.TeamSelectionMenu
import club.mcscrims.speedhg.util.ItemBuilder import club.mcscrims.speedhg.util.ItemBuilder
import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.format.TextDecoration
import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.MiniMessage
@@ -61,9 +62,10 @@ class LobbyItemManager(
companion object { companion object {
const val TAG_KITS = "kits" const val TAG_KITS = "kits"
const val TAG_PERKS = "perks" const val TAG_PERKS = "perks"
const val TAG_TUTORIAL = "tutorial" const val TAG_TUTORIAL = "tutorial"
const val TAG_STATS = "stats" const val TAG_STATS = "stats"
const val TAG_LEADERBOARD = "leaderboard" const val TAG_LEADERBOARD = "leaderboard"
const val TAG_TEAMS = "teams"
} }
// ── Item definitions ────────────────────────────────────────────────────── // ── Item definitions ──────────────────────────────────────────────────────
@@ -145,6 +147,17 @@ class LobbyItemManager(
.build() .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 ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
/** /**
@@ -157,11 +170,15 @@ class LobbyItemManager(
player: Player player: Player
) { ) {
player.inventory.clear() player.inventory.clear()
player.inventory.setItem( 0, buildKitItem( player )) player.inventory.setItem( 0, buildKitItem( player ) )
player.inventory.setItem( 1, buildPerkItem( player )) player.inventory.setItem( 1, buildPerkItem( player ) )
player.inventory.setItem( 4, buildTutorialItem( player ))
player.inventory.setItem( 7, buildStatsItem( player )) if ( plugin.presetTeamManager.isEnabled )
player.inventory.setItem( 8, buildLeaderboardItem( player )) 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_KITS -> KitSelectorMenu( event.player ).open( event.player )
TAG_PERKS -> PerkSelectorMenu( 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_TUTORIAL -> openTutorialBook( event.player )
TAG_STATS -> StatsMenu( event.player ).open( event.player ) TAG_STATS -> StatsMenu( event.player ).open( event.player )
TAG_LEADERBOARD -> LeaderboardMenu( event.player ).open( event.player ) TAG_LEADERBOARD -> LeaderboardMenu( event.player ).open( event.player )

View File

@@ -74,7 +74,7 @@ class OraclePerk : Perk() {
.filter { it != player.uniqueId } .filter { it != player.uniqueId }
.mapNotNull { plugin.server.getPlayer(it) } .mapNotNull { plugin.server.getPlayer(it) }
.filter { !plugin.perkManager.isGhost(it) } .filter { !plugin.perkManager.isGhost(it) }
.filter { !plugin.teamManager.areInSameTeam( player, it ) } .filter { !plugin.presetTeamManager.areInSameTeam( player, it ) }
.minByOrNull { it.location.distanceSquared(player.location) } .minByOrNull { it.location.distanceSquared(player.location) }
private fun buildTrackerComponent(player: Player, nearest: Player): Component { private fun buildTrackerComponent(player: Player, nearest: Player): Component {

View File

@@ -58,21 +58,29 @@ class ScoreboardManager(
player: Player, player: Player,
board: FastBoard board: FastBoard
) { ) {
val gm = plugin.gameManager val gm = plugin.gameManager
val state = gm.currentState val state = gm.currentState
board.updateTitle(player.trans( "scoreboard.title" )) board.updateTitle( player.trans( "scoreboard.title" ) )
val online = Bukkit.getOnlinePlayers().size.toString() val online = Bukkit.getOnlinePlayers().size.toString()
val max = Bukkit.getMaxPlayers().toString() val max = Bukkit.getMaxPlayers().toString()
val variantName = plugin.config.getString( "lunarclient.variantName" ).toString() val variantName = plugin.config.getString( "lunarclient.variantName" ).toString()
val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" )
val styleName = plugin.kitManager.getSelectedPlaystyle( player ).displayName val styleName = plugin.kitManager.getSelectedPlaystyle( player ).displayName
val stats = plugin.statsManager.getCachedStats( player.uniqueId ) val stats = plugin.statsManager.getCachedStats( player.uniqueId )
val score = stats?.scrimScore ?: 0 val score = stats?.scrimScore ?: 0
val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 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 rankComponent = Rank.getFormattedRankName( score, games )
val lines: List<Component> val lines: List<Component>
@@ -81,22 +89,44 @@ class ScoreboardManager(
{ {
val timeString = if ( state == GameState.STARTING ) formatTime( gm.timer ) else "Waiting..." 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( lines = plugin.languageManager.getMessageList(
player, "scoreboard.lobby", player, key,
mapOf( "online" to online, "max" to max, "time" to timeString, "style" to styleName, "variant" to variantName ), 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 ) mapOf( "kit" to kitName, "rank" to rankComponent )
) )
} }
else else
{ {
val timeString = formatTime( gm.timer ) val timeString = formatTime( gm.timer )
val alive = gm.alivePlayers.size.toString() val alive = gm.alivePlayers.size.toString()
val kills = player.getStatistic( Statistic.PLAYER_KILLS ).toString() val kills = player.getStatistic( Statistic.PLAYER_KILLS ).toString()
val border = String.format( "%.0f", player.world.worldBorder.size ) val border = String.format( "%.0f", player.world.worldBorder.size )
val key = if ( teamsEnabled ) "scoreboard.ingame_teams" else "scoreboard.ingame"
lines = plugin.languageManager.getMessageList( lines = plugin.languageManager.getMessageList(
player, "scoreboard.ingame", player, key,
mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border, "style" to styleName, "variant" to variantName ), 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 ) mapOf( "kit" to kitName, "rank" to rankComponent )
) )
} }

View File

@@ -2,9 +2,11 @@ package club.mcscrims.speedhg.scoreboard
import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.ranking.Rank 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.MiniMessage
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.Bukkit
import org.bukkit.DyeColor
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.EventPriority
@@ -166,6 +168,16 @@ class TablistManager(
updateHeaderFooter( player ) 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 // Bukkit-Events
// ========================================================================= // =========================================================================
@@ -238,7 +250,7 @@ class TablistManager(
} }
// ── Prefix: Server-Rang (z. B. "[Admin]") ───────────────────────── // ── Prefix: Server-Rang (z. B. "[Admin]") ─────────────────────────
team.prefix(rankProvider.getRankPrefix( player )) team.prefix(buildPrefix( player ))
// ── playerListName: farbiger Spielername ─────────────────────────── // ── playerListName: farbiger Spielername ───────────────────────────
// WICHTIG: KEIN <reset> hier. Das <reset> machen wir am Anfang des Suffixes! // WICHTIG: KEIN <reset> hier. Das <reset> machen wir am Anfang des Suffixes!
@@ -270,6 +282,73 @@ class TablistManager(
mm.deserialize( "<reset> <dark_gray>[<reset>${rankTag}<dark_gray>]</dark_gray>" ) mm.deserialize( "<reset> <dark_gray>[<reset>${rankTag}<dark_gray>]</dark_gray>" )
} }
/**
* 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}<bold>[${presetTeam.name}]</bold> " )
}
else
{
Component.empty()
}
}
return rankProvider.getRankPrefix( player )
}
/**
* Konvertiert [DyeColor] zu einem MiniMessage-Farb-Tag für den Tab-Prefix.
*
* | DyeColor | MiniMessage Tag |
* |----------|-----------------|
* | RED | `<red>` |
* | BLUE | `<blue>` |
* | GREEN | `<green>` |
* | YELLOW | `<yellow>` |
* | ORANGE | `<gold>` |
* | PURPLE | `<dark_purple>` |
* | CYAN | `<aqua>` |
* | PINK | `<light_purple>`|
* | (sonst) | `<gray>` |
*/
private fun dyeColorToMiniMessage(
color: DyeColor
): String = when( color )
{
DyeColor.RED -> "<red>"
DyeColor.BLUE -> "<blue>"
DyeColor.GREEN -> "<green>"
DyeColor.LIME -> "<green>"
DyeColor.YELLOW -> "<yellow>"
DyeColor.ORANGE -> "<gold>"
DyeColor.PURPLE -> "<dark_purple>"
DyeColor.MAGENTA -> "<light_purple>"
DyeColor.CYAN -> "<aqua>"
DyeColor.LIGHT_BLUE -> "<aqua>"
DyeColor.PINK -> "<light_purple>"
DyeColor.WHITE -> "<white>"
DyeColor.GRAY -> "<gray>"
DyeColor.LIGHT_GRAY -> "<gray>"
DyeColor.BLACK -> "<dark_gray>"
DyeColor.BROWN -> "<gold>"
}
/** Entfernt das Scoreboard-Team des Spielers vollständig. */ /** Entfernt das Scoreboard-Team des Spielers vollständig. */
private fun removePlayerTeam( private fun removePlayerTeam(
uuid: UUID uuid: UUID
@@ -337,10 +416,16 @@ class TablistManager(
{ {
updateTask = plugin.server.scheduler.runTaskTimer( plugin, { -> updateTask = plugin.server.scheduler.runTaskTimer( plugin, { ->
Bukkit.getOnlinePlayers().forEach { player -> Bukkit.getOnlinePlayers().forEach { player ->
// Footer mit aktuellen Ping-Werten neu senden
updateHeaderFooter( player ) updateHeaderFooter( player )
// SpeedHG-Suffix synchronisieren (falls Rang sich geändert hat)
refreshRankSuffix( player ) 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 ) }, UPDATE_INTERVAL_TICKS, UPDATE_INTERVAL_TICKS )
} }

View File

@@ -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<UUID> = 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
}

View File

@@ -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)
}
}
}

View File

@@ -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<UUID, Team>()
/** PlayerUUID → Team (Reverse-Lookup, O(1)) */
private val teamByPlayer = ConcurrentHashMap<UUID, Team>()
/**
* Offene Einladungen: InviteeUUID → Pair(InviterUUID, ExpiryMs)
* Nur eine offene Einladung pro Spieler gleichzeitig.
*/
private val pendingInvites = ConcurrentHashMap<UUID, Pair<UUID, Long>>()
// =========================================================================
// 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<Team> = 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<UUID>): 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)
}
}

View File

@@ -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<UUID> = 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 )
}

View File

@@ -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<PresetTeam> — 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<PresetTeam>
/** Reverse-Lookup: PlayerUUID → PresetTeam. Thread-safe für Scheduler. */
private val teamByPlayer = ConcurrentHashMap<UUID, PresetTeam>()
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<UUID>): 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." )
}
}

View File

@@ -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
}
}

View File

@@ -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<Component>()
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
}
}

View File

@@ -29,7 +29,8 @@ lunarclient:
teams: teams:
enabled: false enabled: false
max-size: 2 preset-count: 10 # Anzahl der Team-Slots
max-size: 2 # Spieler pro Team
recraftNerf: recraftNerf:
enabled: false enabled: false

View File

@@ -32,6 +32,9 @@ game:
leaderboard: leaderboard:
name: '<gold><bold>Top 10 Leaderboard</bold></gold>' name: '<gold><bold>Top 10 Leaderboard</bold></gold>'
lore: '<gray>Who are the best players?</gray>' lore: '<gray>Who are the best players?</gray>'
teams:
name: '<gold><bold>Teams'
lore: '<gray>Click to choose your team.'
ranking: ranking:
placement_progress: '<prefix><gray>Placement <aqua><current>/<total></aqua> — Placed <aqua>#<placement></aqua> · <aqua><kills></aqua> Kill(s)</gray>' placement_progress: '<prefix><gray>Placement <aqua><current>/<total></aqua> — Placed <aqua>#<placement></aqua> · <aqua><kills></aqua> Kill(s)</gray>'
@@ -175,27 +178,70 @@ commands:
scoreboard: scoreboard:
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>' title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'
# ── Ohne Teams ────────────────────────────────────────────────────────────
lobby: lobby:
- " <variant>" - ''
- "<gray><st> " - '<gray>Type: <white><variant></white></gray>'
- "Players: <green><online>/<max>" - '<gray>Players: <white><online>/<max></white></gray>'
- "Kit: <yellow><kit>" - '<gray>Time: <white><time></white></gray>'
- "Style: <yellow><style>" - ''
- "Rank: <rank>" - '<gray>Kit: <kit></gray>'
- "" - '<gray>Style: <white><style></white></gray>'
- "<gray>Waiting for start..." - ''
- "" - '<gray>Rank: <rank></gray>'
- "<yellow>play.mcscrims.club" - ''
- '<dark_gray>play.mcscrims.club</dark_gray>'
- ''
ingame: ingame:
- "<gray><st> " - ''
- "Time: <green><timer>" - '<gray>Time: <white><timer></white></gray>'
- "Players: <red><alive>" - '<gray>Alive: <white><alive></white></gray>'
- "Kills: <green><kills>" - '<gray>Border: <white><border>m</white></gray>'
- "Rank: <rank>" - ''
- "" - '<gray>Kit: <kit></gray>'
- "Border: <red><border>" - '<gray>Style: <white><style></white></gray>'
- "" - ''
- "<yellow>play.mcscrims.club" - '<gray>Kills: <white><kills></white></gray>'
- ''
- '<dark_gray>play.mcscrims.club</dark_gray>'
- ''
# ── Mit Teams ─────────────────────────────────────────────────────────────
lobby_teams:
- ''
- '<gray>Type: <white><variant></white></gray>'
- '<gray>Players: <white><online>/<max></white></gray>'
- '<gray>Time: <white><time></white></gray>'
- ''
- '<gray>Kit: <kit></gray>'
- '<gray>Style: <white><style></white></gray>'
- ''
- '<gray>Team: <white><team></white></gray>'
- '<gray>Members: <white><members></white></gray>'
- ''
- '<dark_gray>play.mcscrims.club</dark_gray>'
- ''
ingame_teams:
- ''
- '<gray>Time: <white><timer></white></gray>'
- '<gray>Alive: <white><alive></white></gray>'
- '<gray>Border: <white><border>m</white></gray>'
- ''
- '<gray>Kit: <kit></gray>'
- '<gray>Style: <white><style></white></gray>'
- ''
- '<gray>Team: <white><team></white></gray>'
- '<gray>Members: <white><members></white></gray>'
- ''
- '<gray>Kills: <white><kills></white></gray>'
- ''
- '<dark_gray>play.mcscrims.club</dark_gray>'
- ''
gui: gui:
kit_selector: kit_selector:
@@ -235,6 +281,19 @@ gui:
title: '<aqua><bold>Profile & Stats</bold></aqua>' title: '<aqua><bold>Profile & Stats</bold></aqua>'
leaderboard_menu: leaderboard_menu:
title: '<gold><bold>Top 10 Leaderboard</bold></gold>' title: '<gold><bold>Top 10 Leaderboard</bold></gold>'
team_menu:
title: '<dark_gray>Choose your Team'
joined: '<green>You joined <white><team></white>!'
full: '<red>This team is already full!'
already_here: '<yellow>You are already in this team.'
disabled: '<red>Teams are disabled.'
item:
name: '<white><bold><team></bold>'
size: '<gray>Players: <white><current>/<max>'
empty: ' <dark_gray><i>Nobody yet</i>'
member: ' <aqua>• <player>'
join_hint: '<green>▶ Click to join'
full_hint: '<red>⛔ Team is full'
perks: perks:
oracle: oracle:

View File

@@ -41,7 +41,4 @@ commands:
permission: speedhg.admin.ranking permission: speedhg.admin.ranking
perks: perks:
description: 'Perk-Auswahl öffnen' description: 'Perk-Auswahl öffnen'
usage: '/perks' usage: '/perks'
team:
description: 'Team-System for SpeedHG'
usage: '/team <invite|accept|deny|leave|kick|info> [player]'