/* * CloudSkins — Standalone skin management with Persistent Database & Textured Heads * Powered by Mojang Profile Services & Live Global Skins Feed. * Crossplay ready: Java Inventory GUI + Bedrock (Geyser/Floodgate) Native Forms. * * Permissions: * cloudskins.player -> Full access * cloudskins.command.* -> Wildcard access * cloudskins.admin -> Admin access * cloudskins.command.set -> /skin | set * cloudskins.command.set.url -> /skin url | upload * cloudskins.command.clear -> /skin clear | reset * cloudskins.command.update -> /skin update | refresh * cloudskins.command.undo -> /skin undo | revert | history * cloudskins.command.random -> /skin random * cloudskins.command.search -> /skin search * cloudskins.command.favourite -> /skin favourite | favourites * cloudskins.command.gui -> /skin menu | gui * cloudskins.command.edit -> /skin edit */ import org.bukkit.Bukkit import org.bukkit.Material import org.bukkit.Sound import org.bukkit.command.CommandSender import org.bukkit.entity.Player import org.bukkit.event.EventHandler import org.bukkit.event.HandlerList import org.bukkit.event.Listener import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import org.bukkit.inventory.meta.SkullMeta import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import java.io.File import java.net.URI import java.net.HttpURLConnection import java.util.UUID // ─── Data Classes ───────────────────────────────────────────────────────────── private data class SkinEntry(val label: String, val value: String, val signature: String) // ─── State & Database ───────────────────────────────────────────────────────── private val activeSkins = mutableMapOf() private val skinHistories = mutableMapOf>() private val favourites = mutableMapOf>() private val clickCooldowns = mutableMapOf() private val PREFIX = "[CloudSkins] " private val kitePlugin get() = Bukkit.getPluginManager().getPlugin("Kite") private val mm = MiniMessage.miniMessage() // ─── Persistence (JSON Database) ────────────────────────────────────────────── private val dataFolder by lazy { val dir = File("plugins/Kite/data/CloudSkins") if (!dir.exists()) dir.mkdirs() dir } private val dbFile by lazy { File(dataFolder, "database.json") } /** * Salva favoritos e skins ativas em formato JSON em disco */ private fun saveDatabase() { try { val sb = StringBuilder("{\n") // Salva Favoritos sb.append(" \"favourites\": {\n") val favEntries = favourites.entries.toList() favEntries.forEachIndexed { fIdx, (uuid, list) -> sb.append(" \"$uuid\": [\n") list.forEachIndexed { sIdx, skin -> sb.append(" {\"label\":\"${skin.label.replace("\"", "\\\"")}\",\"value\":\"${skin.value}\",\"signature\":\"${skin.signature}\"}") if (sIdx < list.size - 1) sb.append(",") sb.append("\n") } sb.append(" ]") if (fIdx < favEntries.size - 1) sb.append(",") sb.append("\n") } sb.append(" },\n") // Salva Skins Ativas sb.append(" \"active\": {\n") val actEntries = activeSkins.entries.toList() actEntries.forEachIndexed { aIdx, (uuid, skin) -> sb.append(" \"$uuid\": {\"label\":\"${skin.label.replace("\"", "\\\"")}\",\"value\":\"${skin.value}\",\"signature\":\"${skin.signature}\"}") if (aIdx < actEntries.size - 1) sb.append(",") sb.append("\n") } sb.append(" }\n") sb.append("}") dbFile.writeText(sb.toString(), Charsets.UTF_8) } catch (e: Exception) { logger.warn("[CloudSkins] Failed to save database to disk: ${e.message}") } } /** * Carrega a base de dados ao iniciar o script */ private fun loadDatabase() { if (!dbFile.exists()) return try { val text = dbFile.readText(Charsets.UTF_8) // Parse simples e robusto por regex val favBlock = Regex(""""favourites"\s*:\s*\{([\s\S]*?)\}\s*,\s*"active"""").find(text)?.groupValues?.get(1) ?: "" val userFavMatches = Regex(""""([a-f0-9\-]{36})"\s*:\s*\[([\s\S]*?)\]""").findAll(favBlock) for (uMatch in userFavMatches) { val uuid = runCatching { UUID.fromString(uMatch.groupValues[1]) }.getOrNull() ?: continue val listText = uMatch.groupValues[2] val skinMatches = Regex("""\{"label":"(.*?)","value":"(.*?)","signature":"(.*?)"\}""").findAll(listText) val list = favourites.getOrPut(uuid) { mutableListOf() } for (sm in skinMatches) { list.add(SkinEntry(sm.groupValues[1].replace("\\\"", "\""), sm.groupValues[2], sm.groupValues[3])) } } val actBlock = Regex(""""active"\s*:\s*\{([\s\S]*?)\}""").find(text)?.groupValues?.get(1) ?: "" val activeMatches = Regex(""""([a-f0-9\-]{36})"\s*:\s*\{"label":"(.*?)","value":"(.*?)","signature":"(.*?)"\}""").findAll(actBlock) for (am in activeMatches) { val uuid = runCatching { UUID.fromString(am.groupValues[1]) }.getOrNull() ?: continue activeSkins[uuid] = SkinEntry(am.groupValues[2].replace("\\\"", "\""), am.groupValues[3], am.groupValues[4]) } logger.info("[CloudSkins] Database loaded successfully! (${favourites.size} users with favourites)") } catch (e: Exception) { logger.warn("[CloudSkins] Failed to load database: ${e.message}") } } // ─── Messaging & Perms ──────────────────────────────────────────────────────── private fun msg(sender: CommandSender, text: String) = sender.sendRichMessage("$PREFIX$text") private fun hasFullAccess(player: Player) = player.isOp || player.hasPermission("cloudskins.admin") || player.hasPermission("cloudskins.player") || player.hasPermission("cloudskins.command.*") private fun hasPerm(player: Player, node: String): Boolean { if (hasFullAccess(player) || player.hasPermission(node)) return true msg(player, "You don't have permission to use this command.") return false } // ─── Scheduler ─────────────────────────────────────────────────────────────── private fun async(player: Player, block: () -> Unit) { val plugin = kitePlugin ?: run { msg(player, "Internal error: Kite plugin not found."); return } Bukkit.getScheduler().runTaskAsynchronously(plugin, Runnable { try { block() } catch (e: Throwable) { logger.warn("[CloudSkins] Async error: ${e.javaClass.name}: ${e.message}") sync(player) { msg(player, "Unexpected error: ${e.message}") } } }) } private fun sync(player: Player, block: () -> Unit) { val plugin = kitePlugin ?: return Bukkit.getScheduler().runTask(plugin, Runnable { try { block() } catch (e: Throwable) { logger.warn("[CloudSkins] Sync error: ${e.message}") } }) } // ─── HTTP Helpers ───────────────────────────────────────────────────────────── private fun httpGet(urlStr: String): String? = try { val conn = URI(urlStr).toURL().openConnection() as HttpURLConnection conn.setRequestProperty("User-Agent", "CloudSkins/1.0 (Minecraft Paper)") conn.connectTimeout = 10000; conn.readTimeout = 10000; conn.requestMethod = "GET" when (conn.responseCode) { 200 -> conn.inputStream.bufferedReader().readText() 204 -> "" else -> { logger.warn("[CloudSkins] GET ${conn.responseCode}: $urlStr"); null } } } catch (e: Exception) { logger.warn("[CloudSkins] GET failed: ${e.message}"); null } private fun httpPost(urlStr: String, body: String): String? = try { val conn = URI(urlStr).toURL().openConnection() as HttpURLConnection conn.setRequestProperty("User-Agent", "CloudSkins/1.0 (Minecraft Paper)") conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "application/json") conn.connectTimeout = 15000; conn.readTimeout = 30000; conn.requestMethod = "POST"; conn.doOutput = true conn.outputStream.use { os -> os.write(body.toByteArray(Charsets.UTF_8)); os.flush() } val code = conn.responseCode val stream = if (code in 200..299) conn.inputStream else conn.errorStream val text = stream?.bufferedReader()?.readText() if (code in 200..299) text else { logger.warn("[CloudSkins] POST $code: $urlStr | $text"); null } } catch (e: Exception) { logger.warn("[CloudSkins] POST failed: ${e.message}"); null } // ─── Skin Fetching ─────────────────────────────────────────────────────────── private fun fetchByName(name: String): Pair? { val uuidJson = httpGet("https://api.mojang.com/users/profiles/minecraft/$name") ?: return null val uuid = Regex(""""id"\s*:\s*"([a-f0-9]{32})"""", RegexOption.IGNORE_CASE).find(uuidJson)?.groupValues?.get(1) ?: return null val profile = httpGet("https://sessionserver.mojang.com/session/minecraft/profile/$uuid?unsigned=false") ?: return null val value = Regex(""""value"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(profile)?.groupValues?.get(1) val sig = Regex(""""signature"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(profile)?.groupValues?.get(1) return if (value != null && sig != null) Pair(value, sig) else null } private fun normalizeImageUrl(rawUrl: String): String { val url = rawUrl.trim() val namemcMatch = Regex("""namemc\.com/skin/([a-f0-9]+)""").find(url) if (namemcMatch != null) return "https://s.namemc.com/i/${namemcMatch.groupValues[1]}.png" return url } private fun fetchByUrl(imageUrl: String, variant: String): Pair? { val cleanUrl = normalizeImageUrl(imageUrl) val postBody = """{"url":"$cleanUrl","variant":"${variant.lowercase()}"}""" val queueResponse = httpPost("https://api.mineskin.org/v2/queue", postBody) ?: return null val jobId = Regex(""""id"\s*:\s*"([^"]+)"""").find(queueResponse)?.groupValues?.get(1) ?: return null for (i in 1..15) { Thread.sleep(2000) val pollJson = httpGet("https://api.mineskin.org/v2/queue/$jobId") ?: continue val value = Regex(""""value"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(pollJson)?.groupValues?.get(1) val sig = Regex(""""signature"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(pollJson)?.groupValues?.get(1) if (value != null && sig != null) return Pair(value, sig) if (pollJson.contains(""""failed"""")) return null } return null } private fun fetchRandomOnlineSkin(): SkinEntry? { val feedJson = httpGet("https://api.mineskin.org/v2/skins") if (feedJson != null) { val matches = Regex(""""uuid"\s*:\s*"([a-f0-9]{32})"""").findAll(feedJson).map { it.groupValues[1] }.toList() if (matches.isNotEmpty()) { val randomUuid = matches.random() val skinJson = httpGet("https://api.mineskin.org/v2/skins/$randomUuid") if (skinJson != null) { val value = Regex(""""value"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(skinJson)?.groupValues?.get(1) val sig = Regex(""""signature"\s*:\s*"([A-Za-z0-9+/=]+)"""").find(skinJson)?.groupValues?.get(1) if (value != null && sig != null) return SkinEntry("Random-$randomUuid", value, sig) } } } val fallbackList = listOf("Notch", "jeb_", "Dinnerbone", "Dream", "Technoblade", "Grian", "Mumbo", "Ph1LzA") val fallbackName = fallbackList.random() val mojangSkin = fetchByName(fallbackName) ?: return null return SkinEntry(fallbackName, mojangSkin.first, mojangSkin.second) } // ─── Skin Application ──────────────────────────────────────────────────────── private fun applyToPlayerSync(player: Player, value: String, signature: String): Boolean { try { val profile = player.playerProfile val ppClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile") val propClass = Class.forName("com.destroystokyo.paper.profile.ProfileProperty") if (ppClass.isInstance(profile)) { ppClass.getMethod("removeProperty", String::class.java).invoke(profile, "textures") val prop = propClass.getConstructor(String::class.java, String::class.java, String::class.java) .newInstance("textures", value, signature) ppClass.getMethod("setProperty", propClass).invoke(profile, prop) player.playerProfile = profile return true } } catch (_: Exception) {} try { val handle = player.javaClass.getMethod("getHandle").invoke(player) var gp: Any? = null for (m in listOf("getGameProfile", "getProfile", "gg", "fp")) { gp = runCatching { handle.javaClass.methods.firstOrNull { it.name == m && it.parameterCount == 0 }?.invoke(handle) }.getOrNull() if (gp != null) break } if (gp == null) return false var props: Any? = null for (m in listOf("getProperties", "properties")) { props = runCatching { gp.javaClass.methods.firstOrNull { it.name == m && it.parameterCount == 0 }?.invoke(gp) }.getOrNull() if (props != null) break } if (props == null) return false props.javaClass.methods.firstOrNull { it.name == "removeAll" && it.parameterCount == 1 }?.invoke(props, "textures") val pc = Class.forName("com.mojang.authlib.properties.Property") val np = runCatching { pc.getConstructor(String::class.java, String::class.java, String::class.java).newInstance("textures", value, signature) } .getOrElse { pc.getConstructor(String::class.java, String::class.java).newInstance(value, signature) } props.javaClass.methods.firstOrNull { it.name == "put" && it.parameterCount == 2 }?.invoke(props, "textures", np) return true } catch (_: Exception) {} return false } private fun refreshSkin(player: Player) { val plugin = kitePlugin ?: return Bukkit.getOnlinePlayers().forEach { it.hidePlayer(plugin, player) } Bukkit.getScheduler().runTaskLater(plugin, Runnable { Bukkit.getOnlinePlayers().forEach { it.showPlayer(plugin, player) } }, 2L) } private fun pushHistory(player: Player, entry: SkinEntry) { val current = activeSkins[player.uniqueId] if (current != null) { val hist = skinHistories.getOrPut(player.uniqueId) { ArrayDeque() } hist.addFirst(current) if (hist.size > 10) hist.removeLast() } activeSkins[player.uniqueId] = entry saveDatabase() } // ─── Cabeça com Textura Real Personalizada (Direct Texture Injection) ──────── private fun createTexturedHead(skin: SkinEntry, displayName: String, lore: List): ItemStack { val item = ItemStack(Material.PLAYER_HEAD) val meta = item.itemMeta as? SkullMeta ?: return item try { // Usa Paper PlayerProfile API para injetar a textura direta no SkullMeta val profile = Bukkit.createProfile(UUID.randomUUID(), skin.label.take(16)) val ppClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile") val propClass = Class.forName("com.destroystokyo.paper.profile.ProfileProperty") val prop = propClass.getConstructor(String::class.java, String::class.java, String::class.java) .newInstance("textures", skin.value, skin.signature) ppClass.getMethod("setProperty", propClass).invoke(profile, prop) meta.playerProfile = profile } catch (_: Exception) { // Fallback para nomes if (!isUrl(skin.label)) { meta.owningPlayer = Bukkit.getOfflinePlayer(skin.label) } } meta.displayName(mm.deserialize(displayName)) meta.lore(lore.map { mm.deserialize(it) }) item.itemMeta = meta return item } // ─── Bedrock & Crossplay ────────────────────────────────────────────────────── private fun isBedrockPlayer(player: Player): Boolean { try { val fgClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi") val instance = fgClass.getMethod("getInstance").invoke(null) val isFg = fgClass.getMethod("isFloodgatePlayer", UUID::class.java).invoke(instance, player.uniqueId) as? Boolean if (isFg == true) return true } catch (_: Exception) {} return player.name.startsWith(".") || player.name.startsWith("*") } private fun openBedrockMainMenu(player: Player) { try { val currentLabel = activeSkins[player.uniqueId]?.label ?: player.name val formClass = Class.forName("org.geysermc.cumulus.form.SimpleForm") var builder = formClass.getMethod("builder").invoke(null) val bClass = builder.javaClass builder = bClass.getMethod("title", String::class.java).invoke(builder, "CloudSkins — Menu") builder = bClass.getMethod("content", String::class.java).invoke(builder, "Current skin: $currentLabel\nSelect an action:") builder = bClass.getMethod("button", String::class.java).invoke(builder, "🎲 Random Online Skin") builder = bClass.getMethod("button", String::class.java).invoke(builder, "⭐ My Favourites") builder = bClass.getMethod("button", String::class.java).invoke(builder, "↻ Update / Refresh Skin") builder = bClass.getMethod("button", String::class.java).invoke(builder, "↩ Undo to Last Skin") builder = bClass.getMethod("button", String::class.java).invoke(builder, "✖ Reset to Account Skin") builder = bClass.getMethod("button", String::class.java).invoke(builder, "🔗 Custom URL Guide") val form = bClass.getMethod("build").invoke(builder) val fgClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi") val instance = fgClass.getMethod("getInstance").invoke(null) val fgPlayer = fgClass.getMethod("getPlayer", UUID::class.java).invoke(instance, player.uniqueId) if (fgPlayer != null) { val sendMethod = fgPlayer.javaClass.methods.firstOrNull { it.name == "sendForm" && it.parameterCount >= 1 } sendMethod?.invoke(fgPlayer, form) return } } catch (_: Exception) {} openSkinGui(player) } // ─── Unified Set Command ────────────────────────────────────────────────────── private fun isUrl(input: String): Boolean { val lower = input.lowercase() return lower.startsWith("http://") || lower.startsWith("https://") } private fun doAutoSet(player: Player, target: String, variant: String = "classic") { if (isUrl(target)) doUrl(player, target, variant) else doSet(player, target) } private fun doSet(player: Player, skinName: String) { if (skinName.equals("skin", ignoreCase = true) || skinName.equals("skins", ignoreCase = true)) { msg(player, "Please specify a skin name! Example: /skin Notch") return } msg(player, "Fetching skin for $skinName...") async(player) { val skin = fetchByName(skinName) if (skin == null) { sync(player) { msg(player, "Skin $skinName not found.") } return@async } val (v, s) = skin sync(player) { val ok = applyToPlayerSync(player, v, s) if (ok) { pushHistory(player, SkinEntry(skinName, v, s)) refreshSkin(player) player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.2f) msg(player, "Your skin has been changed to $skinName!") } else { msg(player, "Failed to apply skin. Check the console.") } } } } private fun doUrl(player: Player, url: String, variant: String) { msg(player, "Generating skin from URL...") msg(player, "Processing via NameMC/Mojang queue, please wait...") async(player) { val skin = fetchByUrl(url, variant) if (skin == null) { sync(player) { msg(player, "Failed to generate skin from URL. Ensure it's a direct .png image.") } return@async } val (v, s) = skin sync(player) { val ok = applyToPlayerSync(player, v, s) if (ok) { pushHistory(player, SkinEntry(url, v, s)) refreshSkin(player) player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.2f) msg(player, "Skin from URL applied successfully!") } else { msg(player, "Failed to apply skin. Check the console.") } } } } private fun doClear(player: Player) { msg(player, "Resetting your skin...") async(player) { val skin = fetchByName(player.name) if (skin == null) { sync(player) { msg(player, "Could not fetch your Mojang skin.") } return@async } val (v, s) = skin sync(player) { val ok = applyToPlayerSync(player, v, s) if (ok) { activeSkins.remove(player.uniqueId) skinHistories.remove(player.uniqueId) saveDatabase() refreshSkin(player) player.playSound(player.location, Sound.BLOCK_BEACON_DEACTIVATE, 1f, 1f) msg(player, "Your skin has been reset to your account skin!") } else { msg(player, "Failed to reset skin. Check the console.") } } } } private fun doUpdate(player: Player) { val current = activeSkins[player.uniqueId] ?: run { msg(player, "No custom skin active — resetting to your account skin...") doClear(player); return } msg(player, "Updating your skin...") async(player) { val skin = if (isUrl(current.label)) fetchByUrl(current.label, "classic") ?: Pair(current.value, current.signature) else fetchByName(current.label) ?: Pair(current.value, current.signature) val (v, s) = skin sync(player) { val ok = applyToPlayerSync(player, v, s) if (ok) { refreshSkin(player) player.playSound(player.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1.5f) msg(player, "Skin updated successfully!") } } } } private fun doUndo(player: Player) { val hist = skinHistories[player.uniqueId] if (hist.isNullOrEmpty()) { msg(player, "No previous skin to revert to."); return } val prev = hist.removeFirst() sync(player) { val ok = applyToPlayerSync(player, prev.value, prev.signature) if (ok) { activeSkins[player.uniqueId] = prev saveDatabase() refreshSkin(player) player.playSound(player.location, Sound.ITEM_ARMOR_EQUIP_LEATHER, 1f, 1f) msg(player, "Reverted to previous skin: ${prev.label}!") } } } private fun doHistory(player: Player) { val hist = skinHistories[player.uniqueId] if (hist.isNullOrEmpty()) { msg(player, "Your skin history is empty."); return } msg(player, "Skin History (${hist.size} entries):") hist.forEachIndexed { i, entry -> player.sendRichMessage(" ${i + 1}. ${entry.label.take(50)}") } } private fun doRandom(player: Player) { msg(player, "Discovering a random online skin...") async(player) { val randomEntry = fetchRandomOnlineSkin() ?: run { sync(player) { msg(player, "Could not fetch an online random skin.") } return@async } sync(player) { val ok = applyToPlayerSync(player, randomEntry.value, randomEntry.signature) if (ok) { pushHistory(player, randomEntry) refreshSkin(player) player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.2f) msg(player, "Applied random online skin!") } } } } private fun doSearch(player: Player, query: String) { msg(player, "Skin search results for $query:") player.sendRichMessage(" Browse on NameMC: https://namemc.com/search?q=$query") } private fun doFavourite(player: Player) { val current = activeSkins[player.uniqueId] ?: run { msg(player, "You have no active skin to favourite. Set a skin first.") return } val favs = favourites.getOrPut(player.uniqueId) { mutableListOf() } val existing = favs.firstOrNull { it.label.equals(current.label, ignoreCase = true) } if (existing != null) { favs.remove(existing) saveDatabase() msg(player, "Removed ${current.label} from your favourites.") } else { favs.add(current) saveDatabase() player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.8f, 1.5f) msg(player, "Added ${current.label} to your favourites database!") } } private fun doUpload(player: Player) { msg(player, "How to apply a custom skin:") player.sendRichMessage(" 1. Upload to Imgur or copy from NameMC") player.sendRichMessage(" 2. Copy direct image link ending in .png") player.sendRichMessage(" 3. Run: /skin url https://i.imgur.com/example.png") } // ─── GUI System (Chest Menus com Cabeças Reais) ─────────────────────────────── private val MAIN_GUI_TITLE = mm.deserialize("CloudSkins Menu") private val FAV_GUI_TITLE = mm.deserialize("CloudSkins Favourites") private fun createGuiItem(material: Material, name: String, lore: List): ItemStack { val item = ItemStack(material) val meta = item.itemMeta ?: return item meta.displayName(mm.deserialize(name)) meta.lore(lore.map { mm.deserialize(it) }) item.itemMeta = meta return item } private fun openAppropriateMenu(player: Player) { if (isBedrockPlayer(player)) openBedrockMainMenu(player) else openSkinGui(player) } private fun openSkinGui(player: Player) { val inv = Bukkit.createInventory(null, 36, MAIN_GUI_TITLE) val filler = createGuiItem(Material.GRAY_STAINED_GLASS_PANE, " ", emptyList()) for (i in 0 until 36) { if (i < 9 || i >= 27 || i % 9 == 0 || i % 9 == 8) inv.setItem(i, filler) } val currentSkin = activeSkins[player.uniqueId] ?: SkinEntry(player.name, "", "") // Slot 10: Cabeça do Jogador Atual com a textura real aplicada inv.setItem(10, createTexturedHead( currentSkin, "Current Skin", listOf( "Active: ${currentSkin.label}", "", "Click to refresh skin!" ) )) inv.setItem(12, createGuiItem( Material.COMPASS, "Random Online Skin", listOf("Fetch a random live skin from the community.", "", "Click to discover!") )) val favCount = favourites[player.uniqueId]?.size ?: 0 inv.setItem(14, createGuiItem( Material.NETHER_STAR, "Favourites Database", listOf( "Saved skins: $favCount", "", "Left-Click: Open favourites GUI", "Right-Click: Add/remove current skin" ) )) val histCount = skinHistories[player.uniqueId]?.size ?: 0 inv.setItem(16, createGuiItem( Material.RECOVERY_COMPASS, "Undo / History", listOf("History count: $histCount", "", "Left-Click: Undo to last skin") )) inv.setItem(21, createGuiItem( Material.NAME_TAG, "Custom URL / Upload", listOf("Learn how to apply custom URL skins.", "", "Click for instructions!") )) inv.setItem(23, createGuiItem( Material.BARRIER, "Clear Skin", listOf("Reset to your original account skin.", "", "Click to clear skin!") )) player.playSound(player.location, Sound.UI_BUTTON_CLICK, 0.8f, 1f) player.openInventory(inv) } /** * Menu de Favoritos com Cabeças Reais Texturizadas */ private fun openFavouritesGui(player: Player) { val favs = favourites[player.uniqueId] ?: mutableListOf() val inv = Bukkit.createInventory(null, 36, FAV_GUI_TITLE) val filler = createGuiItem(Material.GRAY_STAINED_GLASS_PANE, " ", emptyList()) for (i in 0 until 36) { if (i < 9 || i >= 27 || i % 9 == 0 || i % 9 == 8) inv.setItem(i, filler) } inv.setItem(31, createGuiItem( Material.ARROW, "« Back to Menu", listOf("Return to the main skin menu.") )) if (favs.isEmpty()) { inv.setItem(13, createGuiItem( Material.BARRIER, "No Favourites Saved", listOf( "Your database is empty.", "Right-click the Nether Star in the main menu", "or run /skin favourite to save skins!" ) )) } else { val usableSlots = listOf(10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25) favs.take(usableSlots.size).forEachIndexed { idx, skin -> val slot = usableSlots[idx] inv.setItem(slot, createTexturedHead( skin, "${skin.label}", listOf( "Left-Click: Apply this skin", "Right-Click: Remove from database" ) )) } } player.playSound(player.location, Sound.ITEM_ARMOR_EQUIP_GENERIC, 0.8f, 1f) player.openInventory(inv) } // ─── Event Listeners ────────────────────────────────────────────────────────── class CloudSkinsEventListener : Listener { @EventHandler fun onPlayerJoin(event: PlayerJoinEvent) { val player = event.player val plugin = kitePlugin ?: return Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, Runnable { if (!player.isOnline) return@Runnable // Se o jogador já tem uma skin ativa salva no banco de dados, restaura-a val savedSkin = activeSkins[player.uniqueId] if (savedSkin != null) { Bukkit.getScheduler().runTask(plugin, Runnable { if (!player.isOnline) return@Runnable applyToPlayerSync(player, savedSkin.value, savedSkin.signature) refreshSkin(player) logger.info("[CloudSkins] Restored persistent skin for ${player.name}.") }) return@Runnable } // Caso contrário, busca na Mojang para jogadores em offline-mode val mojangSkin = fetchByName(player.name) ?: return@Runnable val (value, signature) = mojangSkin Bukkit.getScheduler().runTask(plugin, Runnable { if (!player.isOnline) return@Runnable val applied = applyToPlayerSync(player, value, signature) if (applied) { activeSkins[player.uniqueId] = SkinEntry(player.name, value, signature) saveDatabase() refreshSkin(player) logger.info("[CloudSkins] Automatically loaded default account skin for ${player.name} on join.") } }) }, 20L) } @EventHandler fun onInventoryClick(event: InventoryClickEvent) { val player = event.whoClicked as? Player ?: return val title = event.view.title() if (title != MAIN_GUI_TITLE && title != FAV_GUI_TITLE) return event.isCancelled = true val slot = event.rawSlot val now = System.currentTimeMillis() val lastClick = clickCooldowns[player.uniqueId] ?: 0L if (now - lastClick < 600L) return clickCooldowns[player.uniqueId] = now if (title == MAIN_GUI_TITLE) { when (slot) { 10 -> { player.closeInventory(); doUpdate(player) } 12 -> { player.closeInventory(); doRandom(player) } 14 -> { if (event.isRightClick) { doFavourite(player) openSkinGui(player) } else { openFavouritesGui(player) } } 16 -> { player.closeInventory() if (event.isRightClick) doHistory(player) else doUndo(player) } 21 -> { player.closeInventory(); doUpload(player) } 23 -> { player.closeInventory(); doClear(player) } } return } if (title == FAV_GUI_TITLE) { if (slot == 31) { openSkinGui(player) return } val favs = favourites[player.uniqueId] ?: return val usableSlots = listOf(10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25) val favIndex = usableSlots.indexOf(slot) if (favIndex != -1 && favIndex < favs.size) { val skin = favs[favIndex] if (event.isRightClick) { favs.removeAt(favIndex) saveDatabase() player.playSound(player.location, Sound.ENTITY_ITEM_BREAK, 0.8f, 1f) msg(player, "Removed ${skin.label} from your database.") openFavouritesGui(player) } else { player.closeInventory() msg(player, "Applying favourite skin ${skin.label}...") val ok = applyToPlayerSync(player, skin.value, skin.signature) if (ok) { pushHistory(player, skin) refreshSkin(player) player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.2f) msg(player, "Applied favourite skin ${skin.label}!") } } } } } } // ─── Help & Commands ────────────────────────────────────────────────────────── private fun showHelp(player: Player, query: String? = null) { if (query != null) { msg(player, "Help — $query:") val match = ALL_SUBS.firstOrNull { it.startsWith(query, ignoreCase = true) } if (match == null) { player.sendRichMessage(" No command found for $query."); return } player.sendRichMessage(" /skin $match") return } msg(player, "CloudSkins — Commands:") player.sendRichMessage("/skin » Set skin by name or direct URL") player.sendRichMessage("/skin set » Set skin by name or direct URL") player.sendRichMessage("/skin url [classic|slim] » Set skin from direct image URL") player.sendRichMessage("/skin upload » How to upload and apply a custom skin") player.sendRichMessage("/skin clear|reset » Reset to your account skin") player.sendRichMessage("/skin update|refresh » Re-apply your current skin") player.sendRichMessage("/skin undo|revert » Revert to previous skin") player.sendRichMessage("/skin history » View your recent skin history") player.sendRichMessage("/skin random » Apply a random live community skin") player.sendRichMessage("/skin search » Search for skins on NameMC") player.sendRichMessage("/skin favourite » Toggle current skin in database") player.sendRichMessage("/skin favourites » Open graphical favourites GUI") player.sendRichMessage("/skin menu|gui » Open the graphical skin menu") player.sendRichMessage("/skin edit » How to edit a skin") } private val ALL_SUBS = listOf( "help", "set", "select", "url", "upload", "clear", "reset", "update", "refresh", "undo", "revert", "history", "random", "search", "favourite", "favorite", "favourites", "favorites", "menu", "gui", "edit" ) command("skin") { description = "CloudSkins system" usage = "/skin " aliases = listOf("skins", "cloudskins", "cs", "cskins") execute { sender, args -> if (sender !is Player) { sender.sendMessage("This command can only be used by players."); return@execute } val player = sender if (args.isEmpty()) { if (hasPerm(player, "cloudskins.command.gui")) openAppropriateMenu(player) else if (hasPerm(player, "cloudskins.command")) showHelp(player) return@execute } when (args[0].lowercase()) { "help" -> { if (!hasPerm(player, "cloudskins.command")) return@execute showHelp(player, args.getOrNull(1)) } "menu", "gui" -> { if (!hasPerm(player, "cloudskins.command.gui")) return@execute openAppropriateMenu(player) } "set", "select" -> { if (!hasPerm(player, "cloudskins.command.set")) return@execute if (args.size < 2) { msg(player, "Usage: /skin set [classic|slim]"); return@execute } val variant = if (args.size >= 3 && args[2].equals("slim", ignoreCase = true)) "slim" else "classic" doAutoSet(player, args[1], variant) } "url" -> { if (!hasPerm(player, "cloudskins.command.set.url")) return@execute if (args.size < 2) { msg(player, "Usage: /skin url [classic|slim]"); return@execute } val variant = if (args.size >= 3 && args[2].equals("slim", ignoreCase = true)) "slim" else "classic" doUrl(player, args[1], variant) } "upload" -> { if (!hasPerm(player, "cloudskins.command.set.url")) return@execute doUpload(player) } "clear", "reset" -> { if (!hasPerm(player, "cloudskins.command.clear")) return@execute doClear(player) } "update", "refresh" -> { if (!hasPerm(player, "cloudskins.command.update")) return@execute doUpdate(player) } "undo", "revert" -> { if (!hasPerm(player, "cloudskins.command.undo")) return@execute doUndo(player) } "history" -> { if (!hasPerm(player, "cloudskins.command.undo")) return@execute doHistory(player) } "random" -> { if (!hasPerm(player, "cloudskins.command.random")) return@execute doRandom(player) } "search" -> { if (!hasPerm(player, "cloudskins.command.search")) return@execute if (args.size < 2) { msg(player, "Usage: /skin search "); return@execute } doSearch(player, args.drop(1).joinToString(" ")) } "favourite", "favorite" -> { if (!hasPerm(player, "cloudskins.command.favourite")) return@execute doFavourite(player) } "favourites", "favorites" -> { if (!hasPerm(player, "cloudskins.command.favourite")) return@execute openFavouritesGui(player) } else -> { if (!hasPerm(player, "cloudskins.command.set")) return@execute val variant = if (args.size >= 2 && args[1].equals("slim", ignoreCase = true)) "slim" else "classic" doAutoSet(player, args[0], variant) } } } tabComplete { _, args -> when { args.size == 1 -> ALL_SUBS.filter { it.startsWith(args[0], ignoreCase = true) } args.size == 3 && (args[0].equals("url", ignoreCase = true) || args[0].equals("set", ignoreCase = true)) -> listOf("classic", "slim").filter { it.startsWith(args[2], ignoreCase = true) } else -> emptyList() } } } onLoad { loadDatabase() val plugin = kitePlugin if (plugin != null) { try { HandlerList.getRegisteredListeners(plugin).forEach { reg -> if (reg.listener.javaClass.simpleName.contains("CloudSkins")) { HandlerList.unregisterAll(reg.listener) } } } catch (_: Exception) {} Bukkit.getPluginManager().registerEvents(CloudSkinsEventListener(), plugin) logger.info("CloudSkins loaded cleanly! Database & Custom Textured Heads active.") } }