From 4d32fe677c16e686e51cb9ae649ff7ff28a280f7 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Thu, 9 Apr 2026 02:33:14 +0200 Subject: [PATCH] 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. --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 21 ++ .../mcscrims/speedhg/command/TeamCommand.kt | 232 ++++++++++++++ .../speedhg/config/CustomGameSettings.kt | 14 +- .../club/mcscrims/speedhg/game/GameManager.kt | 64 +++- .../speedhg/kit/ability/ActiveAbility.kt | 6 - .../mcscrims/speedhg/kit/impl/AnchorKit.kt | 5 - .../speedhg/kit/impl/BlackPantherKit.kt | 5 - .../mcscrims/speedhg/kit/impl/GladiatorKit.kt | 8 - .../mcscrims/speedhg/kit/impl/GoblinKit.kt | 14 - .../mcscrims/speedhg/kit/impl/IceMageKit.kt | 7 - .../mcscrims/speedhg/kit/impl/PuppetKit.kt | 10 - .../speedhg/kit/impl/RattlesnakeKit.kt | 2 - .../mcscrims/speedhg/kit/impl/TeslaKit.kt | 7 - .../mcscrims/speedhg/kit/impl/VenomKit.kt | 14 - .../mcscrims/speedhg/kit/impl/VoodooKit.kt | 10 - .../kit/listener/KitEventDispatcher.kt | 34 +- .../mcscrims/speedhg/perk/impl/OraclePerk.kt | 1 + .../kotlin/club/mcscrims/speedhg/team/Team.kt | 36 +++ .../mcscrims/speedhg/team/TeamListener.kt | 55 ++++ .../club/mcscrims/speedhg/team/TeamManager.kt | 294 ++++++++++++++++++ src/main/resources/config.yml | 4 + src/main/resources/languages/en_US.yml | 50 +++ src/main/resources/plugin.yml | 5 +- 23 files changed, 788 insertions(+), 110 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/Team.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 2d0f361..cfa125f 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -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() diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt new file mode 100644 index 0000000..9799f77 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/command/TeamCommand.kt @@ -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 + ): 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) { + if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } + + val targetName = args.getOrNull(1) ?: run { player.sendMsg("team.invite.usage"); return } + val target = plugin.server.getPlayerExact(targetName) ?: run { + player.sendMsg("team.player_not_found", "name" to targetName); return + } + + when (plugin.teamManager.invite(player, target)) { + is TeamManager.InviteResult.Success -> { + player.sendMsg("team.invite.sent", "name" to target.name) + target.sendMsg("team.invite.received", + "name" to player.name, + "time" to (TeamManager.INVITE_TTL_MS / 1000).toString() + ) + } + is TeamManager.InviteResult.TeamsDisabled -> player.sendMsg("team.disabled") + is TeamManager.InviteResult.InvitedSelf -> player.sendMsg("team.invite.self") + is TeamManager.InviteResult.AlreadyInSameTeam -> player.sendMsg("team.invite.already_teammate") + is TeamManager.InviteResult.TargetAlreadyInTeam -> player.sendMsg("team.invite.target_has_team", "name" to target.name) + is TeamManager.InviteResult.SenderAlreadyInFullTeam -> player.sendMsg("team.invite.team_full") + is TeamManager.InviteResult.InviteAlreadyPending -> player.sendMsg("team.invite.already_pending", "name" to target.name) + } + } + + private fun handleAccept(player: Player, args: Array) { + if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } + + val inviterName = args.getOrNull(1) ?: run { player.sendMsg("team.accept.usage"); return } + val inviter = plugin.server.getPlayerExact(inviterName) ?: run { + player.sendMsg("team.player_not_found", "name" to inviterName); return + } + + when (val result = plugin.teamManager.accept(player, inviter.uniqueId)) { + is TeamManager.AcceptResult.Success -> { + val memberNames = result.team.members + .mapNotNull { plugin.server.getPlayer(it)?.name } + .joinToString(", ") + + // Alle Teammitglieder benachrichtigen + result.team.members.forEach { uuid -> + plugin.server.getPlayer(uuid)?.sendMsg( + "team.accept.joined", + "name" to player.name, + "members" to memberNames + ) + } + } + is TeamManager.AcceptResult.TeamsDisabled -> player.sendMsg("team.disabled") + is TeamManager.AcceptResult.NoInvite -> player.sendMsg("team.accept.no_invite", "name" to inviterName) + is TeamManager.AcceptResult.WrongInviter -> player.sendMsg("team.accept.no_invite", "name" to inviterName) + is TeamManager.AcceptResult.InviteExpired -> player.sendMsg("team.accept.expired", "name" to inviterName) + is TeamManager.AcceptResult.AlreadyInTeam -> player.sendMsg("team.already_in_team") + is TeamManager.AcceptResult.TeamFull -> player.sendMsg("team.invite.team_full") + is TeamManager.AcceptResult.InviterNotFound -> player.sendMsg("team.player_not_found", "name" to inviterName) + } + } + + private fun handleDeny(player: Player, args: Array) { + if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } + + val inviterName = args.getOrNull(1) ?: run { player.sendMsg("team.deny.usage"); return } + val inviter = plugin.server.getPlayerExact(inviterName) ?: run { + player.sendMsg("team.player_not_found", "name" to inviterName); return + } + + if (plugin.teamManager.deny(player, inviter.uniqueId)) { + player.sendMsg("team.deny.success", "name" to inviterName) + inviter.sendMsg("team.deny.received", "name" to player.name) + } else { + player.sendMsg("team.accept.no_invite", "name" to inviterName) + } + } + + private fun handleLeave(player: Player) { + if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } + + val team = plugin.teamManager.getTeam(player) + + when (plugin.teamManager.leave(player)) { + is TeamManager.LeaveResult.NotInTeam -> { + player.sendMsg("team.not_in_team") + } + is TeamManager.LeaveResult.TeamDisbanded -> { + // Alle Ex-Mitglieder (außer dem Verlassenden) benachrichtigen + team?.members?.forEach { uuid -> + plugin.server.getPlayer(uuid)?.sendMsg("team.leave.disbanded") + } + player.sendMsg("team.leave.disbanded") + } + is TeamManager.LeaveResult.Success -> { + player.sendMsg("team.leave.success") + // Verbleibende Mitglieder informieren + team?.members?.forEach { uuid -> + plugin.server.getPlayer(uuid)?.sendMsg( + "team.leave.member_left", "name" to player.name + ) + } + } + } + } + + private fun handleKick(player: Player, args: Array) { + if (!isLobbyPhase()) { player.sendMsg("team.game_running"); return } + + val targetName = args.getOrNull(1) ?: run { player.sendMsg("team.kick.usage"); return } + val target = plugin.server.getPlayerExact(targetName) ?: run { + player.sendMsg("team.player_not_found", "name" to targetName); return + } + + when (plugin.teamManager.kick(player, target)) { + is TeamManager.KickResult.Success -> { + player.sendMsg("team.kick.success", "name" to target.name) + target.sendMsg("team.kick.received", "name" to player.name) + } + is TeamManager.KickResult.NotInTeam -> player.sendMsg("team.not_in_team") + is TeamManager.KickResult.NotLeader -> player.sendMsg("team.kick.not_leader") + is TeamManager.KickResult.TargetNotInTeam -> player.sendMsg("team.kick.not_in_your_team", "name" to targetName) + is TeamManager.KickResult.CannotKickSelf -> player.sendMsg("team.kick.self") + } + } + + private fun handleInfo(player: Player) { + val team = plugin.teamManager.getTeam(player) ?: run { + player.sendMsg("team.not_in_team"); return + } + + player.sendMsg("team.info.header") + team.members.forEachIndexed { i, uuid -> + val name = plugin.server.getPlayer(uuid)?.name ?: uuid.toString().take(8) + val isLeader = team.isLeader(uuid) + player.sendMsg( + "team.info.member", + "index" to (i + 1).toString(), + "name" to name, + "leader" to if (isLeader) " ★" else "" + ) + } + player.sendMsg("team.info.footer", "size" to team.size.toString(), "max" to plugin.teamManager.maxTeamSize.toString()) + } + + // ========================================================================= + // Tab Completion + // ========================================================================= + + override fun onTabComplete( + sender: CommandSender, + command: Command, + label: String, + args: Array + ): List { + if (sender !is Player) return emptyList() + + if (args.size == 1) { + return listOf("invite", "accept", "deny", "leave", "kick", "info") + .filter { it.startsWith(args[0], ignoreCase = true) } + } + + if (args.size == 2) { + return when (args[0].lowercase()) { + "invite", "accept", "deny", "kick" -> + plugin.server.onlinePlayers + .filter { it != sender } + .map { it.name } + .filter { it.startsWith(args[1], ignoreCase = true) } + else -> emptyList() + } + } + + return emptyList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt index 03ac5d3..f60e9d9 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt @@ -54,19 +54,19 @@ data class CustomGameSettings( */ @Serializable data class KitOverride( - @SerialName("hits_required") val hitsRequired: Int = -1, + @SerialName("hits_required") val hitsRequired: Int = -1, // Goblin @SerialName("steal_duration_seconds") val stealDuration: Int = 60, - @SerialName("bunker_radius") val bunkerRadius: Double = 10.0, + @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 - @SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L, + @SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L, @SerialName("shield_capacity") val shieldCapacity: Double = 15.0, // Voodoo @@ -82,10 +82,10 @@ data class CustomGameSettings( // Rattlesnake @SerialName("pounce_cooldown_ms") val pounceCooldownMs: Long = 20_000L, - @SerialName("pounce_max_sneak_ms") val pounceMaxSneakMs: Long = 3_000L, + @SerialName("pounce_max_sneak_ms") val pounceMaxSneakMs: Long = 3_000L, @SerialName("pounce_min_range") val pounceMinRange: Double = 3.0, @SerialName("pounce_max_range") val pounceMaxRange: Double = 10.0, - @SerialName("pounce_timeout_ticks") val pounceTimeoutTicks: Long = 30L, + @SerialName("pounce_timeout_ticks") val pounceTimeoutTicks: Long = 30L, // TheWorld @SerialName("tw_ability_cooldown_ms") val abilityCooldownMs: Long = 20_000L, diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index a838de9..72ca911 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt index 2795e90..80e7462 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt @@ -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 ) {} - } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt index ee067c7..c1f9f4b 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt @@ -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")) - } } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BlackPantherKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BlackPantherKit.kt index b8231d1..b403f01 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BlackPantherKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BlackPantherKit.kt @@ -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")) - } } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt index b14c6ce..d0a0780 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt index 36b1965..1941e35 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt index 14c1913..99448bb 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt index e4e13c2..04452f8 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt @@ -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) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt index 60e35ef..cd6d560 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt @@ -184,8 +184,6 @@ class RattlesnakeKit : Kit() { return AbilityResult.Success } - - override fun onFullyCharged(player: Player) { /* not used – hitsRequired = 0 */ } } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt index 6dc884a..e17149e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt @@ -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" )) - } - } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt index 03b4424..7ee5264 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt index 531b3bc..472de19 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt @@ -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")) - } } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt index edf124b..6c02d3e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt @@ -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 ) + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt index 2cb8f8e..7d3140f 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt @@ -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 { diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt b/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt new file mode 100644 index 0000000..f527ec0 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/Team.kt @@ -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 = mutableListOf(leader) +) { + + /** Gibt `true` zurück wenn [uuid] Mitglied dieses Teams ist. */ + fun contains(uuid: UUID): Boolean = members.contains(uuid) + + /** Gibt `true` zurück wenn [uuid] der Leader dieses Teams ist. */ + fun isLeader(uuid: UUID): Boolean = uuid == leader + + /** Anzahl der aktuellen Mitglieder. */ + val size: Int get() = members.size +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt b/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt new file mode 100644 index 0000000..6dd7015 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/TeamListener.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt b/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt new file mode 100644 index 0000000..ff22de2 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/team/TeamManager.kt @@ -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() + + /** PlayerUUID → Team (Reverse-Lookup, O(1)) */ + private val teamByPlayer = ConcurrentHashMap() + + /** + * Offene Einladungen: InviteeUUID → Pair(InviterUUID, ExpiryMs) + * Nur eine offene Einladung pro Spieler gleichzeitig. + */ + private val pendingInvites = ConcurrentHashMap>() + + // ========================================================================= + // Team-Queries (O(1), Main-Thread safe) + // ========================================================================= + + /** Gibt das Team des Spielers zurück, oder `null` wenn er keins hat. */ + fun getTeam(player: Player): Team? = teamByPlayer[player.uniqueId] + + /** Gibt das Team des Spielers zurück, oder `null` wenn er keins hat. */ + fun getTeam(uuid: UUID): Team? = teamByPlayer[uuid] + + /** + * Gibt `true` zurück wenn beide Spieler im selben Team sind. + * Wird verwendet für: + * - Friendly-Fire-Check ([TeamListener]) + * - Kompass-Tracking ([GameManager.updateCompass]) + * - Orakel-Perk ([OraclePerk.findNearestEnemy]) + */ + fun areInSameTeam(a: Player, b: Player): Boolean { + val teamA = teamByPlayer[a.uniqueId] ?: return false + return teamA.contains(b.uniqueId) + } + + fun areInSameTeam(a: UUID, b: UUID): Boolean { + val teamA = teamByPlayer[a] ?: return false + return teamA.contains(b) + } + + /** Gibt alle aktiven Teams zurück (nur lesend). */ + fun getAllTeams(): Collection = teamsById.values + + // ========================================================================= + // Invite-System + // ========================================================================= + + sealed class InviteResult { + object Success : InviteResult() + object TeamsDisabled : InviteResult() + object TargetAlreadyInTeam : InviteResult() + object SenderAlreadyInFullTeam : InviteResult() + object InvitedSelf : InviteResult() + object AlreadyInSameTeam : InviteResult() + object InviteAlreadyPending : InviteResult() + } + + /** + * Versendet eine Einladung von [sender] an [target]. + * Erstellt bei Bedarf ein neues Team für [sender]. + */ + fun invite(sender: Player, target: Player): InviteResult { + if (!isEnabled) return InviteResult.TeamsDisabled + if (sender.uniqueId == target.uniqueId) return InviteResult.InvitedSelf + + val senderTeam = teamByPlayer[sender.uniqueId] + val targetTeam = teamByPlayer[target.uniqueId] + + if (senderTeam != null && senderTeam.contains(target.uniqueId)) + return InviteResult.AlreadyInSameTeam + + if (targetTeam != null) + return InviteResult.TargetAlreadyInTeam + + if (senderTeam != null && senderTeam.size >= maxTeamSize) + return InviteResult.SenderAlreadyInFullTeam + + if (pendingInvites.containsKey(target.uniqueId)) + return InviteResult.InviteAlreadyPending + + pendingInvites[target.uniqueId] = Pair(sender.uniqueId, System.currentTimeMillis() + INVITE_TTL_MS) + return InviteResult.Success + } + + sealed class AcceptResult { + data class Success(val team: Team) : AcceptResult() + object TeamsDisabled : AcceptResult() + object NoInvite : AcceptResult() + object InviteExpired : AcceptResult() + object AlreadyInTeam : AcceptResult() + object TeamFull : AcceptResult() + object InviterNotFound : AcceptResult() + object WrongInviter : AcceptResult() + } + + /** + * Nimmt eine Einladung von [inviterName] an. + * Wenn [inviterName] null ist, nimmt es die einzige vorhandene Einladung an. + */ + fun accept(invitee: Player, inviterUUID: UUID): AcceptResult { + if (!isEnabled) return AcceptResult.TeamsDisabled + if (teamByPlayer.containsKey(invitee.uniqueId)) return AcceptResult.AlreadyInTeam + + val (storedInviterUUID, expiry) = pendingInvites[invitee.uniqueId] + ?: return AcceptResult.NoInvite + + if (storedInviterUUID != inviterUUID) return AcceptResult.WrongInviter + + if (System.currentTimeMillis() > expiry) { + pendingInvites.remove(invitee.uniqueId) + return AcceptResult.InviteExpired + } + + pendingInvites.remove(invitee.uniqueId) + + // Team des Inviters abrufen oder neu erstellen + val team = teamByPlayer.getOrPut(inviterUUID) { + val newTeam = Team(leader = inviterUUID) + teamsById[newTeam.id] = newTeam + newTeam + } + + if (team.size >= maxTeamSize) return AcceptResult.TeamFull + + team.members.add(invitee.uniqueId) + teamByPlayer[invitee.uniqueId] = team + + return AcceptResult.Success(team) + } + + fun deny(invitee: Player, inviterUUID: UUID): Boolean { + val invite = pendingInvites[invitee.uniqueId] ?: return false + if (invite.first != inviterUUID) return false + pendingInvites.remove(invitee.uniqueId) + return true + } + + // ========================================================================= + // Team-Mutations + // ========================================================================= + + sealed class LeaveResult { + object Success : LeaveResult() + object NotInTeam : LeaveResult() + /** Leader hat das Team verlassen → Team aufgelöst */ + object TeamDisbanded : LeaveResult() + } + + fun leave(player: Player): LeaveResult { + val team = teamByPlayer[player.uniqueId] ?: return LeaveResult.NotInTeam + + val wasLeader = team.isLeader(player.uniqueId) + + team.members.remove(player.uniqueId) + teamByPlayer.remove(player.uniqueId) + + return if (wasLeader || team.size == 0) { + // Leader geht → Team auflösen (alle verbleibenden Mitglieder rausnehmen) + disbandTeam(team) + LeaveResult.TeamDisbanded + } else { + if (team.size == 0) teamsById.remove(team.id) + LeaveResult.Success + } + } + + sealed class KickResult { + object Success : KickResult() + object NotInTeam : KickResult() + object NotLeader : KickResult() + object TargetNotInTeam : KickResult() + object CannotKickSelf : KickResult() + } + + fun kick(kicker: Player, target: Player): KickResult { + val team = teamByPlayer[kicker.uniqueId] ?: return KickResult.NotInTeam + if (!team.isLeader(kicker.uniqueId)) return KickResult.NotLeader + if (kicker.uniqueId == target.uniqueId) return KickResult.CannotKickSelf + if (!team.contains(target.uniqueId)) return KickResult.TargetNotInTeam + + team.members.remove(target.uniqueId) + teamByPlayer.remove(target.uniqueId) + + if (team.size == 0) { + teamsById.remove(team.id) + teamByPlayer.remove(kicker.uniqueId) + } + + return KickResult.Success + } + + // ========================================================================= + // Win-Condition Helper + // ========================================================================= + + /** + * Prüft ob alle [aliveUUIDs] im selben Team sind. + * Gibt `true` zurück wenn: + * - Teams deaktiviert → niemals (nur 1 Spieler = Sieg, Standardlogik) + * - Alle Überlebenden exakt dasselbe [Team]-Objekt teilen + * + * Aufruf in [GameManager.checkWin]: + * ```kotlin + * if (alivePlayers.size <= 1 || plugin.teamManager.allAliveInSameTeam(alivePlayers)) + * endGame(...) + * ``` + */ + fun allAliveInSameTeam(aliveUUIDs: Set): Boolean { + if (!isEnabled || aliveUUIDs.size <= 1) return false + + val firstTeam = teamByPlayer[aliveUUIDs.first()] ?: return false + return aliveUUIDs.all { uuid -> + val team = teamByPlayer[uuid] ?: return false + team.id == firstTeam.id + } + } + + // ========================================================================= + // Lifecycle + // ========================================================================= + + /** Räumt abgelaufene Einladungen auf. Einmal pro Sekunde aufrufen. */ + fun cleanExpiredInvites() { + val now = System.currentTimeMillis() + pendingInvites.entries.removeIf { (_, pair) -> now > pair.second } + } + + /** + * Setzt den gesamten Team-State zurück. + * In [GameManager.startGame] aufrufen (vor Spielstart). + */ + fun reset() { + teamsById.clear() + teamByPlayer.clear() + pendingInvites.clear() + plugin.logger.info("[TeamManager] Teams zurückgesetzt.") + } + + // ── Pending-Invite Queries ──────────────────────────────────────────────── + + /** Gibt die UUID des Inviters zurück, falls eine Einladung für [invitee] vorliegt. */ + fun getInviterFor(invitee: Player): UUID? { + val entry = pendingInvites[invitee.uniqueId] ?: return null + return if (System.currentTimeMillis() < entry.second) entry.first else null + } + + // ── Private Helpers ─────────────────────────────────────────────────────── + + private fun disbandTeam(team: Team) { + team.members.forEach { teamByPlayer.remove(it) } + team.members.clear() + teamsById.remove(team.id) + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4fa72fb..63d6831 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 5ab69d9..8a59847 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -81,6 +81,53 @@ disasters: warning-main: 'THUNDERSTORM!' warning-sub: 'Your height attracts lightning!' +team: + disabled: 'Teams are disabled in this round.' + usage: 'Usage: /team [player]' + game_running: 'Teams cannot be changed while the game is running!' + not_in_team: 'You are not in a team.' + already_in_team: 'You are already in a team!' + player_not_found: 'Player is not online.' + + invite: + usage: 'Usage: /team invite ' + sent: '✉ Invite sent to . (Expires in ' + received: ' invited you to their team! /team accept or /team deny (expires in ' + self: 'You cannot invite yourself.' + already_teammate: 'This player is already your teammate.' + target_has_team: ' is already in a team.' + team_full: 'Your team is full!' + already_pending: 'There is already a pending invite for .' + + accept: + usage: 'Usage: /team accept ' + no_invite: 'You have no pending invite from .' + expired: 'The invite from has expired.' + joined: ' joined the team! Members: ' + + deny: + usage: 'Usage: /team deny ' + success: 'Invite from declined.' + received: ' declined your team invite.' + + leave: + success: 'You left the team.' + disbanded: 'The team has been disbanded.' + member_left: ' left the team.' + + kick: + usage: 'Usage: /team kick ' + success: 'Kicked from the team.' + received: 'You were kicked from the team by .' + not_leader: 'Only the team leader can kick players.' + not_in_your_team: ' is not in your team.' + self: 'You cannot kick yourself. Use /team leave.' + + info: + header: '━━━━━ Team Members ━━━━━' + member: ' # ' + footer: '━━━━━━━━━━━━━━━━━━━━━ (/)' + commands: kit: usage: 'Usage: /kit ' @@ -239,6 +286,9 @@ perks: message: '🍎 Scavenged a Golden Apple!' kits: + needed_hits: '⚡ Ability: / Hits' + ability_charged: '⚡ ABILITY READY!' + backup: name: 'Backup' lore: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index fa8f480..cae3080 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -34,4 +34,7 @@ commands: permission: speedhg.admin.ranking perks: description: 'Perk-Auswahl öffnen' - usage: '/perks' \ No newline at end of file + usage: '/perks' + team: + description: 'Team-System for SpeedHG' + usage: '/team [player]' \ No newline at end of file