diff --git a/build.gradle.kts b/build.gradle.kts index d0537e2..836ab34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "net.azisaba" -version = "6.17.4+1.20.2" +version = "6.18.0+1.20.2" java { toolchain.languageVersion.set(JavaLanguageVersion.of(17)) diff --git a/src/main/java/com/github/mori01231/lifecore/DBConnector.java b/src/main/java/com/github/mori01231/lifecore/DBConnector.java index 9bde9be..e750245 100644 --- a/src/main/java/com/github/mori01231/lifecore/DBConnector.java +++ b/src/main/java/com/github/mori01231/lifecore/DBConnector.java @@ -38,6 +38,14 @@ public static void init(@NotNull LifeCore plugin) throws SQLException { " `word` VARCHAR(255) NOT NULL," + " UNIQUE KEY `id_word` (`id`, `word`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS `player_maps` (" + + " `owner` VARCHAR(36) NOT NULL," + + " `name` VARCHAR(128) NOT NULL," + + " `data` MEDIUMBLOB NOT NULL," + + " `amount` BIGINT NOT NULL DEFAULT 1," + + " `hash` VARCHAR(256) NOT NULL," + + " PRIMARY KEY (`owner`, `hash`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"); }); } diff --git a/src/main/java/com/github/mori01231/lifecore/LifeCore.kt b/src/main/java/com/github/mori01231/lifecore/LifeCore.kt index d0da149..e02678d 100644 --- a/src/main/java/com/github/mori01231/lifecore/LifeCore.kt +++ b/src/main/java/com/github/mori01231/lifecore/LifeCore.kt @@ -6,6 +6,7 @@ import com.github.mori01231.lifecore.config.* import com.github.mori01231.lifecore.data.DataLoader import com.github.mori01231.lifecore.gui.CommandListScreen import com.github.mori01231.lifecore.gui.DropProtectScreen +import com.github.mori01231.lifecore.gui.MapListScreen import com.github.mori01231.lifecore.gui.TrashProtectScreen import com.github.mori01231.lifecore.listener.* import com.github.mori01231.lifecore.listener.item.* @@ -152,6 +153,7 @@ class LifeCore : JavaPlugin() { registerCommand("gclistenerrestartextendtimecommand", GCListenerRestartExtendTimeCommand(this)) registerCommand("lifecoreutil", LifeCoreUtilCommand(this)) registerCommand("commandlist", CommandListCommand) + registerCommand("maplist", MapListCommand) registerCommand("respawn") { _, _, _, args -> args.getOrNull(0)?.let { Bukkit.getPlayerExact(it)?.spigot()?.respawn() } true @@ -253,7 +255,7 @@ class LifeCore : JavaPlugin() { if (pipeline["lifecore"] != null) { pipeline.remove("lifecore") } - } catch (ignored: NoSuchElementException) { + } catch (_: NoSuchElementException) { } } } @@ -261,7 +263,7 @@ class LifeCore : JavaPlugin() { } private fun preloadClasses() { - for (i in 0..5) { + for (i in 0..10) { preloadClass("com.github.mori01231.lifecore.LifeCore\$onDisable\$$i", false) } preloadClass("com.github.mori01231.lifecore.lib.com.charleskorn.kaml.Yaml\$encodeToString\$writer\$1") @@ -269,6 +271,13 @@ class LifeCore : JavaPlugin() { preloadClass("com.github.mori01231.lifecore.lib.org.yaml.snakeyaml.Yaml") preloadClass("com.github.mori01231.lifecore.lib.org.yaml.snakeyaml.nodes.CollectionNode") preloadClass("com.github.mori01231.lifecore.lib.org.yaml.snakeyaml.nodes.SequenceNode") + preloadClass("com.github.mori01231.lifecore.lib.org.snakeyaml.engine.v2.api.DumpSettings") + preloadClass("com.github.mori01231.lifecore.lib.org.snakeyaml.engine.v2.api.DumpSettingsBuilder") + preloadClass("com.github.mori01231.lifecore.lib.org.yaml.snakeyaml.nodes.MappingNode") + preloadClass("com.github.mori01231.lifecore.lib.org.snakeyaml.engine.v2.serializer.NumberAnchorGenerator") + preloadClass("com.github.mori01231.lifecore.lib.org.snakeyaml.engine.v2.common.NonPrintableStyle") + preloadClass("com.github.mori01231.lifecore.lib.org.snakeyaml.engine.v2.emitter.Emitter") + preloadClass("com.github.mori01231.lifecore.lib.org.yaml.snakeyaml.constructor.ConstructorException") } private fun preloadClass(name: String, required: Boolean = true) { @@ -319,6 +328,7 @@ class LifeCore : JavaPlugin() { pm.registerEvents(PromptSignListener, this) pm.registerEvents(PicksawItemListener(dataLoader), this) pm.registerEvents(BlockListener, this) + pm.registerEvents(MapListScreen.EventListener, this) // Items pm.registerEvents(OreOnlyItemListener(), this) diff --git a/src/main/java/com/github/mori01231/lifecore/command/LifeCoreUtilCommand.kt b/src/main/java/com/github/mori01231/lifecore/command/LifeCoreUtilCommand.kt index d6e8e70..db85318 100644 --- a/src/main/java/com/github/mori01231/lifecore/command/LifeCoreUtilCommand.kt +++ b/src/main/java/com/github/mori01231/lifecore/command/LifeCoreUtilCommand.kt @@ -419,9 +419,9 @@ class LifeCoreUtilCommand(val plugin: LifeCore) : TabExecutor { }, SaveMapData("地図をサーバー移動可能な形に変換します") { override fun execute(plugin: LifeCore, player: CommandSender, args: Array) { - val meta = (player as Player).inventory.itemInMainHand.itemMeta as? MapMeta? ?: return player.sendMessage("this is not a map") + val meta = (player as Player).inventory.itemInMainHand.itemMeta as? MapMeta? ?: return player.sendMessage("地図を手に持って再度実行してください") val mapView = meta.mapView ?: return player.sendMessage("mapView is null") - if (mapView.renderers.getOrNull(0) !is CraftMapRenderer) return player.sendMessage("renderers[0] is not an instance of CraftMapRenderer") + if (mapView.renderers.getOrNull(0) !is CraftMapRenderer) return player.sendMessage("すでに変換済みのようです") if (mapView is CraftMapView) mapView.render(player as CraftPlayer) val canvas = mapView.getCanvases()[mapView.renderers.first()]?.get(player as CraftPlayer) diff --git a/src/main/java/com/github/mori01231/lifecore/command/MapListCommand.kt b/src/main/java/com/github/mori01231/lifecore/command/MapListCommand.kt new file mode 100644 index 0000000..1fb81c9 --- /dev/null +++ b/src/main/java/com/github/mori01231/lifecore/command/MapListCommand.kt @@ -0,0 +1,11 @@ +package com.github.mori01231.lifecore.command + +import com.github.mori01231.lifecore.gui.MapListScreen +import org.bukkit.entity.Player + +object MapListCommand : PlayerTabExecutor() { + override fun execute(player: Player, args: Array): Boolean { + MapListScreen(player).openAsync() + return true + } +} \ No newline at end of file diff --git a/src/main/java/com/github/mori01231/lifecore/gui/MapListScreen.kt b/src/main/java/com/github/mori01231/lifecore/gui/MapListScreen.kt new file mode 100644 index 0000000..63092c6 --- /dev/null +++ b/src/main/java/com/github/mori01231/lifecore/gui/MapListScreen.kt @@ -0,0 +1,236 @@ +package com.github.mori01231.lifecore.gui + +import com.github.mori01231.lifecore.LifeCore +import com.github.mori01231.lifecore.util.ItemUtil +import com.github.mori01231.lifecore.util.MapDatabaseUtil +import com.github.mori01231.lifecore.util.PromptSign +import org.bukkit.Bukkit +import org.bukkit.ChatColor +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.InventoryHolder +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack +import org.bukkit.plugin.java.JavaPlugin +import java.util.UUID +import kotlin.math.min + +class MapListScreen(val player: Player) : InventoryHolder { + private val inventory = Bukkit.createInventory(this, 54, "${ChatColor.BLUE}マップリスト") + private val itemsInCurrentPage = mutableListOf() + @set:JvmName("setPage0") + var page = 0 + + override fun getInventory() = inventory + + fun async(callback: () -> Unit) { + Bukkit.getScheduler().runTaskAsynchronously(JavaPlugin.getPlugin(LifeCore::class.java), Runnable { + callback() + }) + } + + fun sync(callback: () -> Unit) { + Bukkit.getScheduler().runTask(JavaPlugin.getPlugin(LifeCore::class.java), Runnable { + callback() + }) + } + + fun resetAsync() { + async { + reset() + } + } + + fun reset() { + inventory.clear() + itemsInCurrentPage.clear() + val items = MapDatabaseUtil.getAll(player) + val start = page * 45 + val end = (page + 1) * 45 + for (i in start until end) { + if (i >= items.size) break + val item = items[i] + inventory.setItem(i - start, createMenuItem(item)) + itemsInCurrentPage.add(item) + } + inventory.setItem(45, createGenericItemStack(Material.ARROW, "${ChatColor.YELLOW}前のページ")) + inventory.setItem(49, ItemUtil.createItemStack(Material.NETHER_STAR, "${ChatColor.BLUE}ⓘ このページはなに?", listOf( + "${ChatColor.WHITE}保存したマップの一覧がここに表示されています。", + "${ChatColor.WHITE}それぞれのマップにカーソルを合わせると詳細が表示されます。", + "${ChatColor.WHITE}インベントリ内のマップでShift+クリックすると", + "${ChatColor.WHITE}名前を入力した後にこの画面にマップが追加されます。", + ))) + inventory.setItem(53, createGenericItemStack(Material.ARROW, "${ChatColor.YELLOW}次のページ")) + } + + fun createGenericItemStack(material: Material, name: String) = + ItemStack(material, 1).apply { + val meta = itemMeta + meta.setDisplayName(name) + itemMeta = meta + } + + fun createMenuItem(itemData: MapDatabaseUtil.ItemData) = + ItemStack(Material.FILLED_MAP, 1).apply { + val meta = itemMeta + meta.setDisplayName(itemData.name) + val lore = mutableListOf( + "${ChatColor.YELLOW}アイテム数: ${if (itemData.amount > 0) ChatColor.GREEN else ChatColor.RED}${itemData.amount}", + "", + "${ChatColor.YELLOW}✦ 左クリックで1個入手 (Shiftで64個)", + "${ChatColor.YELLOW}✎ 右クリックで名前を編集" + ) + if (itemData.amount <= 0L) { + lore.add("${ChatColor.YELLOW}✖ Shift+右クリックで削除") + } else { + lore.add("${ChatColor.YELLOW}✦ Shift+右クリックですべて取り出す") + } + meta.lore = lore + meta.addItemFlags(ItemFlag.HIDE_DESTROYS) + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES) + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS) + meta.addItemFlags(ItemFlag.HIDE_UNBREAKABLE) + meta.addItemFlags(ItemFlag.HIDE_PLACED_ON) + meta.addItemFlags(ItemFlag.HIDE_POTION_EFFECTS) + itemMeta = meta + } + + fun getPreviousPage() = if (page == 0) 0 else page - 1 + + fun getNextPage(): Int { + val items = MapDatabaseUtil.getAll(player) + val maxPage = (items.size - 1) / 45 + return if (page == maxPage) maxPage else page + 1 + } + + fun setPage(page: Int) { + this.page = page + resetAsync() + } + + fun openAsync() { + async { + reset() + sync { + player.openInventory(inventory) + } + } + } + + object EventListener : Listener { + private val processing = mutableSetOf() + + @EventHandler + fun onInventoryClick(e: InventoryClickEvent) { + val holder = e.inventory.holder + if (holder !is MapListScreen) return + e.isCancelled = true + val player = e.whoClicked as? Player ?: return + if (processing.contains(player.uniqueId)) return + if (e.clickedInventory != holder.inventory) { + val currentItem = e.currentItem + if (currentItem?.type != Material.FILLED_MAP) return + if (e.click != ClickType.SHIFT_LEFT && e.click != ClickType.SHIFT_RIGHT) return + ItemUtil.getByteArrayTag(currentItem, "SerializedMapData")?.let { if (it.isEmpty()) null else true } ?: run { + player.closeInventory() + player.sendMessage("${ChatColor.RED}先に${ChatColor.AQUA}/lifecoreutil saveMapData${ChatColor.RED}でマップデータを保存してください") + return + } + e.currentItem = null + holder.async { + MapDatabaseUtil.save(player, currentItem) { + holder.openAsync() + } + } + return + } + if (e.slot == 45) { + holder.async { + holder.setPage(holder.getPreviousPage()) + } + return + } else if (e.slot == 53) { + holder.async { + holder.setPage(holder.getNextPage()) + } + return + } + val itemData = holder.itemsInCurrentPage.getOrNull(e.slot) ?: return + fun takeItem(amount: Int) { + processing.add(player.uniqueId) + holder.async { + val item = try { + MapDatabaseUtil.take(player, itemData.data, amount) + } catch (e: Exception) { + player.sendMessage("${ChatColor.RED}アイテムが足りません。 ${ChatColor.DARK_GRAY}(${e.message})") + return@async + } finally { + processing.remove(player.uniqueId) + } + holder.sync { + player.inventory.addItem(item) + holder.resetAsync() + } + } + } + when (e.click) { + ClickType.LEFT -> { + if (player.inventory.firstEmpty() == -1) { + player.sendMessage("${ChatColor.RED}インベントリがいっぱいです") + return + } + takeItem(1) + } + ClickType.SHIFT_LEFT -> { + if (player.inventory.firstEmpty() == -1) { + player.sendMessage("${ChatColor.RED}インベントリがいっぱいです") + return + } + takeItem(min(64, itemData.amount.toInt())) + } + ClickType.RIGHT -> { + PromptSign.promptSign(player) { lines -> + val name = ChatColor.translateAlternateColorCodes('&', lines.joinToString("")) + MapDatabaseUtil.updateName(player, itemData.data, name) + holder.openAsync() + } + } + ClickType.SHIFT_RIGHT -> { + if (itemData.amount <= 0) { + holder.async { + MapDatabaseUtil.delete(player, itemData.data) + holder.resetAsync() + } + } else { + val emptySpace = player.inventory.contents.count { + @Suppress("SENSELESS_COMPARISON") + it == null || it.type == Material.AIR + } + if (emptySpace == 0) { + player.sendMessage("${ChatColor.RED}インベントリがいっぱいです") + return + } + val amount = if (itemData.amount.toInt() < 0) { + 5000 + } else { + itemData.amount.toInt() + } + takeItem(min(amount, emptySpace * 64)) + } + } + else -> {} + } + } + + @EventHandler + fun onInventoryDrag(e: InventoryClickEvent) { + val holder = e.inventory.holder + if (holder !is MapListScreen) return + e.isCancelled = true + } + } +} diff --git a/src/main/java/com/github/mori01231/lifecore/command/BlockListener.kt b/src/main/java/com/github/mori01231/lifecore/listener/BlockListener.kt similarity index 94% rename from src/main/java/com/github/mori01231/lifecore/command/BlockListener.kt rename to src/main/java/com/github/mori01231/lifecore/listener/BlockListener.kt index 2a318bd..28ff857 100644 --- a/src/main/java/com/github/mori01231/lifecore/command/BlockListener.kt +++ b/src/main/java/com/github/mori01231/lifecore/listener/BlockListener.kt @@ -1,4 +1,4 @@ -package com.github.mori01231.lifecore.command +package com.github.mori01231.lifecore.listener import com.github.mori01231.lifecore.util.ItemUtil import org.bukkit.ChatColor diff --git a/src/main/java/com/github/mori01231/lifecore/util/MapDatabaseUtil.kt b/src/main/java/com/github/mori01231/lifecore/util/MapDatabaseUtil.kt new file mode 100644 index 0000000..7bc4b24 --- /dev/null +++ b/src/main/java/com/github/mori01231/lifecore/util/MapDatabaseUtil.kt @@ -0,0 +1,151 @@ +package com.github.mori01231.lifecore.util + +import com.github.mori01231.lifecore.DBConnector +import com.github.mori01231.lifecore.LifeCore +import net.minecraft.server.v1_15_R1.NBTTagByteArray +import org.bukkit.Bukkit +import org.bukkit.ChatColor +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.MapMeta +import org.bukkit.plugin.java.JavaPlugin +import org.mariadb.jdbc.MariaDbBlob +import java.security.MessageDigest +import java.util.UUID + +@Suppress("SqlNoDataSourceInspection") +object MapDatabaseUtil { + @JvmStatic + fun getAll(player: Player): List { + return DBConnector.getPrepareStatement("SELECT `name`, `data`, `amount` FROM `player_maps` WHERE `owner` = ?") { ps -> + ps.setString(1, player.uniqueId.toString()) + ps.executeQuery().use { rs -> + val list = mutableListOf() + while (rs.next()) { + list.add(ItemData( + player.uniqueId, + rs.getString("name"), + rs.getBlob("data").binaryStream.use { it.readBytes() }, + rs.getLong("amount"), + )) + } + list + } + } + } + + @JvmStatic + fun getItemData(player: Player, item: ItemStack): ItemData? { + val data = ItemUtil.getByteArrayTag(item, "SerializedMapData") ?: error("item does not contain SerializedMapData") + return getItemData(player, data) + } + + @JvmStatic + fun getItemData(player: Player, data: ByteArray): ItemData? { + if (data.isEmpty()) error("data is empty") + return DBConnector.getPrepareStatement("SELECT `name`, `amount` FROM `player_maps` WHERE `owner` = ? AND `hash` = ?") { ps -> + ps.setString(1, player.uniqueId.toString()) + ps.setString(2, hashData(data)) + ps.executeQuery().use { + if (it.next()) { + ItemData(player.uniqueId, it.getString("name"), data, it.getLong("amount")) + } else { + null + } + } + } + } + + @JvmStatic + @JvmOverloads + fun save(player: Player, item: ItemStack, callback: () -> Unit = {}) { + val existing = getItemData(player, item) + if (existing != null) { + save(player, item, existing.name, callback) + return + } + PromptSign.promptSign(player) { lines -> + val name = ChatColor.translateAlternateColorCodes('&', lines.joinToString("")) + save(player, item, name, callback) + } + } + + @JvmStatic + @JvmOverloads + fun save(player: Player, item: ItemStack, name: String, callback: () -> Unit = {}) { + val data = ItemUtil.getByteArrayTag(item, "SerializedMapData") ?: error("item does not contain SerializedMapData") + if (data.isEmpty()) error("data is empty") + DBConnector.runPrepareStatement("INSERT INTO `player_maps` (`owner`, `name`, `data`, `hash`, `amount`) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `amount` = `amount` + VALUES(`amount`)") { ps -> + ps.setString(1, player.uniqueId.toString()) + ps.setString(2, name) + ps.setBlob(3, MariaDbBlob(data)) + ps.setString(4, hashData(data)) + ps.setInt(5, item.amount) + ps.executeUpdate() + } + callback() + } + + @JvmStatic + fun delete(player: Player, data: ByteArray) { + DBConnector.runPrepareStatement("DELETE FROM `player_maps` WHERE `owner` = ? AND `hash` = ?") { ps -> + ps.setString(1, player.uniqueId.toString()) + ps.setString(2, hashData(data)) + if (ps.executeUpdate() == 0) { + error("item not found") + } + } + } + + @JvmStatic + fun updateName(player: Player, data: ByteArray, name: String) { + DBConnector.runPrepareStatement("UPDATE `player_maps` SET `name` = ? WHERE `owner` = ? AND `hash` = ?") { ps -> + ps.setString(1, name) + ps.setString(2, player.uniqueId.toString()) + ps.setString(3, hashData(data)) + if (ps.executeUpdate() == 0) { + error("item not found") + } + } + } + + @JvmStatic + fun take(player: Player, data: ByteArray, amount: Int): ItemStack { + if (amount <= 0) error("amount must be greater than 0") + val itemData = getItemData(player, data) ?: error("item not found") + DBConnector.runPrepareStatement("UPDATE `player_maps` SET `amount` = `amount` - ? WHERE `owner` = ? AND `hash` = ? AND `amount` >= ?") { ps -> + ps.setInt(1, amount) + ps.setString(2, player.uniqueId.toString()) + ps.setString(3, hashData(data)) + ps.setInt(4, amount) + if (ps.executeUpdate() == 0) { + error("not enough amount") + } + } + return itemData.copy(amount = amount.toLong()).toItemStack() + } + + @JvmStatic + fun hashData(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-512") + val hash = digest.digest(data) + return hash.joinToString("") { "%02x".format(it) } + } + + data class ItemData( + val owner: UUID, + val name: String, + val data: ByteArray, + val amount: Long, + ) { + fun toItemStack() = + ItemStack(Material.FILLED_MAP, amount.toInt()) + .let { ItemUtil.setTag(it, "SerializedMapData", NBTTagByteArray(data)) } + .apply { + itemMeta = (itemMeta as MapMeta).apply { + mapView = Bukkit.createMap(Bukkit.getWorlds()[0]) + } + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 66beb51..e69e304 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -183,6 +183,9 @@ commands: commandlist: permission: lifecore.commandlist aliases: ["commands"] + maplist: + permission: lifecore.maplist + aliases: ["maps"] permissions: lifecore.*: