From af4008ee458fe268d87993011c981692593c0dd1 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 12 Dec 2025 05:21:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9E=E8=BB=A2=E3=81=A8=E7=A0=B4?= =?UTF-8?q?=E5=A3=8A=E3=83=BB=E8=A8=AD=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hcu/landsector/LandSectorPlugin.kt | 3 + .../landsector/command/LandSectorCommand.kt | 19 +- .../hcu/landsector/listener/SectorListener.kt | 231 ++++++++++-------- .../hcu/landsector/service/SectorService.kt | 102 ++++++-- .../hcu/landsector/task/SectorRotationTask.kt | 88 +++++++ 5 files changed, 311 insertions(+), 132 deletions(-) create mode 100644 src/main/kotlin/net/hareworks/hcu/landsector/task/SectorRotationTask.kt diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt index efe142f..03166e9 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt @@ -64,6 +64,9 @@ class LandSectorPlugin : JavaPlugin() { service.flushChanges() }, 6000L, 6000L) + // Schedule rotation task + net.hareworks.hcu.landsector.task.SectorRotationTask(this).runTaskTimer(this, 1L, 1L) + logger.info("LandSector initialized with services.") } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt index 3d9d27d..d3a24da 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt @@ -103,19 +103,18 @@ class LandSectorCommand(private val landSectorPlugin: LandSectorPlugin) { val blockBase = world.getBlockAt(x, y - 1, z) val blockTop = world.getBlockAt(x, y + 1, z) - if (blockBase.type == Material.BEDROCK) blockBase.type = Material.AIR - if (blockTop.type == Material.BEDROCK) blockTop.type = Material.AIR + if (blockBase.type == Material.DEEPSLATE_TILE_SLAB) blockBase.type = Material.AIR + if (blockTop.type == Material.DEEPSLATE_TILE_SLAB) blockTop.type = Material.AIR - // Remove Entity + // Remove All Entities with this sector ID val center = Location(world, x + 0.5, y.toDouble(), z + 0.5) if (center.chunk.isLoaded) { - val entities = world.getNearbyEntities(center, 0.5, 0.5, 0.5) - entities.forEach { entity -> - if (entity is Shulker) { - val key = NamespacedKey(landSectorPlugin, "sector_id") - val sId = entity.persistentDataContainer.get(key, PersistentDataType.INTEGER) - if (sId == id || sId == null) { - entity.remove() + val sectorKey = NamespacedKey(landSectorPlugin, "sector_id") + center.chunk.entities.forEach { entity -> + if (entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) + if (sId == id) { + entity.remove() } } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt index 937f77b..2e99bdd 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt @@ -14,6 +14,7 @@ import org.bukkit.entity.Shulker import org.bukkit.attribute.Attribute import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.event.block.BlockBreakEvent import org.bukkit.event.block.BlockPlaceEvent import org.bukkit.event.entity.EntityDamageEvent import org.bukkit.event.entity.EntityDamageByEntityEvent @@ -32,8 +33,31 @@ class SectorListener( private val playerIdService: PlayerIdService ) : Listener { + @EventHandler + fun onBreak(event: BlockBreakEvent) { + val block = event.block + val loc = block.location + val player = event.player + + if (sectorService.isSectorBlock(loc.world.name, loc.blockX, loc.blockY, loc.blockZ)) { + player.sendMessage(Component.text("You cannot break Sector Core blocks!", NamedTextColor.RED)) + event.isCancelled = true + } + } + @EventHandler fun onPlace(event: BlockPlaceEvent) { + val block = event.block + val loc = block.location + val player = event.player + + // Global protection check + if (sectorService.isSectorArea(loc.world.name, loc.blockX, loc.blockY, loc.blockZ)) { + player.sendMessage(Component.text("This area is protected by a Sector Core.", NamedTextColor.RED)) + event.isCancelled = true + return + } + val item = event.itemInHand val meta = item.itemMeta ?: return val key = NamespacedKey(plugin, "component") @@ -42,48 +66,54 @@ class SectorListener( return } - val player = event.player val playerEntry = playerIdService.find(player.uniqueId) if (playerEntry == null) { player.sendMessage(Component.text("Identity not found.", NamedTextColor.RED)) event.isCancelled = true return } - - val block = event.blockPlaced - val loc = block.location - // Space check - val above1 = block.getRelative(0, 1, 0) - val above2 = block.getRelative(0, 2, 0) - - if (!above1.type.isAir || !above2.type.isAir) { - player.sendMessage(Component.text("Not enough space for Sector Core.", NamedTextColor.RED)) - event.isCancelled = true - return + // ... (rest of the code) + + // Check 3x3x3 space availability (relative to placed block base) + val baseLoc = block.location + for (dx in -1..1) { + for (dy in 0..2) { + for (dz in -1..1) { + if (dx == 0 && dy == 0 && dz == 0) continue // Skip the placed block itself + val checkLoc = baseLoc.clone().add(dx.toDouble(), dy.toDouble(), dz.toDouble()) + if (!checkLoc.block.type.isAir) { + player.sendMessage(Component.text("Not enough space! Need 3x3x3 free area.", NamedTextColor.RED)) + event.isCancelled = true + return + } + } + } } - // Record to DB first to get ID + // Define Center Location (This will be the DB coordinates) + val centerLoc = baseLoc.clone().add(0.0, 1.0, 0.0) // y+1 from base + val sector = sectorService.createSector( playerEntry.actorId, player.world.name, - above1.x, - above1.y, - above1.z + centerLoc.blockX, + centerLoc.blockY, + centerLoc.blockZ ) if (sector == null) { - player.sendMessage(Component.text("Failed to create sector record.", NamedTextColor.RED)) + player.sendMessage(Component.text("Failed to create sector.", NamedTextColor.RED)) event.isCancelled = true return } // Create Visuals val sectorKey = NamespacedKey(plugin, "sector_id") - val locCenter = loc.clone().add(0.5, 0.0, 0.5) + val visualCenter = centerLoc.clone().add(0.5, 0.0, 0.5) // Center of the block space - // 1. Command Block - val cbLoc = locCenter.clone().add(0.0, 1.5, 0.0) + // 1. Command Block (Center + 0.5y) -> Matches old base+1.5 + val cbLoc = visualCenter.clone().add(0.0, 0.5, 0.0) val cb = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay cb.block = Material.COMMAND_BLOCK.createBlockData { (it as org.bukkit.block.data.Directional).facing = org.bukkit.block.BlockFace.UP @@ -96,7 +126,7 @@ class SectorListener( ) cb.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // 2. Tinted Glass + // 2. Tinted Glass (Center + 0.5y) -> Matches old base+1.5 val glass = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay glass.block = Material.TINTED_GLASS.createBlockData() glass.transformation = Transformation( @@ -107,8 +137,8 @@ class SectorListener( ) glass.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // 3. Top Cauldron - val topCauldronLoc = locCenter.clone().add(0.0, 2.3125, 0.0) + // 3. Top Cauldron (Center + 1.3125y) -> Matches old base+2.3125 + val topCauldronLoc = visualCenter.clone().add(0.0, 1.3125, 0.0) val topCauldron = player.world.spawnEntity(topCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay topCauldron.block = Material.CAULDRON.createBlockData() topCauldron.transformation = Transformation( @@ -119,8 +149,8 @@ class SectorListener( ) topCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // 4. Bottom Cauldron - val botCauldronLoc = locCenter.clone().add(0.0, 0.6875, 0.0) + // 4. Bottom Cauldron (Center - 0.3125y) -> Matches old base+0.6875 + val botCauldronLoc = visualCenter.clone().add(0.0, -0.3125, 0.0) val botCauldron = player.world.spawnEntity(botCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay botCauldron.block = Material.CAULDRON.createBlockData() botCauldron.transformation = Transformation( @@ -131,8 +161,8 @@ class SectorListener( ) botCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // Spawn Shulker (Hitbox) - val shulkerLoc = loc.clone().add(0.5, 1.0, 0.5) // Y+1, sits in space 1-2. + // Spawn Shulker (Hitbox) at Center + val shulkerLoc = visualCenter.clone() // Center is exactly y+1 from base val shulker = player.world.spawnEntity(shulkerLoc, EntityType.SHULKER) as Shulker shulker.setAI(false) shulker.isInvisible = true @@ -146,16 +176,20 @@ class SectorListener( // Tag Shulker with Sector ID shulker.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) - // Place Blocks + // Place Blocks (Base and Top) + // Base is at center.y - 1 (The placed block) + // Top is at center.y + 1 + block.type = Material.DEEPSLATE_TILE_SLAB val bottomSlab = block.blockData as Slab bottomSlab.type = Slab.Type.BOTTOM block.blockData = bottomSlab - above2.type = Material.DEEPSLATE_TILE_SLAB - val topSlab = above2.blockData as Slab + val topBlock = centerLoc.clone().add(0.0, 1.0, 0.0).block + topBlock.type = Material.DEEPSLATE_TILE_SLAB + val topSlab = topBlock.blockData as Slab topSlab.type = Slab.Type.TOP - above2.blockData = topSlab + topBlock.blockData = topSlab player.sendMessage(Component.text("Sector Core placed!", NamedTextColor.GREEN)) } @@ -175,80 +209,81 @@ class SectorListener( val damage = event.finalDamage val maxHealth = entity.getAttribute(Attribute.MAX_HEALTH)?.value ?: 1000.0 - val newHp = sectorService.reduceHealth(sectorId, damage.toInt()) - // Cancel the event so it doesn't actually die in vanilla terms, // OR let it happen but force health reset. // Cancelling prevents the red flash in some versions/cases, so setting damage to 0 is often better // if we want the effect. But resetting health is robust. - // Let's reset health. - entity.health = maxHealth - entity.noDamageTicks = 0 + val oldHp = sectorService.getHealth(sectorId) ?: return // Get current health from service + val newHp = sectorService.reduceHealth(sectorId, damage.toInt()) + + entity.health = maxHealth + entity.noDamageTicks = 0 // Disable invulnerability - if (newHp != null) { - val loc = entity.location - val world = entity.world + if (newHp != null) { + val loc = entity.location + val world = entity.world + + // Sound Logic + val threshold = 100 // 10% of 1000 + val crossedThreshold = (oldHp / threshold) > (newHp / threshold) - // Effects - world.playSound(loc, Sound.BLOCK_ANVIL_PLACE, 1.0f, 0.5f) // Heavy metallic sound - world.spawnParticle(Particle.BLOCK, loc.add(0.0, 0.5, 0.0), 20, 0.3, 0.3, 0.3, Material.BEDROCK.createBlockData()) - - // Action Bar Display - if (event is EntityDamageByEntityEvent && event.damager is Player) { - val player = event.damager as Player - val percent = newHp.toDouble() / maxHealth.toDouble() - val color = when { - percent > 0.5 -> NamedTextColor.GREEN - percent > 0.2 -> NamedTextColor.YELLOW - else -> NamedTextColor.RED - } - - val progressBar = createProgressBar(newHp, maxHealth.toInt(), color) - - val message = Component.text() - .append(Component.text("Sector Core ", NamedTextColor.GOLD)) - .append(progressBar) - .append(Component.text(" ")) - .append(Component.text(newHp, color)) - .append(Component.text("/", NamedTextColor.GRAY)) - .append(Component.text(maxHealth.toInt(), NamedTextColor.GRAY)) - .build() - - player.sendActionBar(message) - } - - if (newHp <= 0) { - // Destruction - entity.remove() - - // Remove other visuals in chunk - val chunk = entity.chunk - chunk.entities.forEach { ent -> - val sId = ent.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) - if (sId == sectorId) ent.remove() - } - - // Remove blocks. - // We know Shulker is at x, y, z. - // Base is y-1, Top is y+1 - val loc = entity.location - val world = entity.world - val x = loc.blockX - val y = loc.blockY - val z = loc.blockZ - - val base = world.getBlockAt(x, y - 1, z) - val top = world.getBlockAt(x, y + 1, z) - - if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR - if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR - - world.dropItemNaturally(loc, org.bukkit.inventory.ItemStack(Material.DEEPSLATE_TILE_SLAB, 2)) - - // Maybe delete from DB or mark as destroyed? - // For now, valid destruction. - } + if (crossedThreshold) { + world.playSound(loc, Sound.BLOCK_ANVIL_PLACE, 1.0f, 0.5f) + } else { + world.playSound(loc, Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f) } + + world.spawnParticle(Particle.BLOCK, loc.add(0.0, 0.5, 0.0), 20, 0.3, 0.3, 0.3, Material.BEDROCK.createBlockData()) + + if (event is EntityDamageByEntityEvent && event.damager is Player) { + val player = event.damager as Player + + val percent = newHp.toDouble() / maxHealth + val color = when { + percent > 0.5 -> NamedTextColor.GREEN + percent > 0.2 -> NamedTextColor.YELLOW + else -> NamedTextColor.RED + } + + val progressBar = createProgressBar(newHp, maxHealth.toInt(), color) + + val message = Component.text() + .append(Component.text("Sector Core ", NamedTextColor.GOLD)) + .append(progressBar) + .append(Component.text(" ")) + .append(Component.text(newHp, color)) + .append(Component.text("/", NamedTextColor.GRAY)) + .append(Component.text(maxHealth.toInt(), NamedTextColor.GRAY)) + .build() + + player.sendActionBar(message) + } + + if (newHp <= 0) { + // Destruction + entity.remove() + + // Remove other visuals in chunk + val chunk = entity.chunk + chunk.entities.forEach { ent -> + val sId = ent.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) + if (sId == sectorId) ent.remove() + } + + // Remove blocks. + val x = loc.blockX + val y = loc.blockY + val z = loc.blockZ + + val base = world.getBlockAt(x, y - 1, z) + val top = world.getBlockAt(x, y + 1, z) + + if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR + if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR + + world.dropItemNaturally(loc, org.bukkit.inventory.ItemStack(Material.DEEPSLATE_TILE_SLAB, 2)) + } + } } private fun createProgressBar(current: Int, max: Int, color: NamedTextColor): Component { diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt index a48b5d1..2ec7dfc 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt @@ -18,10 +18,24 @@ class SectorService(private val database: Database) { private val hpCache = ConcurrentHashMap() private val dirtySet = ConcurrentHashMap.newKeySet() + private val sectorsCache = ConcurrentHashMap() fun init() { transaction(database) { SchemaUtils.createMissingTablesAndColumns(SectorsTable) + SectorsTable.selectAll().forEach { + val id = it[SectorsTable.id] + val sector = Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + it[SectorsTable.hp] + ) + sectorsCache[id] = sector + } } } @@ -37,42 +51,41 @@ class SectorService(private val database: Database) { }[SectorsTable.id] hpCache[id] = 1000 - Sector(id, ownerActorId, world, x, y, z, 1000) + val sector = Sector(id, ownerActorId, world, x, y, z, 1000) + sectorsCache[id] = sector + sector } } fun getSectorAt(world: String, x: Int, y: Int, z: Int): Sector? { - return transaction(database) { - SectorsTable.selectAll() - .andWhere { SectorsTable.world eq world } - .andWhere { SectorsTable.x eq x } - .andWhere { SectorsTable.y eq y } - .andWhere { SectorsTable.z eq z } - .map { - Sector( - it[SectorsTable.id], - it[SectorsTable.ownerActorId], - it[SectorsTable.world], - it[SectorsTable.x], - it[SectorsTable.y], - it[SectorsTable.z], - it[SectorsTable.hp] - ) - }.singleOrNull() + // Optimized to use cache + return sectorsCache.values.firstOrNull { + it.world == world && it.x == x && it.y == y && it.z == z } } + fun getHealth(id: Int): Int? { + // Try get from cache + var currentHp = hpCache[id] + + // If not in cache, load from DB + if (currentHp == null) { + val sector = sectorsCache[id] ?: return null + + currentHp = sector.hp + hpCache[id] = currentHp + } + return currentHp + } + fun reduceHealth(id: Int, amount: Int): Int? { // Try get from cache var currentHp = hpCache[id] - // If not in cache, load from DB + // If not in cache, try to init from sectorsCache if (currentHp == null) { - val sector = transaction(database) { - SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull() - } ?: return null // Not found - - currentHp = sector[SectorsTable.hp] + val sector = sectorsCache[id] ?: return null + currentHp = sector.hp hpCache[id] = currentHp } @@ -122,10 +135,32 @@ class SectorService(private val database: Database) { if (sector != null) { hpCache.remove(id) dirtySet.remove(id) + sectorsCache.remove(id) } return sector } + + fun isSectorBlock(world: String, x: Int, y: Int, z: Int): Boolean { + // Sector blocks are at center.y - 1 (Base) and center.y + 1 (Top) + // Center is at sector.y + return sectorsCache.values.any { + it.world == world && + it.x == x && + it.z == z && + (it.y - 1 == y || it.y + 1 == y) + } + } + + fun isSectorArea(world: String, x: Int, y: Int, z: Int): Boolean { + // Check 3x3x3 around center (sector.x, sector.y, sector.z) + return sectorsCache.values.any { + it.world == world && + x >= it.x - 1 && x <= it.x + 1 && + y >= it.y - 1 && y <= it.y + 1 && + z >= it.z - 1 && z <= it.z + 1 + } + } fun exists(id: Int): Boolean { if (hpCache.containsKey(id)) return true @@ -135,6 +170,25 @@ class SectorService(private val database: Database) { } } + fun getAllSectorsList(): List { + return transaction(database) { + SectorsTable.selectAll().map { + val id = it[SectorsTable.id] + val cachedHp = hpCache[id] + val hp = cachedHp ?: it[SectorsTable.hp] + Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + hp + ) + } + } + } + fun getAllSectors(world: String): List { return transaction(database) { SectorsTable.selectAll() diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/task/SectorRotationTask.kt b/src/main/kotlin/net/hareworks/hcu/landsector/task/SectorRotationTask.kt new file mode 100644 index 0000000..6d87210 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/landsector/task/SectorRotationTask.kt @@ -0,0 +1,88 @@ +package net.hareworks.hcu.landsector.task + +import net.hareworks.hcu.landsector.LandSectorPlugin +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.BlockDisplay +import org.bukkit.entity.EntityType +import org.bukkit.persistence.PersistentDataType +import org.bukkit.scheduler.BukkitRunnable +import org.bukkit.util.Transformation +import org.joml.Quaternionf +import org.joml.Vector3f +import kotlin.math.PI + +class SectorRotationTask(private val plugin: LandSectorPlugin) : BukkitRunnable() { + + private val sectorKey = NamespacedKey(plugin, "sector_id") + private val scale = Vector3f(0.5f, 0.5f, 0.5f) + private val centerOffset = Vector3f(0.25f, 0.25f, 0.25f) // Scale 0.5 * Model Center (0.5, 0.5, 0.5) + + override fun run() { + val sectors = plugin.sectorService?.getAllSectorsList() ?: return + // Bukkit.getLogger().info("Task running. Sectors count: ${sectors.size}") + + sectors.forEach { sector -> + val world = Bukkit.getWorld(sector.world) ?: return@forEach + // Simple check if chunk is loaded to avoid loading chunks + val chunkX = sector.x shr 4 + val chunkZ = sector.z shr 4 + + if (!world.isChunkLoaded(chunkX, chunkZ)) { + // Bukkit.getLogger().info("Chunk not loaded for sector ${sector.id}") + return@forEach + } + + // Check entities at the core location + val centerLoc = org.bukkit.Location(world, sector.x + 0.5, sector.y + 0.5, sector.z + 0.5) + + // Search radius expanded to 2.0 to ensure hit + val entities = world.getNearbyEntities(centerLoc, 2.0, 2.0, 2.0) + + // Bukkit.getLogger().info("Checking sector ${sector.id} at $centerLoc. Found entities: ${entities.size}") + + entities.forEach { entity -> + if (entity.type == EntityType.BLOCK_DISPLAY && entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val bd = entity as BlockDisplay + // Check if it's the command block + if (bd.block.material == Material.COMMAND_BLOCK) { + // Bukkit.getLogger().info("Rotater found target: ${bd.uniqueId}") + updateRotation(bd) + } + } + } + } + } + + private fun updateRotation(bd: BlockDisplay) { + // Continuous rotation based on time: 1 full rotation every 4 seconds + val periodMs = 4000L + val time = System.currentTimeMillis() + val phase = (time % periodMs).toDouble() / periodMs.toDouble() + val angle = (phase * 2 * Math.PI).toFloat() // 0 to 2PI + + // Log for debug (once per 100 ticks approx to avoid spam? No, just once to verify) + // Bukkit.getLogger().info("Rotating BD: angle=$angle") + + // Rotation around Y axis + val rot = Quaternionf().rotateY(angle) + + val centerOffset = Vector3f(0.25f, 0.25f, 0.25f) // The center in Scaled space + // T = - (R * C) + val rotatedOffset = Vector3f(centerOffset).rotate(rot) + val newTranslation = Vector3f(rotatedOffset).negate() + + val newTrans = Transformation( + newTranslation, + rot, + scale, + Quaternionf(0f, 0f, 0f, 1f) + ) + + // Interpolation settings + bd.interpolationDuration = 3 // Short duration to match high frequency update + bd.interpolationDelay = -1 // Start immediately + bd.transformation = newTrans + } +}