Add team system and team-based win condition

Introduce a full in-memory team system and integrate it into gameplay and UI.

- Add Team, TeamManager, TeamListener and TeamCommand to manage teams, invites, accept/deny, leave, kick and info. TeamManager stores teams and pending invites (INVITE_TTL_MS) using concurrent maps and exposes helper/query methods (areInSameTeam, allAliveInSameTeam, getInviterFor, cleanExpiredInvites, reset).
- Wire TeamManager into SpeedHG: initialize, schedule periodic invite cleanup, register command and listener, and reset teams on plugin disable.
- Update GameManager to consider teams for win conditions, build team winner names, exclude teammates from random teleport/compass logic, and credit all winning team members for stats/ranking.
- Move ability charge feedback out of individual kits into KitEventDispatcher: remove per-kit onFullyCharged overrides and add centralized ActionBar/sound feedback (sendChargeUpdateActionBar/sendChargeReadyActionBar). ActiveAbility no longer defines onFullyCharged.
- Make OraclePerk ignore teammates when finding nearest enemy.
- Add config entries for teams and corresponding language strings; tweak some default values in CustomGameSettings (gladiator arena radius/height and minor formatting changes).

These changes enable 2-player teams (configurable), friendly-fire prevention, invite lifecycle handling, and team-aware endgame logic.
This commit is contained in:
TDSTOS
2026-04-09 02:33:14 +02:00
parent a6675c882b
commit 4d32fe677c
23 changed files with 788 additions and 110 deletions

View File

@@ -4,6 +4,7 @@ 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.CustomGameSettings
@@ -35,6 +36,8 @@ import club.mcscrims.speedhg.perk.impl.VampirePerk
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
import club.mcscrims.speedhg.ranking.RankingManager
import club.mcscrims.speedhg.scoreboard.ScoreboardManager
import club.mcscrims.speedhg.team.TeamListener
import club.mcscrims.speedhg.team.TeamManager
import club.mcscrims.speedhg.webhook.DiscordWebhookManager
import club.mcscrims.speedhg.world.DataPackManager
import club.mcscrims.speedhg.world.SurfaceBlockPopulator
@@ -100,6 +103,9 @@ class SpeedHG : JavaPlugin() {
lateinit var dataPackManager: DataPackManager
private set
lateinit var teamManager: TeamManager
private set
override fun onLoad()
{
instance = this
@@ -157,6 +163,13 @@ class SpeedHG : JavaPlugin() {
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()
@@ -173,6 +186,7 @@ class SpeedHG : JavaPlugin() {
{
podiumManager.cleanup()
if ( ::perkManager.isInitialized ) perkManager.shutdown()
if ( ::teamManager.isInitialized ) teamManager.reset()
if ( ::statsManager.isInitialized ) statsManager.shutdown()
if ( ::databaseManager.isInitialized ) databaseManager.disconnect()
if ( ::dataPackManager.isInitialized ) dataPackManager.uninstall()
@@ -230,6 +244,12 @@ class SpeedHG : JavaPlugin() {
tabCompleter = rankingCommand
}
val teamCommand = TeamCommand()
getCommand( "team" )?.apply {
setExecutor( teamCommand )
tabCompleter = teamCommand
}
getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() )
getCommand( "perks" )?.setExecutor( PerksCommand() )
}
@@ -245,6 +265,7 @@ class SpeedHG : JavaPlugin() {
pm.registerEvents( StatsListener(), this )
pm.registerEvents( MenuListener(), this )
pm.registerEvents(PerkEventDispatcher( this, perkManager ), this )
pm.registerEvents( TeamListener(), this )
}
private fun registerRecipes()

View File

@@ -0,0 +1,232 @@
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("§cNur Spieler können diesen Befehl nutzen.")
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

@@ -61,8 +61,8 @@ data class CustomGameSettings(
@SerialName("bunker_radius") val bunkerRadius: Double = 10.0,
// Gladiator
@SerialName("arena_radius") val arenaRadius: Int = 23,
@SerialName("arena_height") val arenaHeight: Int = 10,
@SerialName("arena_radius") val arenaRadius: Int = 11,
@SerialName("arena_height") val arenaHeight: Int = 7,
@SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180,
// Venom

View File

@@ -274,15 +274,27 @@ class GameManager(
checkWin()
}
private fun checkWin()
{
if ( currentState != GameState.INGAME && currentState != GameState.INVINCIBILITY ) return
private fun checkWin() {
if (currentState != GameState.INGAME && currentState != GameState.INVINCIBILITY) return
if ( alivePlayers.size <= 1 )
{
val teamManager = plugin.teamManager
val roundOver = when {
// Nur noch 0 oder 1 Spieler übrig → immer Ende
alivePlayers.size <= 1 -> true
// Teams aktiv: Alle Überlebenden im selben Team → Team gewinnt
teamManager.isEnabled && teamManager.allAliveInSameTeam(alivePlayers) -> true
else -> false
}
if (roundOver) {
val winnerUUID = alivePlayers.firstOrNull()
val winnerName = if ( winnerUUID != null ) Bukkit.getPlayer( winnerUUID )?.name ?: "N/A" else "N/A"
endGame( winnerName )
// Den sichtbaren Gewinner-Namen ermitteln:
// Bei Team-Sieg alle Mitglieder des Gewinner-Teams anzeigen.
val winnerName = buildWinnerName(winnerUUID)
endGame(winnerName)
}
}
@@ -296,18 +308,18 @@ class GameManager(
val winnerUUID = alivePlayers.firstOrNull()
val winnerTeam = if ( plugin.teamManager.isEnabled && winnerUUID != null )
plugin.teamManager.getTeam( winnerUUID ) else null
Bukkit.getOnlinePlayers().forEach { p ->
if ( p.uniqueId == winnerUUID )
val isWinner = winnerTeam?.contains( p.uniqueId ) ?: ( p.uniqueId == winnerUUID )
if ( isWinner )
{
plugin.statsManager.addWin( p.uniqueId )
plugin.rankingManager.onPlayerResult( p, isWinner = true )
}
}
plugin.kitManager.clearAll()
plugin.perkManager.removeAllActivePerks()
Bukkit.getOnlinePlayers().forEach { p ->
p.showTitle(Title.title(
p.trans( "title.win-main", "winner" to winnerName ),
p.trans( "title.win-sub" )
@@ -315,6 +327,9 @@ class GameManager(
p.sendMsg( "game.win-chat", "winner" to winnerName )
}
plugin.kitManager.clearAll()
plugin.perkManager.removeAllActivePerks()
plugin.discordWebhookManager.broadcastEmbed(
title = "🏆 Spiel beendet!",
description = "**$winnerName** hat das Spiel gewonnen! GG!",
@@ -328,6 +343,25 @@ class GameManager(
// --- Helfer Methoden ---
private fun buildWinnerName(anyAliveUUID: UUID?): String {
anyAliveUUID ?: return "N/A"
val teamManager = plugin.teamManager
if (!teamManager.isEnabled) {
return Bukkit.getPlayer(anyAliveUUID)?.name ?: "N/A"
}
val winnerTeam = teamManager.getTeam(anyAliveUUID)
return if (winnerTeam != null && winnerTeam.size > 1) {
// "PlayerA & PlayerB" als Team-Gewinner-String
winnerTeam.members
.mapNotNull { Bukkit.getPlayer(it)?.name }
.joinToString(" & ")
} else {
Bukkit.getPlayer(anyAliveUUID)?.name ?: "N/A"
}
}
private fun teleportRandomly(
player: Player,
world: World,
@@ -368,6 +402,10 @@ class GameManager(
{
if ( p == target ) continue
if ( plugin.teamManager.isEnabled &&
plugin.teamManager.areInSameTeam( p, target ))
continue
val dist = p.location.distanceSquared( target.location )
if ( dist < minDistance )
{

View File

@@ -72,10 +72,4 @@ abstract class ActiveAbility(
*/
abstract fun execute( player: Player ): AbilityResult
/**
* Called when the ability's charge completes ([hitsRequired] hits landed).
* Override for sounds, particles, ActionBar feedback, etc.
*/
open fun onFullyCharged( player: Player ) {}
}

View File

@@ -224,11 +224,6 @@ class AnchorKit : Kit() {
)
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 0.8f)
player.sendActionBar(player.trans("kits.anchor.messages.ability_charged"))
}
}
// =========================================================================

View File

@@ -179,11 +179,6 @@ class BlackPantherKit : Kit()
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.blackpanther.messages.ability_charged"))
}
}
// =========================================================================

View File

@@ -10,7 +10,6 @@ import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.WorldEditUtils
import club.mcscrims.speedhg.util.trans
import com.sk89q.worldedit.bukkit.BukkitAdapter
import com.sk89q.worldedit.math.Vector2
import com.sk89q.worldedit.regions.CylinderRegion
@@ -154,13 +153,6 @@ class GladiatorKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.gladiator.messages.ability_charged" ))
}
}
private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) {

View File

@@ -172,13 +172,6 @@ class GoblinKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.goblin.messages.ability_charged" ))
}
fun cancelStealTask(
player: Player
) {
@@ -237,13 +230,6 @@ class GoblinKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.goblin.messages.ability_charged" ))
}
}
private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) {

View File

@@ -143,13 +143,6 @@ class IceMageKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.icemage.messages.ability_charged" ))
}
}
private class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) {

View File

@@ -218,11 +218,6 @@ class PuppetKit : Kit() {
player.sendActionBar(player.trans("kits.puppet.messages.drain_start"))
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.puppet.messages.ability_charged"))
}
}
// =========================================================================
@@ -277,11 +272,6 @@ class PuppetKit : Kit() {
)
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.puppet.messages.ability_charged"))
}
}
class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {

View File

@@ -184,8 +184,6 @@ class RattlesnakeKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) { /* not used hitsRequired = 0 */ }
}
// =========================================================================

View File

@@ -209,13 +209,6 @@ class TeslaKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_BEACON_ACTIVATE, 0.8f, 1.8f )
player.sendActionBar(player.trans( "kits.tesla.messages.ability_charged" ))
}
}
// =========================================================================

View File

@@ -169,13 +169,6 @@ class VenomKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.venom.messages.ability_charged" ))
}
}
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
@@ -257,13 +250,6 @@ class VenomKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
player.sendActionBar(player.trans( "kits.venom.messages.ability_charged" ))
}
}
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) {

View File

@@ -159,11 +159,6 @@ class VoodooKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.voodoo.messages.ability_charged"))
}
}
// =========================================================================
@@ -209,11 +204,6 @@ class VoodooKit : Kit() {
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.voodoo.messages.ability_charged"))
}
}
// =========================================================================

View File

@@ -6,9 +6,11 @@ import club.mcscrims.speedhg.kit.KitManager
import club.mcscrims.speedhg.kit.KitMetaData
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.charge.ChargeState
import club.mcscrims.speedhg.kit.impl.BlackPantherKit
import club.mcscrims.speedhg.kit.impl.IceMageKit
import club.mcscrims.speedhg.kit.impl.VenomKit
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import org.bukkit.Material
@@ -92,7 +94,10 @@ class KitEventDispatcher(
// ── 1. Increment charge counter ──────────────────────────────────────
val justFullyCharged = chargeData.registerHit()
if ( justFullyCharged ) {
attackerKit.getActiveAbility( attackerPlaystyle ).onFullyCharged( attacker )
sendChargeReadyActionBar( attacker )
} else if ( chargeData.state == ChargeState.CHARGING ) {
val currentHits = chargeData.hitsRequired - chargeData.hitsRemaining
sendChargeUpdateActionBar( attacker, currentHits, chargeData.hitsRequired )
}
// ── 2. Attacker passive hook ─────────────────────────────────────────
@@ -405,4 +410,31 @@ class KitEventDispatcher(
return plugin.gameManager.alivePlayers.contains( player.uniqueId )
}
// ── Charge-Feedback ───────────────────────────────────────────────────────
/**
* Sendet eine Actionbar-Anzeige über den aktuellen Ladestand der Fähigkeit.
* Wird nur aufgerufen wenn die Fähigkeit noch im CHARGING-Zustand ist —
* also niemals bei hitsRequired == 0 (Always READY) und niemals wenn
* die Fähigkeit gerade erst vollständig aufgeladen wurde.
*/
private fun sendChargeUpdateActionBar(
player: Player,
currentHits: Int,
requiredHits: Int
) {
// Guard: sollte nie 0 sein, aber defensiv absichern
if ( requiredHits <= 0 ) return
val component = player.trans( "kits.needed_hits", "current" to currentHits.toString(), "required" to requiredHits.toString() )
player.sendActionBar( component )
}
private fun sendChargeReadyActionBar(
player: Player
) {
val component = player.trans( "kits.ability_charged" )
player.sendActionBar( component )
player.playSound( player.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.7f, 1.4f )
}
}

View File

@@ -74,6 +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 ) }
.minByOrNull { it.location.distanceSquared(player.location) }
private fun buildTrackerComponent(player: Player, nearest: Player): Component {

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,294 @@
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

@@ -21,6 +21,10 @@ anti-runner:
ignore-vertical-distance: 15.0 # Wenn Höhenunterschied > 15, Timer ignorieren
ignore-cave-surface-mix: true # Ignorieren, wenn einer Sonne hat und der andere nicht
teams:
enabled: true
max-size: 2
recraftNerf:
enabled: false
beforeFeast: true

View File

@@ -81,6 +81,53 @@ disasters:
warning-main: '<yellow><bold>THUNDERSTORM!</bold></yellow>'
warning-sub: '<gray>Your height attracts lightning!</gray>'
team:
disabled: '<prefix><red>Teams are disabled in this round.</red>'
usage: '<prefix><gray>Usage: /team <invite|accept|deny|leave|kick|info> [player]</gray>'
game_running: '<prefix><red>Teams cannot be changed while the game is running!</red>'
not_in_team: '<prefix><red>You are not in a team.</red>'
already_in_team: '<prefix><red>You are already in a team!</red>'
player_not_found: '<prefix><red>Player <name> is not online.</red>'
invite:
usage: '<prefix><red>Usage: /team invite <player></red>'
sent: '<prefix><green>✉ Invite sent to <yellow><name></yellow>. (Expires in <time>s)</green>'
received: '<prefix><gold>✉ <yellow><name></yellow> invited you to their team! <green>/team accept <name></green> or <red>/team deny <name></red> (expires in <time>s)</gold>'
self: '<prefix><red>You cannot invite yourself.</red>'
already_teammate: '<prefix><red>This player is already your teammate.</red>'
target_has_team: '<prefix><red><name> is already in a team.</red>'
team_full: '<prefix><red>Your team is full!</red>'
already_pending: '<prefix><red>There is already a pending invite for <name>.</red>'
accept:
usage: '<prefix><red>Usage: /team accept <player></red>'
no_invite: '<prefix><red>You have no pending invite from <name>.</red>'
expired: '<prefix><red>The invite from <name> has expired.</red>'
joined: '<prefix><green>✔ <yellow><name></yellow> joined the team! Members: <white><members></white></green>'
deny:
usage: '<prefix><red>Usage: /team deny <player></red>'
success: '<prefix><gray>Invite from <name> declined.</gray>'
received: '<prefix><gray><name> declined your team invite.</gray>'
leave:
success: '<prefix><gray>You left the team.</gray>'
disbanded: '<prefix><red>The team has been disbanded.</red>'
member_left: '<prefix><gray><name> left the team.</gray>'
kick:
usage: '<prefix><red>Usage: /team kick <player></red>'
success: '<prefix><yellow>Kicked <name> from the team.</yellow>'
received: '<prefix><red>You were kicked from the team by <name>.</red>'
not_leader: '<prefix><red>Only the team leader can kick players.</red>'
not_in_your_team: '<prefix><red><name> is not in your team.</red>'
self: '<prefix><red>You cannot kick yourself. Use /team leave.</red>'
info:
header: '<gray>━━━━━ <gold>Team Members</gold> ━━━━━</gray>'
member: ' <gray>#<index></gray> <white><name></white><yellow><leader></yellow>'
footer: '<gray>━━━━━━━━━━━━━━━━━━━━━ (<size>/<max>)</gray>'
commands:
kit:
usage: '<red>Usage: /kit <kitName> <playstyle></red>'
@@ -239,6 +286,9 @@ perks:
message: '<gold>🍎 Scavenged a Golden Apple!</gold>'
kits:
needed_hits: '<gold>⚡ Ability: <white><current>/<required> Hits</white></gold>'
ability_charged: '<green><bold>⚡ ABILITY READY!</bold></green>'
backup:
name: '<gradient:gold:#ff841f><bold>Backup</bold></gradient>'
lore:

View File

@@ -35,3 +35,6 @@ commands:
perks:
description: 'Perk-Auswahl öffnen'
usage: '/perks'
team:
description: 'Team-System for SpeedHG'
usage: '/team <invite|accept|deny|leave|kick|info> [player]'