feat: ショップのアイテム編集

This commit is contained in:
Keisuke Hirata 2025-12-12 05:21:33 +09:00
parent 6b8937ae6b
commit 79d34ee2dc
7 changed files with 884 additions and 1 deletions

View File

@ -11,11 +11,12 @@ plugins {
} }
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/") maven("https://repo.papermc.io/repository/maven-public/")
} }
val exposedVersion = "1.0.0-rc-4"
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib") compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
@ -25,8 +26,17 @@ dependencies {
compileOnly("net.hareworks.hcu:economy:1.0") compileOnly("net.hareworks.hcu:economy:1.0")
compileOnly("net.hareworks.hcu:faction:1.0") compileOnly("net.hareworks.hcu:faction:1.0")
compileOnly("net.hareworks:kommand-lib")
compileOnly("net.hareworks:permits-lib")
// Libs // Libs
compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0") compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0")
val exposedVersion = "1.0.0-rc-4"
compileOnly("org.jetbrains.exposed:exposed-core:$exposedVersion")
compileOnly("org.jetbrains.exposed:exposed-dao:$exposedVersion")
compileOnly("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
} }
tasks { tasks {

View File

@ -1,13 +1,74 @@
package net.hareworks.hcu.shop package net.hareworks.hcu.shop
import net.hareworks.hcu.shop.command.ShopCommands
import net.hareworks.hcu.shop.listeners.ShopListener
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.permits_lib.PermitsLib
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
import net.hareworks.hcu.shop.database.HcuDatabase
import net.hareworks.hcu.shop.database.ShopRepository
import org.bukkit.scheduler.BukkitTask
import java.util.logging.Level
class ShopPlugin : JavaPlugin() { class ShopPlugin : JavaPlugin() {
private var commands: KommandLib? = null
private val permits = PermitsLib.session(this)
override fun onEnable() { override fun onEnable() {
// Register Command via KommandLib
commands = ShopCommands.register(this, permits)
// Register Listener
server.pluginManager.registerEvents(ShopListener(this), this)
// Initialize Database handled by readiness monitor
// net.hareworks.hcu.shop.database.ShopRepository.init()
monitorDatabaseReadiness()
logger.info("Shop plugin enabled!") logger.info("Shop plugin enabled!")
} }
override fun onDisable() { override fun onDisable() {
readinessTask?.cancel()
readinessTask = null
databaseReady = false
commands?.unregister()
commands = null
logger.info("Shop plugin disabled!") logger.info("Shop plugin disabled!")
} }
private var readinessTask: BukkitTask? = null
@Volatile private var databaseReady: Boolean = false
private fun monitorDatabaseReadiness() {
readinessTask?.cancel()
readinessTask = server.scheduler.runTaskTimer(
this,
Runnable {
if (!HcuDatabase.dependencyAvailable(this)) {
return@Runnable
}
if (!HcuDatabase.sessionAvailable()) {
return@Runnable
}
if (databaseReady) return@Runnable
databaseReady = true
readinessTask?.cancel()
readinessTask = null
// Init Repository
runCatching {
ShopRepository.init()
logger.info("Shop database initialized.")
}.onFailure {
logger.log(Level.SEVERE, "Failed to initialize Shop database", it)
server.pluginManager.disablePlugin(this)
}
},
20L,
100L
)
}
} }

View File

@ -0,0 +1,74 @@
package net.hareworks.hcu.shop.command
import net.hareworks.kommand_lib.KommandLib
import net.hareworks.kommand_lib.kommand
import net.hareworks.permits_lib.bukkit.MutationSession
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.persistence.PersistentDataType
import org.bukkit.plugin.java.JavaPlugin
import net.hareworks.hcu.shop.ShopPlugin
object ShopCommands {
private val tierKeyStr = "shop_tier"
fun register(plugin: ShopPlugin, permits: MutationSession): KommandLib {
// Need to access NamespacedKey. Ideally pass plugin or use the plugin instance passed to kommand
// plugin is available as 'plugin' in local scope or passed in.
// We'll calculate the NamespacedKey inside the command logic.
val tierKey = NamespacedKey(plugin, tierKeyStr)
return kommand(plugin) {
permissions {
namespace = "shop"
session(permits)
}
command("shop") {
description = "Shop commands"
literal("give") {
permission {
description = "Give shop sign"
defaultValue = org.bukkit.permissions.PermissionDefault.OP
}
// Logic for /shop give (No tier specified, default 1)
executes {
giveShopSign(plugin, sender, 1, tierKey)
}
// Logic for /shop give <tier>
integer("tier", min = 1, max = 5) {
executes {
val tier: Int = argument("tier")
giveShopSign(plugin, sender, tier, tierKey)
}
}
}
}
}
}
private fun giveShopSign(plugin: JavaPlugin, sender: org.bukkit.command.CommandSender, tier: Int, key: NamespacedKey) {
if (sender !is Player) {
sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED))
return
}
val signItem = ItemStack(Material.OAK_SIGN)
val meta = signItem.itemMeta
meta.displayName(Component.text("Shop Sign (Tier $tier)").color(NamedTextColor.GOLD))
meta.persistentDataContainer.set(key, PersistentDataType.INTEGER, tier)
signItem.itemMeta = meta
sender.inventory.addItem(signItem)
sender.sendMessage(Component.text("Given Shop Sign Tier $tier", NamedTextColor.GREEN))
}
}

View File

@ -0,0 +1,16 @@
package net.hareworks.hcu.shop.database
import net.hareworks.hcu.core.database.DatabaseSessionManager
import org.bukkit.plugin.java.JavaPlugin
import org.jetbrains.exposed.v1.jdbc.JdbcTransaction
object HcuDatabase {
fun dependencyAvailable(plugin: JavaPlugin): Boolean {
val dependency = plugin.server.pluginManager.getPlugin("hcu-core")
return dependency?.isEnabled == true
}
fun sessionAvailable(): Boolean = DatabaseSessionManager.isConnected() && DatabaseSessionManager.ping()
fun <T> transaction(block: JdbcTransaction.() -> T): T = DatabaseSessionManager.transaction(block)
}

View File

@ -0,0 +1,137 @@
package net.hareworks.hcu.shop.database
import net.hareworks.hcu.shop.ShopPlugin
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.block.BlockFace
import org.bukkit.inventory.ItemStack
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import java.util.UUID
object ShopRepository {
fun init() {
HcuDatabase.transaction {
SchemaUtils.create(Shops)
}
}
fun create(actorId: Int, location: Location, face: String, item: ItemStack? = null, stock: Int = 0, price: Double = 0.0): ShopData? {
val world = location.world ?: return null
val itemBytes = item?.serializeAsBytes()
return HcuDatabase.transaction {
val id = Shops.insert {
it[Shops.actorId] = actorId
it[Shops.worldUid] = world.uid
it[Shops.x] = location.blockX
it[Shops.y] = location.blockY
it[Shops.z] = location.blockZ
it[Shops.face] = face
it[Shops.stock] = stock
it[Shops.price] = price
it[Shops.item] = itemBytes
} get Shops.id
ShopData(
id = id!!,
actorId = actorId,
worldUid = world.uid,
x = location.blockX,
y = location.blockY,
z = location.blockZ,
face = face,
price = price,
stock = stock,
item = item,
enabled = false
)
}
}
fun findByLocationAndFace(location: Location, face: String): ShopData? {
val world = location.world ?: return null
return HcuDatabase.transaction {
Shops.selectAll().where {
(Shops.worldUid eq world.uid) and
(Shops.x eq location.blockX) and
(Shops.y eq location.blockY) and
(Shops.z eq location.blockZ) and
(Shops.face eq face)
}.map { toShopData(it) }
.singleOrNull()
}
}
fun findById(id: Int): ShopData? {
return HcuDatabase.transaction {
Shops.selectAll().where { Shops.id eq id }
.map { toShopData(it) }
.singleOrNull()
}
}
fun updateStock(id: Int, newStock: Int) {
HcuDatabase.transaction {
Shops.update({ Shops.id eq id }) {
it[Shops.stock] = newStock
}
}
}
fun updatePrice(id: Int, newPrice: Double) {
HcuDatabase.transaction {
Shops.update({ Shops.id eq id }) {
it[Shops.price] = newPrice
}
}
}
fun updateItem(id: Int, newItem: ItemStack?) {
val itemBytes = newItem?.serializeAsBytes()
HcuDatabase.transaction {
Shops.update({ Shops.id eq id }) {
it[Shops.item] = itemBytes
}
}
}
fun updateEnabled(id: Int, enabled: Boolean) {
HcuDatabase.transaction {
Shops.update({ Shops.id eq id }) {
it[Shops.enabled] = enabled
}
}
}
fun delete(targetId: Int) {
HcuDatabase.transaction {
Shops.deleteWhere { Shops.id eq targetId }
}
}
private fun toShopData(row: ResultRow): ShopData {
val itemBytes = row[Shops.item]
val item = itemBytes?.let { ItemStack.deserializeBytes(it) }
return ShopData(
id = row[Shops.id],
actorId = row[Shops.actorId],
worldUid = row[Shops.worldUid],
x = row[Shops.x],
y = row[Shops.y],
z = row[Shops.z],
face = row[Shops.face],
price = row[Shops.price],
stock = row[Shops.stock],
item = item,
enabled = row[Shops.enabled]
)
}
}

View File

@ -0,0 +1,37 @@
package net.hareworks.hcu.shop.database
import org.bukkit.inventory.ItemStack
import org.jetbrains.exposed.v1.core.Table
object Shops : Table("shops") {
val id = integer("id").autoIncrement()
val actorId = integer("actor_id")
// Position (Vector3i equivalent + World)
val worldUid = uuid("world_uid")
val x = integer("x")
val y = integer("y")
val z = integer("z")
val face = varchar("face", 16) // FRONT or BACK
val price = double("price").default(0.0)
val stock = integer("stock")
val item = binary("item_bytes").nullable() // Item can be null initially.
val enabled = bool("enabled").default(false)
override val primaryKey = PrimaryKey(id)
}
data class ShopData(
val id: Int,
val actorId: Int,
val worldUid: java.util.UUID,
val x: Int,
val y: Int,
val z: Int,
val face: String,
val price: Double,
val stock: Int,
val item: ItemStack?,
val enabled: Boolean
)

View File

@ -0,0 +1,548 @@
package net.hareworks.hcu.shop.listeners
import net.hareworks.hcu.shop.ShopPlugin
import net.hareworks.hcu.shop.database.ShopRepository
import net.hareworks.hcu.core.player.PlayerIdServiceImpl
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import org.bukkit.DyeColor
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.block.BlockFace
import org.bukkit.block.Sign
import org.bukkit.block.data.Rotatable
import org.bukkit.block.sign.Side
import org.bukkit.entity.ItemDisplay
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.block.BlockPlaceEvent
import org.bukkit.event.block.SignChangeEvent
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.block.Action
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.event.inventory.InventoryCloseEvent
import org.bukkit.event.inventory.InventoryType
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
import org.bukkit.persistence.PersistentDataType
import org.bukkit.util.Transformation
import org.joml.Quaternionf
import org.joml.Vector3f
class ShopListener(private val plugin: ShopPlugin) : Listener {
private val tierKey = NamespacedKey(plugin, "shop_tier")
@EventHandler
fun onShopSignPlace(event: BlockPlaceEvent) {
val item = event.itemInHand
val meta = item.itemMeta ?: return
// Check if it is a shop sign
val tier = meta.persistentDataContainer.get(tierKey, PersistentDataType.INTEGER) ?: return
// Check block type (Standing Sign)
if (event.blockPlaced.type != Material.OAK_SIGN) {
event.isCancelled = true
return
}
// Check block against (Chest)
if (event.blockAgainst.type != Material.CHEST) {
// Only valid on top of chests
event.isCancelled = true
return
}
// Apply Sign Text and Properties
val signState = event.blockPlaced.state as? Sign ?: return
// Front Text
val frontSide = signState.getSide(Side.FRONT)
frontSide.line(0, Component.text("Front"))
frontSide.line(1, Component.text("oak_log"))
frontSide.color = DyeColor.BLACK
frontSide.isGlowingText = false
// Back Text
val backSide = signState.getSide(Side.BACK)
backSide.line(0, Component.text("Back"))
backSide.line(1, Component.text("gunpowder"))
backSide.color = DyeColor.BLACK
backSide.isGlowingText = false
// Save Tier to Block and Wax (Lock)
signState.persistentDataContainer.set(tierKey, PersistentDataType.INTEGER, tier)
signState.isWaxed = true
signState.update()
// --- Create Shop Records (DB) ---
// FIXME: Ideally get service via dependency injection or service manager
val playerIdService = PlayerIdServiceImpl(plugin.logger)
val playerEntry = playerIdService.find(event.player.uniqueId)
if (playerEntry == null) {
plugin.logger.warning("Could not find actor ID for player ${event.player.name}")
// Consider cancelling if critical, but for now just log
} else {
val actorId = playerEntry.actorId
// Create FRONT shop
ShopRepository.create(
actorId = actorId,
location = event.blockPlaced.location,
face = "FRONT"
)
// Create BACK shop
ShopRepository.create(
actorId = actorId,
location = event.blockPlaced.location,
face = "BACK"
)
}
// Spawn Displays
spawnDisplays(event.blockPlaced)
}
private fun spawnDisplays(block: org.bukkit.block.Block) {
val origin = block.location.clone()
val data = block.blockData as? Rotatable ?: return
val rotation = data.rotation // BlockFace
val yaw = yawFromBlockFace(rotation) + 90f
// Calculate offsets relative to center (0.5, 0, 0.5)
// Original request assumes 0,0,0 is block origin.
// Front: 0.75 0.25 0.5 -> Rel: 0.25, 0.25, 0.0
// Back: 0.25 0.25 0.5 -> Rel: -0.25, 0.25, 0.0
val frontOffset = Vector3f(0.25f, 0.25f, 0.0f)
val backOffset = Vector3f(-0.25f, 0.25f, 0.0f)
// Rotate offsets
// yaw is degrees clockwise from South (Z+).
// radians need to be negated for standard CCW rotation logic if we use that,
// or just use rotateY with appropriate sign.
// rotateY in JOML: "Rotates this vector by the given angle in radians around the Y axis"
// Standard geometric Y rotation is CCW.
// Minecraft Yaw 90 (West) should rotate (0,0,1) to (-1,0,0).
// Std Rot 90 CCW: (0,0,1) -> (1,0,0).
// So they are opposite. We should rotate by -yaw.
val rad = Math.toRadians(yaw.toDouble())
frontOffset.rotateY(-rad.toFloat())
backOffset.rotateY(-rad.toFloat())
// Final positions
val center = origin.clone().add(0.5, 0.0, 0.5)
val frontPos = center.clone().add(frontOffset.x.toDouble(), frontOffset.y.toDouble(), frontOffset.z.toDouble())
val backPos = center.clone().add(backOffset.x.toDouble(), backOffset.y.toDouble(), backOffset.z.toDouble())
val world = block.world
// Spawn Front
world.spawn(frontPos, ItemDisplay::class.java) { display ->
// display.setItemStack(ItemStack(Material.AIR)) // Default empty
display.setRotation(yaw, 0f)
// Transformation
val left = Quaternionf(0.0f, -0.7071068f, 0.0f, 0.7071068f)
val right = Quaternionf(0.0f, 0.0f, 0.0f, 1.0f)
val scale = Vector3f(0.5f, 0.5f, 0.5f)
val translation = Vector3f(0.0f, 0.0f, 0.0f)
display.setTransformation(Transformation(translation, left, scale, right))
display.interpolationDuration = 0
// Add custom tag or PDC to link to sign if needed?
}
// Spawn Back
world.spawn(backPos, ItemDisplay::class.java) { display ->
// display.setItemStack(ItemStack(Material.AIR)) // Default empty
display.setRotation(yaw - 90f, 0f)
// Transformation
val left = Quaternionf(0.0f, 1.0f, 0.0f, 0.0f)
val right = Quaternionf(0.0f, 0.0f, 0.0f, 1.0f)
val scale = Vector3f(0.5f, 0.5f, 0.5f)
val translation = Vector3f(0.0f, 0.0f, 0.0f)
display.setTransformation(Transformation(translation, left, scale, right))
display.interpolationDuration = 0
}
}
private fun yawFromBlockFace(face: BlockFace): Float {
return when (face) {
BlockFace.SOUTH -> 0f
BlockFace.SOUTH_SOUTH_WEST -> 22.5f
BlockFace.SOUTH_WEST -> 45f
BlockFace.WEST_SOUTH_WEST -> 67.5f
BlockFace.WEST -> 90f
BlockFace.WEST_NORTH_WEST -> 112.5f
BlockFace.NORTH_WEST -> 135f
BlockFace.NORTH_NORTH_WEST -> 157.5f
BlockFace.NORTH -> 180f
BlockFace.NORTH_NORTH_EAST -> 202.5f
BlockFace.NORTH_EAST -> 225f
BlockFace.EAST_NORTH_EAST -> 247.5f
BlockFace.EAST -> 270f
BlockFace.EAST_SOUTH_EAST -> 292.5f
BlockFace.SOUTH_EAST -> 315f
BlockFace.SOUTH_SOUTH_EAST -> 337.5f
else -> 0f
}
}
@EventHandler
fun onSignChange(event: SignChangeEvent) {
val player = event.player
val shopId = ShopInputMap.pendingInputs[player.uniqueId]
if (shopId != null) {
// Retrieve Shop Data
val shopData = ShopRepository.findById(shopId)
if (shopData == null) {
ShopInputMap.pendingInputs.remove(player.uniqueId)
return
}
// Handle Shop Setup Input
// Line 0: Price (User Input)
val priceInput = event.line(0)
var priceString = (priceInput as? net.kyori.adventure.text.TextComponent)?.content() ?: ""
priceString = priceString.replace("Price : ", "", true).trim()
val price = priceString.toDoubleOrNull()
if (price != null && price >= 0) {
// Update DB
ShopRepository.updatePrice(shopId, price)
player.sendMessage(Component.text("Shop price set to $$price", NamedTextColor.GREEN))
// Update Actual Sign Visuals
updateShopSignVisuals(event.block, shopData.copy(price = price), event.side)
} else {
player.sendMessage(Component.text("Invalid price format. Please enter a valid number.", NamedTextColor.RED))
// Optionally reopen? For now just fail.
}
// Clean up
ShopInputMap.pendingInputs.remove(player.uniqueId)
// Cancel the raw update so we don't just paste "100" on the sign
// But we DO want to update it with the visual method above.
// Since we called updateShopSignVisuals which does state.update(),
// verifying if cancelling event interferes.
// Usually, event changes the state *after* this handler.
// If we cancel, no change happens.
// So we cancel, and manually set the text we want.
event.isCancelled = true
} else {
// Normal sign editing protection
val state = event.block.state as? Sign ?: return
if (state.persistentDataContainer.has(tierKey, PersistentDataType.INTEGER)) {
// If strictly preventing editing of shop signs unless in setup mode
event.isCancelled = true
}
}
}
private fun updateShopSignVisuals(block: org.bukkit.block.Block, shopData: net.hareworks.hcu.shop.database.ShopData, side: Side) {
val state = block.state as? Sign ?: return
val signSide = state.getSide(side) // Currently updating the side being edited
// Fetch item name
val itemName = shopData.item?.type?.name?.lowercase()?.replace("_", " ") ?: "Unknown"
// Format:
// L1: <Item Name>
// L2: Buy: <Price>
// L3: Stock: <Stock>
signSide.line(0, Component.text(itemName, NamedTextColor.BLACK))
signSide.line(1, Component.text("Buy: $${shopData.price}", NamedTextColor.BLACK))
signSide.line(2, Component.text("Stock: ${shopData.stock}", NamedTextColor.BLACK))
signSide.line(3, Component.empty())
// If updating the sign block during event cancellation, we might need a scheduler or force update
// But since we are cancelling the event, the state remains as is.
// We need to apply THESE changes.
// state.update() pushes to world.
// Wait, if we are in SignChangeEvent, the block is currently processing update.
// It's safer to run a task to update it 1 tick later if we cancel the event.
plugin.server.scheduler.runTask(plugin, Runnable {
state.update()
})
}
@EventHandler
fun onShopInteract(event: PlayerInteractEvent) {
if (event.action != Action.RIGHT_CLICK_BLOCK) return
val block = event.clickedBlock ?: return
val state = block.state as? Sign ?: return
// Check if shop sign
if (!state.persistentDataContainer.has(tierKey, PersistentDataType.INTEGER)) return
// Determine Face (Front/Back)
val data = block.blockData as? Rotatable ?: return
val rotation = data.rotation
val clickedFace = event.blockFace
val face = when (clickedFace) {
rotation -> "FRONT"
rotation.oppositeFace -> "BACK"
else -> return // Clicked side/top/bottom, ignore or handle? Ignoring for now.
}
// Check Owner
val playerIdService = PlayerIdServiceImpl(plugin.logger)
val playerEntry = playerIdService.find(event.player.uniqueId) ?: return
// Find Shop Data
val shopData = ShopRepository.findByLocationAndFace(block.location, face)
if (shopData != null) {
if (shopData.actorId == playerEntry.actorId) {
// Is Owner -> Open Edit GUI
openEditGui(event.player, shopData)
} else {
// Is Customer -> Buy Logic (TODO)
event.player.sendMessage(Component.text("Customer interactions not implemented yet.", NamedTextColor.GRAY))
}
} else {
plugin.logger.warning("Shop data missing for location ${block.location} face $face")
}
}
private fun openEditGui(player: org.bukkit.entity.Player, shopData: net.hareworks.hcu.shop.database.ShopData) {
// 1x5 UI using Hopper
val inventory = org.bukkit.Bukkit.createInventory(player, InventoryType.HOPPER, Component.text("Shop Setup: ${shopData.face}"))
// Slot 0: Toggle Enabled
val toggleItem = if (shopData.enabled) {
ItemStack(Material.LIME_STAINED_GLASS_PANE).apply {
editMeta { it.displayName(Component.text("Enabled", NamedTextColor.GREEN)) }
}
} else {
ItemStack(Material.RED_STAINED_GLASS_PANE).apply {
editMeta { it.displayName(Component.text("Disabled", NamedTextColor.RED)) }
}
}
inventory.setItem(0, toggleItem)
// Filler
val filler = ItemStack(Material.GRAY_STAINED_GLASS_PANE).apply {
editMeta { it.displayName(Component.empty()) }
}
inventory.setItem(1, filler)
inventory.setItem(3, filler)
// Slot 4: Delete Item (Barrier)
val deleteItem = ItemStack(Material.BARRIER).apply {
editMeta { it.displayName(Component.text("Remove Item", NamedTextColor.RED)) }
}
inventory.setItem(4, deleteItem)
// Setup center slot (Index 2)
if (shopData.item != null) {
val displayItem = shopData.item.clone()
// If we want to show stock/price info in lore, do it here
inventory.setItem(2, displayItem)
} else {
val displayItem = ItemStack(Material.BARRIER)
val meta = displayItem.itemMeta
meta.displayName(Component.text("Not Set", NamedTextColor.RED))
displayItem.itemMeta = meta
inventory.setItem(2, displayItem)
}
// We need to persist the shopId in the inventory holder or some map to handle events
// Since InventoryHolder is complex with chest GUIs, we can use a Holder class or a Map.
// For simplicity, let's assume we handle it in InventoryClickEvent by checking title or holding a map.
// A custom Holder is cleaner.
player.openInventory(inventory)
ShopGuiMap.openInvs[inventory] = shopData.id
}
@EventHandler
fun onShopGuiClick(event: InventoryClickEvent) {
val inventory = event.inventory
val shopId = ShopGuiMap.openInvs[inventory] ?: return
// Allow player inventory interaction
if (event.clickedInventory != inventory) {
if (event.isShiftClick) event.isCancelled = true
return
}
event.isCancelled = true // Always lock
// Handle Slot 0: Toggle Logic
if (event.clickedInventory == inventory && event.slot == 0) {
val shopData = ShopRepository.findById(shopId) ?: return
val newState = !shopData.enabled
ShopRepository.updateEnabled(shopId, newState)
// Update Icon
val newItem = if (newState) {
ItemStack(Material.LIME_STAINED_GLASS_PANE).apply {
editMeta { it.displayName(Component.text("Enabled", NamedTextColor.GREEN)) }
}
} else {
ItemStack(Material.RED_STAINED_GLASS_PANE).apply {
editMeta { it.displayName(Component.text("Disabled", NamedTextColor.RED)) }
}
}
inventory.setItem(0, newItem)
// Play sound?
}
// Handle Slot 4: Remove Item
if (event.clickedInventory == inventory && event.slot == 4) {
// Remove item
ShopRepository.updateItem(shopId, null)
// Update UI Center to Barrier
val barrier = ItemStack(Material.BARRIER).apply {
editMeta { it.displayName(Component.text("Not Set", NamedTextColor.RED)) }
}
inventory.setItem(2, barrier)
// Update Display
val updatedShop = ShopRepository.findById(shopId)
if (updatedShop != null) {
updateShopDisplay(updatedShop)
}
}
// Handle item placement in slot 2
if (event.clickedInventory == inventory && event.slot == 2) {
val cursor = event.cursor
// If clicking with an item (Icon/Amount setting)
if (cursor != null && cursor.type != Material.AIR) {
// Update Item in DB (Use clone to capture Type, Meta, and Amount)
val newItem = cursor.clone()
ShopRepository.updateItem(shopId, newItem)
// Visual Update (Ghost Item)
inventory.setItem(2, newItem)
// Update World Display
val updatedShop = ShopRepository.findById(shopId)
if (updatedShop != null) {
updateShopDisplay(updatedShop)
}
// Prompt for Price via Sign
plugin.server.scheduler.runTask(plugin, Runnable {
event.whoClicked.closeInventory()
openPriceInputSign(event.whoClicked as org.bukkit.entity.Player, shopId)
})
} else {
// Clicking with empty cursor on existing item -> Update Price
val currentItem = inventory.getItem(2)
if (currentItem != null && currentItem.type != Material.BARRIER && currentItem.type != Material.AIR) {
plugin.server.scheduler.runTask(plugin, Runnable {
event.whoClicked.closeInventory()
openPriceInputSign(event.whoClicked as org.bukkit.entity.Player, shopId)
})
}
}
// If clicking with air (Removal?) -> Optional: Clear item
// else if (cursor == null || cursor.type == Material.AIR) {
// ShopRepository.updateItem(shopId, null) // Needs nullable update support
// inventory.setItem(2, null)
// }
}
}
@EventHandler
fun onShopGuiClose(event: InventoryCloseEvent) {
ShopGuiMap.openInvs.remove(event.inventory)
}
private fun openPriceInputSign(player: org.bukkit.entity.Player, shopId: Int) {
val shopData = ShopRepository.findById(shopId) ?: return
val location = org.bukkit.Location(
org.bukkit.Bukkit.getWorld(shopData.worldUid),
shopData.x.toDouble(),
shopData.y.toDouble(),
shopData.z.toDouble()
)
val block = location.block
if (block.type != Material.OAK_SIGN) return
val sign = block.state as? Sign ?: return
// Determine side based on ShopData face
val side = if (shopData.face == "FRONT") Side.FRONT else Side.BACK
val signSide = sign.getSide(side)
// Send client-side update with prompt
// Using Paper API: player.sendBlockUpdate(location, blockData) doesnt allow setting text easily
// We can modify the sign state locally and send update, then revert?
// Or actually, `player.sendBlockUpdate` takes a `TileState` in recent Paper versions?
// If not, we can use `player.sendSignChange`.
val promptLines = listOf(
Component.text("Price : ", NamedTextColor.BLACK),
Component.empty(),
Component.empty(),
Component.empty()
)
player.sendSignChange(location, promptLines, DyeColor.BLACK, true) // hasGlowingText=true/false?
// Register pending input
ShopInputMap.pendingInputs[player.uniqueId] = shopId
// Open Editor
player.openSign(sign, side)
}
private fun updateShopDisplay(shopData: net.hareworks.hcu.shop.database.ShopData) {
val world = org.bukkit.Bukkit.getWorld(shopData.worldUid) ?: return
val block = world.getBlockAt(shopData.x, shopData.y, shopData.z)
// Ensure block is still a sign and rotatable
val data = block.blockData as? Rotatable ?: return
val rotation = data.rotation
val yaw = yawFromBlockFace(rotation) + 90f
val offset = if (shopData.face == "FRONT") Vector3f(0.25f, 0.25f, 0.0f) else Vector3f(-0.25f, 0.25f, 0.0f)
val rad = Math.toRadians(yaw.toDouble())
offset.rotateY(-rad.toFloat())
val center = block.location.clone().add(0.5, 0.0, 0.5)
val targetPos = center.clone().add(offset.x.toDouble(), offset.y.toDouble(), offset.z.toDouble())
// Search for entity
val nearby = world.getNearbyEntities(targetPos, 0.2, 0.2, 0.2)
val display = nearby.filterIsInstance<ItemDisplay>().firstOrNull()
if (display != null) {
if (shopData.item != null) {
display.setItemStack(shopData.item)
} else {
display.setItemStack(ItemStack(Material.AIR))
}
}
}
}
object ShopGuiMap {
val openInvs = java.util.WeakHashMap<Inventory, Int>()
}
object ShopInputMap {
val pendingInputs = java.util.HashMap<java.util.UUID, Int>()
}