Last active
March 9, 2026 17:11
-
-
Save InfiniteCoder01/701e6f871d46fc21b1875a9fbea96893 to your computer and use it in GitHub Desktop.
Fixing minestom sounds and other sync issues. Not accurate, but good enough for me. Licenced under CC0 (https://creativecommons.org/publicdomain/zero/1.0/), so feel free to use any of this code wherever you want!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| Registers global event node named "player-actions", forwarding many sounds to other players (by default, other players don't even hear footsteps!), | |
| implementing some basic features like applying item attributes, a gamemode switcher, etc. | |
| To use it, just call `PlayerActions.init();` somewhere after MinecraftServer has been initialized. | |
| */ | |
| package events; | |
| import java.time.Duration; | |
| import java.util.*; | |
| import java.util.concurrent.*; | |
| import net.kyori.adventure.sound.Sound; | |
| import net.minestom.server.sound.SoundEvent; | |
| import net.minestom.server.MinecraftServer; | |
| import net.minestom.server.adventure.AdventurePacketConvertor; | |
| import net.minestom.server.component.DataComponents; | |
| import net.minestom.server.coordinate.*; | |
| import net.minestom.server.entity.EntityPose; | |
| import net.minestom.server.entity.EquipmentSlot; | |
| import net.minestom.server.entity.EquipmentSlotGroup; | |
| import net.minestom.server.entity.GameMode; | |
| import net.minestom.server.entity.LivingEntity; | |
| import net.minestom.server.entity.Player; | |
| import net.minestom.server.entity.attribute.AttributeModifier; | |
| import net.minestom.server.event.EventFilter; | |
| import net.minestom.server.event.EventNode; | |
| import net.minestom.server.event.item.EntityEquipEvent; | |
| import net.minestom.server.event.player.*; | |
| import net.minestom.server.instance.block.Block; | |
| import net.minestom.server.instance.block.BlockHandler.PlayerPlacement; | |
| import net.minestom.server.item.ItemStack; | |
| import net.minestom.server.item.component.AttributeList; | |
| import net.minestom.server.item.component.EnchantmentList; | |
| import net.minestom.server.item.enchant.AttributeEffect; | |
| import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket; | |
| import net.minestom.server.network.packet.server.play.BlockBreakAnimationPacket; | |
| import net.minestom.server.timer.Task; | |
| import net.minestom.server.timer.TaskSchedule; | |
| import net.minestom.server.utils.PacketSendingUtils; | |
| import net.minestom.server.utils.block.BlockBreakCalculation; | |
| public class PlayerActions { | |
| private static final Map<Player, PlayerData> playerData = new ConcurrentHashMap<>(); | |
| private static class PlayerData { | |
| boolean lastOnGround = false; | |
| boolean lastInLiquid = false; | |
| Task diggingSound = null; | |
| Task diggingProgressTask = null; | |
| double diggingProgress = 0.0; | |
| double distanceUntilNextSound = 0.0; | |
| double fallHighestY = 0.0; | |
| } | |
| public static void init() { | |
| final var events = MinecraftServer.getGlobalEventHandler().addChild(EventNode.type("player-actions", EventFilter.PLAYER)); | |
| events.addListener(PlayerDisconnectEvent.class, event -> { | |
| final var data = playerData.remove(event.getPlayer()); | |
| if (data != null) clearDigging(data); | |
| }); | |
| events.addListener(PlayerMoveEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var data = playerData.computeIfAbsent(player, p -> new PlayerData()); | |
| final var instance = event.getInstance(); | |
| final var inLiquid = instance.getBlock(player.getPosition()).registry().isLiquid(); | |
| final var inWater = instance.getBlock(player.getPosition()) == Block.WATER; | |
| /// Swim mode | |
| final var head = player.getPosition().add(0.0, player.getBoundingBox().height(), 0.0); | |
| final var swimChange = player.isSprinting() && instance.getBlock(head).registry().isLiquid(); | |
| if (swimChange || event.getNewPosition().y() > player.getPreviousPosition().y()) | |
| player.getPlayerMeta().setSwimming(swimChange); | |
| // Walk & Swim sounds | |
| final var movedThisTick = event.getNewPosition().distance(player.getPreviousPosition()); | |
| data.distanceUntilNextSound -= Math.min(movedThisTick, MinecraftServer.TICK_MS / 250.0); | |
| if (player.getPose() == EntityPose.SWIMMING && data.distanceUntilNextSound <= 0.0) { | |
| if (inWater) { | |
| final var sound = SoundEvent.ENTITY_PLAYER_SWIM; | |
| play(player, player.getPosition(), sound, 0.05f, 1.0f); | |
| } | |
| data.distanceUntilNextSound = 2.0; | |
| } else if (player.isOnGround() && movedThisTick > 0.0 && data.distanceUntilNextSound <= 0.0) { | |
| final var block = event.getInstance().getBlock(event.getNewPosition().sub(0.0, 0.1, 0.0)); | |
| final var sound = block.registry().getBlockSoundType().stepSound(); | |
| play(player, player.getPosition(), sound, 0.15f, 1.0f); | |
| data.distanceUntilNextSound = 0.9; | |
| } else if (!player.isOnGround()) data.distanceUntilNextSound = 0.0; // sprintjumping | |
| // Fall & Splash | |
| final var fallDistance = data.fallHighestY - event.getNewPosition().y(); | |
| if (inLiquid) { | |
| if (inWater && !data.lastInLiquid) | |
| play(player, player.getPosition(), SoundEvent.ENTITY_PLAYER_SPLASH, 0.2f, 1.0f); | |
| data.fallHighestY = event.getNewPosition().y(); | |
| } else if (player.isOnGround() && !data.lastOnGround) { | |
| SoundEvent sound = null; | |
| if (fallDistance > 4.0) | |
| sound = fallDistance > 15.0 ? SoundEvent.ENTITY_PLAYER_BIG_FALL : SoundEvent.ENTITY_PLAYER_SMALL_FALL; | |
| else { | |
| final var block = event.getInstance().getBlock(event.getNewPosition().sub(0.0, 0.1, 0.0)); | |
| sound = block.registry().getBlockSoundType().fallSound(); | |
| } | |
| if (fallDistance > 1.0) | |
| play(player, player.getPosition(), sound, 1.0f, 1.0f); | |
| } | |
| if (!player.isOnGround()) { | |
| data.fallHighestY = Math.max(data.fallHighestY, event.getNewPosition().y()); | |
| } else { | |
| data.fallHighestY = event.getNewPosition().y(); | |
| } | |
| // Update last state | |
| data.lastOnGround = player.isOnGround(); | |
| data.lastInLiquid = inLiquid; | |
| }); | |
| // Digging | |
| events.addListener(PlayerPacketEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var data = playerData.computeIfAbsent(player, p -> new PlayerData()); | |
| if (event.getPacket() instanceof ClientPlayerActionPacket packet) { | |
| if (packet.status() == ClientPlayerActionPacket.Status.STARTED_DIGGING) { | |
| final var block = event.getInstance().getBlock(packet.blockPosition()); | |
| if (block.isAir()) return; | |
| startDigging(player, block, packet.blockPosition().asBlockVec()); | |
| } else if (packet.status() == ClientPlayerActionPacket.Status.CANCELLED_DIGGING) { | |
| PacketSendingUtils.sendGroupedPacket( | |
| player.getInstance().getChunkAt(packet.blockPosition()).getViewers(), | |
| new BlockBreakAnimationPacket(player.getEntityId(), packet.blockPosition(), (byte)-1), | |
| (p) -> p != player | |
| ); | |
| clearDigging(data); | |
| } else if (packet.status() == ClientPlayerActionPacket.Status.FINISHED_DIGGING) { | |
| clearDigging(data); | |
| } | |
| } | |
| }); | |
| events.addListener(PlayerBlockBreakEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var data = playerData.get(player); | |
| if (data != null) clearDigging(data); // TODO: Anticheat (actually, there is a comment in minestom's PlayerListener about it, so might as well do a PR) | |
| }); | |
| // Block placement sound | |
| events.addListener(PlayerBlockPlaceEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var block = event.getBlock(); | |
| if (block.isAir()) return; | |
| final var sound = block.registry().getBlockSoundType().placeSound(); | |
| play(player, event.getBlockPosition().asPos().add(0.5), sound, 1.0f, 1.0f); | |
| }); | |
| // Block pickup (TODO: survival & adventure) | |
| events.addListener(PlayerPickBlockEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var block = event.getBlock(); | |
| if (block.isAir()) return; | |
| if (player.getGameMode() == GameMode.CREATIVE) | |
| player.setEquipment(EquipmentSlot.MAIN_HAND, ItemStack.of(block.registry().material())); | |
| }); | |
| // Game mode switching | |
| events.addListener(PlayerGameModeRequestEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| if (player.getPermissionLevel() >= 2) { | |
| player.setGameMode(event.getRequestedGameMode()); | |
| } | |
| }); | |
| // Block replacing | |
| events.addListener(PlayerBlockPlaceEvent.class, event -> { | |
| final var instance = event.getInstance(); | |
| final var support = event.getBlockPosition().relative(event.getBlockFace().getOppositeFace()); | |
| final var supportBlock = instance.getBlock(support); | |
| if (supportBlock.registry().isReplaceable() && !event.getBlock().registry().isReplaceable()) { | |
| instance.placeBlock(new PlayerPlacement( | |
| event.getBlock(), | |
| supportBlock, | |
| instance, | |
| support, | |
| event.getPlayer(), | |
| event.getHand(), | |
| event.getBlockFace().getOppositeFace(), | |
| (float)support.x(), | |
| (float)support.y(), | |
| (float)support.z() | |
| )); | |
| event.setBlock(instance.getBlock(event.getBlockPosition())); | |
| event.setDoBlockUpdates(false); | |
| } | |
| }); | |
| // Player teleports to entity in specator mode using hotbar | |
| events.addListener(PlayerSpectateEvent.class, event -> { | |
| final var player = event.getPlayer(); | |
| final var target = event.getTarget(); | |
| if (player.getInstance() != target.getInstance()) | |
| player.setInstance(target.getInstance(), target.getPosition()); | |
| else player.teleport(target.getPosition()); | |
| }); | |
| // Make spectators invisible (FIXME: buggy with invisibility effect) | |
| events.addListener(PlayerGameModeChangeEvent.class, event -> { | |
| event.getPlayer().setInvisible(event.getNewGameMode() == GameMode.SPECTATOR); | |
| }); | |
| // Attributes (should work with and without MinestomPvP) | |
| events.addListener(EntityEquipEvent.class, event -> { | |
| if (event.getEntity() instanceof LivingEntity entity) | |
| updateEquipment(entity, entity.getEquipment(event.getSlot()), event.getEquippedItem(), event.getSlot()); | |
| }); | |
| // Required because changing held slot doesn't trigger equip event | |
| events.addListener(PlayerChangeHeldSlotEvent.class, event -> updateEquipment( | |
| event.getPlayer(), | |
| event.getItemInOldSlot(), | |
| event.getItemInNewSlot(), | |
| EquipmentSlot.MAIN_HAND | |
| )); | |
| } | |
| private static void startDigging(Player player, Block block, BlockVec pos) { | |
| final var data = playerData.computeIfAbsent(player, p -> new PlayerData()); | |
| clearDigging(data); | |
| final var sound = block.registry().getBlockSoundType().hitSound(); | |
| data.diggingSound = MinecraftServer.getSchedulerManager() | |
| .buildTask(() -> play(player, pos.asPos().add(0.5, 0.5, 0.5), sound, 0.5f, 0.5f)) | |
| .repeat(Duration.ofMillis(250)) | |
| .schedule(); | |
| final var breakTicks = BlockBreakCalculation.breakTicks(block, player); | |
| final var damagePerTick = breakTicks < 0 ? 0.0 : 1.0 / breakTicks; | |
| data.diggingProgress = 0.0; | |
| data.diggingProgressTask = MinecraftServer.getSchedulerManager() | |
| .buildTask(new Runnable() { | |
| @Override | |
| public void run() { | |
| data.diggingProgress += damagePerTick; | |
| PacketSendingUtils.sendGroupedPacket( | |
| player.getInstance().getChunkAt(pos).getViewers(), | |
| new BlockBreakAnimationPacket(player.getEntityId(), pos, (byte)(data.diggingProgress * 10 - 1)), | |
| (p) -> p != player | |
| ); | |
| } | |
| }) | |
| .repeat(TaskSchedule.tick(1)) | |
| .schedule(); | |
| } | |
| private static void clearDigging(PlayerData data) { | |
| if (data.diggingSound != null) { | |
| data.diggingSound.cancel(); | |
| data.diggingSound = null; | |
| } | |
| if (data.diggingProgressTask != null) { | |
| data.diggingProgressTask.cancel(); | |
| data.diggingProgressTask = null; | |
| } | |
| data.diggingProgress = 0.0; | |
| } | |
| private static void updateEquipment(LivingEntity entity, ItemStack oldItem, ItemStack newItem, EquipmentSlot slot) { | |
| // Remove old modifiers | |
| for (final var modifier : getItemAttrs(oldItem) | |
| .modifiers()) { | |
| if (!modifier.slot().contains(slot)) continue; | |
| entity.getAttribute(modifier.attribute()).removeModifier(modifier.modifier()); | |
| } | |
| /// Apply new modifiers | |
| for (final var modifier : getItemAttrs(newItem) | |
| .modifiers()) { | |
| if (!modifier.slot().contains(slot)) continue; | |
| entity.getAttribute(modifier.attribute()).addModifier(modifier.modifier()); | |
| } | |
| } | |
| private static AttributeList getItemAttrs(ItemStack item) { | |
| // Applies patches from enchantments | |
| var attrs = item.get(DataComponents.ATTRIBUTE_MODIFIERS, AttributeList.EMPTY); | |
| var slotGroup = EquipmentSlotGroup.ANY; | |
| final var equippable = item.get(DataComponents.EQUIPPABLE); | |
| if (equippable != null) { | |
| for (final var variant : EquipmentSlotGroup.values()) { | |
| if (variant.equipmentSlots().equals(List.of(equippable.slot()))) | |
| slotGroup = variant; | |
| } | |
| } | |
| final var enchantments = item.get(DataComponents.ENCHANTMENTS, EnchantmentList.EMPTY).enchantments(); | |
| final var registry = MinecraftServer.getEnchantmentRegistry(); | |
| for (final var key : enchantments.keySet()) { | |
| final var enchantment = registry.get(key); | |
| final var level = enchantments.get(key); | |
| for (final var effectEntry : enchantment.effects().entrySet()) { | |
| if (!(effectEntry.value() instanceof List effects)) continue; // Yeah, idk how to properly check it. `.component()` returns item_model for some reason. | |
| for (final var effectRaw : effects) { | |
| if (!(effectRaw instanceof AttributeEffect effect)) continue; | |
| attrs = attrs.with(new AttributeList.Modifier( | |
| effect.attribute(), | |
| new AttributeModifier( | |
| effect.id(), | |
| effect.amount().calc(level), | |
| effect.operation() | |
| ), slotGroup | |
| )); | |
| } | |
| } | |
| } | |
| return attrs; | |
| } | |
| private static void play(Player player, Point pos, SoundEvent sound, float volume, float pitch) { | |
| if (sound == null) return; | |
| // Doing this instead of playSoundExcept, because playSoundExcept sends the packet to ALL players, | |
| // whereas this function only sends the packet to players viewing the chunk (if the chunk is loaded) | |
| final var instance = player.getInstance(); | |
| final var chunk = instance.getChunkAt(pos); | |
| final var packet = AdventurePacketConvertor.createSoundPacket( | |
| Sound.sound(sound, Sound.Source.PLAYER, volume, pitch), | |
| pos.x(), pos.y(), pos.z()); | |
| PacketSendingUtils.sendGroupedPacket( | |
| chunk == null ? instance.getPlayers() : chunk.getViewers(), packet, (p) -> { | |
| return p != player; | |
| }); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment