├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src └── main ├── java └── semmiedev │ └── disc_jockey │ ├── BinaryReader.java │ ├── Config.java │ ├── DiscjockeyCommand.java │ ├── Main.java │ ├── ModMenuIntegration.java │ ├── Note.java │ ├── Previewer.java │ ├── Song.java │ ├── SongLoader.java │ ├── SongPlayer.java │ ├── gui │ ├── SongListWidget.java │ ├── hud │ │ └── BlocksOverlay.java │ └── screen │ │ └── DiscJockeyScreen.java │ └── mixin │ └── ClientWorldMixin.java └── resources ├── assets └── disc_jockey │ ├── icon.png │ ├── lang │ └── en_us.json │ └── textures │ └── gui │ └── icons.png ├── disc_jockey.mixins.json └── fabric.mod.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff 2 | .idea/ 3 | 4 | *.iml 5 | *.ipr 6 | *.iws 7 | 8 | # IntelliJ 9 | out/ 10 | # mpeltonen/sbt-idea plugin 11 | .idea_modules/ 12 | 13 | # JIRA plugin 14 | atlassian-ide-plugin.xml 15 | 16 | # Compiled class file 17 | *.class 18 | 19 | # Log file 20 | *.log 21 | 22 | # BlueJ files 23 | *.ctxt 24 | 25 | # Package Files # 26 | *.jar 27 | *.war 28 | *.nar 29 | *.ear 30 | *.zip 31 | *.tar.gz 32 | *.rar 33 | 34 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 35 | hs_err_pid* 36 | 37 | *~ 38 | 39 | # temporary files which can be created if a process still has a handle open of a deleted file 40 | .fuse_hidden* 41 | 42 | # KDE directory preferences 43 | .directory 44 | 45 | # Linux trash folder which might appear on any partition or disk 46 | .Trash-* 47 | 48 | # .nfs files are created when an open file is removed but is still being accessed 49 | .nfs* 50 | 51 | # General 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | 56 | # Icon must end with two \r 57 | Icon 58 | 59 | # Thumbnails 60 | ._* 61 | 62 | # Files that might appear in the root of a volume 63 | .DocumentRevisions-V100 64 | .fseventsd 65 | .Spotlight-V100 66 | .TemporaryItems 67 | .Trashes 68 | .VolumeIcon.icns 69 | .com.apple.timemachine.donotpresent 70 | 71 | # Directories potentially created on remote AFP share 72 | .AppleDB 73 | .AppleDesktop 74 | Network Trash Folder 75 | Temporary Items 76 | .apdisk 77 | 78 | # Windows thumbnail cache files 79 | Thumbs.db 80 | Thumbs.db:encryptable 81 | ehthumbs.db 82 | ehthumbs_vista.db 83 | 84 | # Dump file 85 | *.stackdump 86 | 87 | # Folder config file 88 | [Dd]esktop.ini 89 | 90 | # Recycle Bin used on file shares 91 | $RECYCLE.BIN/ 92 | 93 | # Windows Installer files 94 | *.cab 95 | *.msi 96 | *.msix 97 | *.msm 98 | *.msp 99 | 100 | # Windows shortcuts 101 | *.lnk 102 | 103 | .gradle 104 | build/ 105 | 106 | # Ignore Gradle GUI config 107 | gradle-app.setting 108 | 109 | # Cache of project 110 | .gradletasknamecache 111 | 112 | **/build/ 113 | 114 | # Common working directory 115 | run/ 116 | 117 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 118 | !gradle-wrapper.jar 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Semmieboy_YT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Download the mod at [Modrinth](https://modrinth.com/mod/disc-jockey) or [CurseForge](https://www.curseforge.com/minecraft/mc-mods/disc-jockey) 2 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'fabric-loom' version '1.6-SNAPSHOT' 3 | id 'maven-publish' 4 | } 5 | 6 | version = project.mod_version 7 | group = project.maven_group 8 | 9 | repositories { 10 | maven { url "https://maven.shedaniel.me/" } 11 | maven { url "https://maven.terraformersmc.com/" } 12 | } 13 | 14 | dependencies { 15 | // To change the versions see the gradle.properties file 16 | minecraft "com.mojang:minecraft:${project.minecraft_version}" 17 | mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" 18 | modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" 19 | 20 | // Fabric API. This is technically optional, but you probably want it anyway. 21 | modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" 22 | 23 | include modApi("me.shedaniel.cloth:cloth-config-fabric:15.0.127") { 24 | exclude(group: "net.fabricmc.fabric-api") 25 | } 26 | 27 | modCompileOnly("com.terraformersmc:modmenu:10.0.0-beta.1") 28 | } 29 | 30 | processResources { 31 | inputs.property "version", project.version 32 | filteringCharset "UTF-8" 33 | 34 | filesMatching("fabric.mod.json") { 35 | expand "version": project.version 36 | } 37 | } 38 | 39 | def targetJavaVersion = 21 40 | tasks.withType(JavaCompile).configureEach { 41 | // ensure that the encoding is set to UTF-8, no matter what the system default is 42 | // this fixes some edge cases with special characters not displaying correctly 43 | // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html 44 | // If Javadoc is generated, this must be specified in that task too. 45 | it.options.encoding = "UTF-8" 46 | if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { 47 | it.options.release = targetJavaVersion 48 | } 49 | } 50 | 51 | java { 52 | def javaVersion = JavaVersion.toVersion(targetJavaVersion) 53 | if (JavaVersion.current() < javaVersion) { 54 | toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) 55 | } 56 | archivesBaseName = project.archives_base_name 57 | // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task 58 | // if it is present. 59 | // If you remove this line, sources will not be generated. 60 | withSourcesJar() 61 | } 62 | 63 | jar { 64 | from("LICENSE") { 65 | rename { "${it}_${project.archivesBaseName}" } 66 | } 67 | } 68 | 69 | // configure the maven publication 70 | publishing { 71 | publications { 72 | mavenJava(MavenPublication) { 73 | from components.java 74 | } 75 | } 76 | 77 | // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. 78 | repositories { 79 | // Add repositories to publish to here. 80 | // Notice: This block does NOT have the same function as the block in the top level. 81 | // The repositories here will be used for publishing your artifact, not for 82 | // retrieving dependencies. 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Done to increase the memory available to gradle. 2 | org.gradle.jvmargs=-Xmx1G 3 | # Fabric Properties 4 | # check these on https://modmuss50.me/fabric.html 5 | minecraft_version=1.21 6 | yarn_mappings=1.21+build.2 7 | loader_version=0.15.11 8 | # Mod Properties 9 | mod_version=1.7.0 10 | maven_group=semmiedev 11 | archives_base_name=disc_jockey 12 | # Dependencies 13 | # check this on https://modmuss50.me/fabric.html 14 | fabric_version=0.100.1+1.21 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SemmieDev/Disc-Jockey/875d9ada1b3f46b4de0147b071a63986bc5ddc1e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SemmieDev/Disc-Jockey/875d9ada1b3f46b4de0147b071a63986bc5ddc1e/gradlew -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { 4 | name = 'Fabric' 5 | url = 'https://maven.fabricmc.net/' 6 | } 7 | gradlePluginPortal() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/BinaryReader.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import java.io.EOFException; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.nio.ByteBuffer; 7 | import java.nio.ByteOrder; 8 | 9 | public class BinaryReader { 10 | private final InputStream in; 11 | private final ByteBuffer buffer = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN); 12 | 13 | public BinaryReader(InputStream in) { 14 | this.in = in; 15 | } 16 | 17 | public int readInt() throws IOException { 18 | return buffer.clear().put(readBytes(Integer.BYTES)).rewind().getInt(); 19 | } 20 | 21 | public long readUInt() throws IOException { 22 | return readInt() & 0xFFFFFFFFL; 23 | } 24 | 25 | public int readUShort() throws IOException { 26 | return readShort() & 0xFFFF; 27 | } 28 | 29 | public short readShort() throws IOException { 30 | return buffer.clear().put(readBytes(Short.BYTES)).rewind().getShort(); 31 | } 32 | 33 | public String readString() throws IOException { 34 | return new String(readBytes(readInt())); 35 | } 36 | 37 | public float readFloat() throws IOException { 38 | return buffer.clear().put(readBytes(Float.BYTES)).rewind().getFloat(); 39 | } 40 | 41 | /*private int getStringLength() throws IOException { 42 | int count = 0; 43 | int shift = 0; 44 | boolean more = true; 45 | while (more) { 46 | byte b = (byte) in.read(); 47 | count |= (b & 0x7F) << shift; 48 | shift += 7; 49 | if ((b & 0x80) == 0) { 50 | more = false; 51 | } 52 | } 53 | return count; 54 | }*/ 55 | 56 | public byte readByte() throws IOException { 57 | int b = in.read(); 58 | if (b < 0) throw new EOFException(); 59 | return (byte)(b); 60 | } 61 | 62 | public byte[] readBytes(int length) throws IOException { 63 | byte[] bytes = new byte[length]; 64 | for (int i = 0; i < length; i++) bytes[i] = readByte(); 65 | return bytes; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/Config.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import me.shedaniel.autoconfig.ConfigData; 4 | import me.shedaniel.autoconfig.annotation.ConfigEntry; 5 | 6 | import java.util.ArrayList; 7 | 8 | @me.shedaniel.autoconfig.annotation.Config(name = Main.MOD_ID) 9 | @me.shedaniel.autoconfig.annotation.Config.Gui.Background("textures/block/note_block.png") 10 | public class Config implements ConfigData { 11 | public boolean hideWarning; 12 | @ConfigEntry.Gui.Tooltip(count = 2) public boolean disableAsyncPlayback; 13 | @ConfigEntry.Gui.Tooltip(count = 2) public boolean omnidirectionalNoteBlockSounds = true; 14 | 15 | public enum ExpectedServerVersion { 16 | All, 17 | v1_20_4_Or_Earlier, 18 | v1_20_5_Or_Later; 19 | 20 | @Override 21 | public String toString() { 22 | if(this == All) { 23 | return "All (universal)"; 24 | }else if(this == v1_20_4_Or_Earlier) { 25 | return "≤1.20.4"; 26 | }else if (this == v1_20_5_Or_Later) { 27 | return "≥1.20.5"; 28 | }else { 29 | return super.toString(); 30 | } 31 | } 32 | } 33 | 34 | @ConfigEntry.Gui.EnumHandler(option = ConfigEntry.Gui.EnumHandler.EnumDisplayOption.BUTTON) 35 | @ConfigEntry.Gui.Tooltip(count = 4) 36 | public ExpectedServerVersion expectedServerVersion = ExpectedServerVersion.All; 37 | 38 | @ConfigEntry.Gui.Tooltip(count = 1) 39 | public float delayPlaybackStartBySecs = 0.0f; 40 | 41 | @ConfigEntry.Gui.Excluded 42 | public ArrayList favorites = new ArrayList<>(); 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/DiscjockeyCommand.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import com.mojang.brigadier.CommandDispatcher; 4 | import com.mojang.brigadier.arguments.FloatArgumentType; 5 | import com.mojang.brigadier.arguments.StringArgumentType; 6 | import com.mojang.brigadier.context.CommandContext; 7 | import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; 8 | import net.minecraft.block.enums.NoteBlockInstrument; 9 | import net.minecraft.client.MinecraftClient; 10 | import net.minecraft.command.CommandSource; 11 | import net.minecraft.text.Text; 12 | import org.jetbrains.annotations.Nullable; 13 | import semmiedev.disc_jockey.gui.screen.DiscJockeyScreen; 14 | 15 | import java.util.ArrayList; 16 | import java.util.Arrays; 17 | import java.util.Map; 18 | import java.util.Optional; 19 | 20 | import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; 21 | import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; 22 | 23 | public class DiscjockeyCommand { 24 | 25 | 26 | public static void register(CommandDispatcher commandDispatcher) { 27 | final ArrayList instrumentNames = new ArrayList<>(); 28 | for (NoteBlockInstrument instrument : NoteBlockInstrument.values()) { 29 | instrumentNames.add(instrument.toString().toLowerCase()); 30 | } 31 | final ArrayList instrumentNamesAndAll = new ArrayList<>(instrumentNames); 32 | instrumentNamesAndAll.add("all"); 33 | final ArrayList instrumentNamesAndNothing = new ArrayList<>(instrumentNames); 34 | instrumentNamesAndNothing.add("nothing"); 35 | 36 | commandDispatcher.register( 37 | literal("discjockey") 38 | .executes(context -> { 39 | FabricClientCommandSource source = context.getSource(); 40 | if (!isLoading(context)) { 41 | MinecraftClient client = source.getClient(); 42 | client.send(() -> client.setScreen(new DiscJockeyScreen())); 43 | return 1; 44 | } 45 | return 0; 46 | }) 47 | .then(literal("reload") 48 | .executes(context -> { 49 | if (!isLoading(context)) { 50 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID+".reloading")); 51 | SongLoader.loadSongs(); 52 | return 1; 53 | } 54 | return 0; 55 | }) 56 | ) 57 | .then(literal("play") 58 | .then(argument("song", StringArgumentType.greedyString()) 59 | .suggests((context, builder) -> CommandSource.suggestMatching(SongLoader.SONG_SUGGESTIONS, builder)) 60 | .executes(context -> { 61 | if (!isLoading(context)) { 62 | String songName = StringArgumentType.getString(context, "song"); 63 | Optional song = SongLoader.SONGS.stream().filter(input -> input.displayName.equals(songName)).findAny(); 64 | if (song.isPresent()) { 65 | Main.SONG_PLAYER.start(song.get()); 66 | return 1; 67 | } 68 | context.getSource().sendError(Text.translatable(Main.MOD_ID+".song_not_found", songName)); 69 | return 0; 70 | } 71 | return 0; 72 | }) 73 | ) 74 | ) 75 | .then(literal("stop") 76 | .executes(context -> { 77 | if (Main.SONG_PLAYER.running) { 78 | Main.SONG_PLAYER.stop(); 79 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID+".stopped_playing", Main.SONG_PLAYER.song)); 80 | return 1; 81 | } 82 | context.getSource().sendError(Text.translatable(Main.MOD_ID+".not_playing")); 83 | return 0; 84 | }) 85 | ) 86 | .then(literal("speed") 87 | .then(argument("speed", FloatArgumentType.floatArg(0.0001F, 15.0F)) 88 | .suggests((context, builder) -> CommandSource.suggestMatching(Arrays.asList("0.5", "0.75", "1", "1.25", "1.5", "2"), builder)) 89 | .executes(context -> { 90 | float newSpeed = FloatArgumentType.getFloat(context, "speed"); 91 | Main.SONG_PLAYER.speed = newSpeed; 92 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".speed_changed", Main.SONG_PLAYER.speed)); 93 | return 0; 94 | }) 95 | ) 96 | ) 97 | .then(literal("info") 98 | .executes(context -> { 99 | 100 | if (!Main.SONG_PLAYER.running) { 101 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".info_not_running", Main.SONG_PLAYER.speed)); 102 | return 0; 103 | } 104 | if (!Main.SONG_PLAYER.tuned) { 105 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".info_tuning", Main.SONG_PLAYER.song.displayName, Main.SONG_PLAYER.speed)); 106 | return 0; 107 | }else if(!Main.SONG_PLAYER.didSongReachEnd) { 108 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".info_playing", formatTimestamp((int) Main.SONG_PLAYER.getSongElapsedSeconds()), formatTimestamp((int) Main.SONG_PLAYER.song.getLengthInSeconds()), Main.SONG_PLAYER.song.displayName, Main.SONG_PLAYER.speed)); 109 | return 0; 110 | }else { 111 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".info_finished", Main.SONG_PLAYER.song != null ? Main.SONG_PLAYER.song.displayName : "???", Main.SONG_PLAYER.speed)); 112 | return 0; 113 | } 114 | }) 115 | ) 116 | .then(literal("remapInstruments") 117 | .executes(context -> { 118 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".instrument_info")); 119 | return 0; 120 | }) 121 | .then(literal("map") 122 | .then(argument("originalInstrument", StringArgumentType.word()) 123 | .suggests((context, builder) -> CommandSource.suggestMatching(instrumentNamesAndAll, builder)) 124 | .then(argument("newInstrument", StringArgumentType.word()) 125 | .suggests((context, builder) -> CommandSource.suggestMatching(instrumentNamesAndNothing, builder)) 126 | .executes(context -> { 127 | String originalInstrumentStr = StringArgumentType.getString(context, "originalInstrument"); 128 | String newInstrumentStr = StringArgumentType.getString(context, "newInstrument"); 129 | @Nullable NoteBlockInstrument originalInstrument = null, newInstrument = null; 130 | for(NoteBlockInstrument maybeInstrument : NoteBlockInstrument.values()) { 131 | if(maybeInstrument.toString().equalsIgnoreCase(originalInstrumentStr)) { 132 | originalInstrument = maybeInstrument; 133 | } 134 | if(maybeInstrument.toString().equalsIgnoreCase(newInstrumentStr)) { 135 | newInstrument = maybeInstrument; 136 | } 137 | } 138 | 139 | if(originalInstrument == null && !originalInstrumentStr.equalsIgnoreCase("all")) { 140 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".invalid_instrument", originalInstrumentStr)); 141 | return 0; 142 | } 143 | 144 | if(newInstrument == null && !newInstrumentStr.equalsIgnoreCase("nothing")) { 145 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".invalid_instrument", newInstrumentStr)); 146 | return 0; 147 | } 148 | 149 | // (originalInstrument == null) means: all instruments 150 | // (newInstrument == null) means: nothing (represented by null in hashmap, so no special handling below) 151 | 152 | if(originalInstrument == null) { 153 | // All instruments 154 | for(NoteBlockInstrument instrument : NoteBlockInstrument.values()) { 155 | Main.SONG_PLAYER.instrumentMap.put(instrument, newInstrument); 156 | } 157 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".instrument_mapped_all", newInstrumentStr.toLowerCase())); 158 | }else { 159 | Main.SONG_PLAYER.instrumentMap.put(originalInstrument, newInstrument); 160 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".instrument_mapped", originalInstrumentStr.toLowerCase(), newInstrumentStr.toLowerCase())); 161 | } 162 | return 1; 163 | }) 164 | ) 165 | ) 166 | ) 167 | .then(literal("unmap") 168 | .then(argument("instrument", StringArgumentType.word()) 169 | .suggests((context, builder) -> CommandSource.suggestMatching(instrumentNames, builder)) 170 | .executes(context -> { 171 | String instrumentStr = StringArgumentType.getString(context, "instrument"); 172 | 173 | NoteBlockInstrument instrument = null; 174 | for(NoteBlockInstrument maybeInstrument : NoteBlockInstrument.values()) { 175 | if(maybeInstrument.toString().equalsIgnoreCase(instrumentStr)) { 176 | instrument = maybeInstrument; 177 | break; 178 | } 179 | } 180 | 181 | if(instrument == null) { 182 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".invalid_instrument", instrumentStr)); 183 | return 0; 184 | } 185 | 186 | Main.SONG_PLAYER.instrumentMap.remove(instrument); 187 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".instrument_unmapped", instrumentStr.toLowerCase())); 188 | return 1; 189 | }) 190 | ) 191 | ) 192 | .then(literal("show") 193 | .executes(context -> { 194 | if(Main.SONG_PLAYER.instrumentMap.isEmpty()) { 195 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".no_mapped_instruments")); 196 | return 1; 197 | } 198 | 199 | StringBuilder maps = new StringBuilder(); 200 | for(Map.Entry entry : Main.SONG_PLAYER.instrumentMap.entrySet()) { 201 | if(maps.length() > 0) { 202 | maps.append(", "); 203 | } 204 | maps 205 | .append(entry.getKey().toString().toLowerCase()) 206 | .append("->") 207 | .append(entry.getValue() == null ? "nothing" : entry.getValue().toString().toLowerCase()); 208 | } 209 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".mapped_instruments", maps.toString())); 210 | return 1; 211 | }) 212 | ) 213 | .then(literal("clear") 214 | .executes(context -> { 215 | Main.SONG_PLAYER.instrumentMap.clear(); 216 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".instrument_maps_cleared")); 217 | return 1; 218 | }) 219 | ) 220 | ) 221 | 222 | .then(literal("loop") 223 | .executes(context -> { 224 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".loop_status", Main.SONG_PLAYER.loopSong ? "yes" : "no")); 225 | return 1; 226 | }) 227 | .then(literal("yes") 228 | .executes(context -> { 229 | Main.SONG_PLAYER.loopSong = true; 230 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".loop_enabled")); 231 | return 1; 232 | })) 233 | .then(literal("no") 234 | .executes(context -> { 235 | Main.SONG_PLAYER.loopSong = false; 236 | context.getSource().sendFeedback(Text.translatable(Main.MOD_ID + ".loop_disabled")); 237 | return 1; 238 | })) 239 | ) 240 | ); 241 | } 242 | 243 | private static boolean isLoading(CommandContext context) { 244 | if (SongLoader.loadingSongs) { 245 | context.getSource().sendError(Text.translatable(Main.MOD_ID + ".still_loading")); 246 | SongLoader.showToast = true; 247 | return true; 248 | } 249 | return false; 250 | } 251 | 252 | private static String padZeroes(int number, int length) { 253 | StringBuilder builder = new StringBuilder("" + number); 254 | while(builder.length() < length) 255 | builder.insert(0, '0'); 256 | return builder.toString(); 257 | } 258 | 259 | private static String formatTimestamp(int seconds) { 260 | return padZeroes(seconds / 60, 2) + ":" + padZeroes(seconds % 60, 2); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/Main.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import me.shedaniel.autoconfig.AutoConfig; 4 | import me.shedaniel.autoconfig.ConfigHolder; 5 | import me.shedaniel.autoconfig.serializer.JanksonConfigSerializer; 6 | import net.fabricmc.api.ClientModInitializer; 7 | import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; 8 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 9 | import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; 10 | import net.fabricmc.fabric.api.client.networking.v1.ClientLoginConnectionEvents; 11 | import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; 12 | import net.fabricmc.loader.api.FabricLoader; 13 | import net.minecraft.client.MinecraftClient; 14 | import net.minecraft.client.option.KeyBinding; 15 | import net.minecraft.client.util.InputUtil; 16 | import net.minecraft.client.world.ClientWorld; 17 | import net.minecraft.text.MutableText; 18 | import net.minecraft.text.Text; 19 | import net.minecraft.util.Formatting; 20 | import org.apache.logging.log4j.LogManager; 21 | import org.apache.logging.log4j.Logger; 22 | import org.lwjgl.glfw.GLFW; 23 | import semmiedev.disc_jockey.gui.hud.BlocksOverlay; 24 | import semmiedev.disc_jockey.gui.screen.DiscJockeyScreen; 25 | 26 | import java.io.File; 27 | import java.util.ArrayList; 28 | 29 | public class Main implements ClientModInitializer { 30 | public static final String MOD_ID = "disc_jockey"; 31 | public static final MutableText NAME = Text.literal("Disc Jockey"); 32 | public static final Logger LOGGER = LogManager.getLogger("Disc Jockey"); 33 | public static final ArrayList TICK_LISTENERS = new ArrayList<>(); 34 | public static final Previewer PREVIEWER = new Previewer(); 35 | public static final SongPlayer SONG_PLAYER = new SongPlayer(); 36 | 37 | public static File songsFolder; 38 | public static Config config; 39 | public static ConfigHolder configHolder; 40 | 41 | @Override 42 | public void onInitializeClient() { 43 | configHolder = AutoConfig.register(Config.class, JanksonConfigSerializer::new); 44 | config = configHolder.getConfig(); 45 | 46 | songsFolder = new File(FabricLoader.getInstance().getConfigDir()+File.separator+MOD_ID+File.separator+"songs"); 47 | if (!songsFolder.isDirectory()) songsFolder.mkdirs(); 48 | 49 | SongLoader.loadSongs(); 50 | 51 | KeyBinding openScreenKeyBind = KeyBindingHelper.registerKeyBinding(new KeyBinding(MOD_ID+".key_bind.open_screen", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_J, "key.category."+MOD_ID)); 52 | 53 | ClientTickEvents.START_CLIENT_TICK.register(new ClientTickEvents.StartTick() { 54 | private ClientWorld prevWorld; 55 | 56 | @Override 57 | public void onStartTick(MinecraftClient client) { 58 | if (prevWorld != client.world) { 59 | PREVIEWER.stop(); 60 | SONG_PLAYER.stop(); 61 | } 62 | prevWorld = client.world; 63 | 64 | if (openScreenKeyBind.wasPressed()) { 65 | if (SongLoader.loadingSongs) { 66 | client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".still_loading").formatted(Formatting.RED)); 67 | SongLoader.showToast = true; 68 | } else { 69 | client.setScreen(new DiscJockeyScreen()); 70 | } 71 | } 72 | } 73 | }); 74 | 75 | ClientTickEvents.START_WORLD_TICK.register(world -> { 76 | for (ClientTickEvents.StartWorldTick listener : TICK_LISTENERS) listener.onStartTick(world); 77 | }); 78 | 79 | ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { 80 | DiscjockeyCommand.register(dispatcher); 81 | }); 82 | 83 | ClientLoginConnectionEvents.DISCONNECT.register((handler, client) -> { 84 | PREVIEWER.stop(); 85 | SONG_PLAYER.stop(); 86 | }); 87 | 88 | HudRenderCallback.EVENT.register(BlocksOverlay::render); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/ModMenuIntegration.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import com.terraformersmc.modmenu.api.ConfigScreenFactory; 4 | import com.terraformersmc.modmenu.api.ModMenuApi; 5 | import me.shedaniel.autoconfig.AutoConfig; 6 | 7 | public class ModMenuIntegration implements ModMenuApi { 8 | @Override 9 | public ConfigScreenFactory getModConfigScreenFactory() { 10 | return parent -> AutoConfig.getConfigScreen(Config.class, parent).get(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/Note.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import net.minecraft.block.Block; 4 | import net.minecraft.block.Blocks; 5 | import net.minecraft.block.enums.NoteBlockInstrument; 6 | 7 | import java.util.HashMap; 8 | 9 | public record Note(NoteBlockInstrument instrument, byte note) { 10 | public static final HashMap INSTRUMENT_BLOCKS = new HashMap<>(); 11 | 12 | public static final byte LAYER_SHIFT = Short.SIZE; 13 | public static final byte INSTRUMENT_SHIFT = Short.SIZE * 2; 14 | public static final byte NOTE_SHIFT = Short.SIZE * 2 + Byte.SIZE; 15 | 16 | public static final NoteBlockInstrument[] INSTRUMENTS = new NoteBlockInstrument[]{ 17 | NoteBlockInstrument.HARP, 18 | NoteBlockInstrument.BASS, 19 | NoteBlockInstrument.BASEDRUM, 20 | NoteBlockInstrument.SNARE, 21 | NoteBlockInstrument.HAT, 22 | NoteBlockInstrument.GUITAR, 23 | NoteBlockInstrument.FLUTE, 24 | NoteBlockInstrument.BELL, 25 | NoteBlockInstrument.CHIME, 26 | NoteBlockInstrument.XYLOPHONE, 27 | NoteBlockInstrument.IRON_XYLOPHONE, 28 | NoteBlockInstrument.COW_BELL, 29 | NoteBlockInstrument.DIDGERIDOO, 30 | NoteBlockInstrument.BIT, 31 | NoteBlockInstrument.BANJO, 32 | NoteBlockInstrument.PLING 33 | 34 | }; 35 | 36 | static { 37 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.HARP, Blocks.AIR); 38 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.BASEDRUM, Blocks.STONE); 39 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.SNARE, Blocks.SAND); 40 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.HAT, Blocks.GLASS); 41 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.BASS, Blocks.OAK_PLANKS); 42 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.FLUTE, Blocks.CLAY); 43 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.BELL, Blocks.GOLD_BLOCK); 44 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.GUITAR, Blocks.WHITE_WOOL); 45 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.CHIME, Blocks.PACKED_ICE); 46 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.XYLOPHONE, Blocks.BONE_BLOCK); 47 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.IRON_XYLOPHONE, Blocks.IRON_BLOCK); 48 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.COW_BELL, Blocks.SOUL_SAND); 49 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.DIDGERIDOO, Blocks.PUMPKIN); 50 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.BIT, Blocks.EMERALD_BLOCK); 51 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.BANJO, Blocks.HAY_BLOCK); 52 | INSTRUMENT_BLOCKS.put(NoteBlockInstrument.PLING, Blocks.GLOWSTONE); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/Previewer.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 4 | import net.minecraft.client.MinecraftClient; 5 | import net.minecraft.client.world.ClientWorld; 6 | import net.minecraft.sound.SoundCategory; 7 | import net.minecraft.util.math.Vec3d; 8 | 9 | public class Previewer implements ClientTickEvents.StartWorldTick { 10 | public boolean running; 11 | 12 | private int i; 13 | private float tick; 14 | private Song song; 15 | 16 | public void start(Song song) { 17 | this.song = song; 18 | Main.TICK_LISTENERS.add(this); 19 | running = true; 20 | } 21 | 22 | public void stop() { 23 | MinecraftClient.getInstance().send(() -> Main.TICK_LISTENERS.remove(this)); 24 | running = false; 25 | i = 0; 26 | tick = 0; 27 | } 28 | 29 | @Override 30 | public void onStartTick(ClientWorld world) { 31 | while (running) { 32 | long note = song.notes[i]; 33 | if ((short)note == Math.round(tick)) { 34 | Vec3d pos = MinecraftClient.getInstance().gameRenderer.getCamera().getPos(); 35 | world.playSound(pos.x, pos.y, pos.z, Note.INSTRUMENTS[(byte)(note >> Note.INSTRUMENT_SHIFT)].getSound().value(), SoundCategory.RECORDS, 3, (float)Math.pow(2.0, ((byte)(note >> Note.NOTE_SHIFT) - 12) / 12.0), false); 36 | i++; 37 | if (i >= song.notes.length) { 38 | stop(); 39 | break; 40 | } 41 | } else { 42 | break; 43 | } 44 | } 45 | 46 | tick += song.tempo / 100f / 20f; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/Song.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import semmiedev.disc_jockey.gui.SongListWidget; 4 | 5 | import java.util.ArrayList; 6 | 7 | public class Song { 8 | public final ArrayList uniqueNotes = new ArrayList<>(); 9 | 10 | public long[] notes = new long[0]; 11 | 12 | public short length, height, tempo, loopStartTick; 13 | public String fileName, name, author, originalAuthor, description, displayName; 14 | public byte autoSaving, autoSavingDuration, timeSignature, vanillaInstrumentCount, formatVersion, loop, maxLoopCount; 15 | public int minutesSpent, leftClicks, rightClicks, blocksAdded, blocksRemoved; 16 | public String importFileName; 17 | 18 | public SongListWidget.SongEntry entry; 19 | public String searchableFileName, searchableName; 20 | 21 | @Override 22 | public String toString() { 23 | return displayName; 24 | } 25 | 26 | public double millisecondsToTicks(long milliseconds) { 27 | // From NBS Format: The tempo of the song multiplied by 100 (for example, 1225 instead of 12.25). Measured in ticks per second. 28 | double songSpeed = (tempo / 100.0) / 20.0; // 20 Ticks per second (temp / 100 = 20) would be 1x speed 29 | double oneMsTo20TickFraction = 1.0 / 50.0; 30 | return milliseconds * oneMsTo20TickFraction * songSpeed; 31 | } 32 | 33 | public double ticksToMilliseconds(double ticks) { 34 | double songSpeed = (tempo / 100.0) / 20.0; 35 | double oneMsTo20TickFraction = 1.0 / 50.0; 36 | return ticks / oneMsTo20TickFraction / songSpeed; 37 | } 38 | 39 | public double getLengthInSeconds() { 40 | return ticksToMilliseconds(length) / 1000.0; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/SongLoader.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import net.minecraft.client.MinecraftClient; 4 | import net.minecraft.client.toast.SystemToast; 5 | import net.minecraft.text.Text; 6 | import semmiedev.disc_jockey.gui.SongListWidget; 7 | 8 | import java.io.File; 9 | import java.io.IOException; 10 | import java.nio.file.Files; 11 | import java.util.ArrayList; 12 | import java.util.Arrays; 13 | import java.util.Comparator; 14 | 15 | public class SongLoader { 16 | public static final ArrayList SONGS = new ArrayList<>(); 17 | public static final ArrayList SONG_SUGGESTIONS = new ArrayList<>(); 18 | public static volatile boolean loadingSongs; 19 | public static volatile boolean showToast; 20 | 21 | public static void loadSongs() { 22 | if (loadingSongs) return; 23 | new Thread(() -> { 24 | loadingSongs = true; 25 | SONGS.clear(); 26 | SONG_SUGGESTIONS.clear(); 27 | SONG_SUGGESTIONS.add("Songs are loading, please wait"); 28 | for (File file : Main.songsFolder.listFiles()) { 29 | Song song = null; 30 | try { 31 | song = loadSong(file); 32 | } catch (Exception exception) { 33 | Main.LOGGER.error("Unable to read or parse song {}", file.getName(), exception); 34 | } 35 | if (song != null) SONGS.add(song); 36 | } 37 | for (Song song : SONGS) SONG_SUGGESTIONS.add(song.displayName); 38 | Main.config.favorites.removeIf(favorite -> SongLoader.SONGS.stream().map(song -> song.fileName).noneMatch(favorite::equals)); 39 | 40 | if (showToast && MinecraftClient.getInstance().textRenderer != null) SystemToast.add(MinecraftClient.getInstance().getToastManager(), SystemToast.Type.PACK_LOAD_FAILURE, Main.NAME, Text.translatable(Main.MOD_ID+".loading_done")); 41 | showToast = true; 42 | loadingSongs = false; 43 | }).start(); 44 | } 45 | 46 | public static Song loadSong(File file) throws IOException { 47 | if (file.isFile()) { 48 | BinaryReader reader = new BinaryReader(Files.newInputStream(file.toPath())); 49 | Song song = new Song(); 50 | 51 | song.fileName = file.getName().replaceAll("[\\n\\r]", ""); 52 | 53 | song.length = reader.readShort(); 54 | 55 | boolean newFormat = song.length == 0; 56 | if (newFormat) { 57 | song.formatVersion = reader.readByte(); 58 | song.vanillaInstrumentCount = reader.readByte(); 59 | song.length = reader.readShort(); 60 | } 61 | 62 | song.height = reader.readShort(); 63 | song.name = reader.readString().replaceAll("[\\n\\r]", ""); 64 | song.author = reader.readString().replaceAll("[\\n\\r]", ""); 65 | song.originalAuthor = reader.readString().replaceAll("[\\n\\r]", ""); 66 | song.description = reader.readString().replaceAll("[\\n\\r]", ""); 67 | song.tempo = reader.readShort(); 68 | song.autoSaving = reader.readByte(); 69 | song.autoSavingDuration = reader.readByte(); 70 | song.timeSignature = reader.readByte(); 71 | song.minutesSpent = reader.readInt(); 72 | song.leftClicks = reader.readInt(); 73 | song.rightClicks = reader.readInt(); 74 | song.blocksAdded = reader.readInt(); 75 | song.blocksRemoved = reader.readInt(); 76 | song.importFileName = reader.readString().replaceAll("[\\n\\r]", ""); 77 | 78 | if (newFormat) { 79 | song.loop = reader.readByte(); 80 | song.maxLoopCount = reader.readByte(); 81 | song.loopStartTick = reader.readShort(); 82 | } 83 | 84 | song.displayName = song.name.replaceAll("\\s", "").isEmpty() ? song.fileName : song.name+" ("+song.fileName+")"; 85 | song.entry = new SongListWidget.SongEntry(song, SONGS.size()); 86 | song.entry.favorite = Main.config.favorites.contains(song.fileName); 87 | song.searchableFileName = song.fileName.toLowerCase().replaceAll("\\s", ""); 88 | song.searchableName = song.name.toLowerCase().replaceAll("\\s", ""); 89 | 90 | short tick = -1; 91 | short jumps; 92 | while ((jumps = reader.readShort()) != 0) { 93 | tick += jumps; 94 | short layer = -1; 95 | while ((jumps = reader.readShort()) != 0) { 96 | layer += jumps; 97 | 98 | byte instrumentId = reader.readByte(); 99 | byte noteId = (byte)(reader.readByte() - 33); 100 | 101 | if (newFormat) { 102 | // Data that is not needed as it only works with commands 103 | reader.readByte(); // Velocity 104 | reader.readByte(); // Panning 105 | reader.readShort(); // Pitch 106 | } 107 | 108 | if (noteId < 0) { 109 | noteId = 0; 110 | } else if (noteId > 24) { 111 | noteId = 24; 112 | } 113 | 114 | Note note = new Note(Note.INSTRUMENTS[instrumentId], noteId); 115 | if (!song.uniqueNotes.contains(note)) song.uniqueNotes.add(note); 116 | 117 | song.notes = Arrays.copyOf(song.notes, song.notes.length + 1); 118 | song.notes[song.notes.length - 1] = tick | layer << Note.LAYER_SHIFT | (long)instrumentId << Note.INSTRUMENT_SHIFT | (long)noteId << Note.NOTE_SHIFT; 119 | } 120 | } 121 | 122 | return song; 123 | } 124 | return null; 125 | } 126 | 127 | public static void sort() { 128 | SONGS.sort(Comparator.comparing(song -> song.displayName)); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/SongPlayer.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey; 2 | 3 | import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 4 | import net.minecraft.block.Block; 5 | import net.minecraft.block.BlockState; 6 | import net.minecraft.block.Blocks; 7 | import net.minecraft.block.enums.NoteBlockInstrument; 8 | import net.minecraft.client.MinecraftClient; 9 | import net.minecraft.client.gui.hud.ChatHud; 10 | import net.minecraft.client.network.ClientPlayerEntity; 11 | import net.minecraft.client.network.PlayerListEntry; 12 | import net.minecraft.client.world.ClientWorld; 13 | import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; 14 | import net.minecraft.network.packet.c2s.play.PlayerMoveC2SPacket; 15 | import net.minecraft.state.property.Properties; 16 | import net.minecraft.text.Text; 17 | import net.minecraft.util.Formatting; 18 | import net.minecraft.util.Hand; 19 | import net.minecraft.util.Pair; 20 | import net.minecraft.util.hit.BlockHitResult; 21 | import net.minecraft.util.math.*; 22 | import net.minecraft.world.GameMode; 23 | import org.apache.commons.lang3.NotImplementedException; 24 | import org.jetbrains.annotations.NotNull; 25 | import org.jetbrains.annotations.Nullable; 26 | 27 | import java.util.ArrayList; 28 | import java.util.HashMap; 29 | import java.util.Map; 30 | 31 | public class SongPlayer implements ClientTickEvents.StartWorldTick { 32 | private static boolean warned; 33 | public boolean running; 34 | public Song song; 35 | 36 | private int index; 37 | private double tick; // Aka song position 38 | private HashMap> noteBlocks = null; 39 | public boolean tuned; 40 | private long lastPlaybackTickAt = -1L; 41 | 42 | // Used to check and enforce packet rate limits to not get kicked 43 | private long last100MsSpanAt = -1L; 44 | private int last100MsSpanEstimatedPackets = 0; 45 | // At how many packets/100ms should the player just reduce / stop sending packets for a while 46 | final private int last100MsReducePacketsAfter = 300 / 10, last100MsStopPacketsAfter = 450 / 10; 47 | // If higher than current millis, don't send any packets of this kind (temp disable) 48 | private long reducePacketsUntil = -1L, stopPacketsUntil = -1L; 49 | 50 | // Use to limit swings and look to only each tick. More will not be visually visible anyway due to interpolation 51 | private long lastLookSentAt = -1L, lastSwingSentAt = -1L; 52 | 53 | // The thread executing the tickPlayback method 54 | private Thread playbackThread = null; 55 | public long playbackLoopDelay = 5; 56 | // Just for external debugging purposes 57 | public HashMap missingInstrumentBlocks = new HashMap<>(); 58 | public float speed = 1.0f; // Toy 59 | 60 | private long lastInteractAt = -1; 61 | private float availableInteracts = 8; 62 | private int tuneInitialUntunedBlocks = -1; 63 | private HashMap> notePredictions = new HashMap<>(); 64 | public boolean didSongReachEnd = false; 65 | public boolean loopSong = false; 66 | private long pausePlaybackUntil = -1L; // Set after tuning, if configured 67 | 68 | public SongPlayer() { 69 | Main.TICK_LISTENERS.add(this); 70 | } 71 | 72 | public @NotNull HashMap instrumentMap = new HashMap<>(); // Toy 73 | public synchronized void startPlaybackThread() { 74 | if(Main.config.disableAsyncPlayback) { 75 | playbackThread = null; 76 | return; 77 | } 78 | 79 | this.playbackThread = new Thread(() -> { 80 | Thread ownThread = this.playbackThread; 81 | while(ownThread == this.playbackThread) { 82 | try { 83 | // Accuracy doesn't really matter at this precision imo 84 | Thread.sleep(playbackLoopDelay); 85 | }catch (Exception ex) { 86 | ex.printStackTrace(); 87 | } 88 | tickPlayback(); 89 | } 90 | }); 91 | this.playbackThread.start(); 92 | } 93 | 94 | public synchronized void stopPlaybackThread() { 95 | this.playbackThread = null; // Should stop on its own then 96 | } 97 | 98 | public synchronized void start(Song song) { 99 | if (!Main.config.hideWarning && !warned) { 100 | MinecraftClient.getInstance().inGameHud.getChatHud().addMessage(Text.translatable("disc_jockey.warning").formatted(Formatting.BOLD, Formatting.RED)); 101 | warned = true; 102 | return; 103 | } 104 | if (running) stop(); 105 | this.song = song; 106 | //Main.LOGGER.info("Song length: " + song.length + " and tempo " + song.tempo); 107 | //Main.TICK_LISTENERS.add(this); 108 | if(this.playbackThread == null) startPlaybackThread(); 109 | running = true; 110 | lastPlaybackTickAt = System.currentTimeMillis(); 111 | last100MsSpanAt = System.currentTimeMillis(); 112 | last100MsSpanEstimatedPackets = 0; 113 | reducePacketsUntil = -1L; 114 | stopPacketsUntil = -1L; 115 | lastLookSentAt = -1L; 116 | lastSwingSentAt = -1L; 117 | missingInstrumentBlocks.clear(); 118 | didSongReachEnd = false; 119 | } 120 | 121 | public synchronized void stop() { 122 | //MinecraftClient.getInstance().send(() -> Main.TICK_LISTENERS.remove(this)); 123 | stopPlaybackThread(); 124 | running = false; 125 | index = 0; 126 | tick = 0; 127 | noteBlocks = null; 128 | notePredictions.clear(); 129 | tuned = false; 130 | tuneInitialUntunedBlocks = -1; 131 | lastPlaybackTickAt = -1L; 132 | last100MsSpanAt = -1L; 133 | last100MsSpanEstimatedPackets = 0; 134 | reducePacketsUntil = -1L; 135 | stopPacketsUntil = -1L; 136 | lastLookSentAt = -1L; 137 | lastSwingSentAt = -1L; 138 | didSongReachEnd = false; // Change after running stop() if actually ended cleanly 139 | } 140 | 141 | public synchronized void tickPlayback() { 142 | if (!running) { 143 | lastPlaybackTickAt = -1L; 144 | last100MsSpanAt = -1L; 145 | return; 146 | } 147 | long previousPlaybackTickAt = lastPlaybackTickAt; 148 | lastPlaybackTickAt = System.currentTimeMillis(); 149 | if(last100MsSpanAt != -1L && System.currentTimeMillis() - last100MsSpanAt >= 100) { 150 | last100MsSpanEstimatedPackets = 0; 151 | last100MsSpanAt = System.currentTimeMillis(); 152 | }else if (last100MsSpanAt == -1L) { 153 | last100MsSpanAt = System.currentTimeMillis(); 154 | last100MsSpanEstimatedPackets = 0; 155 | } 156 | if(noteBlocks != null && tuned) { 157 | if(pausePlaybackUntil != -1L && System.currentTimeMillis() <= pausePlaybackUntil) return; 158 | while (running) { 159 | MinecraftClient client = MinecraftClient.getInstance(); 160 | GameMode gameMode = client.interactionManager == null ? null : client.interactionManager.getCurrentGameMode(); 161 | // In the best case, gameMode would only be queried in sync Ticks, no here 162 | if (gameMode == null || !gameMode.isSurvivalLike()) { 163 | client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.invalid_game_mode", gameMode == null ? "unknown" : gameMode.getTranslatableName()).formatted(Formatting.RED)); 164 | stop(); 165 | return; 166 | } 167 | 168 | long note = song.notes[index]; 169 | final long now = System.currentTimeMillis(); 170 | if ((short)note <= Math.round(tick)) { 171 | @Nullable BlockPos blockPos = noteBlocks.get(Note.INSTRUMENTS[(byte)(note >> Note.INSTRUMENT_SHIFT)]).get((byte)(note >> Note.NOTE_SHIFT)); 172 | if(blockPos == null) { 173 | // Instrument got likely mapped to "nothing". Skip it 174 | index++; 175 | continue; 176 | } 177 | if (!canInteractWith(client.player, blockPos)) { 178 | stop(); 179 | client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.to_far").formatted(Formatting.RED)); 180 | return; 181 | } 182 | Vec3d unit = Vec3d.ofCenter(blockPos, 0.5).subtract(client.player.getEyePos()).normalize(); 183 | if((lastLookSentAt == -1L || now - lastLookSentAt >= 50) && last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { 184 | 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)); 185 | last100MsSpanEstimatedPackets++; 186 | lastLookSentAt = now; 187 | }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ 188 | reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); 189 | } 190 | if(last100MsSpanEstimatedPackets < last100MsStopPacketsAfter && (stopPacketsUntil == -1L || stopPacketsUntil < now)) { 191 | // TODO: 5/30/2022 Check if the block needs tuning 192 | //client.interactionManager.attackBlock(blockPos, Direction.UP); 193 | client.player.networkHandler.sendPacket(new PlayerActionC2SPacket(PlayerActionC2SPacket.Action.START_DESTROY_BLOCK, blockPos, Direction.UP, 0)); 194 | last100MsSpanEstimatedPackets++; 195 | }else if(last100MsSpanEstimatedPackets >= last100MsStopPacketsAfter) { 196 | Main.LOGGER.info("Stopping all packets for a bit!"); 197 | stopPacketsUntil = Math.max(stopPacketsUntil, now + 250); 198 | reducePacketsUntil = Math.max(reducePacketsUntil, now + 10000); 199 | } 200 | if(last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { 201 | client.player.networkHandler.sendPacket(new PlayerActionC2SPacket(PlayerActionC2SPacket.Action.ABORT_DESTROY_BLOCK, blockPos, Direction.UP, 0)); 202 | last100MsSpanEstimatedPackets++; 203 | }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ 204 | reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); 205 | } 206 | if((lastSwingSentAt == -1L || now - lastSwingSentAt >= 50) &&last100MsSpanEstimatedPackets < last100MsReducePacketsAfter && (reducePacketsUntil == -1L || reducePacketsUntil < now)) { 207 | client.executeSync(() -> client.player.swingHand(Hand.MAIN_HAND)); 208 | lastSwingSentAt = now; 209 | last100MsSpanEstimatedPackets++; 210 | }else if(last100MsSpanEstimatedPackets >= last100MsReducePacketsAfter){ 211 | reducePacketsUntil = Math.max(reducePacketsUntil, now + 500); 212 | } 213 | 214 | index++; 215 | if (index >= song.notes.length) { 216 | stop(); 217 | didSongReachEnd = true; 218 | if(loopSong) { 219 | start(song); 220 | } 221 | break; 222 | } 223 | } else { 224 | break; 225 | } 226 | } 227 | 228 | if(running) { // Might not be running anymore (prevent small offset on song, even if that is not played anymore) 229 | long elapsedMs = previousPlaybackTickAt != -1L && lastPlaybackTickAt != -1L ? lastPlaybackTickAt - previousPlaybackTickAt : (16); // Assume 16ms if unknown 230 | tick += song.millisecondsToTicks(elapsedMs) * speed; 231 | } 232 | } 233 | } 234 | 235 | // TODO: 6/2/2022 Play note blocks every song tick, instead of every tick. That way the song will sound better 236 | // 11/1/2023 Playback now done in separate thread. Not ideal but better especially when FPS are low. 237 | @Override 238 | public void onStartTick(ClientWorld world) { 239 | MinecraftClient client = MinecraftClient.getInstance(); 240 | if(world == null || client.world == null || client.player == null) return; 241 | if(song == null || !running) return; 242 | 243 | // Clear outdated note predictions 244 | ArrayList outdatedPredictions = new ArrayList<>(); 245 | for(Map.Entry> entry : notePredictions.entrySet()) { 246 | if(entry.getValue().getRight() < System.currentTimeMillis()) 247 | outdatedPredictions.add(entry.getKey()); 248 | } 249 | for(BlockPos outdatedPrediction : outdatedPredictions) notePredictions.remove(outdatedPrediction); 250 | 251 | if (noteBlocks == null) { 252 | noteBlocks = new HashMap<>(); 253 | 254 | ClientPlayerEntity player = client.player; 255 | 256 | // Create list of available noteblock positions per used instrument 257 | HashMap> noteblocksForInstrument = new HashMap<>(); 258 | for(NoteBlockInstrument instrument : NoteBlockInstrument.values()) 259 | noteblocksForInstrument.put(instrument, new ArrayList<>()); 260 | final Vec3d playerEyePos = player.getEyePos(); 261 | 262 | final int maxOffset; // Rough estimates, of which blocks could be in reach 263 | if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_4_Or_Earlier) { 264 | maxOffset = 7; 265 | }else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_5_Or_Later) { 266 | maxOffset = (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0); 267 | }else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.All) { 268 | maxOffset = Math.min(7, (int) Math.ceil(player.getBlockInteractionRange() + 1.0 + 1.0)); 269 | }else { 270 | throw new NotImplementedException("ExpectedServerVersion Value not implemented: " + Main.config.expectedServerVersion.name()); 271 | } 272 | final ArrayList orderedOffsets = new ArrayList<>(); 273 | for(int offset = 0; offset <= maxOffset; offset++) { 274 | orderedOffsets.add(offset); 275 | if(offset != 0) orderedOffsets.add(offset * -1); 276 | } 277 | 278 | for(NoteBlockInstrument instrument : noteblocksForInstrument.keySet().toArray(new NoteBlockInstrument[0])) { 279 | for (int y : orderedOffsets) { 280 | for (int x : orderedOffsets) { 281 | for (int z : orderedOffsets) { 282 | Vec3d vec3d = playerEyePos.add(x, y, z); 283 | BlockPos blockPos = new BlockPos(MathHelper.floor(vec3d.x), MathHelper.floor(vec3d.y), MathHelper.floor(vec3d.z)); 284 | if (!canInteractWith(player, blockPos)) 285 | continue; 286 | BlockState blockState = world.getBlockState(blockPos); 287 | if (!blockState.isOf(Blocks.NOTE_BLOCK) || !world.isAir(blockPos.up())) 288 | continue; 289 | 290 | if (blockState.get(Properties.INSTRUMENT) == instrument) 291 | noteblocksForInstrument.get(instrument).add(blockPos); 292 | } 293 | } 294 | } 295 | } 296 | 297 | // Remap instruments for funzies 298 | if(!instrumentMap.isEmpty()) { 299 | HashMap> newNoteblocksForInstrument = new HashMap<>(); 300 | for(NoteBlockInstrument orig : noteblocksForInstrument.keySet()) { 301 | NoteBlockInstrument mappedInstrument = instrumentMap.getOrDefault(orig, orig); 302 | if(mappedInstrument == null) { 303 | // Instrument got likely mapped to "nothing" 304 | newNoteblocksForInstrument.put(orig, null); 305 | continue; 306 | } 307 | 308 | newNoteblocksForInstrument.put(orig, noteblocksForInstrument.getOrDefault(instrumentMap.getOrDefault(orig, orig), new ArrayList<>())); 309 | } 310 | noteblocksForInstrument = newNoteblocksForInstrument; 311 | } 312 | 313 | // Find fitting noteblocks with the least amount of adjustments required (to reduce tuning time) 314 | ArrayList capturedNotes = new ArrayList<>(); 315 | for(Note note : song.uniqueNotes) { 316 | ArrayList availableBlocks = noteblocksForInstrument.get(note.instrument()); 317 | if(availableBlocks == null) { 318 | // Note was mapped to "nothing". Pretend it got captured, but just ignore it 319 | capturedNotes.add(note); 320 | getNotes(note.instrument()).put(note.note(), null); 321 | continue; 322 | } 323 | BlockPos bestBlockPos = null; 324 | int bestBlockTuningSteps = Integer.MAX_VALUE; 325 | for(BlockPos blockPos : availableBlocks) { 326 | int wantedNote = note.note(); 327 | int currentNote = client.world.getBlockState(blockPos).get(Properties.NOTE); 328 | int tuningSteps = wantedNote >= currentNote ? wantedNote - currentNote : (25 - currentNote) + wantedNote; 329 | 330 | if(tuningSteps < bestBlockTuningSteps) { 331 | bestBlockPos = blockPos; 332 | bestBlockTuningSteps = tuningSteps; 333 | } 334 | } 335 | 336 | if(bestBlockPos != null) { 337 | capturedNotes.add(note); 338 | availableBlocks.remove(bestBlockPos); 339 | getNotes(note.instrument()).put(note.note(), bestBlockPos); 340 | } // else will be a missing note 341 | } 342 | 343 | ArrayList missingNotes = new ArrayList<>(song.uniqueNotes); 344 | missingNotes.removeAll(capturedNotes); 345 | if (!missingNotes.isEmpty()) { 346 | ChatHud chatHud = MinecraftClient.getInstance().inGameHud.getChatHud(); 347 | chatHud.addMessage(Text.translatable(Main.MOD_ID+".player.invalid_note_blocks").formatted(Formatting.RED)); 348 | 349 | HashMap missing = new HashMap<>(); 350 | for (Note note : missingNotes) { 351 | NoteBlockInstrument mappedInstrument = instrumentMap.getOrDefault(note.instrument(), note.instrument()); 352 | if(mappedInstrument == null) continue; // Ignore if mapped to nothing 353 | Block block = Note.INSTRUMENT_BLOCKS.get(mappedInstrument); 354 | Integer got = missing.get(block); 355 | if (got == null) got = 0; 356 | missing.put(block, got + 1); 357 | } 358 | 359 | missingInstrumentBlocks = missing; 360 | missing.forEach((block, integer) -> chatHud.addMessage(Text.literal(block.getName().getString()+" × "+integer).formatted(Formatting.RED))); 361 | stop(); 362 | } 363 | } else if (!tuned) { 364 | //tuned = true; 365 | 366 | int ping = 0; 367 | { 368 | PlayerListEntry playerListEntry; 369 | if (client.getNetworkHandler() != null && (playerListEntry = client.getNetworkHandler().getPlayerListEntry(client.player.getGameProfile().getId())) != null) 370 | ping = playerListEntry.getLatency(); 371 | } 372 | 373 | if(lastInteractAt != -1L) { 374 | // Paper allows 8 interacts per 300 ms (actually 9 it turns out, but lets keep it a bit lower anyway) 375 | availableInteracts += ((System.currentTimeMillis() - lastInteractAt) / (310.0f / 8.0f)); 376 | availableInteracts = Math.min(8f, Math.max(0f, availableInteracts)); 377 | }else { 378 | availableInteracts = 8f; 379 | lastInteractAt = System.currentTimeMillis(); 380 | } 381 | 382 | int fullyTunedBlocks = 0; 383 | HashMap untunedNotes = new HashMap<>(); 384 | for (Note note : song.uniqueNotes) { 385 | if(noteBlocks == null || noteBlocks.get(note.instrument()) == null) 386 | continue; 387 | BlockPos blockPos = noteBlocks.get(note.instrument()).get(note.note()); 388 | if(blockPos == null) continue; 389 | BlockState blockState = world.getBlockState(blockPos); 390 | int assumedNote = notePredictions.containsKey(blockPos) ? notePredictions.get(blockPos).getLeft() : blockState.get(Properties.NOTE); 391 | 392 | if (blockState.contains(Properties.NOTE)) { 393 | if(assumedNote == note.note() && blockState.get(Properties.NOTE) == note.note()) 394 | fullyTunedBlocks++; 395 | if (assumedNote != note.note()) { 396 | if (!canInteractWith(client.player, blockPos)) { 397 | stop(); 398 | client.inGameHud.getChatHud().addMessage(Text.translatable(Main.MOD_ID+".player.to_far").formatted(Formatting.RED)); 399 | return; 400 | } 401 | untunedNotes.put(blockPos, blockState.get(Properties.NOTE)); 402 | } 403 | } else { 404 | noteBlocks = null; 405 | break; 406 | } 407 | } 408 | 409 | if(tuneInitialUntunedBlocks == -1 || tuneInitialUntunedBlocks < untunedNotes.size()) 410 | tuneInitialUntunedBlocks = untunedNotes.size(); 411 | 412 | int existingUniqueNotesCount = 0; 413 | for(Note n : song.uniqueNotes) { 414 | if(noteBlocks.get(n.instrument()).get(n.note()) != null) 415 | existingUniqueNotesCount++; 416 | } 417 | 418 | if(untunedNotes.isEmpty() && fullyTunedBlocks == existingUniqueNotesCount) { 419 | // Wait roundrip + 100ms before considering tuned after changing notes (in case the server rejects an interact) 420 | if(lastInteractAt == -1 || System.currentTimeMillis() - lastInteractAt >= ping * 2 + 100) { 421 | tuned = true; 422 | pausePlaybackUntil = System.currentTimeMillis() + (long) (Math.abs(Main.config.delayPlaybackStartBySecs) * 1000); 423 | tuneInitialUntunedBlocks = -1; 424 | // Tuning finished 425 | } 426 | } 427 | 428 | BlockPos lastBlockPos = null; 429 | int lastTunedNote = Integer.MIN_VALUE; 430 | float roughTuneProgress = 1 - (untunedNotes.size() / Math.max(tuneInitialUntunedBlocks + 0f, 1f)); 431 | while(availableInteracts >= 1f && untunedNotes.size() > 0) { 432 | BlockPos blockPos = null; 433 | int searches = 0; 434 | while(blockPos == null) { 435 | searches++; 436 | // Find higher note 437 | for (Map.Entry entry : untunedNotes.entrySet()) { 438 | if (entry.getValue() > lastTunedNote) { 439 | blockPos = entry.getKey(); 440 | break; 441 | } 442 | } 443 | // Find higher note or equal 444 | if (blockPos == null) { 445 | for (Map.Entry entry : untunedNotes.entrySet()) { 446 | if (entry.getValue() >= lastTunedNote) { 447 | blockPos = entry.getKey(); 448 | break; 449 | } 450 | } 451 | } 452 | // Not found. Reset last note 453 | if(blockPos == null) 454 | lastTunedNote = Integer.MIN_VALUE; 455 | if(blockPos == null && searches > 1) { 456 | // Something went wrong. Take any note (one should at least exist here) 457 | blockPos = untunedNotes.keySet().toArray(new BlockPos[0])[0]; 458 | break; 459 | } 460 | } 461 | if(blockPos == null) return; // Something went very, very wrong! 462 | 463 | lastTunedNote = untunedNotes.get(blockPos); 464 | untunedNotes.remove(blockPos); 465 | int assumedNote = notePredictions.containsKey(blockPos) ? notePredictions.get(blockPos).getLeft() : client.world.getBlockState(blockPos).get(Properties.NOTE); 466 | notePredictions.put(blockPos, new Pair<>((assumedNote + 1) % 25, System.currentTimeMillis() + ping * 2 + 100)); 467 | client.interactionManager.interactBlock(client.player, Hand.MAIN_HAND, new BlockHitResult(Vec3d.of(blockPos), Direction.UP, blockPos, false)); 468 | lastInteractAt = System.currentTimeMillis(); 469 | availableInteracts -= 1f; 470 | lastBlockPos = blockPos; 471 | } 472 | if(lastBlockPos != null) { 473 | // Turn head into spinning with time and lookup up further the further tuning is progressed 474 | //client.getNetworkHandler().sendPacket(new PlayerMoveC2SPacket.LookAndOnGround(((float) (System.currentTimeMillis() % 2000)) * (360f/2000f), (1 - roughTuneProgress) * 180 - 90, true)); 475 | client.player.swingHand(Hand.MAIN_HAND); 476 | } 477 | }else if((playbackThread == null || !playbackThread.isAlive()) && running && Main.config.disableAsyncPlayback) { 478 | // Sync playback (off by default). Replacement for playback thread 479 | try { 480 | tickPlayback(); 481 | }catch (Exception ex) { 482 | ex.printStackTrace(); 483 | stop(); 484 | } 485 | } 486 | } 487 | 488 | private HashMap getNotes(NoteBlockInstrument instrument) { 489 | return noteBlocks.computeIfAbsent(instrument, k -> new HashMap<>()); 490 | } 491 | 492 | // Before 1.20.5, the server limits interacts to 6 Blocks from Player Eye to Block Center 493 | // With 1.20.5 and later, the server does a more complex check, to the closest point of a full block hitbox 494 | // (max distance is BlockInteractRange + 1.0). 495 | private boolean canInteractWith(ClientPlayerEntity player, BlockPos blockPos) { 496 | final Vec3d eyePos = player.getEyePos(); 497 | if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_4_Or_Earlier) { 498 | return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0; 499 | }else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.v1_20_5_Or_Later) { 500 | double blockInteractRange = player.getBlockInteractionRange() + 1.0; 501 | return new Box(blockPos).squaredMagnitude(eyePos) < blockInteractRange * blockInteractRange; 502 | }else if(Main.config.expectedServerVersion == Config.ExpectedServerVersion.All) { 503 | // Require both checks to succeed (aka use worst distance) 504 | double blockInteractRange = player.getBlockInteractionRange() + 1.0; 505 | return eyePos.squaredDistanceTo(blockPos.toCenterPos()) <= 6.0 * 6.0 506 | && new Box(blockPos).squaredMagnitude(eyePos) < blockInteractRange * blockInteractRange; 507 | }else { 508 | throw new NotImplementedException("ExpectedServerVersion Value not implemented: " + Main.config.expectedServerVersion.name()); 509 | } 510 | } 511 | 512 | public double getSongElapsedSeconds() { 513 | if(song == null) return 0; 514 | return song.ticksToMilliseconds(tick) / 1000; 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/gui/SongListWidget.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey.gui; 2 | 3 | import com.mojang.blaze3d.systems.RenderSystem; 4 | import net.minecraft.client.MinecraftClient; 5 | import net.minecraft.client.gui.DrawContext; 6 | import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; 7 | import net.minecraft.client.gui.widget.EntryListWidget; 8 | import net.minecraft.util.Identifier; 9 | import org.jetbrains.annotations.Nullable; 10 | import semmiedev.disc_jockey.Main; 11 | import semmiedev.disc_jockey.Song; 12 | 13 | public class SongListWidget extends EntryListWidget { 14 | 15 | public SongListWidget(MinecraftClient client, int width, int height, int top, int itemHeight) { 16 | super(client, width, height, top, itemHeight); 17 | } 18 | 19 | @Override 20 | public int getRowWidth() { 21 | return width - 40; 22 | } 23 | 24 | @Override 25 | protected int getScrollbarX() { 26 | return width - 12; 27 | } 28 | 29 | @Override 30 | public void setSelected(@Nullable SongListWidget.SongEntry entry) { 31 | SongListWidget.SongEntry selectedEntry = getSelectedOrNull(); 32 | if (selectedEntry != null) selectedEntry.selected = false; 33 | if (entry != null) entry.selected = true; 34 | super.setSelected(entry); 35 | } 36 | 37 | @Override 38 | protected void appendClickableNarrations(NarrationMessageBuilder builder) { 39 | // Who cares 40 | } 41 | 42 | // TODO: 6/2/2022 Add a delete icon 43 | public static class SongEntry extends Entry { 44 | private static final Identifier ICONS = Identifier.of(Main.MOD_ID, "textures/gui/icons.png"); 45 | 46 | public final int index; 47 | public final Song song; 48 | 49 | public boolean selected, favorite; 50 | public SongListWidget songListWidget; 51 | 52 | private final MinecraftClient client = MinecraftClient.getInstance(); 53 | 54 | private int x, y, entryWidth, entryHeight; 55 | 56 | public SongEntry(Song song, int index) { 57 | this.song = song; 58 | this.index = index; 59 | } 60 | 61 | @Override 62 | public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { 63 | this.x = x; this.y = y; this.entryWidth = entryWidth; this.entryHeight = entryHeight; 64 | 65 | if (selected) { 66 | context.fill(x, y, x + entryWidth, y + entryHeight, 0xFFFFFF); 67 | context.fill(x + 1, y + 1, x + entryWidth - 1, y + entryHeight - 1, 0x000000); 68 | } 69 | 70 | context.drawCenteredTextWithShadow(client.textRenderer, song.displayName, x + entryWidth / 2, y + 5, selected ? 0xFFFFFF : 0x808080); 71 | 72 | RenderSystem.setShaderTexture(0, ICONS); 73 | context.drawTexture(ICONS, x + 2, y + 2, (favorite ? 26 : 0) + (isOverFavoriteButton(mouseX, mouseY) ? 13 : 0), 0, 13, 12, 52, 12); 74 | } 75 | 76 | @Override 77 | public boolean mouseClicked(double mouseX, double mouseY, int button) { 78 | if (isOverFavoriteButton(mouseX, mouseY)) { 79 | favorite = !favorite; 80 | if (favorite) { 81 | Main.config.favorites.add(song.fileName); 82 | } else { 83 | Main.config.favorites.remove(song.fileName); 84 | } 85 | return true; 86 | } 87 | songListWidget.setSelected(this); 88 | return true; 89 | } 90 | 91 | private boolean isOverFavoriteButton(double mouseX, double mouseY) { 92 | return mouseX > x + 2 && mouseX < x + 15 && mouseY > y + 2 && mouseY < y + 14; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/gui/hud/BlocksOverlay.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey.gui.hud; 2 | 3 | import net.minecraft.block.Blocks; 4 | import net.minecraft.client.MinecraftClient; 5 | import net.minecraft.client.font.TextRenderer; 6 | import net.minecraft.client.gui.DrawContext; 7 | import net.minecraft.client.render.RenderTickCounter; 8 | import net.minecraft.client.render.item.ItemRenderer; 9 | import net.minecraft.item.ItemStack; 10 | import net.minecraft.util.math.ColorHelper; 11 | 12 | public class BlocksOverlay { 13 | public static ItemStack[] itemStacks; 14 | public static int[] amounts; 15 | public static int amountOfNoteBlocks; 16 | 17 | private static final ItemStack NOTE_BLOCK = Blocks.NOTE_BLOCK.asItem().getDefaultStack(); 18 | 19 | public static void render(DrawContext context, RenderTickCounter tickCounter) { 20 | if (itemStacks != null) { 21 | context.fill(2, 2, 62, (itemStacks.length + 1) * 20 + 7, ColorHelper.Argb.getArgb(255, 22, 22, 27)); 22 | context.fill(4, 4, 60, (itemStacks.length + 1) * 20 + 5, ColorHelper.Argb.getArgb(255, 42, 42, 47)); 23 | 24 | MinecraftClient client = MinecraftClient.getInstance(); 25 | TextRenderer textRenderer = client.textRenderer; 26 | ItemRenderer itemRenderer = client.getItemRenderer(); 27 | 28 | //textRenderer.draw(matrices, " × "+amountOfNoteBlocks, 26, 13, 0xFFFFFF); 29 | context.drawText(textRenderer, " × "+amountOfNoteBlocks, 26, 13, 0xFFFFFF, true); 30 | //itemRenderer.renderInGui(matrices, NOTE_BLOCK, 6, 6); 31 | context.drawItem(NOTE_BLOCK, 6, 6); 32 | 33 | for (int i = 0; i < itemStacks.length; i++) { 34 | //textRenderer.draw(matrices, " × "+amounts[i], 26, 13 + 20 * (i + 1), 0xFFFFFF); 35 | context.drawText(textRenderer, " × "+amounts[i], 26, 13 + 20 * (i + 1), 0xFFFFFF, true); 36 | //itemRenderer.renderInGui(matrices, itemStacks[i], 6, 6 + 20 * (i + 1)); 37 | context.drawItem(itemStacks[i], 6, 6 + 20 * (i + 1)); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/gui/screen/DiscJockeyScreen.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey.gui.screen; 2 | 3 | import net.minecraft.client.gui.DrawContext; 4 | import net.minecraft.client.gui.screen.ConfirmScreen; 5 | import net.minecraft.client.gui.screen.Screen; 6 | import net.minecraft.client.gui.widget.ButtonWidget; 7 | import net.minecraft.client.gui.widget.TextFieldWidget; 8 | import net.minecraft.item.ItemStack; 9 | import net.minecraft.text.MutableText; 10 | import net.minecraft.text.Text; 11 | import net.minecraft.util.Formatting; 12 | import semmiedev.disc_jockey.Main; 13 | import semmiedev.disc_jockey.Note; 14 | import semmiedev.disc_jockey.Song; 15 | import semmiedev.disc_jockey.SongLoader; 16 | import semmiedev.disc_jockey.gui.SongListWidget; 17 | import semmiedev.disc_jockey.gui.hud.BlocksOverlay; 18 | 19 | import java.io.File; 20 | import java.io.IOException; 21 | import java.nio.file.Files; 22 | import java.nio.file.Path; 23 | import java.util.Arrays; 24 | import java.util.List; 25 | import java.util.stream.Collectors; 26 | 27 | public class DiscJockeyScreen extends Screen { 28 | private static final MutableText 29 | SELECT_SONG = Text.translatable(Main.MOD_ID+".screen.select_song"), 30 | PLAY = Text.translatable(Main.MOD_ID+".screen.play"), 31 | PLAY_STOP = Text.translatable(Main.MOD_ID+".screen.play.stop"), 32 | PREVIEW = Text.translatable(Main.MOD_ID+".screen.preview"), 33 | PREVIEW_STOP = Text.translatable(Main.MOD_ID+".screen.preview.stop"), 34 | DROP_HINT = Text.translatable(Main.MOD_ID+".screen.drop_hint").formatted(Formatting.GRAY) 35 | ; 36 | 37 | private SongListWidget songListWidget; 38 | private ButtonWidget playButton, previewButton; 39 | private boolean shouldFilter; 40 | private String query = ""; 41 | 42 | public DiscJockeyScreen() { 43 | super(Main.NAME); 44 | } 45 | 46 | @Override 47 | protected void init() { 48 | shouldFilter = true; 49 | songListWidget = new SongListWidget(client, width, height - 64 - 32, 32, 20); 50 | addDrawableChild(songListWidget); 51 | for (int i = 0; i < SongLoader.SONGS.size(); i++) { 52 | Song song = SongLoader.SONGS.get(i); 53 | song.entry.songListWidget = songListWidget; 54 | if (song.entry.selected) songListWidget.setSelected(song.entry); 55 | } 56 | 57 | playButton = ButtonWidget.builder(PLAY, button -> { 58 | if (Main.SONG_PLAYER.running) { 59 | Main.SONG_PLAYER.stop(); 60 | } else { 61 | SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); 62 | if (entry != null) { 63 | Main.SONG_PLAYER.start(entry.song); 64 | client.setScreen(null); 65 | } 66 | } 67 | }).dimensions(width / 2 - 160, height - 61, 100, 20).build(); 68 | addDrawableChild(playButton); 69 | 70 | previewButton = ButtonWidget.builder(PREVIEW, button -> { 71 | if (Main.PREVIEWER.running) { 72 | Main.PREVIEWER.stop(); 73 | } else { 74 | SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); 75 | if (entry != null) Main.PREVIEWER.start(entry.song); 76 | } 77 | }).dimensions(width / 2 - 50, height - 61, 100, 20).build(); 78 | addDrawableChild(previewButton); 79 | 80 | addDrawableChild(ButtonWidget.builder(Text.translatable(Main.MOD_ID+".screen.blocks"), button -> { 81 | // TODO: 6/2/2022 Add an auto build mode 82 | if (BlocksOverlay.itemStacks == null) { 83 | SongListWidget.SongEntry entry = songListWidget.getSelectedOrNull(); 84 | if (entry != null) { 85 | client.setScreen(null); 86 | 87 | BlocksOverlay.itemStacks = new ItemStack[0]; 88 | BlocksOverlay.amounts = new int[0]; 89 | BlocksOverlay.amountOfNoteBlocks = entry.song.uniqueNotes.size(); 90 | 91 | for (Note note : entry.song.uniqueNotes) { 92 | ItemStack itemStack = Note.INSTRUMENT_BLOCKS.get(note.instrument()).asItem().getDefaultStack(); 93 | int index = -1; 94 | 95 | for (int i = 0; i < BlocksOverlay.itemStacks.length; i++) { 96 | if (BlocksOverlay.itemStacks[i].getItem() == itemStack.getItem()) { 97 | index = i; 98 | break; 99 | } 100 | } 101 | 102 | if (index == -1) { 103 | BlocksOverlay.itemStacks = Arrays.copyOf(BlocksOverlay.itemStacks, BlocksOverlay.itemStacks.length + 1); 104 | BlocksOverlay.amounts = Arrays.copyOf(BlocksOverlay.amounts, BlocksOverlay.amounts.length + 1); 105 | 106 | BlocksOverlay.itemStacks[BlocksOverlay.itemStacks.length - 1] = itemStack; 107 | BlocksOverlay.amounts[BlocksOverlay.amounts.length - 1] = 1; 108 | } else { 109 | BlocksOverlay.amounts[index] = BlocksOverlay.amounts[index] + 1; 110 | } 111 | } 112 | } 113 | } else { 114 | BlocksOverlay.itemStacks = null; 115 | client.setScreen(null); 116 | } 117 | }).dimensions(width / 2 + 60, height - 61, 100, 20).build()); 118 | 119 | TextFieldWidget searchBar = new TextFieldWidget(textRenderer, width / 2 - 75, height - 31, 150, 20, Text.translatable(Main.MOD_ID+".screen.search")); 120 | searchBar.setChangedListener(query -> { 121 | query = query.toLowerCase().replaceAll("\\s", ""); 122 | if (this.query.equals(query)) return; 123 | this.query = query; 124 | shouldFilter = true; 125 | }); 126 | addDrawableChild(searchBar); 127 | 128 | // TODO: 6/2/2022 Add a reload button 129 | } 130 | 131 | @Override 132 | public void render(DrawContext context, int mouseX, int mouseY, float delta) { 133 | super.render(context, mouseX, mouseY, delta); 134 | 135 | context.drawCenteredTextWithShadow(textRenderer, DROP_HINT, width / 2, 5, 0xFFFFFF); 136 | context.drawCenteredTextWithShadow(textRenderer, SELECT_SONG, width / 2, 20, 0xFFFFFF); 137 | } 138 | 139 | @Override 140 | public void tick() { 141 | previewButton.setMessage(Main.PREVIEWER.running ? PREVIEW_STOP : PREVIEW); 142 | playButton.setMessage(Main.SONG_PLAYER.running ? PLAY_STOP : PLAY); 143 | 144 | if (shouldFilter) { 145 | shouldFilter = false; 146 | songListWidget.setScrollAmount(0); 147 | songListWidget.children().clear(); 148 | boolean empty = query.isEmpty(); 149 | int favoriteIndex = 0; 150 | for (Song song : SongLoader.SONGS) { 151 | if (empty || song.searchableFileName.contains(query) || song.searchableName.contains(query)) { 152 | if (song.entry.favorite) { 153 | songListWidget.children().add(favoriteIndex++, song.entry); 154 | } else { 155 | songListWidget.children().add(song.entry); 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | @Override 163 | public void filesDragged(List paths) { 164 | String string = paths.stream().map(Path::getFileName).map(Path::toString).collect(Collectors.joining(", ")); 165 | if (string.length() > 300) string = string.substring(0, 300)+"..."; 166 | 167 | client.setScreen(new ConfirmScreen(confirmed -> { 168 | if (confirmed) { 169 | paths.forEach(path -> { 170 | try { 171 | File file = path.toFile(); 172 | 173 | if (SongLoader.SONGS.stream().anyMatch(input -> input.fileName.equalsIgnoreCase(file.getName()))) return; 174 | 175 | Song song = SongLoader.loadSong(file); 176 | if (song != null) { 177 | Files.copy(path, Main.songsFolder.toPath().resolve(file.getName())); 178 | SongLoader.SONGS.add(song); 179 | } 180 | } catch (IOException exception) { 181 | Main.LOGGER.warn("Failed to copy song file from {} to {}", path, Main.songsFolder.toPath(), exception); 182 | } 183 | }); 184 | 185 | SongLoader.sort(); 186 | } 187 | client.setScreen(this); 188 | }, Text.translatable(Main.MOD_ID+".screen.drop_confirm"), Text.literal(string))); 189 | } 190 | 191 | @Override 192 | public boolean shouldPause() { 193 | return false; 194 | } 195 | 196 | @Override 197 | public void close() { 198 | super.close(); 199 | new Thread(() -> Main.configHolder.save()).start(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/main/java/semmiedev/disc_jockey/mixin/ClientWorldMixin.java: -------------------------------------------------------------------------------- 1 | package semmiedev.disc_jockey.mixin; 2 | 3 | import net.minecraft.client.MinecraftClient; 4 | import net.minecraft.client.sound.PositionedSoundInstance; 5 | import net.minecraft.client.sound.SoundInstance; 6 | import net.minecraft.client.world.ClientWorld; 7 | import net.minecraft.sound.SoundCategory; 8 | import net.minecraft.sound.SoundEvent; 9 | import net.minecraft.util.math.random.Random; 10 | import org.spongepowered.asm.mixin.Final; 11 | import org.spongepowered.asm.mixin.Mixin; 12 | import org.spongepowered.asm.mixin.Shadow; 13 | import org.spongepowered.asm.mixin.injection.At; 14 | import org.spongepowered.asm.mixin.injection.Inject; 15 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 16 | import semmiedev.disc_jockey.Main; 17 | 18 | @Mixin(ClientWorld.class) 19 | public class ClientWorldMixin { 20 | @Shadow @Final private MinecraftClient client; 21 | 22 | @Inject(method = "playSound(DDDLnet/minecraft/sound/SoundEvent;Lnet/minecraft/sound/SoundCategory;FFZJ)V", at = @At("HEAD"), cancellable = true) 23 | private void makeNoteBlockSoundsOmnidirectional(double x, double y, double z, SoundEvent event, SoundCategory category, float volume, float pitch, boolean useDistance, long seed, CallbackInfo ci) { 24 | if (((Main.config.omnidirectionalNoteBlockSounds && Main.SONG_PLAYER.running) || Main.PREVIEWER.running) && event.getId().getPath().startsWith("block.note_block")) { 25 | ci.cancel(); 26 | client.getSoundManager().play(new PositionedSoundInstance(event.getId(), category, volume, pitch, Random.create(seed), false, 0, SoundInstance.AttenuationType.NONE, 0, 0, 0, true)); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/assets/disc_jockey/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SemmieDev/Disc-Jockey/875d9ada1b3f46b4de0147b071a63986bc5ddc1e/src/main/resources/assets/disc_jockey/icon.png -------------------------------------------------------------------------------- /src/main/resources/assets/disc_jockey/lang/en_us.json: -------------------------------------------------------------------------------- 1 | { 2 | "disc_jockey.screen.select_song": "Select A Song", 3 | "disc_jockey.screen.play": "Play", 4 | "disc_jockey.screen.play.stop": "Stop Playing", 5 | "disc_jockey.screen.preview": "Preview", 6 | "disc_jockey.screen.preview.stop": "Stop Previewing", 7 | "disc_jockey.screen.blocks.title": "Blocks", 8 | "disc_jockey.screen.blocks": "Blocks", 9 | "disc_jockey.screen.search": "Search For Songs", 10 | "disc_jockey.screen.drop_hint": "Drag and drop song files into this window to add them", 11 | "disc_jockey.screen.drop_confirm": "Do you want to add the following songs to Disc Jockey?", 12 | "disc_jockey.player.invalid_note_blocks": "The Note Blocks near you are not in the correct configuration. Missing:", 13 | "disc_jockey.player.invalid_game_mode": "You can't play in %s", 14 | "disc_jockey.player.to_far": "You went to far away", 15 | "disc_jockey.still_loading": "The songs are still loading", 16 | "disc_jockey.reloading": "Reloading all songs", 17 | "disc_jockey.loading_done": "All songs are loaded", 18 | "disc_jockey.song_not_found": " Song '%s' does not exist", 19 | "disc_jockey.not_playing": "Not playing any song", 20 | "disc_jockey.speed_changed": "Changed playback speed to %s", 21 | "disc_jockey.stopped_playing": "Stopped playing '%s'", 22 | "disc_jockey.info_not_running": "No song is playing (Speed: %s)", 23 | "disc_jockey.info_tuning": "Tuning: (Speed: %s)", 24 | "disc_jockey.info_playing": "Playing: [%s/%s] %s (Speed: %s)", 25 | "disc_jockey.info_finished": "Finished: %s (Speed: %s)", 26 | "disc_jockey.instrument_info": "This maps instruments to be played by noteblocks for a different instrument instead.", 27 | "disc_jockey.invalid_instrument": "Invalid instrument: %s", 28 | "disc_jockey.instrument_mapped": "Mapped %s to %s", 29 | "disc_jockey.instrument_mapped_all": "Mapped all instruments to %s", 30 | "disc_jockey.instrument_unmapped": "Unmapped %s", 31 | "disc_jockey.mapped_instruments": "Mapped instruments: %s", 32 | "disc_jockey.no_mapped_instruments": "No instruments mapped, yet.", 33 | "disc_jockey.instrument_maps_cleared": "Instrument mappings cleared.", 34 | "disc_jockey.loop_status": "Loop song: %s", 35 | "disc_jockey.loop_enabled": "Enabled looping of current song.", 36 | "disc_jockey.loop_disabled": "Disabled looping of current song.", 37 | "disc_jockey.warning": "WARNING!!! This mod is very likely to get false flagged as hacks, please contact a server administrator before using this mod! (You can disable this warning in the mod settings)", 38 | "key.category.disc_jockey": "Disc Jockey", 39 | "disc_jockey.key_bind.open_screen": "Open song selection screen", 40 | "text.autoconfig.disc_jockey.title": "Disc Jockey", 41 | "text.autoconfig.disc_jockey.option.hideWarning": "Hide Warning", 42 | "text.autoconfig.disc_jockey.option.disableAsyncPlayback": "Disable Async Playback", 43 | "text.autoconfig.disc_jockey.option.disableAsyncPlayback.@Tooltip[0]": "Will force notes to play synchronously with client ticks instead of in a separate thread.", 44 | "text.autoconfig.disc_jockey.option.disableAsyncPlayback.@Tooltip[1]": "This can lead to performance loss, especially when you client has low or inconsistent fps but can fix issues when playback does not happen at all.", 45 | "text.autoconfig.disc_jockey.option.omnidirectionalNoteBlockSounds": "Omnidirectional Note Block Sounds (clientside)", 46 | "text.autoconfig.disc_jockey.option.omnidirectionalNoteBlockSounds.@Tooltip[0]": "Makes all note block sounds when playing a song omnidirectional, creating a more pleasing listening experience", 47 | "text.autoconfig.disc_jockey.option.omnidirectionalNoteBlockSounds.@Tooltip[1]": "If you don't know what that means, I recommend you just try it and hear the difference", 48 | "text.autoconfig.disc_jockey.option.expectedServerVersion": "Expected Server Version", 49 | "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[0]": "Select the server version, you expect this mod to be used on.", 50 | "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[1]": "This affects how reachable NoteBlocks are determined.", 51 | "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[2]": "Selecting the wrong version could cause you not to be able to play some distant note blocks which could break/worsen playback", 52 | "text.autoconfig.disc_jockey.option.expectedServerVersion.@Tooltip[3]": "If you're unsure, or play on many different server versions and don't mind not reaching every possible note block, select \"All\"", 53 | "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs": "Delay playback by (seconds)", 54 | "text.autoconfig.disc_jockey.option.delayPlaybackStartBySecs.@Tooltip": "Delays playback for specified seconds, after tuning finished, if any (e.g. 0.5 for half a second delay)." 55 | } -------------------------------------------------------------------------------- /src/main/resources/assets/disc_jockey/textures/gui/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SemmieDev/Disc-Jockey/875d9ada1b3f46b4de0147b071a63986bc5ddc1e/src/main/resources/assets/disc_jockey/textures/gui/icons.png -------------------------------------------------------------------------------- /src/main/resources/disc_jockey.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "semmiedev.disc_jockey.mixin", 5 | "compatibilityLevel": "JAVA_17", 6 | "mixins": [ 7 | "ClientWorldMixin" 8 | ], 9 | "injectors": { 10 | "defaultRequire": 1 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "disc_jockey", 4 | "version": "${version}", 5 | "name": "Disc Jockey", 6 | "description": "Play note block songs in Minecraft", 7 | "authors": [ 8 | "SemmieDev", 9 | "EnderKill98" 10 | ], 11 | "contact": { 12 | "repo": "https://github.com/SemmieDev/Disc-Jockey" 13 | }, 14 | "license": "MIT", 15 | "icon": "assets/disc_jockey/icon.png", 16 | "environment": "client", 17 | "entrypoints": { 18 | "client": [ 19 | "semmiedev.disc_jockey.Main" 20 | ], 21 | "modmenu": [ 22 | "semmiedev.disc_jockey.ModMenuIntegration" 23 | ] 24 | }, 25 | "mixins": [ 26 | "disc_jockey.mixins.json" 27 | ], 28 | "depends": { 29 | "fabric": "*", 30 | "minecraft": "~1.21", 31 | "cloth-config": "*" 32 | } 33 | } 34 | --------------------------------------------------------------------------------