package semmiedev.disc_jockey; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.block.Block; import net.minecraft.block.BlockState; import net.minecraft.block.Blocks; import net.minecraft.block.enums.Instrument; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.hud.ChatHud; import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.network.PlayerListEntry; import net.minecraft.client.world.ClientWorld; import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; import net.minecraft.network.packet.c2s.play.PlayerMoveC2SPacket; import net.minecraft.state.property.Properties; import net.minecraft.text.Text; import net.minecraft.util.Formatting; import net.minecraft.util.Hand; import net.minecraft.util.Pair; import net.minecraft.util.hit.BlockHitResult; import net.minecraft.util.math.*; import net.minecraft.world.GameMode; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; public class SongPlayer implements ClientTickEvents.StartWorldTick { private static boolean warned; public boolean running; public Song song; private int index; private double tick; // Aka song position private HashMap> noteBlocks = null; public boolean tuned; private long lastPlaybackTickAt = -1L; // Used to check and enforce packet rate limits to not get kicked private long last100MsSpanAt = -1L; private int last100MsSpanEstimatedPackets = 0; // At how many packets/100ms should the player just reduce / stop sending packets for a while final private int last100MsReducePacketsAfter = 300 / 10, last100MsStopPacketsAfter = 450 / 10; // If higher than current millis, don't send any packets of this kind (temp disable) private long reducePacketsUntil = -1L, stopPacketsUntil = -1L; // Use to limit swings and look to only each tick. More will not be visually visible anyway due to interpolation private long lastLookSentAt = -1L, lastSwingSentAt = -1L; // The thread executing the tickPlayback method private Thread playbackThread = null; public long playbackLoopDelay = 5; // Just for external debugging purposes public HashMap missingInstrumentBlocks = new HashMap<>(); public float speed = 1.0f; // Toy private long lastInteractAt = -1; private float availableInteracts = 8; private int tuneInitialUntunedBlocks = -1; private HashMap> notePredictions = new HashMap<>(); public boolean didSongReachEnd = false; public SongPlayer() { Main.TICK_LISTENERS.add(this); } public @NotNull HashMap instrumentMap = new HashMap<>(); // Toy public synchronized void startPlaybackThread() { this.playbackThread = new Thread(() -> { Thread ownThread = this.playbackThread; while(ownThread == this.playbackThread) { try { // Accuracy doesn't really matter at this precision imo Thread.sleep(playbackLoopDelay); }catch (Exception ex) { ex.printStackTrace(); } tickPlayback(); } }); this.playbackThread.start(); } public synchronized void stopPlaybackThread() { this.playbackThread = null; // Should stop on its own then } public synchronized void start(Song song) { if (!Main.config.hideWarning && !warned) { MinecraftClient.getInstance().inGameHud.getChatHud().addMessage(Text.translatable("disc_jockey.warning").formatted(Formatting.BOLD, Formatting.RED)); warned = true; return; } if (running) stop(); this.song = song; //Main.LOGGER.info("Song length: " + song.length + " and tempo " + song.tempo); //Main.TICK_LISTENERS.add(this); if(this.playbackThread == null) startPlaybackThread(); running = true; lastPlaybackTickAt = System.currentTimeMillis(); last100MsSpanAt = System.currentTimeMillis(); last100MsSpanEstimatedPackets = 0; reducePacketsUntil = -1L; stopPacketsUntil = -1L; lastLookSentAt = -1L; lastSwingSentAt = -1L; missingInstrumentBlocks.clear(); didSongReachEnd = false; } public synchronized void stop() { //MinecraftClient.getInstance().send(() -> Main.TICK_LISTENERS.remove(this)); stopPlaybackThread(); running = false; index = 0; tick = 0; noteBlocks = null; notePredictions.clear(); tuned = false; tuneInitialUntunedBlocks = -1; lastPlaybackTickAt = -1L; last100MsSpanAt = -1L; last100MsSpanEstimatedPackets = 0; reducePacketsUntil = -1L; stopPacketsUntil = -1L; lastLookSentAt = -1L; lastSwingSentAt = -1L; didSongReachEnd = false; // Change after running stop() if actually ended cleanly } public synchronized void tickPlayback() { if (!running) { lastPlaybackTickAt = -1L; last100MsSpanAt = -1L; return; } long previousPlaybackTickAt = lastPlaybackTickAt; lastPlaybackTickAt = System.currentTimeMillis(); if(last100MsSpanAt != -1L && System.currentTimeMillis() - last100MsSpanAt >= 100) { last100MsSpanEstimatedPackets = 0; last100MsSpanAt = System.currentTimeMillis(); }else if (last100MsSpanAt == -1L) { last100MsSpanAt = System.currentTimeMillis(); last100MsSpanEstimatedPackets = 0; } if(noteBlocks != null && tuned) { while (running) { MinecraftClient client = MinecraftClient.getInstance(); GameMode gameMode = client.interactionManager == null ? null : client.interactionManager.getCurrentGameMode(); // In the best case, gameMode would only be queried in sync Ticks, no here if (gameMode == null || !gameMode.isSurvivalLike()) { client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.invalid_game_mode", gameMode.getTranslatableName()).formatted(Formatting.RED)); stop(); return; } long note = song.notes[index]; final long now = System.currentTimeMillis(); if ((short)note <= Math.round(tick)) { BlockPos blockPos = noteBlocks.get(Note.INSTRUMENTS[(byte)(note >> Note.INSTRUMENT_SHIFT)]).get((byte)(note >> Note.NOTE_SHIFT)); if (!canInteractWith(client.player, blockPos)) { stop(); client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.to_far").formatted(Formatting.RED)); return; } Vec3d unit = Vec3d.ofCenter(blockPos, 0.5).subtract(client.player.getEyePos()).normalize(); if((lastLookSentAt == -1L || now - lastLookSentAt >= 50) && last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { client.getNetworkHandler().sendPacket(new PlayerMoveC2SPacket.LookAndOnGround(MathHelper.wrapDegrees((float) (MathHelper.atan2(unit.z, unit.x) * 57.2957763671875) - 90.0f), MathHelper.wrapDegrees((float) (-(MathHelper.atan2(unit.y, Math.sqrt(unit.x * unit.x + unit.z * unit.z)) * 57.2957763671875))), true)); last100MsSpanEstimatedPackets++; lastLookSentAt = now; }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); } if(last100MsSpanEstimatedPackets < last100MsStopPacketsAfter && (stopPacketsUntil == -1L || stopPacketsUntil < now)) { // TODO: 5/30/2022 Check if the block needs tuning //client.interactionManager.attackBlock(blockPos, Direction.UP); client.player.networkHandler.sendPacket(new PlayerActionC2SPacket(PlayerActionC2SPacket.Action.START_DESTROY_BLOCK, blockPos, Direction.UP, 0)); last100MsSpanEstimatedPackets++; }else if(last100MsSpanEstimatedPackets >= last100MsStopPacketsAfter) { Main.LOGGER.info("Stopping all packets for a bit!"); stopPacketsUntil = Math.max(stopPacketsUntil, now + 250); reducePacketsUntil = Math.max(reducePacketsUntil, now + 10000); } if(last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { client.player.networkHandler.sendPacket(new PlayerActionC2SPacket(PlayerActionC2SPacket.Action.ABORT_DESTROY_BLOCK, blockPos, Direction.UP, 0)); last100MsSpanEstimatedPackets++; }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); } if((lastSwingSentAt == -1L || now - lastSwingSentAt >= 50) &&last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { client.executeSync(() -> client.player.swingHand(Hand.MAIN_HAND)); lastSwingSentAt = now; last100MsSpanEstimatedPackets++; }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); } index++; if (index >= song.notes.length) { stop(); didSongReachEnd = true; break; } } else { break; } } if(running) { // Might not be running anymore (prevent small offset on song, even if that is not played anymore) long elapsedMs = previousPlaybackTickAt != -1L && lastPlaybackTickAt != -1L ? lastPlaybackTickAt - previousPlaybackTickAt : (16); // Assume 16ms if unknown tick += song.millisecondsToTicks(elapsedMs) * speed; } } } // TODO: 6/2/2022 Play note blocks every song tick, instead of every tick. That way the song will sound better // 11/1/2023 Playback now done in separate thread. Not ideal but better especially when FPS are low. @Override public void onStartTick(ClientWorld world) { MinecraftClient client = MinecraftClient.getInstance(); if(world == null || client.world == null || client.player == null) return; if(song == null || !running) return; // Clear outdated note predictions ArrayList outdatedPredictions = new ArrayList<>(); for(Map.Entry> entry : notePredictions.entrySet()) { if(entry.getValue().getRight() < System.currentTimeMillis()) outdatedPredictions.add(entry.getKey()); } for(BlockPos outdatedPrediction : outdatedPredictions) notePredictions.remove(outdatedPrediction); if (noteBlocks == null) { noteBlocks = new HashMap<>(); ClientPlayerEntity player = client.player; // Create list of available noteblock positions per used instrument HashMap> noteblocksForInstrument = new HashMap<>(); for(Instrument instrument : Instrument.values()) noteblocksForInstrument.put(instrument, new ArrayList<>()); final Vec3d playerPos = player.getEyePos(); final int[] orderedOffsets = new int[] { 0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5, -6, 6, -7, 7 }; for(Instrument instrument : noteblocksForInstrument.keySet().toArray(new Instrument[0])) { for (int y : orderedOffsets) { for (int x : orderedOffsets) { for (int z : orderedOffsets) { Vec3d vec3d = playerPos.add(x, y, z); BlockPos blockPos = new BlockPos(MathHelper.floor(vec3d.x), MathHelper.floor(vec3d.y), MathHelper.floor(vec3d.z)); if (!canInteractWith(player, blockPos)) continue; BlockState blockState = world.getBlockState(blockPos); if (!blockState.isOf(Blocks.NOTE_BLOCK) || !world.isAir(blockPos.up())) continue; if (blockState.get(Properties.INSTRUMENT) == instrument) noteblocksForInstrument.get(instrument).add(blockPos); } } } } // Remap instruments for funzies if(!instrumentMap.isEmpty()) { HashMap> newNoteblocksForInstrument = new HashMap<>(); for(Instrument orig : noteblocksForInstrument.keySet()) { newNoteblocksForInstrument.put(orig, noteblocksForInstrument.getOrDefault(instrumentMap.getOrDefault(orig, orig), new ArrayList<>())); } noteblocksForInstrument = newNoteblocksForInstrument; } // Find fitting noteblocks with the least amount of adjustments required (to reduce tuning time) ArrayList capturedNotes = new ArrayList<>(); for(Note note : song.uniqueNotes) { ArrayList availableBlocks = noteblocksForInstrument.get(note.instrument); BlockPos bestBlockPos = null; int bestBlockTuningSteps = Integer.MAX_VALUE; for(BlockPos blockPos : availableBlocks) { int wantedNote = note.note; int currentNote = client.world.getBlockState(blockPos).get(Properties.NOTE); int tuningSteps = wantedNote >= currentNote ? wantedNote - currentNote : (25 - currentNote) + wantedNote; if(tuningSteps < bestBlockTuningSteps) { bestBlockPos = blockPos; bestBlockTuningSteps = tuningSteps; } } if(bestBlockPos != null) { capturedNotes.add(note); availableBlocks.remove(bestBlockPos); getNotes(note.instrument).put(note.note, bestBlockPos); } // else will be a missing note } ArrayList missingNotes = new ArrayList<>(song.uniqueNotes); missingNotes.removeAll(capturedNotes); if (!missingNotes.isEmpty()) { ChatHud chatHud = MinecraftClient.getInstance().inGameHud.getChatHud(); chatHud.addMessage(Text.translatable(Main.MOD_ID+".player.invalid_note_blocks").formatted(Formatting.RED)); HashMap missing = new HashMap<>(); for (Note note : missingNotes) { Block block = Note.INSTRUMENT_BLOCKS.get(instrumentMap.getOrDefault(note.instrument, note.instrument)); Integer got = missing.get(block); if (got == null) got = 0; missing.put(block, got + 1); } missingInstrumentBlocks = missing; missing.forEach((block, integer) -> chatHud.addMessage(Text.literal(block.getName().getString()+" × "+integer).formatted(Formatting.RED))); stop(); } } else if (!tuned) { //tuned = true; int ping = 0; { PlayerListEntry playerListEntry; if (client.getNetworkHandler() != null && (playerListEntry = client.getNetworkHandler().getPlayerListEntry(client.player.getGameProfile().getId())) != null) ping = playerListEntry.getLatency(); } if(lastInteractAt != -1L) { // Paper allows 8 interacts per 300 ms availableInteracts += ((System.currentTimeMillis() - lastInteractAt) / (310.0 / 8.0)); availableInteracts = Math.min(8f, Math.max(0f, availableInteracts)); }else { availableInteracts = 8f; lastInteractAt = System.currentTimeMillis(); } int fullyTunedBlocks = 0; HashMap untunedNotes = new HashMap<>(); for (Note note : song.uniqueNotes) { if(noteBlocks == null || noteBlocks.get(note.instrument) == null) continue; BlockPos blockPos = noteBlocks.get(note.instrument).get(note.note); if(blockPos == null) continue; BlockState blockState = world.getBlockState(blockPos); int assumedNote = notePredictions.containsKey(blockPos) ? notePredictions.get(blockPos).getLeft() : blockState.get(Properties.NOTE); if (blockState.contains(Properties.NOTE)) { if(assumedNote == note.note && blockState.get(Properties.NOTE) == note.note) fullyTunedBlocks++; if (assumedNote != note.note) { if (!canInteractWith(client.player, blockPos)) { stop(); client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.to_far").formatted(Formatting.RED)); return; } untunedNotes.put(blockPos, blockState.get(Properties.NOTE)); } } else { noteBlocks = null; break; } } if(tuneInitialUntunedBlocks == -1 || tuneInitialUntunedBlocks < untunedNotes.size()) tuneInitialUntunedBlocks = untunedNotes.size(); if(untunedNotes.isEmpty() && fullyTunedBlocks == song.uniqueNotes.size()) { // Wait roundrip + 100ms before considering tuned after changing notes (in case the server rejects an interact) if(lastInteractAt == -1 || System.currentTimeMillis() - lastInteractAt >= ping * 2 + 100) { tuned = true; tuneInitialUntunedBlocks = -1; } } BlockPos lastBlockPos = null; int lastTunedNote = Integer.MIN_VALUE; float roughTuneProgress = 1 - (untunedNotes.size() / Math.max(tuneInitialUntunedBlocks + 0f, 1f)); while(availableInteracts >= 1f && untunedNotes.size() > 0) { BlockPos blockPos = null; int searches = 0; while(blockPos == null) { searches++; // Find higher note for (Map.Entry entry : untunedNotes.entrySet()) { if (entry.getValue() > lastTunedNote) { blockPos = entry.getKey(); break; } } // Find higher note or equal if (blockPos == null) { for (Map.Entry entry : untunedNotes.entrySet()) { if (entry.getValue() >= lastTunedNote) { blockPos = entry.getKey(); break; } } } // Not found. Reset last note if(blockPos == null) lastTunedNote = Integer.MIN_VALUE; if(blockPos == null && searches > 1) { // Something went wrong. Take any note (one should at least exist here) blockPos = untunedNotes.keySet().toArray(new BlockPos[0])[0]; break; } } if(blockPos == null) return; // Something went very, very wrong! lastTunedNote = untunedNotes.get(blockPos); untunedNotes.remove(blockPos); int assumedNote = notePredictions.containsKey(blockPos) ? notePredictions.get(blockPos).getLeft() : client.world.getBlockState(blockPos).get(Properties.NOTE); notePredictions.put(blockPos, new Pair((assumedNote + 1) % 25, System.currentTimeMillis() + ping * 2 + 100)); client.interactionManager.interactBlock(client.player, Hand.MAIN_HAND, new BlockHitResult(Vec3d.of(blockPos), Direction.UP, blockPos, false)); lastInteractAt = System.currentTimeMillis(); availableInteracts -= 1f; lastBlockPos = blockPos; } if(lastBlockPos != null) { // Turn head into spinning with time and lookup up further the further tuning is progressed //client.getNetworkHandler().sendPacket(new PlayerMoveC2SPacket.LookAndOnGround(((float) (System.currentTimeMillis() % 2000)) * (360f/2000f), (1 - roughTuneProgress) * 180 - 90, true)); client.player.swingHand(Hand.MAIN_HAND); } } } private HashMap getNotes(Instrument instrument) { return noteBlocks.computeIfAbsent(instrument, k -> new HashMap<>()); } // The server limits interacts to 6 Blocks from Player Eye to Block Center private boolean canInteractWith(ClientPlayerEntity player, BlockPos blockPos) { return player.getEyePos().squaredDistanceTo(new Vec3d(blockPos.getX() + 0.5, blockPos.getY() + 0.5, blockPos.getZ() + 0.5)) <= 6.0*6.0; } public double getSongElapsedSeconds() { if(song == null) return 0; return song.ticksToMilliseconds(tick) / 1000; } }