diff --git a/.gitignore b/.gitignore index 19586e3e6..37399e16c 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/README.md b/README.md index b8d5f000c..6062e5db8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ It has by now acquired a lot more sophisticated features to make it as noninvasi Please read the [preprocessor's README](https://github.com/ReplayMod/preprocessor/blob/master/README.md) to understand how it works. ### Versioning -The ReplayMod uses the versioning scheme outlined [here](http://mcforge.readthedocs.io/en/latest/conventions/versioning/) +The ReplayMod uses the versioning scheme outlined [here](https://docs.minecraftforge.net/en/1.12.x/conventions/versioning/) with three changes: - No `MAJORAPI`, the ReplayMod does not provide any external API - "Updating to a new Minecraft version" should not increment `MAJORMOD`, we maintain one version of the ReplayMod diff --git a/build.gradle b/build.gradle index 192236b5f..dfefe2758 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ import com.replaymod.gradle.preprocess.PreprocessTask +import static gg.essential.gradle.util.PrebundleKt.prebundle buildscript { def mcVersion @@ -18,23 +19,23 @@ buildscript { name = "fabric" url = "https://maven.fabricmc.net/" } - if (!fabric) { - maven { - name = "forge" - url = "https://maven.minecraftforge.net" - } + maven { + name = "forge" + url = "https://maven.minecraftforge.net" } maven { name = "sonatype" url = "https://oss.sonatype.org/content/repositories/snapshots/" } maven { url 'https://jitpack.io' } + maven { url "https://maven.architectury.dev" } + maven { url "https://repo.essential.gg/repository/maven-public" } } dependencies { classpath 'gradle.plugin.com.github.jengelman.gradle.plugins:shadow:7.0.0' if (fabric) { - classpath 'fabric-loom:fabric-loom.gradle.plugin:0.8-SNAPSHOT' + classpath 'fabric-loom:fabric-loom.gradle.plugin:0.11-SNAPSHOT' } else if (mcVersion >= 11400) { classpath('net.minecraftforge.gradle:ForgeGradle:5.0.5') { // the FG people still haven't learned to not do breaking changes exclude group: 'trove', module: 'trove' // preprocessor/idea requires more recent one @@ -43,11 +44,12 @@ buildscript { classpath('com.github.ReplayMod:ForgeGradle:' + ( mcVersion >= 11200 ? '34ab703' : // FG 2.3 mcVersion >= 10904 ? '5d1e8d8' : // FG 2.2 - 'd1a7165' // FG 2.1 + 'ceb83c0' // FG 2.1 ) + ':all') } else { classpath 'com.github.ReplayMod:ForgeGradle:a8a9e0ca:all' // FG 1.2 } + classpath 'gg.essential:essential-gradle-toolkit:0.1.10' } } @@ -59,6 +61,7 @@ if (['1.10.2', '1.11', '1.11.2'].contains(jGuiVersion)) jGuiVersion = '1.9.4' if (['1.12.1', '1.12.2'].contains(jGuiVersion)) jGuiVersion = '1.12' def jGui = project.evaluationDependsOn(":jGui:$jGuiVersion") +apply plugin: 'kotlin' apply plugin: 'com.github.johnrengelman.shadow' if (mcVersion >= 10800) { @@ -96,6 +99,7 @@ preprocess { keywords.set([ ".java": PreprocessTask.DEFAULT_KEYWORDS, + ".kt": PreprocessTask.DEFAULT_KEYWORDS, ".json": PreprocessTask.DEFAULT_KEYWORDS, ".mcmeta": PreprocessTask.DEFAULT_KEYWORDS, ".cfg": PreprocessTask.CFG_KEYWORDS, @@ -108,9 +112,15 @@ preprocess { def mcVersionStr = "${(int)(mcVersion/10000)}.${(int)(mcVersion/100)%100}" + (mcVersion%100==0 ? '' : ".${mcVersion%100}") -sourceCompatibility = targetCompatibility = mcVersion >= 11700 ? 16 : 1.8 +sourceCompatibility = targetCompatibility = mcVersion >= 11800 ? 17 : mcVersion >= 11700 ? 16 : 1.8 tasks.withType(JavaCompile).configureEach { - options.release = mcVersion >= 11700 ? 16 : 8 + options.release = mcVersion >= 11800 ? 17 : mcVersion >= 11700 ? 16 : 8 +} +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = mcVersion >= 11700 ? 16 : 1.8 + freeCompilerArgs = ["-Xjvm-default=all", "-Xopt-in=kotlin.time.ExperimentalTime"] + } } if (mcVersion >= 11400) { @@ -124,8 +134,8 @@ group= "com.replaymod" archivesBaseName = "replaymod" if (FABRIC) { - minecraft { - refmapName = 'mixins.replaymod.refmap.json' + loom { + mixin.defaultRefmapName.set('mixins.replaymod.refmap.json') runConfigs.all { ideConfigGenerated = true } @@ -215,15 +225,20 @@ repositories { includeGroupByRegex 'com\\.github\\..*' } } + maven { + // url 'https://repo.essential.gg' + url 'https://repo.sk1er.club/repository/maven-public' + content { + includeGroup 'gg.essential' + } + } } configurations { // Include dep in fat jar without relocation and, when forge supports it, without exploding (TODO) shade - implementation.extendsFrom shade // Include dep in fat jar with relocation and minimization shadow - implementation.extendsFrom shadow } def shadeExclusions = { @@ -243,6 +258,13 @@ dependencies { 11604: '1.16.4', 11700: '1.17', 11701: '1.17.1', + 11800: '1.18', + 11801: '1.18.1', + 11802: '1.18.2', + 11900: '1.19', + 11901: '1.19.1', + 11902: '1.19.2', + 11903: '1.19.3-rc3', ][mcVersion] mappings 'net.fabricmc:yarn:' + [ 11404: '1.14.4+build.16', @@ -252,8 +274,15 @@ dependencies { 11604: '1.16.4+build.6:v2', 11700: '1.17+build.13:v2', 11701: '1.17.1+build.29:v2', + 11800: '1.18+build.1:v2', + 11801: '1.18.1+build.1:v2', + 11802: '1.18.2+build.1:v2', + 11900: '1.19+build.2:v2', + 11901: '1.19.1+build.5:v2', + 11902: '1.19.2+build.28:v2', + 11903: '1.19.3-rc3+build.1:v2', ][mcVersion] - modImplementation 'net.fabricmc:fabric-loader:0.11.6' + modImplementation 'net.fabricmc:fabric-loader:0.14.11' def fabricApiVersion = [ 11404: '0.4.3+build.247-1.14', 11502: '0.5.1+build.294-1.15', @@ -262,6 +291,13 @@ dependencies { 11604: '0.25.1+build.416-1.16', 11700: '0.36.0+1.17', 11701: '0.37.1+1.17', + 11800: '0.43.1+1.18', + 11801: '0.43.1+1.18', + 11802: '0.47.9+1.18.2', + 11900: '0.55.3+1.19', + 11901: '0.58.5+1.19.1', + 11902: '0.68.0+1.19.2', + 11903: '0.68.1+1.19.3', ][mcVersion] def fabricApiModules = [ "api-base", @@ -270,9 +306,11 @@ dependencies { "resource-loader-v0", ] if (mcVersion >= 11600) { + fabricApiModules.remove("keybindings-v0") fabricApiModules.add("key-binding-api-v1") } if (mcVersion >= 11700) { + fabricApiModules.remove("networking-v0") fabricApiModules.add("networking-api-v1") } fabricApiModules.each { module -> @@ -292,9 +330,9 @@ dependencies { def mixinVersion = mcVersion >= 11200 ? '0.8.2' : '0.7.11-SNAPSHOT' annotationProcessor "org.spongepowered:mixin:$mixinVersion" compileOnly "org.spongepowered:mixin:$mixinVersion" - shade("org.spongepowered:mixin:$mixinVersion") { + implementation(shade("org.spongepowered:mixin:$mixinVersion") { transitive = false // deps should all be bundled with MC - } + }) // Mixin needs these (and depends on them but for some reason that's not enough. FG, did you do that?) annotationProcessor 'com.google.code.gson:gson:2.2.4' @@ -302,61 +340,103 @@ dependencies { annotationProcessor 'org.ow2.asm:asm-tree:6.2' annotationProcessor 'org.apache.logging.log4j:log4j-core:2.0-beta9' } - shadow 'com.googlecode.mp4parser:isoparser:1.1.7' - shadow 'org.apache.commons:commons-exec:1.3' - shadow 'com.google.apis:google-api-services-youtube:v3-rev178-1.22.0', shadeExclusions - shadow 'com.google.api-client:google-api-client-gson:1.20.0', shadeExclusions - shadow 'com.google.api-client:google-api-client-java6:1.20.0', shadeExclusions - shadow 'com.google.oauth-client:google-oauth-client-jetty:1.20.0' - - if (mcVersion >= 11400) { // need lwjgl 3 - for (suffix in ['', ':natives-linux', ':natives-windows', ':natives-macos']) { - shadow('org.lwjgl:lwjgl-tinyexr:3.2.2' + suffix) { - exclude group: 'org.lwjgl', module: 'lwjgl' // comes with MC - } - } + + implementation(shadow(platform('org.jetbrains.kotlin:kotlin-bom'))) + implementation(shadow('org.jetbrains.kotlin:kotlin-stdlib-jdk8')) + + // TODO will need to port this to all the versions later, for now let's just guess the closest and hope for the best + String elementaMcVersion + if (mcVersion >= 11700) { + elementaMcVersion = '1.17.1' + } else if (mcVersion >= 11400) { + elementaMcVersion = '1.16.2' + } else if (mcVersion >= 11000) { + elementaMcVersion = '1.12.2' + } else { + elementaMcVersion = '1.8.9' + } + def elementaVersion = '458+pull-58' + if (fabric) { + modImplementation(shadow("gg.essential:elementa-$elementaMcVersion-fabric:$elementaVersion")) + } else { + implementation(shadow("gg.essential:elementa-$elementaMcVersion-forge:$elementaVersion")) + } + + implementation(shadow('com.googlecode.mp4parser:isoparser:1.1.7')) + implementation(shadow('org.apache.commons:commons-exec:1.3')) + implementation(shadow('com.google.apis:google-api-services-youtube:v3-rev178-1.22.0', shadeExclusions)) + implementation(shadow('com.google.api-client:google-api-client-gson:1.20.0', shadeExclusions)) + implementation(shadow('com.google.api-client:google-api-client-java6:1.20.0', shadeExclusions)) + implementation(shadow('com.google.oauth-client:google-oauth-client-jetty:1.20.0')) + + def lwjgl = configurations.create("lwjgl") + for (suffix in ['', ':natives-linux', ':natives-windows', ':natives-macos', ':natives-macos-arm64']) { + add(lwjgl.name, 'org.lwjgl:lwjgl:3.3.1' + suffix) + add(lwjgl.name, 'org.lwjgl:lwjgl-tinyexr:3.3.1' + suffix) } + compileOnly('org.lwjgl:lwjgl-tinyexr:3.3.1') + shadow(prebundle(project, lwjgl, "com/replaymod/render/utils/lwjgl.jar", {})) if (mcVersion < 11200) { // The version which MC ships is too old, we'll need to ship our own - shadow 'com.google.code.gson:gson:2.8.7' + implementation(shadow('com.google.code.gson:gson:2.8.7')) } - shadow 'com.github.javagl.JglTF:jgltf-model:3af6de4' + implementation(shadow('com.github.javagl.JglTF:jgltf-model:3af6de4')) if (FABRIC) { - shadow 'org.apache.maven:maven-artifact:3.6.1' + implementation(shadow('org.apache.maven:maven-artifact:3.6.1')) } - shadow 'org.aspectj:aspectjrt:1.8.2' + implementation(shadow('org.aspectj:aspectjrt:1.8.2')) - shadow 'com.github.ReplayMod.JavaBlend:2.79.0:a0696f8' + implementation(shadow('com.github.ReplayMod.JavaBlend:2.79.0:a0696f8')) - shadow "com.github.ReplayMod:ReplayStudio:c9de2f5", shadeExclusions + implementation(shadow('com.udojava:EvalEx:2.6')) - implementation(jGui){ + implementation(shadow("com.github.ReplayMod:ReplayStudio:21ef505", shadeExclusions)) + + // FIXME this should be pulled in by ReplayStudio, and IntelliJ sees it, but javac for some reason does not + implementation("com.github.viaversion:opennbt:0a02214") // 2.0-SNAPSHOT (ViaVersion Edition) + + implementation(FABRIC ? dependencies.project(path: jGui.path, configuration: "namedElements") : jGui) { transitive = false // FG 1.2 puts all MC deps into the compile configuration and we don't want to shade those } - shadow 'com.github.ReplayMod:lwjgl-utils:27dcd66' + implementation(shadow('com.github.ReplayMod:lwjgl-utils:27dcd66')) if (FABRIC) { - if (mcVersion >= 11700) { + if (mcVersion >= 11903) { + modImplementation 'com.terraformersmc:modmenu:5.0.0-alpha.4' + } else if (mcVersion >= 11901) { + modImplementation 'com.terraformersmc:modmenu:4.0.5' + } else if (mcVersion >= 11900) { + modImplementation 'com.terraformersmc:modmenu:4.0.4' + } else if (mcVersion >= 11802) { + modImplementation 'com.terraformersmc:modmenu:3.1.0' + } else if (mcVersion >= 11800) { + modImplementation 'com.terraformersmc:modmenu:3.0.0' + } else if (mcVersion >= 11700) { modImplementation 'com.terraformersmc:modmenu:2.0.0-beta.7' } else if (mcVersion >= 11602) { modImplementation 'com.terraformersmc:modmenu:1.16.8' } else if (mcVersion >= 11600) { - modImplementation 'io.github.prospector:modmenu:1.14.0+build.24' + modImplementation('com.terraformersmc:modmenu:1.14.15') { + exclude module: 'fabric-resource-loader-v0' // inappropriate version for 1.16.1 + } + } else if (mcVersion >= 11500) { + modImplementation 'com.terraformersmc:modmenu:1.10.6' } else { - modImplementation 'io.github.prospector.modmenu:ModMenu:1.6.2-92' + modCompileOnly 'com.terraformersmc:modmenu:1.10.6' } } if (mcVersion >= 11600) { - modCompileOnly 'com.github.IrisShaders:Iris:1.0.0' + modCompileOnly("com.github.IrisShaders:Iris:1.18.x~v1.2.0") { + transitive = false // we do not want to upgrade our libs, we only need this to compile our mixins + } } testImplementation 'junit:junit:4.11' - shadow 'com.udojava:EvalEx:2.6' } if (mcVersion <= 10710) { @@ -442,6 +522,10 @@ task configureRelocation() { } } } + // Kotlin will be filtered out because of the string constants shadow bug workaround, but we definitely need + // it and luckily that package is fairly distinct and unlikely to be in random string constants. + pkgs << 'kotlin' + pkgs }.flatten().unique() configureRelocationOutput.write(pkgs.join('\n')) @@ -495,6 +579,9 @@ tasks.register('bundleJar', com.github.jengelman.gradle.plugins.shadow.tasks.Sha exclude 'it/unimi/dsi/fastutil/**' } + // These are only required for kotlin-reflect which we are not going to use + exclude '**/*.kotlin_metadata' + minimize { exclude(dependency('.*spongepowered:mixin:.*')) } diff --git a/docs/content.md b/docs/content.md index faea3ff27..627465843 100755 --- a/docs/content.md +++ b/docs/content.md @@ -465,7 +465,7 @@ If you have a Replay in a dark setting (for example at nighttime, or in a cave) This works as a replacement for the **Night Vision Potion Effect**, without the side effect of a weird sky color. -## Quick Mode [quickmode] (Minecraft 1.9 and up) +## Quick Mode [quickmode] ![](img/quickmode-icon.jpg) In **Quick Mode**, this clock symbol is displayed in the lower right corner of the screen. @@ -473,8 +473,6 @@ When you first enable **Quick Mode** in a replay, an internal reference of certa As a side effect, certain features like particles and second skin layers will not be rendered in the preview. By default, **Quick Mode** is toggled with `Q`. -**Quick Mode** is available in ReplayMod for Minecraft 1.9.4 and up. - ## Player Overview [overview] ![](img/player-overview.jpg) The **Player Overview** Screen @@ -584,7 +582,10 @@ In General, the Replay Mod _should_ be compatible with most Forge and Fabric Mod ### Shaders Mod [shaders] _Karyonix' Shaders Mod_ is no longer compatible with Minecraft Forge starting with 1.9.4. As such it is not compatible with the Replay Mod either. -Please use _Optifine_ instead. + +Below Minecraft 1.16 you can try _Optifine_ instead. Note however, that official support has ended and many versions break ReplayMod. + +On Minecraft 1.16.5 and up you can use _Iris_, which is fully supported. For the time being, you will have to use it with the custom _Sodium_ provided on our download page. The fix it includes is pending for the official version. ### Custom Main Menu [custom-main-menu] The _Custom Main Menu_ mod is often used in mod packs to customize their Main Menu with a button layout fitting the background image, links to their website / bug tracker and similar. @@ -621,7 +622,7 @@ The _Baritone_ mod can cause a crash when you're trying to load a replay. If you Minecraft may crash if you try to use _RandomPatches_ together with ReplayMod. Try removing RandomPatches if Minecraft crashes on startup. ### Sodium [sodium] -ReplayMod can record when _Sodium_ is installed but will crash during render. Disable Sodium before rendering, it can be re-enabled after that. +ReplayMod can record when _Sodium_ is installed, but currently lacks the FREX Flawless Frames API to render. A modified build of _Sodium_, that supports this API, is available from the ReplayMod downloads, by clicking the `Click to show compatible Sodium versions` button. ### Resource Loader [resourceloader] The _Resource Loader_ mod is not compatible with ReplayMod. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0f80bbf51..e750102e0 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/jGui b/jGui index 31bcfabe6..7a80b50c2 160000 --- a/jGui +++ b/jGui @@ -1 +1 @@ -Subproject commit 31bcfabe67a5b2de490e0f5c53c1a59e636497be +Subproject commit 7a80b50c2e7cd0557c5ab670c59b46b256c0434c diff --git a/root.gradle.kts b/root.gradle.kts index 870e71bcb..21cbeb93a 100755 --- a/root.gradle.kts +++ b/root.gradle.kts @@ -2,8 +2,9 @@ import groovy.json.JsonOutput import java.io.ByteArrayOutputStream plugins { - id("fabric-loom") version "0.8-SNAPSHOT" apply false - id("com.replaymod.preprocess") version "123fb7a" + kotlin("jvm") version "1.5.21" apply false + id("fabric-loom") version "0.11-SNAPSHOT" apply false + id("com.replaymod.preprocess") version "48e02ad" id("com.github.hierynomus.license") version "0.15.0" } @@ -94,7 +95,11 @@ fun generateVersionsJson(): Map { // We dropped 1.7.10 with the Gradle 7 update but still kept its source in case someone // volunteers to update FG 1.2 to Gradle 7. .filterNot { it == "1.7.10" && versionComparator.compare(version, "2.6.0") >= 0 } - mcVersions.map { "$it-$version" } + val versions = mcVersions.map { "$it-$version" }.toMutableList() + when (version) { + "2.6.7" -> versions.add("1.19.1-2.6.7") // forgot to add the .gitkeep file before merging + } + versions }.flatten() val versions = commitVersions + tagVersions.reversed() @@ -189,6 +194,12 @@ val doRelease by tasks.registering { defaultTasks("bundleJar") preprocess { + val mc11903 = createNode("1.19.3", 11903, "yarn") + val mc11902 = createNode("1.19.2", 11902, "yarn") + val mc11901 = createNode("1.19.1", 11901, "yarn") + val mc11900 = createNode("1.19", 11900, "yarn") + val mc11802 = createNode("1.18.2", 11802, "yarn") + val mc11801 = createNode("1.18.1", 11801, "yarn") val mc11701 = createNode("1.17.1", 11701, "yarn") val mc11700 = createNode("1.17", 11700, "yarn") val mc11604 = createNode("1.16.4", 11604, "yarn") @@ -207,10 +218,16 @@ preprocess { val mc10800 = createNode("1.8", 10800, "srg") val mc10710 = createNode("1.7.10", 10710, "srg") + mc11903.link(mc11902, file("versions/mapping-fabric-1.19.3-1.19.2.txt")) + mc11902.link(mc11901) + mc11901.link(mc11900) + mc11900.link(mc11802, file("versions/mapping-fabric-1.19-1.18.2.txt")) + mc11802.link(mc11801) + mc11801.link(mc11701, file("versions/mapping-fabric-1.18.1-1.17.1.txt")) mc11701.link(mc11700) mc11700.link(mc11604, file("versions/mapping-fabric-1.17-1.16.4.txt")) mc11604.link(mc11601) - mc11601.link(mc11502) + mc11601.link(mc11502, file("versions/mapping-fabric-1.16.1-1.15.2.txt")) mc11502.link(mc11404, file("versions/mapping-fabric-1.15.2-1.14.4.txt")) mc11404.link(mc11404Forge, file("versions/mapping-1.14.4-fabric-forge.txt")) mc11404Forge.link(mc11202, file("versions/1.14.4-forge/mapping.txt")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 02c25403d..fa478e0a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,12 @@ val jGuiVersions = listOf( "1.16.4", "1.17", "1.17.1", + "1.18.1", + "1.18.2", + "1.19", + "1.19.1", + "1.19.2", + "1.19.3", ) val replayModVersions = listOf( // "1.7.10", @@ -50,6 +56,12 @@ val replayModVersions = listOf( "1.16.4", "1.17", "1.17.1", + "1.18.1", + "1.18.2", + "1.19", + "1.19.1", + "1.19.2", + "1.19.3", ) rootProject.buildFileName = "root.gradle.kts" diff --git a/src/main/java/com/replaymod/compat/shaders/mixin/MixinShaderRenderChunk.java b/src/main/java/com/replaymod/compat/shaders/mixin/MixinShaderRenderChunk.java index b803ed2d4..a692c6b01 100644 --- a/src/main/java/com/replaymod/compat/shaders/mixin/MixinShaderRenderChunk.java +++ b/src/main/java/com/replaymod/compat/shaders/mixin/MixinShaderRenderChunk.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11800 package com.replaymod.compat.shaders.mixin; import com.replaymod.render.hooks.EntityRendererHandler; diff --git a/src/main/java/com/replaymod/core/KeyBindingRegistry.java b/src/main/java/com/replaymod/core/KeyBindingRegistry.java index 6223717cf..48fe78305 100644 --- a/src/main/java/com/replaymod/core/KeyBindingRegistry.java +++ b/src/main/java/com/replaymod/core/KeyBindingRegistry.java @@ -14,7 +14,11 @@ //#if FABRIC>=1 import com.replaymod.core.versions.LangResourcePack; -import net.fabricmc.fabric.api.client.keybinding.FabricKeyBinding; +//#if MC>=11600 +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +//#else +//$$ import net.fabricmc.fabric.api.client.keybinding.FabricKeyBinding; +//#endif import net.minecraft.client.util.InputUtil; import net.minecraft.util.Identifier; import static com.replaymod.core.ReplayMod.MOD_ID; @@ -35,8 +39,8 @@ public class KeyBindingRegistry extends EventRegistrations { private static final String CATEGORY = "replaymod.title"; - //#if FABRIC>=1 - static { net.fabricmc.fabric.api.client.keybinding.KeyBindingRegistry.INSTANCE.addCategory(CATEGORY); } + //#if FABRIC>=1 && MC<11600 + //$$ static { net.fabricmc.fabric.api.client.keybinding.KeyBindingRegistry.INSTANCE.addCategory(CATEGORY); } //#endif private final Map bindings = new HashMap<>(); @@ -63,9 +67,15 @@ private Binding registerKeyBinding(String name, int keyCode, boolean onlyInRepay keyCode = -1; } Identifier id = new Identifier(MOD_ID, name.substring(LangResourcePack.LEGACY_KEY_PREFIX.length())); - FabricKeyBinding fabricKeyBinding = FabricKeyBinding.Builder.create(id, InputUtil.Type.KEYSYM, keyCode, CATEGORY).build(); - net.fabricmc.fabric.api.client.keybinding.KeyBindingRegistry.INSTANCE.register(fabricKeyBinding); - KeyBinding keyBinding = fabricKeyBinding; + //#if MC>=11600 + String key = String.format("key.%s.%s", id.getNamespace(), id.getPath()); + KeyBinding keyBinding = new KeyBinding(key, InputUtil.Type.KEYSYM, keyCode, CATEGORY); + KeyBindingHelper.registerKeyBinding(keyBinding); + //#else + //$$ FabricKeyBinding fabricKeyBinding = FabricKeyBinding.Builder.create(id, InputUtil.Type.KEYSYM, keyCode, CATEGORY).build(); + //$$ net.fabricmc.fabric.api.client.keybinding.KeyBindingRegistry.INSTANCE.register(fabricKeyBinding); + //$$ KeyBinding keyBinding = fabricKeyBinding; + //#endif //#else //$$ KeyBinding keyBinding = new KeyBinding(name, keyCode, CATEGORY); //$$ ClientRegistry.registerKeyBinding(keyBinding); @@ -176,6 +186,10 @@ public boolean isBound() { //#endif } + public boolean isPressed() { + return keyBinding.isPressed(); + } + public void trigger() { KeyBindingAccessor acc = (KeyBindingAccessor) keyBinding; acc.setPressTime(acc.getPressTime() + 1); diff --git a/src/main/java/com/replaymod/core/ReplayMod.java b/src/main/java/com/replaymod/core/ReplayMod.java index c21ed0be4..d31a57f7a 100644 --- a/src/main/java/com/replaymod/core/ReplayMod.java +++ b/src/main/java/com/replaymod/core/ReplayMod.java @@ -1,10 +1,10 @@ package com.replaymod.core; -import com.google.common.net.PercentEscaper; import com.replaymod.compat.ReplayModCompat; +import com.replaymod.core.files.ReplayFilesService; +import com.replaymod.core.files.ReplayFoldersService; import com.replaymod.core.gui.GuiBackgroundProcesses; import com.replaymod.core.gui.GuiReplaySettings; -import com.replaymod.core.gui.RestoreReplayGui; import com.replaymod.core.versions.MCVer; import com.replaymod.core.versions.scheduler.Scheduler; import com.replaymod.core.versions.scheduler.SchedulerImpl; @@ -14,14 +14,10 @@ import com.replaymod.render.ReplayModRender; import com.replaymod.replay.ReplayModReplay; import com.replaymod.replaystudio.lib.viaversion.api.protocol.version.ProtocolVersion; -import com.replaymod.replaystudio.replay.ReplayFile; -import com.replaymod.replaystudio.replay.ZipReplayFile; import com.replaymod.replaystudio.studio.ReplayStudio; import com.replaymod.replaystudio.util.I18n; import com.replaymod.simplepathing.ReplayModSimplePathing; -import de.johni0702.minecraft.gui.container.GuiScreen; import net.minecraft.client.MinecraftClient; -import net.minecraft.client.options.Option; import net.minecraft.resource.DirectoryResourcePack; import net.minecraft.text.LiteralText; import net.minecraft.text.Style; @@ -29,26 +25,22 @@ import net.minecraft.text.TranslatableText; import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.FilenameUtils; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +//#if MC>=11900 +//#else +import net.minecraft.client.options.Option; +//#endif + public class ReplayMod implements Module, Scheduler { public static final String MOD_ID = "replaymod"; @@ -73,6 +65,8 @@ public class ReplayMod implements Module, Scheduler { private final List modules = new ArrayList<>(); private final GuiBackgroundProcesses backgroundProcesses = new GuiBackgroundProcesses(); + public final ReplayFoldersService folders = new ReplayFoldersService(settingsRegistry); + public final ReplayFilesService files = new ReplayFilesService(folders); /** * Whether the current MC version is supported by the embedded ReplayStudio version. @@ -116,66 +110,21 @@ public SettingsRegistry getSettingsRegistry() { return settingsRegistry; } - public Path getReplayFolder() throws IOException { - String str = getSettingsRegistry().get(Setting.RECORDING_PATH); - return Files.createDirectories(getMinecraft().runDirectory.toPath().resolve(str)); - } - - /** - * Folder into which replay backups are saved before the MarkerProcessor is unleashed. - */ - public Path getRawReplayFolder() throws IOException { - return Files.createDirectories(getReplayFolder().resolve("raw")); - } - - /** - * Folder into which replays are recorded. - * Distinct from the main folder, so they cannot be opened while they are still saving. - */ - public Path getRecordingFolder() throws IOException { - return Files.createDirectories(getReplayFolder().resolve("recording")); - } - - /** - * Folder in which replay cache files are stored. - * Distinct from the recording folder cause people kept confusing them with recordings. - */ - public Path getCacheFolder() throws IOException { - String str = getSettingsRegistry().get(Setting.CACHE_PATH); - Path path = getMinecraft().runDirectory.toPath().resolve(str); - Files.createDirectories(path); - try { - Files.setAttribute(path, "dos:hidden", true); - } catch (UnsupportedOperationException ignored) { - } catch (Exception e) { - e.printStackTrace(); - } - return path; - } - - private static final PercentEscaper CACHE_FILE_NAME_ENCODER = new PercentEscaper("-_ ", false); - - public Path getCachePathForReplay(Path replay) throws IOException { - Path replayFolder = getReplayFolder(); - Path cacheFolder = getCacheFolder(); - Path relative = replayFolder.toAbsolutePath().relativize(replay.toAbsolutePath()); - return cacheFolder.resolve(CACHE_FILE_NAME_ENCODER.escape(relative.toString())); - } - - public Path getReplayPathForCache(Path cache) throws IOException { - String relative = URLDecoder.decode(cache.getFileName().toString(), "UTF-8"); - Path replayFolder = getReplayFolder(); - return replayFolder.resolve(relative); - } - public static final DirectoryResourcePack jGuiResourcePack = createJGuiResourcePack(); public static final String JGUI_RESOURCE_PACK_NAME = "replaymod_jgui"; private static DirectoryResourcePack createJGuiResourcePack() { File folder = new File("../jGui/src/main/resources"); if (!folder.exists()) { - return null; + folder = new File("../../../jGui/src/main/resources"); + if (!folder.exists()) { + return null; + } } + //#if MC>=11903 + //$$ return new DirectoryResourcePack(JGUI_RESOURCE_PACK_NAME, folder.toPath(), true) { + //#else return new DirectoryResourcePack(folder) { + //#endif @Override //#if MC>=11400 public String getName() { @@ -185,23 +134,37 @@ public String getName() { return JGUI_RESOURCE_PACK_NAME; } + //#if MC>=11903 + //$$ @Override + //$$ public net.minecraft.resource.InputSupplier openRoot(String... segments) { + //$$ if (segments.length == 1 && segments[0].equals("pack.mcmeta")) { + //$$ return () -> new ByteArrayInputStream(generatePackMeta()); + //$$ } + //$$ return super.openRoot(segments); + //$$ } + //#else @Override protected InputStream openFile(String resourceName) throws IOException { try { return super.openFile(resourceName); } catch (IOException e) { if ("pack.mcmeta".equals(resourceName)) { - //#if MC>=11400 - int version = 4; - //#else - //$$ int version = 1; - //#endif - return new ByteArrayInputStream(("{\"pack\": {\"description\": \"dummy pack for jGui resources in dev-env\", \"pack_format\": " - + version + "}}").getBytes(StandardCharsets.UTF_8)); + return new ByteArrayInputStream(generatePackMeta()); } throw e; } } + //#endif + + private byte[] generatePackMeta() { + //#if MC>=11400 + int version = 4; + //#else + //$$ int version = 1; + //#endif + return ("{\"pack\": {\"description\": \"dummy pack for jGui resources in dev-env\", \"pack_format\": " + + version + "}}").getBytes(StandardCharsets.UTF_8); + } }; } @@ -224,120 +187,14 @@ public void initClient() { keyBindingRegistry.register(); // 1.7.10 crashes when render distance > 16 - //#if MC>=10800 + // Post 1.19 this has become non-trivial to do, install Sodium+Bobby or OptiFine if you need it + //#if MC>=10800 && MC<11900 if (!MCVer.hasOptifine()) { Option.RENDER_DISTANCE.setMax(64f); } //#endif - runPostStartup(() -> { - final long DAYS = 24 * 60 * 60 * 1000; - - // Cleanup any cache folders still remaining in the recording folder (we once used to put them there) - try { - Files.walkFileTree(getReplayFolder(), new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - String name = dir.getFileName().toString(); - if (name.endsWith(".mcpr.cache")) { - FileUtils.deleteDirectory(dir.toFile()); - return FileVisitResult.SKIP_SUBTREE; - } - return super.preVisitDirectory(dir, attrs); - } - }); - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup raw folder content three weeks after creation (these are pretty valuable for debugging) - try (DirectoryStream paths = Files.newDirectoryStream(getRawReplayFolder())) { - for (Path path : paths) { - if (Files.getLastModifiedTime(path).toMillis() + 21 * DAYS < System.currentTimeMillis()) { - Files.delete(path); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Move anything which is still in the recording folder into the regular replay folder - // so it can be opened and/or recovered - try (DirectoryStream paths = Files.newDirectoryStream(getRecordingFolder())) { - for (Path path : paths) { - Path destination = getReplayFolder().resolve(path.getFileName()); - if (Files.exists(destination)) { - continue; // better play it save - } - Files.move(path, destination); - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup cache folders 7 days after last modification or when its replay is gone - try (DirectoryStream paths = Files.newDirectoryStream(getCacheFolder())) { - for (Path path : paths) { - if (Files.isDirectory(path)) { - Path replay = getReplayPathForCache(path); - long lastModified = Files.getLastModifiedTime(path).toMillis(); - if (lastModified + 7 * DAYS < System.currentTimeMillis() || !Files.exists(replay)) { - FileUtils.deleteDirectory(path.toFile()); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup deleted corrupted replays - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".mcpr.del") && Files.isDirectory(path)) { - long lastModified = Files.getLastModifiedTime(path).toMillis(); - if (lastModified + 2 * DAYS < System.currentTimeMillis()) { - FileUtils.deleteDirectory(path.toFile()); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Restore corrupted replays - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".mcpr.tmp") && Files.isDirectory(path)) { - Path original = path.resolveSibling(FilenameUtils.getBaseName(name)); - Path noRecoverMarker = original.resolveSibling(original.getFileName() + ".no_recover"); - if (Files.exists(noRecoverMarker)) { - // This file, when its markers are processed, doesn't actually result in any replays. - // So we don't really need to recover it either, let's just get rid of it. - FileUtils.deleteDirectory(path.toFile()); - Files.delete(noRecoverMarker); - continue; - } - new RestoreReplayGui(this, GuiScreen.wrap(mc.currentScreen), original.toFile()).display(); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - - // Cleanup leftover no_recover files - try (DirectoryStream paths = Files.newDirectoryStream(getReplayFolder())) { - for (Path path : paths) { - String name = path.getFileName().toString(); - if (name.endsWith(".no_recover")) { - Files.delete(path); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }); + runPostStartup(() -> files.initialScan(this)); } @Override @@ -437,17 +294,4 @@ public static boolean isCompatible(int fileFormatVersion, int protocolVersion) { return new ReplayStudio().isCompatible(fileFormatVersion, protocolVersion, MCVer.getProtocolVersion()); } } - - public ReplayFile openReplay(Path path) throws IOException { - return openReplay(path, path); - } - - public ReplayFile openReplay(Path input, Path output) throws IOException { - return new ZipReplayFile( - new ReplayStudio(), - input != null ? input.toFile() : null, - output.toFile(), - getCachePathForReplay(output).toFile() - ); - } } diff --git a/src/main/java/com/replaymod/core/ReplayModMixinConfigPlugin.java b/src/main/java/com/replaymod/core/ReplayModMixinConfigPlugin.java index 08259e21f..443a21e08 100644 --- a/src/main/java/com/replaymod/core/ReplayModMixinConfigPlugin.java +++ b/src/main/java/com/replaymod/core/ReplayModMixinConfigPlugin.java @@ -9,6 +9,10 @@ import java.util.List; import java.util.Set; +//#if FABRIC +import net.fabricmc.loader.api.FabricLoader; +//#endif + //#if MC>=11400 import java.io.InputStream; //#else @@ -34,6 +38,9 @@ static boolean hasClass(String name) throws IOException { private final Logger logger = LogManager.getLogger("replaymod/mixin"); private final boolean hasOF = hasClass("optifine.OptiFineForgeTweaker") || hasClass("me.modmuss50.optifabric.mod.Optifabric"); + //#if FABRIC + private final boolean hasIris = FabricLoader.getInstance().isModLoaded("iris"); + //#endif { logger.debug("hasOF: " + hasOF); @@ -49,6 +56,9 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { } if (mixinClassName.endsWith("_OF")) return hasOF; if (mixinClassName.endsWith("_NoOF")) return !hasOF; + //#if FABRIC + if (mixinClassName.endsWith("_Iris")) return hasIris; + //#endif return true; } diff --git a/src/main/java/com/replaymod/core/SettingsRegistryBackend.java b/src/main/java/com/replaymod/core/SettingsRegistryBackend.java index eb996915e..90f50d86f 100644 --- a/src/main/java/com/replaymod/core/SettingsRegistryBackend.java +++ b/src/main/java/com/replaymod/core/SettingsRegistryBackend.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; +import static com.replaymod.core.utils.Utils.ensureDirectoryExists; import static com.replaymod.core.versions.MCVer.getMinecraft; class SettingsRegistryBackend { @@ -179,7 +180,7 @@ public void save() { Gson gson = new GsonBuilder().setPrettyPrinting().create(); String config = gson.toJson(root); try { - Files.createDirectories(configFile.getParent()); + ensureDirectoryExists(configFile.getParent()); Files.write(configFile, config.getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { e.printStackTrace(); diff --git a/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java b/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java new file mode 100644 index 000000000..26950f93b --- /dev/null +++ b/src/main/java/com/replaymod/core/files/DelegatingReplayFile.java @@ -0,0 +1,202 @@ +package com.replaymod.core.files; + +import com.replaymod.replaystudio.data.Marker; +import com.replaymod.replaystudio.data.ModInfo; +import com.replaymod.replaystudio.data.ReplayAssetEntry; +import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.lib.guava.base.Optional; +import com.replaymod.replaystudio.pathing.PathingRegistry; +import com.replaymod.replaystudio.pathing.path.Timeline; +import com.replaymod.replaystudio.protocol.PacketTypeRegistry; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ReplayMetaData; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +public class DelegatingReplayFile implements ReplayFile { + private final ReplayFile delegate; + + public DelegatingReplayFile(ReplayFile delegate) { + this.delegate = delegate; + } + + @Override + public Optional get(String entry) throws IOException { + return this.delegate.get(entry); + } + + @Override + public Optional getCache(String entry) throws IOException { + return this.delegate.getCache(entry); + } + + @Override + public Map getAll(Pattern pattern) throws IOException { + return this.delegate.getAll(pattern); + } + + @Override + public OutputStream write(String entry) throws IOException { + return this.delegate.write(entry); + } + + @Override + public OutputStream writeCache(String entry) throws IOException { + return this.delegate.writeCache(entry); + } + + @Override + public void remove(String entry) throws IOException { + this.delegate.remove(entry); + } + + @Override + public void removeCache(String entry) throws IOException { + this.delegate.removeCache(entry); + } + + @Override + public void save() throws IOException { + this.delegate.save(); + } + + @Override + public void saveTo(File target) throws IOException { + this.delegate.saveTo(target); + } + + @Override + public ReplayMetaData getMetaData() throws IOException { + return this.delegate.getMetaData(); + } + + @Override + public void writeMetaData(PacketTypeRegistry registry, ReplayMetaData metaData) throws IOException { + this.delegate.writeMetaData(registry, metaData); + } + + @Override + public ReplayInputStream getPacketData(PacketTypeRegistry registry) throws IOException { + return this.delegate.getPacketData(registry); + } + + @Override + public ReplayOutputStream writePacketData() throws IOException { + return this.delegate.writePacketData(); + } + + @Override + public Map getResourcePackIndex() throws IOException { + return this.delegate.getResourcePackIndex(); + } + + @Override + public void writeResourcePackIndex(Map index) throws IOException { + this.delegate.writeResourcePackIndex(index); + } + + @Override + public Optional getResourcePack(String hash) throws IOException { + return this.delegate.getResourcePack(hash); + } + + @Override + public OutputStream writeResourcePack(String hash) throws IOException { + return this.delegate.writeResourcePack(hash); + } + + @Override + public Map getTimelines(PathingRegistry pathingRegistry) throws IOException { + return this.delegate.getTimelines(pathingRegistry); + } + + @Override + public void writeTimelines(PathingRegistry pathingRegistry, Map timelines) throws IOException { + this.delegate.writeTimelines(pathingRegistry, timelines); + } + + @Override + public Optional getThumb() throws IOException { + return this.delegate.getThumb(); + } + + @Override + public void writeThumb(BufferedImage image) throws IOException { + this.delegate.writeThumb(image); + } + + @Override + public Optional getThumbBytes() throws IOException { + return this.delegate.getThumbBytes(); + } + + @Override + public void writeThumbBytes(byte[] image) throws IOException { + this.delegate.writeThumbBytes(image); + } + + @Override + public Optional> getInvisiblePlayers() throws IOException { + return this.delegate.getInvisiblePlayers(); + } + + @Override + public void writeInvisiblePlayers(Set uuids) throws IOException { + this.delegate.writeInvisiblePlayers(uuids); + } + + @Override + public Optional> getMarkers() throws IOException { + return this.delegate.getMarkers(); + } + + @Override + public void writeMarkers(Set markers) throws IOException { + this.delegate.writeMarkers(markers); + } + + @Override + public Collection getAssets() throws IOException { + return this.delegate.getAssets(); + } + + @Override + public Optional getAsset(UUID uuid) throws IOException { + return this.delegate.getAsset(uuid); + } + + @Override + public OutputStream writeAsset(ReplayAssetEntry asset) throws IOException { + return this.delegate.writeAsset(asset); + } + + @Override + public void removeAsset(UUID uuid) throws IOException { + this.delegate.removeAsset(uuid); + } + + @Override + public Collection getModInfo() throws IOException { + return this.delegate.getModInfo(); + } + + @Override + public void writeModInfo(Collection modInfo) throws IOException { + this.delegate.writeModInfo(modInfo); + } + + @Override + public void close() throws IOException { + this.delegate.close(); + } +} diff --git a/src/main/java/com/replaymod/core/files/ManagedReplayFile.java b/src/main/java/com/replaymod/core/files/ManagedReplayFile.java new file mode 100644 index 000000000..e000e3541 --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ManagedReplayFile.java @@ -0,0 +1,23 @@ +package com.replaymod.core.files; + +import com.replaymod.replaystudio.replay.ReplayFile; + +import java.io.IOException; + +public class ManagedReplayFile extends DelegatingReplayFile { + private Runnable onClose; + + public ManagedReplayFile(ReplayFile delegate, Runnable onClose) { + super(delegate); + + this.onClose = onClose; + } + + @Override + public void close() throws IOException { + super.close(); + + onClose.run(); + onClose = () -> {}; + } +} diff --git a/src/main/java/com/replaymod/core/files/ReplayFilesService.java b/src/main/java/com/replaymod/core/files/ReplayFilesService.java new file mode 100644 index 000000000..9090f4b5a --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ReplayFilesService.java @@ -0,0 +1,192 @@ +package com.replaymod.core.files; + +import com.replaymod.core.ReplayMod; +import com.replaymod.core.gui.RestoreReplayGui; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ZipReplayFile; +import com.replaymod.replaystudio.studio.ReplayStudio; +import de.johni0702.minecraft.gui.container.GuiScreen; +import net.minecraft.util.Util; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class ReplayFilesService { + private final ReplayFoldersService folders; + private final Set lockedPaths = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + public ReplayFilesService(ReplayFoldersService folders) { + this.folders = folders; + } + + public ReplayFile open(Path path) throws IOException { + return open(path, path); + } + + public ReplayFile open(Path input, Path output) throws IOException { + Path realInput = input != null ? input.toAbsolutePath().normalize() : null; + Path realOutput = output.toAbsolutePath().normalize(); + + if (realInput != null && !lockedPaths.add(realInput)) { + throw new FileLockedException(realInput); + } + if (!Objects.equals(realInput, realOutput) && !lockedPaths.add(realOutput)) { + if (realInput != null) { + lockedPaths.remove(realInput); + } + throw new FileLockedException(realOutput); + } + + Runnable onClose = () -> { + if (realInput != null) { + lockedPaths.remove(realInput); + } + lockedPaths.remove(realOutput); + }; + + ReplayFile replayFile; + try { + replayFile = new ZipReplayFile( + new ReplayStudio(), + realInput != null ? realInput.toFile() : null, + realOutput.toFile(), + folders.getCachePathForReplay(realOutput).toFile() + ); + } catch (IOException e) { + onClose.run(); + throw e; + } + return new ManagedReplayFile(replayFile, onClose); + } + + public void initialScan(ReplayMod core) { + // Move anything which is still in the recording folder into the regular replay folder + // so it can be opened and/or recovered + try (DirectoryStream paths = Files.newDirectoryStream(folders.getRecordingFolder())) { + for (Path path : paths) { + Path destination = folders.getReplayFolder().resolve(path.getFileName()); + if (Files.exists(destination)) { + continue; // better play it save + } + Files.move(path, destination); + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Restore corrupted replays + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".mcpr.tmp") && Files.isDirectory(path)) { + Path original = path.resolveSibling(FilenameUtils.getBaseName(name)); + Path noRecoverMarker = original.resolveSibling(original.getFileName() + ".no_recover"); + if (Files.exists(noRecoverMarker)) { + // This file, when its markers are processed, doesn't actually result in any replays. + // So we don't really need to recover it either, let's just get rid of it. + FileUtils.deleteDirectory(path.toFile()); + Files.delete(noRecoverMarker); + continue; + } + new RestoreReplayGui(core, GuiScreen.wrap(core.getMinecraft().currentScreen), original.toFile()).display(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Run general purpose, non-essential cleanup in a background thread + new Thread(this::cleanup, "replaymod-cleanup").start(); + } + + private void cleanup() { + final long DAYS = 24 * 60 * 60 * 1000; + + // Cleanup any cache folders still remaining in the recording folder (we once used to put them there) + try { + Files.walkFileTree(folders.getReplayFolder(), new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + String name = dir.getFileName().toString(); + if (name.endsWith(".mcpr.cache")) { + FileUtils.deleteDirectory(dir.toFile()); + return FileVisitResult.SKIP_SUBTREE; + } + return super.preVisitDirectory(dir, attrs); + } + }); + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup raw folder content three weeks after creation (these are pretty valuable for debugging) + try (DirectoryStream paths = Files.newDirectoryStream(folders.getRawReplayFolder())) { + for (Path path : paths) { + if (Files.getLastModifiedTime(path).toMillis() + 21 * DAYS < System.currentTimeMillis()) { + Files.delete(path); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup cache folders 7 days after last modification or when its replay is gone + try (DirectoryStream paths = Files.newDirectoryStream(folders.getCacheFolder())) { + for (Path path : paths) { + if (Files.isDirectory(path)) { + Path replay = folders.getReplayPathForCache(path); + long lastModified = Files.getLastModifiedTime(path).toMillis(); + if (lastModified + 7 * DAYS < System.currentTimeMillis() || !Files.exists(replay)) { + FileUtils.deleteDirectory(path.toFile()); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup deleted corrupted replays + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".mcpr.del") && Files.isDirectory(path)) { + long lastModified = Files.getLastModifiedTime(path).toMillis(); + if (lastModified + 2 * DAYS < System.currentTimeMillis()) { + FileUtils.deleteDirectory(path.toFile()); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Cleanup leftover no_recover files + try (DirectoryStream paths = Files.newDirectoryStream(folders.getReplayFolder())) { + for (Path path : paths) { + String name = path.getFileName().toString(); + if (name.endsWith(".no_recover")) { + Files.delete(path); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static class FileLockedException extends IOException { + public FileLockedException(Path path) { + super(path.toString()); + } + } +} diff --git a/src/main/java/com/replaymod/core/files/ReplayFoldersService.java b/src/main/java/com/replaymod/core/files/ReplayFoldersService.java new file mode 100644 index 000000000..2803b8917 --- /dev/null +++ b/src/main/java/com/replaymod/core/files/ReplayFoldersService.java @@ -0,0 +1,71 @@ +package com.replaymod.core.files; + +import com.google.common.net.PercentEscaper; +import com.replaymod.core.Setting; +import com.replaymod.core.SettingsRegistry; +import net.minecraft.client.MinecraftClient; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.Path; + +import static com.replaymod.core.utils.Utils.ensureDirectoryExists; + +public class ReplayFoldersService { + private final Path mcDir = MinecraftClient.getInstance().runDirectory.toPath(); + private final SettingsRegistry settings; + + public ReplayFoldersService(SettingsRegistry settings) { + this.settings = settings; + } + + public Path getReplayFolder() throws IOException { + return ensureDirectoryExists(mcDir.resolve(settings.get(Setting.RECORDING_PATH))); + } + + /** + * Folder into which replay backups are saved before the MarkerProcessor is unleashed. + */ + public Path getRawReplayFolder() throws IOException { + return ensureDirectoryExists(getReplayFolder().resolve("raw")); + } + + /** + * Folder into which replays are recorded. + * Distinct from the main folder, so they cannot be opened while they are still saving. + */ + public Path getRecordingFolder() throws IOException { + return ensureDirectoryExists(getReplayFolder().resolve("recording")); + } + + /** + * Folder in which replay cache files are stored. + * Distinct from the recording folder cause people kept confusing them with recordings. + */ + public Path getCacheFolder() throws IOException { + Path path = ensureDirectoryExists(mcDir.resolve(settings.get(Setting.CACHE_PATH))); + try { + Files.setAttribute(path, "dos:hidden", true); + } catch (UnsupportedOperationException ignored) { + } catch (Exception e) { + e.printStackTrace(); + } + return path; + } + + private static final PercentEscaper CACHE_FILE_NAME_ENCODER = new PercentEscaper("-_ ", false); + + public Path getCachePathForReplay(Path replay) throws IOException { + Path replayFolder = getReplayFolder(); + Path cacheFolder = getCacheFolder(); + Path relative = replayFolder.toAbsolutePath().relativize(replay.toAbsolutePath()); + return cacheFolder.resolve(CACHE_FILE_NAME_ENCODER.escape(relative.toString())); + } + + public Path getReplayPathForCache(Path cache) throws IOException { + String relative = URLDecoder.decode(cache.getFileName().toString(), "UTF-8"); + Path replayFolder = getReplayFolder(); + return replayFolder.resolve(relative); + } +} diff --git a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java index 4d9ba137b..c68a3210c 100644 --- a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java +++ b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java @@ -56,11 +56,16 @@ public RestoreReplayGui(ReplayMod core, GuiScreen parent, File file) { new GuiLabel().setI18nText("replaymod.gui.restorereplay1"), new GuiLabel().setI18nText("replaymod.gui.restorereplay2", Files.getNameWithoutExtension(file.getName())), new GuiLabel().setI18nText("replaymod.gui.restorereplay3")); + + LOGGER.info("Found partially saved replay, offering recovery: " + file); + yesButton.onClick(() -> { + LOGGER.info("Attempting recovery: " + file); recoverInBackground(); parent.display(); }); noButton.onClick(() -> { + LOGGER.info("Recovery rejected, marking for deletion: " + file); try { File tmp = new File(file.getParentFile(), file.getName() + ".tmp"); File deleted = new File(file.getParentFile(), file.getName() + ".del"); @@ -108,7 +113,7 @@ private void recoverInBackground() { } private void tryRecover(Consumer progress) throws IOException { - ReplayFile replayFile = ReplayMod.instance.openReplay(file.toPath()); + ReplayFile replayFile = ReplayMod.instance.files.open(file.toPath()); // Commit all not-yet-committed files into the main zip file. // If we don't do this, then re-writing packet data below can actually overwrite uncommitted packet data! replayFile.save(); diff --git a/src/main/java/com/replaymod/core/mixin/GuiMainMenuAccessor.java b/src/main/java/com/replaymod/core/mixin/GuiMainMenuAccessor.java deleted file mode 100644 index 3716e1abd..000000000 --- a/src/main/java/com/replaymod/core/mixin/GuiMainMenuAccessor.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.replaymod.core.mixin; - -import net.minecraft.client.gui.screen.TitleScreen; -import net.minecraft.client.gui.screen.Screen; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -@Mixin(TitleScreen.class) -public interface GuiMainMenuAccessor { - //#if MC>=10904 - @Accessor("realmsNotificationGui") - Screen getRealmsNotification(); - @Accessor("realmsNotificationGui") - void setRealmsNotification(Screen value); - //#endif -} diff --git a/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java b/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java index 8f26e4f55..5e09f7875 100644 --- a/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java +++ b/src/main/java/com/replaymod/core/mixin/MinecraftAccessor.java @@ -10,6 +10,10 @@ import java.util.Queue; +//#if MC>=11800 +//$$ import java.util.function.Supplier; +//#endif + //#if MC>=11400 import java.util.concurrent.CompletableFuture; //#endif @@ -49,7 +53,11 @@ public interface MinecraftAccessor { //#endif @Accessor("crashReport") + //#if MC>=11800 + //$$ Supplier getCrashReporter(); + //#else CrashReport getCrashReporter(); + //#endif //#if MC<11400 //$$ @Accessor diff --git a/src/main/java/com/replaymod/core/mixin/MixinMinecraft.java b/src/main/java/com/replaymod/core/mixin/MixinMinecraft.java index 626827448..754cd0901 100644 --- a/src/main/java/com/replaymod/core/mixin/MixinMinecraft.java +++ b/src/main/java/com/replaymod/core/mixin/MixinMinecraft.java @@ -63,6 +63,7 @@ private void postRender(boolean unused, CallbackInfo ci) { PostRenderCallback.EVENT.invoker().postRender(); } //#else + //$$ @Shadow long systemTime; //#if MC>=10904 //$$ @Shadow protected abstract void runTickKeyboard() throws IOException; //$$ @Shadow protected abstract void runTickMouse() throws IOException; @@ -80,6 +81,8 @@ private void postRender(boolean unused, CallbackInfo ci) { //$$ public void replayModRunTickMouse() { //$$ try { //$$ runTickMouse(); + //$$ // Update last tick time (MC ignores inputs when there hasn't been a tick in 200ms) + //$$ systemTime = Minecraft.getSystemTime(); //$$ } catch (IOException e) { //$$ e.printStackTrace(); //$$ } @@ -94,7 +97,12 @@ private void postRender(boolean unused, CallbackInfo ci) { //$$ //$$ @Inject(method = "runTick", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;sendClickBlockToController(Z)V"), cancellable = true) //$$ private void doEarlyReturnFromRunTick(CallbackInfo ci) { - //$$ if (earlyReturn) ci.cancel(); + //$$ if (earlyReturn) { + //$$ ci.cancel(); + //$$ + //$$ // Update last tick time (MC ignores inputs when there hasn't been a tick in 200ms) + //$$ systemTime = Minecraft.getSystemTime(); + //$$ } //$$ } //#endif //$$ @Redirect( diff --git a/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java b/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java new file mode 100644 index 000000000..07b5fddde --- /dev/null +++ b/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java @@ -0,0 +1 @@ +// 1.19+ diff --git a/src/main/java/com/replaymod/core/utils/Utils.java b/src/main/java/com/replaymod/core/utils/Utils.java index 50c328c81..826f78fa6 100644 --- a/src/main/java/com/replaymod/core/utils/Utils.java +++ b/src/main/java/com/replaymod/core/utils/Utils.java @@ -1,7 +1,6 @@ package com.replaymod.core.utils; import com.google.common.base.Throwables; -import com.google.common.collect.Iterables; import com.google.common.net.PercentEscaper; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; @@ -27,7 +26,6 @@ import de.johni0702.minecraft.gui.versions.MCVer; import net.minecraft.client.gui.screen.Screen; import net.minecraft.util.crash.CrashReport; -import net.minecraft.util.Identifier; import org.apache.commons.io.Charsets; import org.apache.commons.io.FilenameUtils; import org.apache.logging.log4j.LogManager; @@ -38,15 +36,6 @@ //$$ import org.lwjgl.input.Keyboard; //#endif -//#if MC>=10800 -import net.minecraft.client.network.PlayerListEntry; -import net.minecraft.client.util.DefaultSkinHelper; -//#else -//$$ import net.minecraft.client.Minecraft; -//$$ import net.minecraft.client.entity.AbstractClientPlayer; -//$$ import net.minecraft.entity.player.EntityPlayer; -//#endif - import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.net.ssl.SSLContext; @@ -55,8 +44,14 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -166,8 +161,54 @@ public static boolean isValidEmailAddress(String mail) { private static final PercentEscaper REPLAY_NAME_ENCODER = new PercentEscaper(".-_ ", false); - public static String replayNameToFileName(String replayName) { - return REPLAY_NAME_ENCODER.escape(replayName) + ".mcpr"; + public static Path replayNameToPath(Path folder, String replayName) { + // If we can, prefer directly using the replay name as the file name + if (isUsable(folder, replayName + ".mcpr")) { + return folder.resolve(replayName + ".mcpr"); + } else { + // otherwise, fall back to percent encoding + return folder.resolve(REPLAY_NAME_ENCODER.escape(replayName) + ".mcpr"); + } + } + + /** + * Checks whether a given file name is actually usable with the file system / operating system at the given folder. + */ + private static boolean isUsable(Path folder, String fileName) { + if (fileName.contains(folder.getFileSystem().getSeparator())) { + return false; // file name contains the name separator, definitely not usable + } + + Path path; + try { + path = folder.resolve(fileName); + } catch (InvalidPathException e) { + return false; // file name contains invalid characters, definitely not usable + } + if (Files.exists(path)) { + return true; // if it already exits, it's definitely usable + } + + // Otherwise, there's no sure way to know, so we just gotta try + try (OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW)) { + outputStream.flush(); + } catch (IOException e) { + return false; + } + + // Looking good, but now we gotta clean up that mess (and Anti-Virus / Cloud Sync are know to lock them) + int attempts = 0; + while (true) { + try { + Files.delete(path); + return true; + } catch (IOException e) { + if (attempts++ > 100) { + LOGGER.warn("Repeatedly failed to clean up temporary test file at " + path + ": ", e); + return false; // while we were able to use it, it's taken now and we can't get it back + } + } + } } public static String fileNameToReplayName(String fileName) { @@ -181,25 +222,6 @@ public static String fileNameToReplayName(String fileName) { } } - public static Identifier getResourceLocationForPlayerUUID(UUID uuid) { - //#if MC>=10800 - PlayerListEntry info = getMinecraft().getNetworkHandler().getPlayerListEntry(uuid); - Identifier skinLocation; - if (info != null && info.hasSkinTexture()) { - skinLocation = info.getSkinTexture(); - } else { - skinLocation = DefaultSkinHelper.getTexture(uuid); - } - return skinLocation; - //#else - //$$ EntityPlayer player = Minecraft.getMinecraft().theWorld.getPlayerEntityByUUID(uuid); - //$$ if (player != null || !(player instanceof AbstractClientPlayer)) { - //$$ return AbstractClientPlayer.locationStevePng; - //$$ } - //$$ return ((AbstractClientPlayer) player).getLocationSkin(); - //#endif - } - public static boolean isCtrlDown() { //#if MC>=11400 return Screen.hasControlDown(); @@ -356,4 +378,14 @@ public static T configure(T instance, Consumer configure) { configure.accept(instance); return instance; } + + /** + * Like {@link Files#createDirectories(Path, FileAttribute[])} but doesn't explode if it's a symlink. + */ + public static Path ensureDirectoryExists(Path path) throws IOException { + // Who in their right mind thought the default behavior of throwing when the target is a link to a directory + // was the preferred behavior?! Everyone has to fall for this at least once to learn it... + // https://bugs.openjdk.java.net/browse/JDK-8130464 + return Files.createDirectories(Files.exists(path) ? path.toRealPath() : path); + } } diff --git a/src/main/java/com/replaymod/core/versions/LangResourcePack.java b/src/main/java/com/replaymod/core/versions/LangResourcePack.java index 63e4304c8..41fa3e147 100644 --- a/src/main/java/com/replaymod/core/versions/LangResourcePack.java +++ b/src/main/java/com/replaymod/core/versions/LangResourcePack.java @@ -4,7 +4,6 @@ import com.google.gson.Gson; import com.replaymod.core.ReplayMod; import net.minecraft.resource.AbstractFileResourcePack; -import net.minecraft.resource.ResourceNotFoundException; import net.minecraft.resource.ResourceType; import net.minecraft.util.Identifier; import org.apache.commons.io.IOUtils; @@ -16,16 +15,18 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; +import java.util.stream.Stream; //#if FABRIC>=1 import net.fabricmc.loader.api.FabricLoader; @@ -33,6 +34,11 @@ //#else //#endif +//#if MC>=11903 +//$$ import java.util.Objects; +//$$ import net.minecraft.resource.InputSupplier; +//#endif + //#if MC>=11400 //#else //$$ import net.minecraft.resources.IPackFinder; @@ -58,7 +64,11 @@ public class LangResourcePack extends AbstractFileResourcePack { private final Path basePath; public LangResourcePack() { + //#if MC>=11903 + //$$ super(NAME, true); + //#else super(new File(NAME)); + //#endif //#if FABRIC>=1 ModContainer container = FabricLoader.getInstance().getModContainer(ReplayMod.MOD_ID).orElseThrow(IllegalAccessError::new); @@ -110,14 +120,45 @@ private String convertValue(String value) { return value; } + //#if MC>=11903 + //$$ @Override + //$$ public InputSupplier openRoot(String... segments) { + //$$ byte[] bytes; + //$$ try { + //$$ bytes = readFile(String.join("/", segments)); + //$$ } catch (IOException e) { + //$$ throw new RuntimeException(e); + //$$ } + //$$ if (bytes == null) { + //$$ return null; + //$$ } + //$$ return () -> new ByteArrayInputStream(bytes); + //$$ } + //#endif + + //#if MC>=11903 + //$$ @Override + //$$ public InputSupplier open(ResourceType type, Identifier id) { + //$$ return openRoot(type.getDirectory(), id.getNamespace(), id.getPath()); + //$$ } + //#else @Override protected InputStream openFile(String path) throws IOException { + byte[] bytes = readFile(path); + if (bytes == null) { + throw new net.minecraft.resource.ResourceNotFoundException(this.base, path); + } + return new ByteArrayInputStream(bytes); + } + //#endif + + private byte[] readFile(String path) throws IOException { if ("pack.mcmeta".equals(path)) { - return new ByteArrayInputStream("{\"pack\": {\"description\": \"ReplayMod language files\", \"pack_format\": 4}}".getBytes(StandardCharsets.UTF_8)); + return "{\"pack\": {\"description\": \"ReplayMod language files\", \"pack_format\": 4}}".getBytes(StandardCharsets.UTF_8); } Path langPath = langPath(path); - if (langPath == null) throw new ResourceNotFoundException(this.base, path); + if (langPath == null) return null; List langFile; try (InputStream in = Files.newInputStream(langPath)) { @@ -141,16 +182,25 @@ protected InputStream openFile(String path) throws IOException { properties.put(key, value); } - return new ByteArrayInputStream(GSON.toJson(properties).getBytes(StandardCharsets.UTF_8)); + return GSON.toJson(properties).getBytes(StandardCharsets.UTF_8); } + //#if MC>=11903 + //#else @Override protected boolean containsFile(String path) { Path langPath = langPath(path); return langPath != null && Files.exists(langPath); } + //#endif + //#if MC>=11903 + //$$ @Override + //$$ public void findResources(ResourceType type, String namespace, String prefix, ResultConsumer consumer) { + //$$ findResources(type, prefix, id -> consumer.accept(id, () -> new ByteArrayInputStream(Objects.requireNonNull(readFile(id.getPath()))))); + //$$ } + //#else @Override public Collection findResources( ResourceType resourcePackType, @@ -158,31 +208,46 @@ public Collection findResources( String namespace, //#endif String path, + //#if MC>=11900 + //$$ Predicate filter + //#else int maxDepth, - Predicate filter - ) { - if (resourcePackType == ResourceType.CLIENT_RESOURCES && "lang".equals(path)) { - Path base = baseLangPath(); - //#if MC<11400 - //$$ if (base == null) return Collections.emptyList(); + Predicate pathFilter //#endif - try { - return Files.walk(base, 1) - .skip(1) - .filter(Files::isRegularFile) - .map(Path::getFileName).map(Path::toString) - .map(LANG_FILE_NAME_PATTERN::matcher) - .filter(Matcher::matches) - .map(matcher -> String.format("%s_%s.json", matcher.group(1), matcher.group(1))) - .filter(filter::test) - .map(name -> new Identifier(ReplayMod.MOD_ID, "lang/" + name)) - .collect(Collectors.toList()); - } catch (IOException e) { - e.printStackTrace(); - return Collections.emptyList(); + ) { + //#if MC<11900 + Predicate filter = id -> pathFilter.test(id.getPath()); + //#endif + + List result = new ArrayList<>(); + findResources(resourcePackType, path, id -> { + if (filter.test(id)) { + result.add(id); } - } else { - return Collections.emptyList(); + }); + return result; + } + //#endif + + private void findResources(ResourceType type, String path, Consumer consumer) { + if (type != ResourceType.CLIENT_RESOURCES) return; + if (!"lang".equals(path)) return; + Path base = baseLangPath(); + //#if MC<11400 + //$$ if (base == null) return; + //#endif + try (Stream stream = Files.walk(base, 1)) { + stream + .skip(1) + .filter(Files::isRegularFile) + .map(Path::getFileName).map(Path::toString) + .map(LANG_FILE_NAME_PATTERN::matcher) + .filter(Matcher::matches) + .map(matcher -> String.format("%s_%s.json", matcher.group(1), matcher.group(1))) + .map(name -> new Identifier(ReplayMod.MOD_ID, "lang/" + name)) + .forEach(consumer); + } catch (IOException e) { + e.printStackTrace(); } } diff --git a/src/main/java/com/replaymod/core/versions/MCVer.java b/src/main/java/com/replaymod/core/versions/MCVer.java index ac4dd7545..a353863bd 100644 --- a/src/main/java/com/replaymod/core/versions/MCVer.java +++ b/src/main/java/com/replaymod/core/versions/MCVer.java @@ -13,6 +13,16 @@ import net.minecraft.client.render.BufferBuilder; import net.minecraft.util.Identifier; import net.minecraft.util.Util; +import net.minecraft.util.math.Vec3d; + +//#if MC>=11700 +//$$ import net.minecraft.util.math.Matrix4f; +//#endif + +//#if MC>=11604 +//#else +//$$ import net.minecraft.entity.Entity; +//#endif //#if MC>=11600 import net.minecraft.resource.ResourcePackSource; @@ -21,13 +31,16 @@ //#if MC>=11400 import com.replaymod.render.mixin.MainWindowAccessor; import net.minecraft.SharedConstants; -import net.minecraft.client.gl.Framebuffer; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.ParentElement; import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.gui.widget.AbstractButtonWidget; +import net.minecraft.client.util.Window; import java.util.concurrent.CompletableFuture; //#if MC>=11600 +import net.minecraft.text.Text; import net.minecraft.text.TranslatableText; //#else //$$ import net.minecraft.client.resource.language.I18n; @@ -55,7 +68,6 @@ //#if MC>=10904 import com.replaymod.render.blend.mixin.ParticleAccessor; import net.minecraft.client.particle.Particle; -import net.minecraft.util.math.Vec3d; //#endif //#if MC>=10800 @@ -101,17 +113,10 @@ public static PacketTypeRegistry getPacketTypeRegistry(boolean loginPhase) { public static void resizeMainWindow(MinecraftClient mc, int width, int height) { //#if MC>=11400 - Framebuffer fb = mc.getFramebuffer(); - if (fb.viewportWidth != width || fb.viewportHeight != height) { - fb.resize(width, height, false); - } + Window window = mc.getWindow(); + MainWindowAccessor mainWindow = (MainWindowAccessor) (Object) window; //noinspection ConstantConditions - MainWindowAccessor mainWindow = (MainWindowAccessor) (Object) mc.getWindow(); - mainWindow.setFramebufferWidth(width); - mainWindow.setFramebufferHeight(height); - //#if MC>=11500 - mc.gameRenderer.onResized(width, height); - //#endif + mainWindow.invokeOnFramebufferSizeChanged(window.getHandle(), width, height); //#else //$$ if (width != mc.displayWidth || height != mc.displayHeight) { //$$ mc.resize(width, height); @@ -220,13 +225,23 @@ public static void addButton( } //#if MC>=11400 - public static Optional findButton(Iterable buttonList, @SuppressWarnings("unused") String text, @SuppressWarnings("unused") int id) { + public static Optional findButton(Iterable buttonList, @SuppressWarnings("unused") String text, @SuppressWarnings("unused") int id) { //#if MC>=11600 - final TranslatableText message = new TranslatableText(text); + final Text message = new TranslatableText(text); //#else //$$ final String message = I18n.translate(text); //#endif - for (AbstractButtonWidget b : buttonList) { + for (Element e : buttonList) { + if (e instanceof ParentElement) { + Optional button = findButton(((ParentElement) e).children(), text, id); + if (button.isPresent()) { + return button; + } + } + if (!(e instanceof AbstractButtonWidget)) { + continue; + } + AbstractButtonWidget b = (AbstractButtonWidget) e; if (message.equals(b.getMessage())) { return Optional.of(b); } @@ -297,6 +312,12 @@ public static Vec3d getPosition(Particle particle, float partialTicks) { } //#endif + //#if MC<=11601 + //$$ public static Vec3d getTrackedPosition(Entity entity) { + //$$ return new Vec3d(entity.trackedX / 4096.0, entity.trackedY / 4096.0, entity.trackedZ / 4096.0); + //$$ } + //#endif + public static void openFile(File file) { //#if MC>=11400 Util.getOperatingSystem().open(file); @@ -356,6 +377,24 @@ public static void popMatrix() { //#endif } + //#if MC>=11700 + //$$ public static net.minecraft.util.math.Quaternion quaternion(float angle, net.minecraft.util.math.Vec3f axis) { + //#if MC>=11903 + //$$ return new org.joml.Quaternionf().fromAxisAngleDeg(axis.x, axis.y, axis.z, angle); + //#else + //$$ return new net.minecraft.util.math.Quaternion(axis, angle, true); + //#endif + //$$ } + //$$ + //$$ public static Matrix4f ortho(float left, float right, float top, float bottom, float zNear, float zFar) { + //#if MC>=11903 + //$$ return new Matrix4f().ortho(left, right, bottom, top, zNear, zFar); + //#else + //$$ return Matrix4f.projectionMatrix(left, right, top, bottom, zNear, zFar); + //#endif + //$$ } + //#endif + public static void emitLine(BufferBuilder buffer, Vector2f p1, Vector2f p2, int color) { emitLine(buffer, new Vector3f(p1.x, p1.y, 0), new Vector3f(p2.x, p2.y, 0), color); } @@ -430,6 +469,7 @@ public static abstract class Keyboard { //#if MC>=11400 public static final int KEY_LCONTROL = GLFW.GLFW_KEY_LEFT_CONTROL; public static final int KEY_LSHIFT = GLFW.GLFW_KEY_LEFT_SHIFT; + public static final int KEY_LALT = GLFW.GLFW_KEY_LEFT_ALT; public static final int KEY_ESCAPE = GLFW.GLFW_KEY_ESCAPE; public static final int KEY_HOME = GLFW.GLFW_KEY_HOME; public static final int KEY_END = GLFW.GLFW_KEY_END; @@ -437,6 +477,8 @@ public static abstract class Keyboard { public static final int KEY_DOWN = GLFW.GLFW_KEY_DOWN; public static final int KEY_LEFT = GLFW.GLFW_KEY_LEFT; public static final int KEY_RIGHT = GLFW.GLFW_KEY_RIGHT; + public static final int KEY_PAGE_UP = GLFW.GLFW_KEY_PAGE_UP; + public static final int KEY_PAGE_DOWN = GLFW.GLFW_KEY_PAGE_DOWN; public static final int KEY_BACK = GLFW.GLFW_KEY_BACKSPACE; public static final int KEY_DELETE = GLFW.GLFW_KEY_DELETE; public static final int KEY_RETURN = GLFW.GLFW_KEY_ENTER; @@ -471,6 +513,7 @@ public static abstract class Keyboard { //#else //$$ public static final int KEY_LCONTROL = org.lwjgl.input.Keyboard.KEY_LCONTROL; //$$ public static final int KEY_LSHIFT = org.lwjgl.input.Keyboard.KEY_LSHIFT; + //$$ public static final int KEY_LALT = org.lwjgl.input.Keyboard.KEY_LMENU; //$$ public static final int KEY_ESCAPE = org.lwjgl.input.Keyboard.KEY_ESCAPE; //$$ public static final int KEY_HOME = org.lwjgl.input.Keyboard.KEY_HOME; //$$ public static final int KEY_END = org.lwjgl.input.Keyboard.KEY_END; @@ -478,6 +521,8 @@ public static abstract class Keyboard { //$$ public static final int KEY_DOWN = org.lwjgl.input.Keyboard.KEY_DOWN; //$$ public static final int KEY_LEFT = org.lwjgl.input.Keyboard.KEY_LEFT; //$$ public static final int KEY_RIGHT = org.lwjgl.input.Keyboard.KEY_RIGHT; + //$$ public static final int KEY_PAGE_UP = org.lwjgl.input.Keyboard.KEY_PRIOR; + //$$ public static final int KEY_PAGE_DOWN = org.lwjgl.input.Keyboard.KEY_NEXT; //$$ public static final int KEY_BACK = org.lwjgl.input.Keyboard.KEY_BACK; //$$ public static final int KEY_DELETE = org.lwjgl.input.Keyboard.KEY_DELETE; //$$ public static final int KEY_RETURN = org.lwjgl.input.Keyboard.KEY_RETURN; diff --git a/src/main/java/com/replaymod/core/versions/Patterns.java b/src/main/java/com/replaymod/core/versions/Patterns.java index f7dee2073..c794d0528 100644 --- a/src/main/java/com/replaymod/core/versions/Patterns.java +++ b/src/main/java/com/replaymod/core/versions/Patterns.java @@ -1,7 +1,12 @@ package com.replaymod.core.versions; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.replaymod.core.mixin.MinecraftAccessor; import com.replaymod.gradle.remap.Pattern; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.options.GameOptions; import net.minecraft.client.options.KeyBinding; import net.minecraft.client.render.VertexFormat; import net.minecraft.client.texture.TextureManager; @@ -10,11 +15,21 @@ import net.minecraft.client.render.entity.EntityRenderDispatcher; import net.minecraft.client.sound.PositionedSoundInstance; import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.sound.SoundCategory; +import net.minecraft.text.LiteralText; +import net.minecraft.text.Text; +import net.minecraft.text.TranslatableText; +import net.minecraft.util.crash.CrashException; +import net.minecraft.util.crash.CrashReport; import net.minecraft.util.crash.CrashReportSection; import net.minecraft.entity.Entity; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.network.PacketByteBuf; import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; import net.minecraft.world.chunk.WorldChunk; @@ -23,13 +38,26 @@ import org.lwjgl.opengl.GL11; //#endif +//#if MC>=11600 +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.client.util.math.Vector3f; +import net.minecraft.util.math.Matrix4f; +import net.minecraft.util.math.Quaternion; +//#else +//#endif + //#if MC>=11400 import net.minecraft.client.gui.widget.AbstractButtonWidget; import net.minecraft.client.util.Window; +import net.minecraft.util.registry.Registry; //#else //$$ import net.minecraft.client.gui.GuiButton; //#endif +//#if MC>=11100 +import net.minecraft.util.collection.DefaultedList; +//#endif + //#if MC>=10904 import net.minecraft.sound.SoundEvent; import net.minecraft.util.crash.CrashCallable; @@ -48,6 +76,7 @@ //$$ import net.minecraft.entity.EntityLivingBase; //#endif +import java.io.IOException; import java.util.Collection; import java.util.List; @@ -140,6 +169,42 @@ private static void Entity_setPos(Entity entity, double x, double y, double z) { //#endif } + @Pattern + private static int getX(AbstractButtonWidget button) { + //#if MC>=11903 + //$$ return button.getX(); + //#else + return button.x; + //#endif + } + + @Pattern + private static int getY(AbstractButtonWidget button) { + //#if MC>=11903 + //$$ return button.getY(); + //#else + return button.y; + //#endif + } + + @Pattern + private static void setX(AbstractButtonWidget button, int value) { + //#if MC>=11903 + //$$ button.setX(value); + //#else + button.x = value; + //#endif + } + + @Pattern + private static void setY(AbstractButtonWidget button, int value) { + //#if MC>=11903 + //$$ button.setY(value); + //#else + button.y = value; + //#endif + } + //#if MC>=11400 @Pattern private static void setWidth(AbstractButtonWidget button, int value) { @@ -504,9 +569,332 @@ private static void GL11_glTranslatef(float x, float y, float z) { @Pattern private static void GL11_glRotatef(float angle, float x, float y, float z) { //#if MC>=11700 - //$$ { float $angle = angle; com.mojang.blaze3d.systems.RenderSystem.getModelViewStack().multiply(new net.minecraft.util.math.Quaternion(new net.minecraft.util.math.Vec3f(x, y, z), $angle, true)); } + //$$ com.mojang.blaze3d.systems.RenderSystem.getModelViewStack().multiply(com.replaymod.core.versions.MCVer.quaternion(angle, new net.minecraft.util.math.Vec3f(x, y, z))); //#else GL11.glRotatef(angle, x, y, z); //#endif } + + // FIXME preprocessor bug: there are mappings for this, not sure why it doesn't remap by itself + //#if MC>=11600 + @Pattern + private static Matrix4f getPositionMatrix(MatrixStack.Entry stack) { + //#if MC>=11800 + //$$ return stack.getPositionMatrix(); + //#else + return stack.getModel(); + //#endif + } + //#else + //$$ private static void getPositionMatrix() {} + //#endif + + @SuppressWarnings("rawtypes") // preprocessor bug: doesn't work with generics + @Pattern + private static void Futures_addCallback(ListenableFuture future, FutureCallback callback) { + //#if MC>=11800 + //$$ Futures.addCallback(future, callback, Runnable::run); + //#else + Futures.addCallback(future, callback); + //#endif + } + + @Pattern + private static void setCrashReport(MinecraftClient mc, CrashReport report) { + //#if MC>=11900 + //$$ mc.setCrashReportSupplier(report); + //#elseif MC>=11800 + //$$ mc.setCrashReportSupplier(() -> report); + //#else + mc.setCrashReport(report); + //#endif + } + + @Pattern + private static CrashException crashReportToException(MinecraftClient mc) { + //#if MC>=11800 + //$$ return new CrashException(((MinecraftAccessor) mc).getCrashReporter().get()); + //#else + return new CrashException(((MinecraftAccessor) mc).getCrashReporter()); + //#endif + } + + @Pattern + private static Vec3d getTrackedPosition(Entity entity) { + //#if MC>=11604 + return entity.getTrackedPosition(); + //#else + //$$ return com.replaymod.core.versions.MCVer.getTrackedPosition(entity); + //#endif + } + + @Pattern + private static Text newTextLiteral(String str) { + //#if MC>=11900 + //$$ return net.minecraft.text.Text.literal(str); + //#else + return new LiteralText(str); + //#endif + } + + @Pattern + private static Text newTextTranslatable(String key, Object...args) { + //#if MC>=11900 + //$$ return net.minecraft.text.Text.translatable(key, args); + //#else + return new TranslatableText(key, args); + //#endif + } + + //#if MC>=11500 + @Pattern + private static Vec3d getTrackedPos(Entity entity) { + //#if MC>=11900 + //$$ return entity.getTrackedPosition().withDelta(0, 0, 0); + //#else + return entity.getTrackedPosition(); + //#endif + } + //#else + //$$ @Pattern private static void getTrackedPos() {} + //#endif + + @Pattern + private static void setGamma(GameOptions options, double value) { + //#if MC>=11900 + //$$ ((com.replaymod.core.mixin.SimpleOptionAccessor) (Object) options.getGamma()).setRawValue(value); + //#elseif MC>=11400 + options.gamma = value; + //#else + //$$ options.gammaSetting = (float) value; + //#endif + } + + @Pattern + private static double getGamma(GameOptions options) { + //#if MC>=11900 + //$$ return options.getGamma().getValue(); + //#else + return options.gamma; + //#endif + } + + @Pattern + private static int getViewDistance(GameOptions options) { + //#if MC>=11900 + //$$ return options.getViewDistance().getValue(); + //#else + return options.viewDistance; + //#endif + } + + @Pattern + private static double getFov(GameOptions options) { + //#if MC>=11900 + //$$ return options.getFov().getValue(); + //#else + return options.fov; + //#endif + } + + @Pattern + private static int getGuiScale(GameOptions options) { + //#if MC>=11900 + //$$ return options.getGuiScale().getValue(); + //#else + return options.guiScale; + //#endif + } + + @Pattern + private static Resource getResource(ResourceManager manager, Identifier id) throws IOException { + //#if MC>=11900 + //$$ return manager.getResourceOrThrow(id); + //#else + return manager.getResource(id); + //#endif + } + + @Pattern + private static List DefaultedList_ofSize_ItemStack_Empty(int size) { + //#if MC>=11100 + return DefaultedList.ofSize(size, ItemStack.EMPTY); + //#else + //$$ return java.util.Arrays.asList(new ItemStack[size]); + //#endif + } + + @Pattern + private static void setSoundVolume(GameOptions options, SoundCategory category, float value) { + //#if MC>=11903 + //$$ options.getSoundVolumeOption(category).setValue((double) value); + //#else + options.setSoundVolume(category, value); + //#endif + } + + //#if MC>=10900 + @Pattern + private static SoundEvent SoundEvent_of(Identifier identifier) { + //#if MC>=11903 + //$$ return SoundEvent.of(identifier); + //#else + return new SoundEvent(identifier); + //#endif + } + //#else + //$$ @Pattern private static void SoundEvent_of() {} + //#endif + + //#if MC>=11600 + @Pattern + private static Vector3f POSITIVE_X() { + //#if MC>=11903 + //$$ return new org.joml.Vector3f(1, 0, 0); + //#else + return Vector3f.POSITIVE_X; + //#endif + } + + @Pattern + private static Vector3f POSITIVE_Y() { + //#if MC>=11903 + //$$ return new org.joml.Vector3f(0, 1, 0); + //#else + return Vector3f.POSITIVE_Y; + //#endif + } + + @Pattern + private static Vector3f POSITIVE_Z() { + //#if MC>=11903 + //$$ return new org.joml.Vector3f(0, 0, 1); + //#else + return Vector3f.POSITIVE_Z; + //#endif + } + + @Pattern + private static Quaternion getDegreesQuaternion(Vector3f axis, float angle) { + //#if MC>=11903 + //$$ return new org.joml.Quaternionf().fromAxisAngleDeg(axis, angle); + //#else + return axis.getDegreesQuaternion(angle); + //#endif + } + + @Pattern + private static void Quaternion_mul(Quaternion left, Quaternion right) { + //#if MC>=11903 + //$$ left.mul(right); + //#else + left.hamiltonProduct(right); + //#endif + } + + @Pattern + private static float Quaternion_getX(Quaternion q) { + //#if MC>=11903 + //$$ return q.x; + //#else + return q.getX(); + //#endif + } + + @Pattern + private static float Quaternion_getY(Quaternion q) { + //#if MC>=11903 + //$$ return q.y; + //#else + return q.getY(); + //#endif + } + + @Pattern + private static float Quaternion_getZ(Quaternion q) { + //#if MC>=11903 + //$$ return q.z; + //#else + return q.getZ(); + //#endif + } + + @Pattern + private static float Quaternion_getW(Quaternion q) { + //#if MC>=11903 + //$$ return q.w; + //#else + return q.getW(); + //#endif + } + + @Pattern + private static Quaternion Quaternion_copy(Quaternion source) { + //#if MC>=11903 + //$$ return new org.joml.Quaternionf(source); + //#else + return source.copy(); + //#endif + } + //#else + //$$ @Pattern private static void POSITIVE_X() {} + //$$ @Pattern private static void POSITIVE_Y() {} + //$$ @Pattern private static void POSITIVE_Z() {} + //$$ @Pattern private static void getDegreesQuaternion() {} + //$$ @Pattern private static void Quaternion_mul() {} + //$$ @Pattern private static void Quaternion_getX() {} + //$$ @Pattern private static void Quaternion_getY() {} + //$$ @Pattern private static void Quaternion_getZ() {} + //$$ @Pattern private static void Quaternion_getW() {} + //$$ @Pattern private static void Quaternion_copy() {} + //#endif + + //#if MC>=11600 + @Pattern + private static void Matrix4f_multiply(Matrix4f left, Matrix4f right) { + //#if MC>=11903 + //$$ left.mul(right); + //#else + left.multiply(right); + //#endif + } + + @Pattern + private static Matrix4f Matrix4f_translate(float x, float y, float z) { + //#if MC>=11903 + //$$ return new Matrix4f().translation(x, y, z); + //#else + return Matrix4f.translate(x, y, z); + //#endif + } + //#else + //$$ @Pattern private static void Matrix4f_multiply() {} + //$$ @Pattern private static void Matrix4f_translate() {} + //#endif + + //#if MC>=11700 + //$$ @Pattern + //$$ private static Matrix4f Matrix4f_perspectiveMatrix(float left, float right, float top, float bottom, float zNear, float zFar) { + //#if MC>=11903 + //$$ return com.replaymod.core.versions.MCVer.ortho(left, right, top, bottom, zNear, zFar); + //#else + //$$ return Matrix4f.projectionMatrix(left, right, top, bottom, zNear, zFar); + //#endif + //$$ } + //#else + @Pattern private static void Matrix4f_perspectiveMatrix() {} + //#endif + + //#if MC>=11400 + @Pattern + private static Registry> REGISTRIES() { + //#if MC>=11903 + //$$ return net.minecraft.registry.Registries.REGISTRIES; + //#else + return Registry.REGISTRIES; + //#endif + } + //#else + //$$ @Pattern private static void REGISTRIES() {} + //#endif } diff --git a/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java b/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java index 1ee869959..0d5aefc81 100644 --- a/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java +++ b/src/main/java/com/replaymod/editor/gui/GuiEditReplay.java @@ -21,6 +21,8 @@ import de.johni0702.minecraft.gui.utils.lwjgl.Color; import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; import net.minecraft.util.crash.CrashReport; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.io.IOException; import java.nio.file.Path; @@ -31,6 +33,8 @@ import java.util.stream.Collectors; public class GuiEditReplay extends AbstractGuiPopup { + private static final Logger LOGGER = LogManager.getLogger(); + private final Path inputPath; private final EditTimeline timeline; @@ -60,7 +64,9 @@ protected GuiEditReplay(GuiContainer container, Path inputPath) throws IOExcepti super(container); this.inputPath = inputPath; - try (ReplayFile replayFile = ReplayMod.instance.openReplay(inputPath)) { + LOGGER.info("Opening replay in editor: " + inputPath); + + try (ReplayFile replayFile = ReplayMod.instance.files.open(inputPath)) { markers = replayFile.getMarkers().or(HashSet::new); timeline = new EditTimeline(new HashSet<>(markers), markers -> this.markers = markers); timeline.setSize(300, 20) @@ -147,7 +153,7 @@ private void apply() { ProgressPopup progressPopup = new ProgressPopup(this); new Thread(() -> { - try (ReplayFile replayFile = ReplayMod.instance.openReplay(inputPath)) { + try (ReplayFile replayFile = ReplayMod.instance.files.open(inputPath)) { replayFile.writeMarkers(markers); replayFile.save(); } catch (IOException e) { diff --git a/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java b/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java index e3c40d5a8..54940eb32 100644 --- a/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java +++ b/src/main/java/com/replaymod/editor/gui/MarkerProcessor.java @@ -4,6 +4,7 @@ import com.replaymod.core.versions.MCVer; import com.replaymod.replaystudio.PacketData; import com.replaymod.replaystudio.data.Marker; +import com.replaymod.replaystudio.filter.DimensionTracker; import com.replaymod.replaystudio.filter.SquashFilter; import com.replaymod.replaystudio.filter.StreamFilter; import com.replaymod.replaystudio.io.ReplayInputStream; @@ -48,7 +49,7 @@ public class MarkerProcessor { public static final String MARKER_NAME_SPLIT = "_RM_SPLIT"; private static boolean hasWork(Path path) throws IOException { - try (ReplayFile inputReplayFile = ReplayMod.instance.openReplay(path)) { + try (ReplayFile inputReplayFile = ReplayMod.instance.files.open(path)) { return inputReplayFile.getMarkers().or(HashSet::new).stream().anyMatch(m -> m.getName() != null && m.getName().startsWith("_RM_")); } } @@ -112,7 +113,7 @@ public static List> apply(Path path, Consumer ReplayMod mod = ReplayMod.instance; if (!hasWork(path)) { ReplayMetaData metaData; - try (ReplayFile inputReplayFile = mod.openReplay(path)) { + try (ReplayFile inputReplayFile = mod.files.open(path)) { metaData = inputReplayFile.getMetaData(); } return Collections.singletonList(Pair.of(path, metaData)); @@ -122,19 +123,19 @@ public static List> apply(Path path, Consumer int splitCounter = 0; PacketTypeRegistry registry = MCVer.getPacketTypeRegistry(true); - SquashFilter squashFilter = new SquashFilter(); + DimensionTracker dimensionTracker = new DimensionTracker(); + SquashFilter squashFilter = new SquashFilter(null, null, null); List> outputPaths = new ArrayList<>(); - Path rawFolder = ReplayMod.instance.getRawReplayFolder(); + Path rawFolder = ReplayMod.instance.folders.getRawReplayFolder(); Path inputPath = rawFolder.resolve(path.getFileName()); for (int i = 1; Files.exists(inputPath); i++) { inputPath = inputPath.resolveSibling(replayName + "." + i + ".mcpr"); } - Files.createDirectories(inputPath.getParent()); Files.move(path, inputPath); - try (ReplayFile inputReplayFile = mod.openReplay(inputPath)) { + try (ReplayFile inputReplayFile = mod.files.open(inputPath)) { List markers = inputReplayFile.getMarkers().or(HashSet::new) .stream().sorted(Comparator.comparing(Marker::getTime)).collect(Collectors.toList()); Iterator markerIterator = markers.iterator(); @@ -151,7 +152,7 @@ public static List> apply(Path path, Consumer while (nextPacket != null && outputFileSuffixes.hasNext()) { Path outputPath = path.resolveSibling(replayName + outputFileSuffixes.next() + ".mcpr"); - try (ReplayFile outputReplayFile = mod.openReplay(null, outputPath)) { + try (ReplayFile outputReplayFile = mod.files.open(null, outputPath)) { long duration = 0; Set outputMarkers = new HashSet<>(); ReplayMetaData metaData = inputReplayFile.getMetaData(); @@ -180,7 +181,7 @@ public static List> apply(Path path, Consumer cutFilter.release(); } startCutOffset = nextMarker.getTime(); - cutFilter = new SquashFilter(); + cutFilter = new SquashFilter(dimensionTracker); } else if (MARKER_NAME_END_CUT.equals(nextMarker.getName())) { timeOffset += nextMarker.getTime() - startCutOffset; if (cutFilter != null) { @@ -208,6 +209,7 @@ public static List> apply(Path path, Consumer continue; } + dimensionTracker.onPacket(null, nextPacket); if (hasFurtherOutputs) { squashFilter.onPacket(null, nextPacket); } diff --git a/src/main/java/com/replaymod/extras/FullBrightness.java b/src/main/java/com/replaymod/extras/FullBrightness.java index b6bef6914..301edfb53 100644 --- a/src/main/java/com/replaymod/extras/FullBrightness.java +++ b/src/main/java/com/replaymod/extras/FullBrightness.java @@ -8,9 +8,7 @@ import com.replaymod.replay.ReplayModReplay; import com.replaymod.replay.events.ReplayOpenedCallback; import com.replaymod.replay.gui.overlay.GuiReplayOverlay; -import de.johni0702.minecraft.gui.element.GuiImage; -import de.johni0702.minecraft.gui.element.IGuiImage; -import de.johni0702.minecraft.gui.layout.HorizontalLayout; +import com.replaymod.replay.gui.overlay.UIStatusIndicator; import de.johni0702.minecraft.gui.utils.EventRegistrations; import net.minecraft.client.MinecraftClient; import net.minecraft.entity.effect.StatusEffectInstance; @@ -20,7 +18,7 @@ public class FullBrightness extends EventRegistrations implements Extra { private ReplayMod core; private ReplayModReplay module; - private final IGuiImage indicator = new GuiImage().setTexture(ReplayMod.TEXTURE, 90, 20, 19, 16).setSize(19, 16); + private final UIStatusIndicator indicator = new UIStatusIndicator(90, 20); private MinecraftClient mc; private boolean active; @@ -72,7 +70,7 @@ private void preRender() { Type type = getType(); if (type == Type.Gamma || type == Type.Both) { originalGamma = mc.options.gamma; - mc.options.gamma = 1000; + mc.options.gamma = 1000.0; } if (type == Type.NightVision || type == Type.Both) { if (mc.player != null) { @@ -108,9 +106,9 @@ private void postRender() { { on(ReplayOpenedCallback.EVENT, replayHandler -> updateIndicator(replayHandler.getOverlay())); } private void updateIndicator(GuiReplayOverlay overlay) { if (active) { - overlay.statusIndicatorPanel.addElements(new HorizontalLayout.Data(1), indicator); + overlay.kt.getBottomLeftPanel().addChild(indicator); } else { - overlay.statusIndicatorPanel.removeElement(indicator); + overlay.kt.getBottomLeftPanel().removeChild(indicator); } } diff --git a/src/main/java/com/replaymod/extras/HotkeyButtons.java b/src/main/java/com/replaymod/extras/HotkeyButtons.java deleted file mode 100644 index 418292070..000000000 --- a/src/main/java/com/replaymod/extras/HotkeyButtons.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.replaymod.extras; - -import com.replaymod.core.KeyBindingRegistry; -import com.replaymod.core.ReplayMod; -import com.replaymod.replay.events.ReplayOpenedCallback; -import com.replaymod.replay.gui.overlay.GuiReplayOverlay; -import de.johni0702.minecraft.gui.GuiRenderer; -import de.johni0702.minecraft.gui.RenderInfo; -import de.johni0702.minecraft.gui.container.GuiPanel; -import de.johni0702.minecraft.gui.element.GuiButton; -import de.johni0702.minecraft.gui.element.GuiElement; -import de.johni0702.minecraft.gui.element.GuiLabel; -import de.johni0702.minecraft.gui.element.GuiTooltip; -import de.johni0702.minecraft.gui.layout.CustomLayout; -import de.johni0702.minecraft.gui.layout.GridLayout; -import de.johni0702.minecraft.gui.layout.LayoutData; -import de.johni0702.minecraft.gui.utils.EventRegistrations; -import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.client.resource.language.I18n; - -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.Map; - -public class HotkeyButtons extends EventRegistrations implements Extra { - private ReplayMod mod; - - @Override - public void register(ReplayMod mod) { - this.mod = mod; - - register(); - } - - { on(ReplayOpenedCallback.EVENT, replayHandler -> new Gui(mod, replayHandler.getOverlay())); } - public static final class Gui { - private final GuiButton toggleButton; - private final GridLayout panelLayout; - private final GuiPanel panel; - - private boolean open; - - public Gui(ReplayMod mod, GuiReplayOverlay overlay) { - toggleButton = new GuiButton(overlay).setSize(20, 20) - .setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setSpriteUV(0, 120) - .onClick(new Runnable() { - @Override - public void run() { - open = !open; - } - }); - - panel = new GuiPanel(overlay) { - @Override - public Collection getChildren() { - return open ? super.getChildren() : Collections.emptyList(); - } - - @Override - public Map getElements() { - return open ? super.getElements() : Collections.emptyMap(); - } - }.setLayout(panelLayout = new GridLayout().setSpacingX(5).setSpacingY(5).setColumns(1)); - - final KeyBindingRegistry keyBindingRegistry = mod.getKeyBindingRegistry(); - keyBindingRegistry.getBindings().values().stream() - .sorted(Comparator.comparing(it -> I18n.translate(it.name))) - .forEachOrdered(keyBinding -> { - GuiButton button = new GuiButton(){ - @Override - public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { - // There doesn't seem to be an KeyBindingUpdate event, so we'll just update it every time - setLabel(keyBinding.isBound() ? keyBinding.getBoundKey() : ""); - - if (keyBinding.supportsAutoActivation()) { - setTooltip(new GuiTooltip().setText(new String[]{ - I18n.translate("replaymod.gui.ingame.autoactivating"), - I18n.translate("replaymod.gui.ingame.autoactivating." - + (keyBinding.isAutoActivating() ? "disable" : "enable")), - })); - setLabelColor(keyBinding.isAutoActivating() ? 0x00ff00 : 0xe0e0e0); - } - - super.draw(renderer, size, renderInfo); - } - }.onClick(() -> { - if (keyBinding.supportsAutoActivation() && Screen.hasControlDown()) { - keyBinding.setAutoActivating(!keyBinding.isAutoActivating()); - } else { - keyBinding.trigger(); - } - }); - GuiLabel label = new GuiLabel().setI18nText(keyBinding.name); - panel.addElements(null, new GuiPanel().setLayout(new CustomLayout() { - @Override - protected void layout(GuiPanel container, int width, int height) { - width(button, Math.max(10 /* consistent min width */, width(button)) + 10 /* padding */); - height(button, 20); - - int textWidth = width(label); - - x(label, width(button) + 4); - width(label, width - x(label)); - - if (textWidth > width - x(label)) { - height(label, height(label) * 2); // split over two lines - } - y(label, (height - height(label)) / 2); - } - }).addElements(null, button, label).setSize(150, 20)); - }); - - overlay.setLayout(new CustomLayout(overlay.getLayout()) { - @Override - protected void layout(GuiReplayOverlay container, int width, int height) { - panelLayout.setColumns(Math.max(1, (width - 10) / 155)); - size(panel, panel.getMinSize()); - - pos(toggleButton, 5, height - 25); - pos(panel, 5, y(toggleButton) - 5 - height(panel)); - } - }); - } - } -} diff --git a/src/main/java/com/replaymod/extras/QuickMode.java b/src/main/java/com/replaymod/extras/QuickMode.java index 454d20be2..2b2986a52 100644 --- a/src/main/java/com/replaymod/extras/QuickMode.java +++ b/src/main/java/com/replaymod/extras/QuickMode.java @@ -6,14 +6,13 @@ import com.replaymod.replay.ReplayModReplay; import com.replaymod.replay.events.ReplayOpenedCallback; import com.replaymod.replay.gui.overlay.GuiReplayOverlay; -import de.johni0702.minecraft.gui.element.GuiImage; -import de.johni0702.minecraft.gui.layout.HorizontalLayout; +import com.replaymod.replay.gui.overlay.UIStatusIndicator; import de.johni0702.minecraft.gui.utils.EventRegistrations; public class QuickMode extends EventRegistrations implements Extra { private ReplayModReplay module; - private final GuiImage indicator = new GuiImage().setTexture(ReplayMod.TEXTURE, 40, 100, 16, 16).setSize(16, 16); + private final UIStatusIndicator indicator = new UIStatusIndicator(40, 100); @Override public void register(final ReplayMod mod) { @@ -25,7 +24,7 @@ public void register(final ReplayMod mod) { return; } replayHandler.getReplaySender().setSyncModeAndWait(); - mod.runLater(() -> { + mod.runLaterWithoutLock(() -> { replayHandler.ensureQuickModeInitialized(() -> { boolean enabled = !replayHandler.isQuickMode(); updateIndicator(replayHandler.getOverlay(), enabled); @@ -44,9 +43,9 @@ public void register(final ReplayMod mod) { private void updateIndicator(GuiReplayOverlay overlay, boolean enabled) { if (enabled) { - overlay.statusIndicatorPanel.addElements(new HorizontalLayout.Data(1), indicator); + overlay.kt.getBottomLeftPanel().addChild(indicator); } else { - overlay.statusIndicatorPanel.removeElement(indicator); + overlay.kt.getBottomLeftPanel().removeChild(indicator); } } } diff --git a/src/main/java/com/replaymod/extras/ReplayModExtras.java b/src/main/java/com/replaymod/extras/ReplayModExtras.java index add19778e..da993a1ee 100644 --- a/src/main/java/com/replaymod/extras/ReplayModExtras.java +++ b/src/main/java/com/replaymod/extras/ReplayModExtras.java @@ -23,8 +23,7 @@ public class ReplayModExtras implements Module { PlayerOverview.class, YoutubeUpload.class, FullBrightness.class, - QuickMode.class, - HotkeyButtons.class + QuickMode.class ); private final Map, Extra> instances = new HashMap<>(); diff --git a/src/main/java/com/replaymod/extras/advancedscreenshots/GuiCreateScreenshot.java b/src/main/java/com/replaymod/extras/advancedscreenshots/GuiCreateScreenshot.java index 62d316cf8..cda0409d5 100644 --- a/src/main/java/com/replaymod/extras/advancedscreenshots/GuiCreateScreenshot.java +++ b/src/main/java/com/replaymod/extras/advancedscreenshots/GuiCreateScreenshot.java @@ -40,7 +40,7 @@ public GuiCreateScreenshot(ReplayMod mod) { new GuiLabel().setI18nText("replaymod.gui.advancedscreenshots.resolution"), videoResolutionPanel, new GuiLabel().setI18nText("replaymod.gui.rendersettings.outputfile"), outputFileButton); - resetChildren(advancedPanel).addElements(null, nametagCheckbox, new GuiPanel().setLayout( + resetChildren(advancedPanel).addElements(null, nametagCheckbox, alphaCheckbox , new GuiPanel().setLayout( new GridLayout().setCellsEqualSize(false).setColumns(2).setSpacingX(5).setSpacingY(15)) .addElements(new GridLayout.Data(0, 0.5), new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel, diff --git a/src/main/java/com/replaymod/extras/advancedscreenshots/ScreenshotWriter.java b/src/main/java/com/replaymod/extras/advancedscreenshots/ScreenshotWriter.java index 70522b228..987e78394 100644 --- a/src/main/java/com/replaymod/extras/advancedscreenshots/ScreenshotWriter.java +++ b/src/main/java/com/replaymod/extras/advancedscreenshots/ScreenshotWriter.java @@ -63,4 +63,9 @@ public void consume(Map channels) { public void close() throws IOException { } + + @Override + public boolean isParallelCapable() { + return false; + } } diff --git a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java index d5ecb0be3..14045dff8 100644 --- a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java +++ b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java @@ -1,6 +1,5 @@ package com.replaymod.extras.playeroverview; -import com.google.common.base.Optional; import com.replaymod.core.ReplayMod; import com.replaymod.core.events.PreRenderHandCallback; import com.replaymod.core.utils.Utils; @@ -10,6 +9,7 @@ import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replay.events.ReplayClosedCallback; import com.replaymod.replay.events.ReplayOpenedCallback; +import com.replaymod.replaystudio.lib.guava.base.Optional; import de.johni0702.minecraft.gui.utils.EventRegistrations; import net.minecraft.entity.Entity; import net.minecraft.entity.player.PlayerEntity; diff --git a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverviewGui.java b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverviewGui.java index 97e0d20f8..78ed336ed 100644 --- a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverviewGui.java +++ b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverviewGui.java @@ -1,6 +1,5 @@ package com.replaymod.extras.playeroverview; -import com.replaymod.core.utils.Utils; import com.replaymod.replay.ReplayModReplay; import de.johni0702.minecraft.gui.GuiRenderer; import de.johni0702.minecraft.gui.RenderInfo; @@ -20,6 +19,7 @@ import de.johni0702.minecraft.gui.utils.Colors; import de.johni0702.minecraft.gui.utils.lwjgl.Dimension; import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; +import net.minecraft.client.network.AbstractClientPlayerEntity; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.util.Identifier; @@ -94,7 +94,8 @@ public PlayerOverviewGui(final PlayerOverview extra, List players) Collections.sort(players, new PlayerComparator()); // Sort by name, spectators last for (final PlayerEntity p : players) { - final Identifier texture = Utils.getResourceLocationForPlayerUUID(p.getUuid()); + if (!(p instanceof AbstractClientPlayerEntity)) continue; + final Identifier texture = ((AbstractClientPlayerEntity) p).getSkinTexture(); final GuiClickable panel = new GuiClickable().setLayout(new HorizontalLayout().setSpacing(2)).addElements( new HorizontalLayout.Data(0.5), new GuiImage() { @Override @@ -116,7 +117,7 @@ public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo render }.setSize(16, 16), new GuiLabel().setText( //#if MC>=11400 - p.getName().asString() + p.getName().getString() //#else //#if MC>=10800 //$$ p.getName() @@ -181,7 +182,7 @@ public int compare(PlayerEntity o1, PlayerEntity o2) { if (isSpectator(o1) && !isSpectator(o2)) return 1; if (isSpectator(o2) && !isSpectator(o1)) return -1; //#if MC>=11400 - return o1.getName().asString().compareToIgnoreCase(o2.getName().asString()); + return o1.getName().getString().compareToIgnoreCase(o2.getName().getString()); //#else //#if MC>=10800 //$$ return o1.getName().compareToIgnoreCase(o2.getName()); diff --git a/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java b/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java index af53c5b89..cde6c3e93 100644 --- a/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java +++ b/src/main/java/com/replaymod/pathing/player/RealtimeTimelinePlayer.java @@ -67,7 +67,7 @@ public void onTick() { @Override public long getTimePassed() { - if (firstFrame) return 0; + if (firstFrame) return startOffset; if (loadingResources) return timeBeforeResourceLoading; return System.currentTimeMillis() - startTime; } diff --git a/src/main/java/com/replaymod/recording/ReplayModRecording.java b/src/main/java/com/replaymod/recording/ReplayModRecording.java index 94ae09022..f65e44674 100644 --- a/src/main/java/com/replaymod/recording/ReplayModRecording.java +++ b/src/main/java/com/replaymod/recording/ReplayModRecording.java @@ -16,7 +16,11 @@ import org.apache.logging.log4j.Logger; //#if FABRIC>=1 +//#if MC>=11700 +//$$ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +//#else import net.fabricmc.fabric.api.network.ClientSidePacketRegistry; +//#endif //#else //$$ import net.minecraftforge.fml.network.NetworkRegistry; //#endif @@ -68,7 +72,11 @@ public void initClient() { new GuiHandler(core).register(); //#if FABRIC>=1 + //#if MC>=11700 + //$$ ClientPlayNetworking.registerGlobalReceiver(Restrictions.PLUGIN_CHANNEL, (client, handler, buf, resp) -> {}); + //#else ClientSidePacketRegistry.INSTANCE.register(Restrictions.PLUGIN_CHANNEL, (packetContext, packetByteBuf) -> {}); + //#endif //#else //#if MC>=11400 //$$ NetworkRegistry.newEventChannel(Restrictions.PLUGIN_CHANNEL, () -> "0", any -> true, any -> true); diff --git a/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java b/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java index e766f0a5a..708daee9e 100644 --- a/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java +++ b/src/main/java/com/replaymod/recording/gui/GuiRecordingControls.java @@ -87,6 +87,9 @@ private void injectIntoIngameMenu(Screen guiScreen, if (!(guiScreen instanceof GameMenuScreen)) { return; } + if (buttonList.isEmpty()) { + return; // menu-less pause (F3+Esc) + } Function yPos = MCVer.findButton(buttonList, "menu.returnToMenu", 1) .map(Optional::of) diff --git a/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java b/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java index 5ca75e7ee..9e33903a9 100644 --- a/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java +++ b/src/main/java/com/replaymod/recording/gui/GuiSavingReplay.java @@ -154,10 +154,10 @@ private void applyOutput(Path path, String newName) { } try { - Path replaysFolder = core.getReplayFolder(); - Path newPath = replaysFolder.resolve(Utils.replayNameToFileName(newName)); + Path replaysFolder = core.folders.getReplayFolder(); + Path newPath = Utils.replayNameToPath(replaysFolder, newName); for (int i = 1; Files.exists(newPath); i++) { - newPath = replaysFolder.resolve(Utils.replayNameToFileName(newName + " (" + i + ")")); + newPath = Utils.replayNameToPath(replaysFolder, newName + " (" + i + ")"); } Files.move(path, newPath); } catch (IOException e) { diff --git a/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java b/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java index 0e90c7ebd..1137485f9 100644 --- a/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java +++ b/src/main/java/com/replaymod/recording/handler/ConnectionEventHandler.java @@ -3,7 +3,6 @@ import com.replaymod.core.ReplayMod; import com.replaymod.core.utils.ModCompat; import com.replaymod.core.utils.Utils; -import com.replaymod.core.versions.MCVer; import com.replaymod.editor.gui.MarkerProcessor; import com.replaymod.recording.ServerInfoExt; import com.replaymod.recording.Setting; @@ -20,6 +19,10 @@ import net.minecraft.network.ClientConnection; import org.apache.logging.log4j.Logger; +//#if MC>=11900 +//$$ import com.replaymod.recording.mixin.ClientLoginNetworkHandlerAccessor; +//#endif + //#if MC>=11600 import net.minecraft.world.World; //#else @@ -43,7 +46,6 @@ */ public class ConnectionEventHandler { - private static final String packetHandlerKey = "packet_handler"; private static final String DATE_FORMAT = "yyyy_MM_dd_HH_mm_ss"; private static final SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT); private static final MinecraftClient mc = getMinecraft(); @@ -90,6 +92,15 @@ public void onConnectedToServerEvent(ClientConnection networkManager) { } } + ServerInfo serverInfo; + //#if MC>=11903 + //$$ serverInfo = networkManager.getPacketListener() instanceof ClientLoginNetworkHandlerAccessor loginNetworkHandler + //$$ ? loginNetworkHandler.getServerInfo() + //$$ : null; + //#else + serverInfo = mc.getCurrentServerEntry(); + //#endif + String worldName; String serverName = null; boolean autoStart = core.getSettingsRegistry().get(Setting.AUTO_START_RECORDING); @@ -100,8 +111,12 @@ public void onConnectedToServerEvent(ClientConnection networkManager) { //$$ worldName = mc.getServer().getLevelName(); //#endif serverName = worldName; - } else if (mc.getCurrentServerEntry() != null) { - ServerInfo serverInfo = mc.getCurrentServerEntry(); + //#if MC>=11100 + } else if (mc.isConnectedToRealms()) { + // we can't access the server name without tapping too deep in the Realms Library + worldName = "A Realms Server"; + //#endif + } else if (serverInfo != null) { worldName = serverInfo.address; if (!I18n.translate("selectServer.defaultName").equals(serverInfo.name)) { serverName = serverInfo.name; @@ -111,11 +126,6 @@ public void onConnectedToServerEvent(ClientConnection networkManager) { if (autoStartServer != null) { autoStart = autoStartServer; } - //#if MC>=11100 - } else if (mc.isConnectedToRealms()) { - // we can't access the server name without tapping too deep in the Realms Library - worldName = "A Realms Server"; - //#endif } else { logger.info("Recording not started as the world is neither local nor remote (probably a replay)."); return; @@ -127,8 +137,8 @@ public void onConnectedToServerEvent(ClientConnection networkManager) { } String name = sdf.format(Calendar.getInstance().getTime()); - Path outputPath = core.getRecordingFolder().resolve(Utils.replayNameToFileName(name)); - ReplayFile replayFile = core.openReplay(outputPath); + Path outputPath = Utils.replayNameToPath(core.folders.getRecordingFolder(), name); + ReplayFile replayFile = core.files.open(outputPath); replayFile.writeModInfo(ModCompat.getInstalledNetworkMods()); @@ -141,7 +151,13 @@ public void onConnectedToServerEvent(ClientConnection networkManager) { metaData.setMcVersion(ReplayMod.instance.getMinecraftVersion()); packetListener = new PacketListener(core, outputPath, replayFile, metaData); Channel channel = ((NetworkManagerAccessor) networkManager).getChannel(); - channel.pipeline().addBefore(packetHandlerKey, "replay_recorder", packetListener); + if (channel.pipeline().get(PacketListener.DECODER_KEY) != null) { + // Regular channel, we'll inject our recorder directly before the decoder + channel.pipeline().addBefore(PacketListener.DECODER_KEY, PacketListener.RAW_RECORDER_KEY, packetListener); + } else { + // Integrated server passes packets directly, there's no splitting, decompression or decoding + channel.pipeline().addFirst(PacketListener.RAW_RECORDER_KEY, packetListener); + } recordingEventHandler = new RecordingEventHandler(packetListener); recordingEventHandler.register(); diff --git a/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java b/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java index 2a7cee0f0..fe2772e67 100644 --- a/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java +++ b/src/main/java/com/replaymod/recording/handler/RecordingEventHandler.java @@ -32,7 +32,6 @@ //#if MC>=11600 import com.mojang.datafixers.util.Pair; -import java.util.Collections; //#endif //#if MC>=11400 @@ -40,16 +39,15 @@ //$$ import net.minecraft.network.play.server.SPacketUseBed; //#endif +//#if MC>=11100 +import net.minecraft.util.collection.DefaultedList; +//#endif + //#if MC>=10904 -import com.replaymod.recording.mixin.EntityLivingBaseAccessor; import net.minecraft.network.packet.s2c.play.EntityTrackerUpdateS2CPacket; -import net.minecraft.network.packet.s2c.play.PlaySoundS2CPacket; import net.minecraft.network.packet.s2c.play.WorldEventS2CPacket; import net.minecraft.entity.EquipmentSlot; -import net.minecraft.entity.data.DataTracker; import net.minecraft.util.Hand; -import net.minecraft.sound.SoundCategory; -import net.minecraft.sound.SoundEvent; //#endif //#if MC>=10800 @@ -58,6 +56,8 @@ //$$ import net.minecraft.util.MathHelper; //#endif +import java.util.Collections; +import java.util.List; import java.util.Objects; import static com.replaymod.core.versions.MCVer.*; @@ -68,15 +68,11 @@ public class RecordingEventHandler extends EventRegistrations { private final PacketListener packetListener; private Double lastX, lastY, lastZ; - private ItemStack[] playerItems = new ItemStack[6]; + private final List playerItems = DefaultedList.ofSize(6, ItemStack.EMPTY); private int ticksSinceLastCorrection; private boolean wasSleeping; private int lastRiding = -1; private Integer rotationYawHeadBefore; - //#if MC>=10904 - private boolean wasHandActive; - private Hand lastActiveHand; - //#endif public RecordingEventHandler(PacketListener packetListener) { this.packetListener = packetListener; @@ -97,7 +93,7 @@ public void unregister() { } } - //#if MC>=11400 + //#if MC>=10904 public void onPacket(Packet packet) { packetListener.save(packet); } @@ -108,26 +104,25 @@ public void spawnRecordingPlayer() { ClientPlayerEntity player = mc.player; assert player != null; packetListener.save(new PlayerSpawnS2CPacket(player)); - //#if MC>=11500 + //#if MC>=11903 + //$$ packetListener.save(new EntityTrackerUpdateS2CPacket(player.getId(), player.getDataTracker().getChangedEntries())); + //#elseif MC>=11500 packetListener.save(new EntityTrackerUpdateS2CPacket(player.getEntityId(), player.getDataTracker(), true)); //#endif lastX = lastY = lastZ = null; + //#if MC>=11100 + playerItems.clear(); + //#else + //$$ Collections.fill(playerItems, null); + //#endif + lastRiding = -1; + wasSleeping = false; } catch(Exception e) { e.printStackTrace(); } } //#if MC>=10904 - public void onClientSound(SoundEvent sound, SoundCategory category, - double x, double y, double z, float volume, float pitch) { - try { - // Send to all other players in ServerWorldEventHandler#playSoundToAllNearExcept - packetListener.save(new PlaySoundS2CPacket(sound, category, x, y, z, volume, pitch)); - } catch(Exception e) { - e.printStackTrace(); - } - } - public void onClientEffect(int type, BlockPos pos, int data) { try { // Send to all other players in ServerWorldEventHandler#playEvent @@ -273,8 +268,17 @@ private void onPlayerTick() { //#if MC>=10904 for (EquipmentSlot slot : EquipmentSlot.values()) { ItemStack stack = player.getEquippedStack(slot); - if (playerItems[slot.ordinal()] != stack) { - playerItems[slot.ordinal()] = stack; + int index = slot.ordinal(); + //#else + //$$ for (int slot = 0; slot < 5; slot++) { + //$$ ItemStack stack = player.getEquipmentInSlot(slot); + //$$ int index = slot; + //#endif + if (!ItemStack.areEqual(playerItems.get(index), stack)) { + // ItemStack has internal mutability, so we need to make a copy now if we want to compare its + // current state with future states (e.g. dropping on modern versions will set the count to zero). + stack = stack != null ? stack.copy() : null; + playerItems.set(index, stack); //#if MC>=11600 packetListener.save(new EntityEquipmentUpdateS2CPacket(player.getEntityId(), Collections.singletonList(Pair.of(slot, stack)))); //#else @@ -282,37 +286,6 @@ private void onPlayerTick() { //#endif } } - //#else - //$$ if(playerItems[0] != mc.thePlayer.getHeldItem()) { - //$$ playerItems[0] = mc.thePlayer.getHeldItem(); - //$$ S04PacketEntityEquipment pee = new S04PacketEntityEquipment(player.getEntityId(), 0, playerItems[0]); - //$$ packetListener.save(pee); - //$$ } - //$$ - //$$ if(playerItems[1] != mc.thePlayer.inventory.armorInventory[0]) { - //$$ playerItems[1] = mc.thePlayer.inventory.armorInventory[0]; - //$$ S04PacketEntityEquipment pee = new S04PacketEntityEquipment(player.getEntityId(), 1, playerItems[1]); - //$$ packetListener.save(pee); - //$$ } - //$$ - //$$ if(playerItems[2] != mc.thePlayer.inventory.armorInventory[1]) { - //$$ playerItems[2] = mc.thePlayer.inventory.armorInventory[1]; - //$$ S04PacketEntityEquipment pee = new S04PacketEntityEquipment(player.getEntityId(), 2, playerItems[2]); - //$$ packetListener.save(pee); - //$$ } - //$$ - //$$ if(playerItems[3] != mc.thePlayer.inventory.armorInventory[2]) { - //$$ playerItems[3] = mc.thePlayer.inventory.armorInventory[2]; - //$$ S04PacketEntityEquipment pee = new S04PacketEntityEquipment(player.getEntityId(), 3, playerItems[3]); - //$$ packetListener.save(pee); - //$$ } - //$$ - //$$ if(playerItems[4] != mc.thePlayer.inventory.armorInventory[3]) { - //$$ playerItems[4] = mc.thePlayer.inventory.armorInventory[3]; - //$$ S04PacketEntityEquipment pee = new S04PacketEntityEquipment(player.getEntityId(), 4, playerItems[4]); - //$$ packetListener.save(pee); - //$$ } - //#endif //Leaving Ride @@ -335,116 +308,11 @@ private void onPlayerTick() { wasSleeping = false; } - //#if MC>=10904 - // Active hand (e.g. eating, drinking, blocking) - if (player.isUsingItem() ^ wasHandActive || player.getActiveHand() != lastActiveHand) { - wasHandActive = player.isUsingItem(); - lastActiveHand = player.getActiveHand(); - DataTracker dataManager = new DataTracker(null); - int state = (wasHandActive ? 1 : 0) | (lastActiveHand == Hand.OFF_HAND ? 2 : 0); - dataManager.startTracking(EntityLivingBaseAccessor.getLivingFlags(), (byte) state); - packetListener.save(new EntityTrackerUpdateS2CPacket(player.getEntityId(), dataManager, true)); - } - //#endif - } catch(Exception e1) { e1.printStackTrace(); } } - //#if FABRIC>=1 - // FIXME fabric - //#else - //$$ @SubscribeEvent - //$$ public void onPickupItem(ItemPickupEvent event) { - //$$ try { - //#if MC>=11100 - //#if MC>=11200 - //#if MC>=11400 - //$$ ItemStack stack = event.getStack(); - //$$ packetListener.save(new SCollectItemPacket( - //$$ event.getOriginalEntity().getEntityId(), - //$$ event.getPlayer().getEntityId(), - //$$ event.getStack().getCount() - //$$ )); - //#else - //$$ packetListener.save(new SPacketCollectItem(event.pickedUp.getEntityId(), event.player.getEntityId(), - //$$ event.pickedUp.getItem().getMaxStackSize())); - //#endif - //#else - //$$ packetListener.save(new SPacketCollectItem(event.pickedUp.getEntityId(), event.player.getEntityId(), - //$$ event.pickedUp.getEntityItem().getMaxStackSize())); - //#endif - //#else - //$$ packetListener.save(new SPacketCollectItem(event.pickedUp.getEntityId(), event.player.getEntityId())); - //#endif - //$$ } catch(Exception e) { - //$$ e.printStackTrace(); - //$$ } - //$$ } - //#endif - - //#if MC>=11400 - // FIXME fabric - //#else - //$$ @SubscribeEvent - //$$ public void onSleep(PlayerSleepInBedEvent event) { - //$$ try { - //#if MC>=10904 - //$$ if (event.getEntityPlayer() != mc.player) { - //$$ return; - //$$ } - //$$ - //$$ packetListener.save(new SPacketUseBed(event.getEntityPlayer(), event.getPos())); - //#else - //$$ if (event.entityPlayer != mc.thePlayer) { - //$$ return; - //$$ } - //$$ - //$$ packetListener.save(new S0APacketUseBed(event.entityPlayer, - //#if MC>=10800 - //$$ event.pos - //#else - //$$ event.x, event.y, event.z - //#endif - //$$ )); - //#endif - //$$ - //$$ wasSleeping = true; - //$$ - //$$ } catch(Exception e) { - //$$ e.printStackTrace(); - //$$ } - //$$ } - //#endif - - /* FIXME event not (yet?) on 1.13 - @SubscribeEvent - public void enterMinecart(MinecartInteractEvent event) { - try { - //#if MC>=10904 - if(event.getEntity() != mc.player) { - return; - } - - packetListener.save(new SPacketEntityAttach(event.getPlayer(), event.getMinecart())); - - lastRiding = event.getMinecart().getEntityId(); - //#else - //$$ if(event.entity != mc.thePlayer) { - //$$ return; - //$$ } - //$$ - //$$ packetListener.save(new S1BPacketEntityAttach(0, event.player, event.minecart)); - //$$ - //$$ lastRiding = event.minecart.getEntityId(); - //#endif - } catch(Exception e) { - e.printStackTrace(); - } - } - */ - //#if MC>=10800 public void onBlockBreakAnim(int breakerId, BlockPos pos, int progress) { //#else diff --git a/src/main/java/com/replaymod/recording/mixin/ClientLoginNetworkHandlerAccessor.java b/src/main/java/com/replaymod/recording/mixin/ClientLoginNetworkHandlerAccessor.java new file mode 100644 index 000000000..37990587f --- /dev/null +++ b/src/main/java/com/replaymod/recording/mixin/ClientLoginNetworkHandlerAccessor.java @@ -0,0 +1,17 @@ +package com.replaymod.recording.mixin; + +import net.minecraft.client.network.ClientLoginNetworkHandler; +import org.spongepowered.asm.mixin.Mixin; + +//#if MC>=11903 +//$$ import net.minecraft.client.network.ServerInfo; +//$$ import org.spongepowered.asm.mixin.gen.Accessor; +//#endif + +@Mixin(ClientLoginNetworkHandler.class) +public interface ClientLoginNetworkHandlerAccessor { + //#if MC>=11903 + //$$ @Accessor + //$$ ServerInfo getServerInfo(); + //#endif +} diff --git a/src/main/java/com/replaymod/recording/mixin/MixinClientConnection.java b/src/main/java/com/replaymod/recording/mixin/MixinClientConnection.java new file mode 100644 index 000000000..60d1314c7 --- /dev/null +++ b/src/main/java/com/replaymod/recording/mixin/MixinClientConnection.java @@ -0,0 +1,39 @@ +package com.replaymod.recording.mixin; + +import com.replaymod.recording.packet.PacketListener; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import net.minecraft.network.ClientConnection; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Map; + +@Mixin(ClientConnection.class) +public abstract class MixinClientConnection { + @Shadow + private Channel channel; + + @Inject(method = "setCompressionThreshold", at = @At("RETURN")) + private void ensureReplayModRecorderIsAfterDecompress(CallbackInfo ci) { + ChannelHandler recorder = null; + for (Map.Entry entry : channel.pipeline()) { + String key = entry.getKey(); + if (PacketListener.RAW_RECORDER_KEY.equals(key)) { + recorder = entry.getValue(); + } + if (PacketListener.DECOMPRESS_KEY.equals(key)) { + if (recorder != null) { + // If we've already found the recorder, then that means decompress is after recorder. That's no good + // because it means the recorder is getting compressed packets, we need to move the recorder. + channel.pipeline().remove(recorder); + channel.pipeline().addBefore(PacketListener.DECODER_KEY, PacketListener.RAW_RECORDER_KEY, recorder); + return; + } + } + } + } +} diff --git a/src/main/java/com/replaymod/recording/mixin/MixinDownloadingPackFinder.java b/src/main/java/com/replaymod/recording/mixin/MixinDownloadingPackFinder.java index 8a5810968..196f0ad04 100644 --- a/src/main/java/com/replaymod/recording/mixin/MixinDownloadingPackFinder.java +++ b/src/main/java/com/replaymod/recording/mixin/MixinDownloadingPackFinder.java @@ -33,7 +33,11 @@ public void setRequestCallback(Consumer callback) { } //#if MC>=10800 + //#if MC>=11900 + //$$ @Inject(method = "loadServerPack(Ljava/io/File;Lnet/minecraft/resource/ResourcePackSource;)Ljava/util/concurrent/CompletableFuture;", at = @At("HEAD")) + //#else @Inject(method = "loadServerPack", at = @At("HEAD")) + //#endif private void recordDownloadedPack( File file, //#if MC>=11600 diff --git a/src/main/java/com/replaymod/recording/mixin/MixinMouseHelper.java b/src/main/java/com/replaymod/recording/mixin/MixinMouseHelper.java index 01e9572f9..c7e8f5d49 100644 --- a/src/main/java/com/replaymod/recording/mixin/MixinMouseHelper.java +++ b/src/main/java/com/replaymod/recording/mixin/MixinMouseHelper.java @@ -33,7 +33,9 @@ private void handleReplayModScroll( long _p0, double _p1, double _p2, CallbackInfo ci, double _l1, - //#if MC>=11400 + //#if MC>=11802 + //$$ int yOffsetAccumulated + //#elseif MC>=11400 float yOffsetAccumulated //#else //$$ double yOffsetAccumulated diff --git a/src/main/java/com/replaymod/recording/mixin/MixinNetHandlerPlayClient.java b/src/main/java/com/replaymod/recording/mixin/MixinNetHandlerPlayClient.java index 8c1841fc7..8be310c4b 100644 --- a/src/main/java/com/replaymod/recording/mixin/MixinNetHandlerPlayClient.java +++ b/src/main/java/com/replaymod/recording/mixin/MixinNetHandlerPlayClient.java @@ -61,7 +61,11 @@ public void recordOwnJoin(PlayerListS2CPacket packet, CallbackInfo ci) { if (mcStatic.player == null) return; RecordingEventHandler handler = getRecordingEventHandler(); + //#if MC>=11903 + //$$ if (handler != null && packet.getActions().contains(PlayerListS2CPacket.Action.ADD_PLAYER)) { + //#else if (handler != null && packet.getAction() == PlayerListS2CPacket.Action.ADD_PLAYER) { + //#endif // We cannot reference SPacketPlayerListItem.AddPlayerData directly for complicated (and yet to be // resolved) reasons (see https://github.com/MinecraftForge/ForgeGradle/issues/472), so we use ReplayStudio // to parse it instead. diff --git a/src/main/java/com/replaymod/recording/mixin/MixinWorldClient.java b/src/main/java/com/replaymod/recording/mixin/MixinWorldClient.java index e5c798897..e859e2a3d 100644 --- a/src/main/java/com/replaymod/recording/mixin/MixinWorldClient.java +++ b/src/main/java/com/replaymod/recording/mixin/MixinWorldClient.java @@ -5,6 +5,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.world.ClientWorld; import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.network.packet.s2c.play.PlaySoundS2CPacket; import net.minecraft.util.profiler.Profiler; import net.minecraft.sound.SoundCategory; import net.minecraft.sound.SoundEvent; @@ -16,6 +17,10 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +//#if MC>=11802 +//$$ import net.minecraft.util.registry.RegistryEntry; +//#endif + //#if MC>=11600 import net.minecraft.util.registry.RegistryKey; import net.minecraft.world.MutableWorldProperties; @@ -52,12 +57,25 @@ protected MixinWorldClient(MutableWorldProperties mutableWorldProperties, Regist //#if MC<11602 //$$ RegistryKey registryKey2, //#endif - DimensionType dimensionType, Supplier profiler, boolean bl, boolean bl2, long l) { + //#if MC>=11802 + //$$ RegistryEntry dimensionType, + //#else + DimensionType dimensionType, + //#endif + Supplier profiler, boolean bl, boolean bl2, long l + //#if MC>=11900 + //$$ , int maxChainedNeighborUpdates + //#endif + ) { super(mutableWorldProperties, registryKey, //#if MC<11602 //$$ registryKey2, //#endif - dimensionType, profiler, bl, bl2, l); + dimensionType, profiler, bl, bl2, l + //#if MC>=11900 + //$$ , maxChainedNeighborUpdates + //#endif + ); } //#else //#if MC>=11400 @@ -93,7 +111,13 @@ private RecordingEventHandler replayModRecording_getRecordingEventHandler() { // but are instead played directly by the client. The server only sends these sounds to // other clients so we have to record them manually. // E.g. Block place sounds - //#if MC>=11400 + //#if MC>=11903 + //$$ @Inject(method = "playSound(Lnet/minecraft/entity/player/PlayerEntity;DDDLnet/minecraft/registry/entry/RegistryEntry;Lnet/minecraft/sound/SoundCategory;FFJ)V", + //$$ at = @At("HEAD")) + //#elseif MC>=11900 + //$$ @Inject(method = "playSound(Lnet/minecraft/entity/player/PlayerEntity;DDDLnet/minecraft/sound/SoundEvent;Lnet/minecraft/sound/SoundCategory;FFJ)V", + //$$ at = @At("HEAD")) + //#elseif MC>=11400 //#if FABRIC>=1 @Inject(method = "playSound(Lnet/minecraft/entity/player/PlayerEntity;DDDLnet/minecraft/sound/SoundEvent;Lnet/minecraft/sound/SoundCategory;FF)V", at = @At("HEAD")) @@ -105,12 +129,29 @@ private RecordingEventHandler replayModRecording_getRecordingEventHandler() { //$$ @Inject(method = "playSound(Lnet/minecraft/entity/player/EntityPlayer;DDDLnet/minecraft/util/SoundEvent;Lnet/minecraft/util/SoundCategory;FF)V", //$$ at = @At("HEAD")) //#endif - public void replayModRecording_recordClientSound(PlayerEntity player, double x, double y, double z, SoundEvent sound, SoundCategory category, - float volume, float pitch, CallbackInfo ci) { + public void replayModRecording_recordClientSound( + PlayerEntity player, double x, double y, double z, + //#if MC>=11903 + //$$ RegistryEntry sound, + //#else + SoundEvent sound, + //#endif + SoundCategory category, + float volume, float pitch, + //#if MC>=11900 + //$$ long seed, + //#endif + CallbackInfo ci) { if (player == this.client.player) { RecordingEventHandler handler = replayModRecording_getRecordingEventHandler(); if (handler != null) { - handler.onClientSound(sound, category, x, y, z, volume, pitch); + // Sent to all other players in ServerWorldEventHandler#playSoundToAllNearExcept + handler.onPacket(new PlaySoundS2CPacket( + sound, category, x, y, z, volume, pitch + //#if MC>=11900 + //$$ , seed + //#endif + )); } } } diff --git a/src/main/java/com/replaymod/recording/mixin/SPacketSpawnMobAccessor.java b/src/main/java/com/replaymod/recording/mixin/SPacketSpawnMobAccessor.java index 78b663354..1cc43bbbb 100644 --- a/src/main/java/com/replaymod/recording/mixin/SPacketSpawnMobAccessor.java +++ b/src/main/java/com/replaymod/recording/mixin/SPacketSpawnMobAccessor.java @@ -1,16 +1,16 @@ -package com.replaymod.recording.mixin; - -import net.minecraft.entity.data.DataTracker; -import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -@Mixin(MobSpawnS2CPacket.class) -public interface SPacketSpawnMobAccessor { - //#if MC<11500 - //$$ @Accessor("dataTracker") - //$$ DataTracker getDataManager(); - //$$ @Accessor("dataTracker") - //$$ void setDataManager(DataTracker value); - //#endif -} +//#if MC<11500 +//$$ package com.replaymod.recording.mixin; +//$$ +//$$ import net.minecraft.entity.data.DataTracker; +//$$ import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; +//$$ import org.spongepowered.asm.mixin.Mixin; +//$$ import org.spongepowered.asm.mixin.gen.Accessor; +//$$ +//$$ @Mixin(MobSpawnS2CPacket.class) +//$$ public interface SPacketSpawnMobAccessor { +//$$ @Accessor("dataTracker") +//$$ DataTracker getDataManager(); +//$$ @Accessor("dataTracker") +//$$ void setDataManager(DataTracker value); +//$$ } +//#endif diff --git a/src/main/java/com/replaymod/recording/mixin/SPacketSpawnPlayerAccessor.java b/src/main/java/com/replaymod/recording/mixin/SPacketSpawnPlayerAccessor.java index 73f7f6672..b2eb86075 100644 --- a/src/main/java/com/replaymod/recording/mixin/SPacketSpawnPlayerAccessor.java +++ b/src/main/java/com/replaymod/recording/mixin/SPacketSpawnPlayerAccessor.java @@ -1,16 +1,16 @@ -package com.replaymod.recording.mixin; - -import net.minecraft.entity.data.DataTracker; -import net.minecraft.network.packet.s2c.play.PlayerSpawnS2CPacket; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -@Mixin(PlayerSpawnS2CPacket.class) -public interface SPacketSpawnPlayerAccessor { - //#if MC<11500 - //$$ @Accessor("dataTracker") - //$$ DataTracker getDataManager(); - //$$ @Accessor("dataTracker") - //$$ void setDataManager(DataTracker value); - //#endif -} +//#if MC<11500 +//$$ package com.replaymod.recording.mixin; +//$$ +//$$ import net.minecraft.entity.data.DataTracker; +//$$ import net.minecraft.network.packet.s2c.play.PlayerSpawnS2CPacket; +//$$ import org.spongepowered.asm.mixin.Mixin; +//$$ import org.spongepowered.asm.mixin.gen.Accessor; +//$$ +//$$ @Mixin(PlayerSpawnS2CPacket.class) +//$$ public interface SPacketSpawnPlayerAccessor { +//$$ @Accessor("dataTracker") +//$$ DataTracker getDataManager(); +//$$ @Accessor("dataTracker") +//$$ void setDataManager(DataTracker value); +//$$ } +//#endif diff --git a/src/main/java/com/replaymod/recording/packet/PacketListener.java b/src/main/java/com/replaymod/recording/packet/PacketListener.java index 1771395a9..677673ad5 100644 --- a/src/main/java/com/replaymod/recording/packet/PacketListener.java +++ b/src/main/java/com/replaymod/recording/packet/PacketListener.java @@ -2,7 +2,6 @@ import com.github.steveice10.netty.buffer.PooledByteBufAllocator; import com.github.steveice10.packetlib.tcp.io.ByteBufNetOutput; -import com.google.common.collect.Lists; import com.google.gson.Gson; import com.replaymod.core.ReplayMod; import com.replaymod.core.utils.Restrictions; @@ -13,29 +12,27 @@ import com.replaymod.recording.Setting; import com.replaymod.recording.gui.GuiSavingReplay; import com.replaymod.recording.handler.ConnectionEventHandler; -import com.replaymod.recording.mixin.SPacketSpawnMobAccessor; -import com.replaymod.recording.mixin.SPacketSpawnPlayerAccessor; import com.replaymod.replaystudio.PacketData; import com.replaymod.replaystudio.data.Marker; import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.lib.viaversion.api.protocol.packet.State; +import com.replaymod.replaystudio.protocol.Packet; import com.replaymod.replaystudio.replay.ReplayFile; import com.replaymod.replaystudio.replay.ReplayMetaData; import de.johni0702.minecraft.gui.container.VanillaGuiScreen; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.util.AttributeKey; import net.minecraft.client.MinecraftClient; import net.minecraft.network.ClientConnection; import net.minecraft.network.packet.s2c.play.CustomPayloadS2CPacket; import net.minecraft.network.packet.s2c.play.DisconnectS2CPacket; -import net.minecraft.network.packet.s2c.play.ItemPickupAnimationS2CPacket; -import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerSpawnS2CPacket; import net.minecraft.entity.Entity; -import net.minecraft.entity.data.DataTracker; import net.minecraft.network.NetworkState; -import net.minecraft.network.Packet; import net.minecraft.network.PacketByteBuf; import net.minecraft.text.LiteralText; import net.minecraft.util.crash.CrashReport; @@ -44,17 +41,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -//#if MC>=11400 -import net.minecraft.network.packet.s2c.login.LoginSuccessS2CPacket; -//#else -//$$ import net.minecraftforge.fml.common.network.internal.FMLProxyPacket; -//#endif - -//#if MC>=10904 -//#else -//$$ import java.util.List; -//#endif - //#if MC>=10800 //#if MC<10904 //$$ import net.minecraft.network.play.server.S46PacketSetCompressionLevel; @@ -64,7 +50,6 @@ import net.minecraft.network.NetworkSide; //#endif -import java.io.DataOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; @@ -82,12 +67,31 @@ import static com.replaymod.core.versions.MCVer.*; import static com.replaymod.replaystudio.util.Utils.writeInt; +import static java.util.Objects.requireNonNull; +@ChannelHandler.Sharable // so we can re-order it public class PacketListener extends ChannelInboundHandlerAdapter { + public static final String RAW_RECORDER_KEY = "replay_recorder_raw"; + public static final String DECODED_RECORDER_KEY = "replay_recorder_decoded"; + + public static final String DECOMPRESS_KEY = "decompress"; + public static final String DECODER_KEY = "decoder"; + private static final MinecraftClient mc = getMinecraft(); private static final Logger logger = LogManager.getLogger(); + //#if MC>=11700 + //$$ private static final int PACKET_ID_RESOURCE_PACK_SEND = getPacketId(NetworkState.PLAY, new ResourcePackSendS2CPacket("", "", false, null)); + //$$ private static final int PACKET_ID_LOGIN_COMPRESSION = getPacketId(NetworkState.LOGIN, new LoginCompressionS2CPacket(0)); + //#else + private static final int PACKET_ID_RESOURCE_PACK_SEND = getPacketId(NetworkState.PLAY, new ResourcePackSendS2CPacket()); + private static final int PACKET_ID_LOGIN_COMPRESSION = getPacketId(NetworkState.LOGIN, new LoginCompressionS2CPacket()); + //#endif + //#if MC<10904 + //$$ private static final int PACKET_ID_PLAY_COMPRESSION = getPacketId(EnumConnectionState.PLAY, new S46PacketSetCompressionLevel()); + //#endif + private final ReplayMod core; private final Path outputPath; private final ReplayFile replayFile; @@ -105,13 +109,6 @@ public class PacketListener extends ChannelInboundHandlerAdapter { private long lastSentPacket; private long timePassedWhilePaused; private volatile boolean serverWasPaused; - //#if MC>=11400 - private NetworkState connectionState = NetworkState.LOGIN; - private boolean loginPhase = true; - //#else - //$$ private EnumConnectionState connectionState = EnumConnectionState.PLAY; - //$$ private boolean loginPhase = false; - //#endif /** * Used to keep track of the last metadata save job submitted to the save service and @@ -159,6 +156,17 @@ private void saveMetaData() { }); } + public void save(net.minecraft.network.Packet packet) { + Packet encoded; + try { + encoded = encodeMcPacket(getConnectionState(), packet); + } catch (Exception e) { + logger.error("Encoding packet:", e); + return; + } + save(encoded); + } + public void save(Packet packet) { // If we're not on the main thread (i.e. we're on the netty thread), then we need to schedule the saving // to happen on the main thread so we can guarantee correct ordering of inbound and inject packets. @@ -169,24 +177,12 @@ public void save(Packet packet) { return; } try { - if(packet instanceof PlayerSpawnS2CPacket) { - //#if MC>=10800 - UUID uuid = ((PlayerSpawnS2CPacket) packet).getPlayerUuid(); - //#else - //$$ UUID uuid = ((S0CPacketSpawnPlayer) packet).func_148948_e().getId(); - //#endif - Set uuids = new HashSet<>(Arrays.asList(metaData.getPlayers())); - uuids.add(uuid.toString()); - metaData.setPlayers(uuids.toArray(new String[uuids.size()])); - saveMetaData(); - } - - //#if MC>=10800 - if (packet instanceof LoginCompressionS2CPacket) { + //#if MC>=11800 + if (packet.getRegistry().getState() == State.LOGIN && packet.getId() == PACKET_ID_LOGIN_COMPRESSION) { return; // Replay data is never compressed on the packet level } //#if MC<10904 - //$$ if (packet instanceof S46PacketSetCompressionLevel) { + //$$ if (packet.getRegistry().getState() == State.PLAY && packet.getId() == PACKET_ID_PLAY_COMPRESSION) { //$$ return; // Replay data is never compressed on the packet level //$$ } //#endif @@ -199,7 +195,7 @@ public void save(Packet packet) { } int timestamp = (int) (now - startTime - timePassedWhilePaused); lastSentPacket = timestamp; - PacketData packetData = getPacketData(timestamp, packet); + PacketData packetData = new PacketData(timestamp, packet); saveService.submit(() -> { try { if (ReplayMod.isMinimalMode()) { @@ -226,18 +222,27 @@ public void save(Packet packet) { throw new RuntimeException(e); } }); - - //#if MC>=11400 - if (packet instanceof LoginSuccessS2CPacket) { - connectionState = NetworkState.PLAY; - loginPhase = false; - } - //#endif } catch(Exception e) { logger.error("Writing packet:", e); } } + @Override + public void handlerAdded(ChannelHandlerContext ctx) throws Exception { + super.handlerAdded(ctx); + + if (ctx.pipeline().get(DECODED_RECORDER_KEY) == null) { + if (ctx.pipeline().get(PacketListener.DECODER_KEY) != null) { + // Regular channel, we'll inject our decoded recorder directly after the decoder + ctx.pipeline().addAfter(DECODER_KEY, DECODED_RECORDER_KEY, new DecodedPacketListener()); + } else { + // Integrated server passes packets directly, there's no splitting, decompression or decoding + // The decoded packet handler can just go directly behind this hand + ctx.pipeline().addAfter(RAW_RECORDER_KEY, DECODED_RECORDER_KEY, new DecodedPacketListener()); + } + } + } + @Override public void channelInactive(ChannelHandlerContext ctx) { metaData.setDuration((int) lastSentPacket); @@ -280,12 +285,11 @@ public void channelInactive(ChannelHandlerContext ctx) { // We still have the replay, so we just save it (at least for a few weeks) in case they change their mind String replayName = FilenameUtils.getBaseName(outputPath.getFileName().toString()); - Path rawFolder = ReplayMod.instance.getRawReplayFolder(); + Path rawFolder = ReplayMod.instance.folders.getRawReplayFolder(); Path rawPath = rawFolder.resolve(outputPath.getFileName()); for (int i = 1; Files.exists(rawPath); i++) { rawPath = rawPath.resolveSibling(replayName + "." + i + ".mcpr"); } - Files.createDirectories(rawPath.getParent()); replayFile.saveTo(rawPath.toFile()); replayFile.close(); @@ -325,139 +329,44 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } this.context = ctx; - if (msg instanceof Packet) { - try { - Packet packet = (Packet) msg; - - //#if MC>=10904 - if(packet instanceof ItemPickupAnimationS2CPacket) { - if(mc.player != null || - ((ItemPickupAnimationS2CPacket) packet).getEntityId() == mc.player.getEntityId()) { - //#else - //$$ if(packet instanceof S0DPacketCollectItem) { - //$$ if(mc.thePlayer != null || ((S0DPacketCollectItem) packet).getEntityID() == mc.thePlayer.getEntityId()) { - //#endif - super.channelRead(ctx, msg); - return; - } - } + NetworkState connectionState = getConnectionState(); - //#if MC>=10800 - if (packet instanceof ResourcePackSendS2CPacket) { - ClientConnection connection = ctx.pipeline().get(ClientConnection.class); - save(resourcePackRecorder.handleResourcePack(connection, (ResourcePackSendS2CPacket) packet)); - return; - } - //#else - //$$ if (packet instanceof S3FPacketCustomPayload) { - //$$ S3FPacketCustomPayload p = (S3FPacketCustomPayload) packet; - //$$ if ("MC|RPack".equals(p.func_149169_c())) { - //$$ save(resourcePackRecorder.handleResourcePack(p)); - //$$ return; - //$$ } - //$$ } - //#endif - - //#if MC<11400 - //$$ if (packet instanceof FMLProxyPacket) { - //$$ // This packet requires special handling - //#if MC>=10800 - //$$ ((FMLProxyPacket) packet).toS3FPackets().forEach(this::save); - //#else - //$$ save(((FMLProxyPacket) packet).toS3FPacket()); - //#endif - //$$ super.channelRead(ctx, msg); - //$$ return; - //$$ } - //#endif - - //#if MC>=10800 - if (packet instanceof CustomPayloadS2CPacket) { - // Forge may read from this ByteBuf and/or release it during handling - // We want to save the full thing however, so we create a copy and save that one instead of the - // original one - // Note: This isn't an issue with vanilla MC because our saving code runs on the main thread - // shortly before the vanilla handling code does. Forge however does some stuff on the netty - // threads which leads to this race condition - packet = new CustomPayloadS2CPacket( - ((CustomPayloadS2CPacket) packet).getChannel(), - new PacketByteBuf(((CustomPayloadS2CPacket) packet).getData().slice().retain()) - ); - } - //#endif - - save(packet); + Packet packet = null; + if (msg instanceof ByteBuf) { + // for regular connections, we're expecting to observe `ByteBuf`s here + ByteBuf buf = (ByteBuf) msg; + if (buf.readableBytes() > 0) { + packet = decodePacket(connectionState, buf); + } + } else if (msg instanceof net.minecraft.network.Packet) { + // for integrated server connections MC is passing the packet objects directly, so we need to encode them + // ourselves to be able to store them + packet = encodeMcPacket(connectionState, (net.minecraft.network.Packet) msg); + } - if (packet instanceof CustomPayloadS2CPacket) { - CustomPayloadS2CPacket p = (CustomPayloadS2CPacket) packet; - if (Restrictions.PLUGIN_CHANNEL.equals(p.getChannel())) { - packet = new DisconnectS2CPacket(new LiteralText("Please update to view this replay.")); - save(packet); - } - } - } catch(Exception e) { - logger.error("Handling packet for recording:", e); + if (packet != null) { + if (connectionState == NetworkState.PLAY && packet.getId() == PACKET_ID_RESOURCE_PACK_SEND) { + ClientConnection connection = ctx.pipeline().get(ClientConnection.class); + save(resourcePackRecorder.handleResourcePack(connection, (ResourcePackSendS2CPacket) decodeMcPacket(packet))); + return; } + save(packet); } super.channelRead(ctx, msg); } - //#if MC>=10904 - private void DataManager_set(DataTracker dataManager, DataTracker.Entry entry) { - dataManager.startTracking(entry.getData(), entry.get()); + private NetworkState getConnectionState() { + ChannelHandlerContext ctx = context; + if (ctx == null) { + return NetworkState.LOGIN; + } + AttributeKey key = ClientConnection.ATTR_KEY_PROTOCOL; + return ctx.channel().attr(key).get(); } - //#endif - - @SuppressWarnings("unchecked") - private PacketData getPacketData(int timestamp, Packet packet) throws Exception { - //#if MC<11500 - //$$ if (packet instanceof MobSpawnS2CPacket) { - //$$ MobSpawnS2CPacket p = (MobSpawnS2CPacket) packet; - //$$ SPacketSpawnMobAccessor pa = (SPacketSpawnMobAccessor) p; - //$$ if (pa.getDataManager() == null) { - //$$ pa.setDataManager(new DataTracker(null)); - //$$ if (p.getTrackedValues() != null) { - //$$ Set seen = new HashSet<>(); - //#if MC>=10904 - //$$ for (DataTracker.Entry entry : Lists.reverse(p.getTrackedValues())) { - //$$ if (!seen.add(entry.getData().getId())) continue; - //$$ DataManager_set(pa.getDataManager(), entry); - //$$ } - //#else - //$$ for(DataWatcher.WatchableObject wo : Lists.reverse((List) p.func_149027_c())) { - //$$ if (!seen.add(wo.getDataValueId())) continue; - //$$ pa.getDataManager().addObject(wo.getDataValueId(), wo.getObject()); - //$$ } - //#endif - //$$ } - //$$ } - //$$ } - //$$ - //$$ if (packet instanceof PlayerSpawnS2CPacket) { - //$$ PlayerSpawnS2CPacket p = (PlayerSpawnS2CPacket) packet; - //$$ SPacketSpawnPlayerAccessor pa = (SPacketSpawnPlayerAccessor) p; - //$$ if (pa.getDataManager() == null) { - //$$ pa.setDataManager(new DataTracker(null)); - //$$ if (p.getTrackedValues() != null) { - //$$ Set seen = new HashSet<>(); - //#if MC>=10904 - //$$ for (DataTracker.Entry entry : Lists.reverse(p.getTrackedValues())) { - //$$ if (!seen.add(entry.getData().getId())) continue; - //$$ DataManager_set(pa.getDataManager(), entry); - //$$ } - //#else - //$$ for(DataWatcher.WatchableObject wo : Lists.reverse((List) p.func_148944_c())) { - //$$ if (!seen.add(wo.getDataValueId())) continue; - //$$ pa.getDataManager().addObject(wo.getDataValueId(), wo.getObject()); - //$$ } - //#endif - //$$ } - //$$ } - //$$ } - //#endif + private static Packet encodeMcPacket(NetworkState connectionState, net.minecraft.network.Packet packet) throws Exception { //#if MC>=10800 Integer packetId = connectionState.getPacketId(NetworkSide.CLIENTBOUND, packet); //#else @@ -469,23 +378,55 @@ private PacketData getPacketData(int timestamp, Packet packet) throws Exception ByteBuf byteBuf = Unpooled.buffer(); try { packet.write(new PacketByteBuf(byteBuf)); - return new PacketData(timestamp, new com.replaymod.replaystudio.protocol.Packet( - MCVer.getPacketTypeRegistry(loginPhase), + return new Packet( + MCVer.getPacketTypeRegistry(connectionState == NetworkState.LOGIN), packetId, com.github.steveice10.netty.buffer.Unpooled.wrappedBuffer( byteBuf.array(), byteBuf.arrayOffset(), byteBuf.readableBytes() ) - )); + ); } finally { byteBuf.release(); + } + } - //#if MC>=10800 - if (packet instanceof CustomPayloadS2CPacket) { - ((CustomPayloadS2CPacket) packet).getData().release(); - } - //#endif + private static net.minecraft.network.Packet decodeMcPacket(Packet packet) throws IOException, IllegalAccessException, InstantiationException { + NetworkState connectionState = packet.getRegistry().getState() == State.LOGIN ? NetworkState.LOGIN : NetworkState.PLAY; + int packetId = packet.getId(); + PacketByteBuf packetBuf = new PacketByteBuf(Unpooled.wrappedBuffer(packet.getBuf().nioBuffer())); + + //#if MC>=11700 + //$$ return connectionState.getPacketHandler(NetworkSide.CLIENTBOUND, packetId, packetBuf); + //#else + //#if MC>=10800 + net.minecraft.network.Packet p = connectionState.getPacketHandler(NetworkSide.CLIENTBOUND, packetId); + //#else + //$$ net.minecraft.network.Packet p = net.minecraft.network.Packet.generatePacket(connectionState.func_150755_b(), packetId); + //#endif + p.read(packetBuf); + return p; + //#endif + } + + private static Packet decodePacket(NetworkState connectionState, ByteBuf buf) { + PacketByteBuf packetBuf = new PacketByteBuf(buf.slice()); + int packetId = packetBuf.readVarInt(); + byte[] bytes = new byte[packetBuf.readableBytes()]; + packetBuf.readBytes(bytes); + return new Packet( + MCVer.getPacketTypeRegistry(connectionState == NetworkState.LOGIN), + packetId, + com.github.steveice10.netty.buffer.Unpooled.wrappedBuffer(bytes) + ); + } + + private static int getPacketId(NetworkState networkState, net.minecraft.network.Packet packet) { + try { + return requireNonNull(networkState.getPacketId(NetworkSide.CLIENTBOUND, packet)); + } catch (Exception e) { + throw new RuntimeException("Failed to determine packet id for " + packet.getClass(), e); } } @@ -527,4 +468,31 @@ public long getCurrentDuration() { public void setServerWasPaused() { this.serverWasPaused = true; } + + private class DecodedPacketListener extends ChannelInboundHandlerAdapter { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + + if (msg instanceof CustomPayloadS2CPacket) { + CustomPayloadS2CPacket packet = (CustomPayloadS2CPacket) msg; + if (Restrictions.PLUGIN_CHANNEL.equals(packet.getChannel())) { + save(new DisconnectS2CPacket(new LiteralText("Please update to view this replay."))); + } + } + + if (msg instanceof PlayerSpawnS2CPacket) { + //#if MC>=10800 + UUID uuid = ((PlayerSpawnS2CPacket) msg).getPlayerUuid(); + //#else + //$$ UUID uuid = ((S0CPacketSpawnPlayer) msg).func_148948_e().getId(); + //#endif + Set uuids = new HashSet<>(Arrays.asList(metaData.getPlayers())); + uuids.add(uuid.toString()); + metaData.setPlayers(uuids.toArray(new String[uuids.size()])); + saveMetaData(); + } + + super.channelRead(ctx, msg); + } + } } diff --git a/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java b/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java index 689f2cfd6..01fd26c9d 100644 --- a/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java +++ b/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java @@ -11,6 +11,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +//#if MC>=11900 +//$$ import java.net.MalformedURLException; +//$$ import java.net.URL; +//#endif + //#if MC>=11400 import net.minecraft.text.TranslatableText; //#else @@ -192,7 +197,18 @@ private void downloadResourcePackFuture(ClientConnection connection, int request downloadResourcePack(final int requestId, String url, String hash) { ClientBuiltinResourcePackProvider packFinder = mc.getResourcePackDownloader(); ((IDownloadingPackFinder) packFinder).setRequestCallback(file -> recordResourcePack(file, requestId)); - //#if MC>=11700 + //#if MC>=11900 + //$$ try { + //$$ URL theUrl = new URL(url); + //$$ String protocol = theUrl.getProtocol(); + //$$ if (!"http".equals(protocol) && !"https".equals(protocol)) { + //$$ throw new MalformedURLException("Unsupported protocol."); + //$$ } + //$$ return packFinder.download(theUrl, hash, true); + //$$ } catch (MalformedURLException e) { + //$$ return CompletableFuture.failedFuture(e); + //$$ } + //#elseif MC>=11700 //$$ return packFinder.download(url, hash, true); //#else return packFinder.download(url, hash); diff --git a/src/main/java/com/replaymod/render/EXRWriter.java b/src/main/java/com/replaymod/render/EXRWriter.java index 1b2348fe4..2b072bdae 100644 --- a/src/main/java/com/replaymod/render/EXRWriter.java +++ b/src/main/java/com/replaymod/render/EXRWriter.java @@ -1,4 +1,3 @@ -//#if MC>=11400 package com.replaymod.render; import com.replaymod.core.versions.MCVer; @@ -6,6 +5,7 @@ import com.replaymod.render.rendering.Channel; import com.replaymod.render.rendering.FrameConsumer; import com.replaymod.render.utils.ByteBufferPool; +import com.replaymod.render.utils.Lwjgl3Loader; import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; import net.minecraft.util.crash.CrashReport; import org.lwjgl.PointerBuffer; @@ -27,10 +27,25 @@ public class EXRWriter implements FrameConsumer { + public static FrameConsumer create(Path outputFolder, boolean keepAlpha) { + return Lwjgl3Loader.createFrameConsumer( + EXRWriter.class, + new Class[]{ Path.class, boolean.class }, + new Object[]{ outputFolder, keepAlpha } + ); + } + + // Compression is pretty slow, so we'll only use it when we've got enough cpu cores to make up for that + private static final int COMPRESSION = Runtime.getRuntime().availableProcessors() >= 8 + ? TINYEXR_COMPRESSIONTYPE_ZIPS + : TINYEXR_COMPRESSIONTYPE_NONE; + private final Path outputFolder; + private final boolean keepAlpha; - public EXRWriter(Path outputFolder) throws IOException { + public EXRWriter(Path outputFolder, boolean keepAlpha) throws IOException { this.outputFolder = outputFolder; + this.keepAlpha = keepAlpha; Files.createDirectories(outputFolder); } @@ -61,6 +76,7 @@ public void consume(Map channels) { header.channels(channelInfos); header.pixel_types(pixelTypes); header.requested_pixel_types(requestedPixelTypes); + header.compression_type(COMPRESSION); // Some readers ignore this, so we use the most expected order memASCII("A", true, channelInfos.get(0).name()); @@ -92,11 +108,15 @@ public void consume(Map channels) { bgrChannels[(i + 3) % 4] = channel; } } + + int alphaMask = keepAlpha ? 0 : 0xff; + for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - for (FloatBuffer channel : bgrChannels) { - channel.put(((int) bgra.get() & 0xff) / 255f); - } + bgrChannels[0].put(((int) bgra.get() & 0xff) / 255f); // b + bgrChannels[1].put(((int) bgra.get() & 0xff) / 255f); // g + bgrChannels[2].put(((int) bgra.get() & 0xff) / 255f); // r + bgrChannels[3].put(((int) bgra.get() & 0xff | alphaMask) / 255f); // a } } if (depthFrame != null && depthChannel != null) { @@ -121,5 +141,9 @@ public void consume(Map channels) { @Override public void close() { } + + @Override + public boolean isParallelCapable() { + return true; + } } -//#endif diff --git a/src/main/java/com/replaymod/render/FFmpegWriter.java b/src/main/java/com/replaymod/render/FFmpegWriter.java index f4e1381cf..332f346ca 100644 --- a/src/main/java/com/replaymod/render/FFmpegWriter.java +++ b/src/main/java/com/replaymod/render/FFmpegWriter.java @@ -134,6 +134,11 @@ public void consume(Map channels) { } } + @Override + public boolean isParallelCapable() { + return false; + } + private void checkSize(ReadableDimension size) { checkSize(size.getWidth(), size.getHeight()); } diff --git a/src/main/java/com/replaymod/render/PNGWriter.java b/src/main/java/com/replaymod/render/PNGWriter.java index 846e63be2..8912dcb0f 100644 --- a/src/main/java/com/replaymod/render/PNGWriter.java +++ b/src/main/java/com/replaymod/render/PNGWriter.java @@ -19,9 +19,11 @@ public class PNGWriter implements FrameConsumer { private final Path outputFolder; + private final boolean keepAlpha; - public PNGWriter(Path outputFolder) throws IOException { + public PNGWriter(Path outputFolder, boolean keepAlpha) throws IOException { this.outputFolder = outputFolder; + this.keepAlpha = keepAlpha; Files.createDirectories(outputFolder); } @@ -47,6 +49,7 @@ public void consume(Map channels) { } private void withImage(BitmapFrame frame, IOConsumer consumer) throws IOException { + byte alphaMask = (byte) (keepAlpha ? 0 : 0xff); ByteBuffer buffer = frame.getByteBuffer(); ReadableDimension size = frame.getSize(); int width = size.getWidth(); @@ -58,7 +61,7 @@ private void withImage(BitmapFrame frame, IOConsumer consumer) throws IOE byte g = buffer.get(); byte r = buffer.get(); byte a = buffer.get(); - image.setRGBA(x, y, r, g, b, a); + image.setRGBA(x, y, r, g, b, a | alphaMask); } } consumer.accept(image); @@ -68,4 +71,9 @@ private void withImage(BitmapFrame frame, IOConsumer consumer) throws IOE @Override public void close() { } + + @Override + public boolean isParallelCapable() { + return true; + } } diff --git a/src/main/java/com/replaymod/render/RenderSettings.java b/src/main/java/com/replaymod/render/RenderSettings.java index e83d49477..f5e92ffb2 100644 --- a/src/main/java/com/replaymod/render/RenderSettings.java +++ b/src/main/java/com/replaymod/render/RenderSettings.java @@ -105,13 +105,6 @@ public String toString() { public boolean isSupported() { if (this == BLEND) { return RenderMethod.BLEND.isSupported(); - } else if (this == EXR) { - // Need LJWGL 3 - //#if MC>=11400 - return true; - //#else - //$$ return false; - //#endif } else { return true; } @@ -151,6 +144,7 @@ public String toString() { private final File outputFile; private final boolean renderNameTags; + private final boolean includeAlphaChannel; private final boolean stabilizeYaw; private final boolean stabilizePitch; private final boolean stabilizeRoll; @@ -187,6 +181,7 @@ public RenderSettings() { false, false, false, + false, null, 360, 180, @@ -209,6 +204,7 @@ public RenderSettings( int bitRate, File outputFile, boolean renderNameTags, + boolean includeAlphaChannel, boolean stabilizeYaw, boolean stabilizePitch, boolean stabilizeRoll, @@ -231,6 +227,7 @@ public RenderSettings( this.bitRate = bitRate; this.outputFile = outputFile; this.renderNameTags = renderNameTags; + this.includeAlphaChannel = includeAlphaChannel; this.stabilizeYaw = stabilizeYaw; this.stabilizePitch = stabilizePitch; this.stabilizeRoll = stabilizeRoll; @@ -256,6 +253,7 @@ public RenderSettings withEncodingPreset(EncodingPreset encodingPreset) { bitRate, outputFile, renderNameTags, + includeAlphaChannel, stabilizeYaw, stabilizePitch, stabilizeRoll, @@ -415,6 +413,10 @@ public boolean isRenderNameTags() { return renderNameTags; } + public boolean isIncludeAlphaChannel() { + return includeAlphaChannel; + } + public boolean isStabilizeYaw() { return stabilizeYaw; } @@ -478,6 +480,7 @@ public String toString() { ", bitRate=" + bitRate + ", outputFile=" + outputFile + ", renderNameTags=" + renderNameTags + + ", includeAlphaChannel=" + includeAlphaChannel + ", stabilizeYaw=" + stabilizeYaw + ", stabilizePitch=" + stabilizePitch + ", stabilizeRoll=" + stabilizeRoll + diff --git a/src/main/java/com/replaymod/render/blend/BlendMeshBuilder.java b/src/main/java/com/replaymod/render/blend/BlendMeshBuilder.java index 8b9e2f507..de61bb5f6 100644 --- a/src/main/java/com/replaymod/render/blend/BlendMeshBuilder.java +++ b/src/main/java/com/replaymod/render/blend/BlendMeshBuilder.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend; import com.replaymod.render.blend.data.DMaterial; diff --git a/src/main/java/com/replaymod/render/blend/BlendState.java b/src/main/java/com/replaymod/render/blend/BlendState.java index 0ef4a29bf..a13c02a3b 100644 --- a/src/main/java/com/replaymod/render/blend/BlendState.java +++ b/src/main/java/com/replaymod/render/blend/BlendState.java @@ -2,7 +2,7 @@ import com.replaymod.render.blend.data.DScene; import com.replaymod.render.blend.data.Serializer; -//#if MC>=10800 +//#if MC>=10800 && MC<11900 // FIXME 1.15 //#if MC<11500 //$$ import com.replaymod.render.blend.exporters.ChunkExporter; @@ -53,7 +53,7 @@ public BlendState(File file) throws IOException { this.blenderFile = BlenderFactory.newBlenderFile(file); this.factory = new BlenderFactory(blenderFile); - //#if MC>=10800 + //#if MC>=10800 && MC<11900 RenderState renderState = new RenderState(this); register(renderState); // FIXME 1.15 diff --git a/src/main/java/com/replaymod/render/blend/Util.java b/src/main/java/com/replaymod/render/blend/Util.java index c43e695a2..78138995e 100644 --- a/src/main/java/com/replaymod/render/blend/Util.java +++ b/src/main/java/com/replaymod/render/blend/Util.java @@ -183,12 +183,16 @@ public static void insert(ListBase list, CPointer element) throws IOExcept } public static String getTileEntityId(BlockEntity tileEntity) { + //#if MC>=11800 + //$$ NbtCompound nbt = tileEntity.createNbt(); + //#else CompoundTag nbt = new CompoundTag(); //#if MC>=11400 tileEntity.toTag(nbt); //#else //$$ tileEntity.writeToNBT(nbt); //#endif + //#endif return nbt.getString("id"); } diff --git a/src/main/java/com/replaymod/render/blend/exporters/EntityExporter.java b/src/main/java/com/replaymod/render/blend/exporters/EntityExporter.java index 8b6834b72..d4dfda21c 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/EntityExporter.java +++ b/src/main/java/com/replaymod/render/blend/exporters/EntityExporter.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.core.versions.MCVer; diff --git a/src/main/java/com/replaymod/render/blend/exporters/ItemExporter.java b/src/main/java/com/replaymod/render/blend/exporters/ItemExporter.java index b25b6556c..368e55b84 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/ItemExporter.java +++ b/src/main/java/com/replaymod/render/blend/exporters/ItemExporter.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.render.blend.BlendMeshBuilder; diff --git a/src/main/java/com/replaymod/render/blend/exporters/ModelRendererExporter.java b/src/main/java/com/replaymod/render/blend/exporters/ModelRendererExporter.java index 2320f2366..9fba791c2 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/ModelRendererExporter.java +++ b/src/main/java/com/replaymod/render/blend/exporters/ModelRendererExporter.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.render.blend.BlendMeshBuilder; diff --git a/src/main/java/com/replaymod/render/blend/exporters/ParticlesExporter.java b/src/main/java/com/replaymod/render/blend/exporters/ParticlesExporter.java index 2aecf1a48..ec164ed6e 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/ParticlesExporter.java +++ b/src/main/java/com/replaymod/render/blend/exporters/ParticlesExporter.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.core.versions.MCVer; diff --git a/src/main/java/com/replaymod/render/blend/exporters/RenderState.java b/src/main/java/com/replaymod/render/blend/exporters/RenderState.java index 9f385ce36..72b7abb9a 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/RenderState.java +++ b/src/main/java/com/replaymod/render/blend/exporters/RenderState.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.render.blend.BlendState; diff --git a/src/main/java/com/replaymod/render/blend/exporters/TileEntityExporter.java b/src/main/java/com/replaymod/render/blend/exporters/TileEntityExporter.java index 0fd15a814..18b0ce016 100644 --- a/src/main/java/com/replaymod/render/blend/exporters/TileEntityExporter.java +++ b/src/main/java/com/replaymod/render/blend/exporters/TileEntityExporter.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.exporters; import com.replaymod.core.versions.MCVer; diff --git a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderGlobal.java b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderGlobal.java index af495f1d6..683b33f18 100644 --- a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderGlobal.java +++ b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderGlobal.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.mixin; import com.replaymod.render.blend.BlendState; diff --git a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderItem.java b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderItem.java index cf41e5e24..48a86d7f8 100644 --- a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderItem.java +++ b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderItem.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.mixin; import com.replaymod.render.blend.BlendState; diff --git a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderLivingBase.java b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderLivingBase.java index 04efa6333..fa247467b 100644 --- a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderLivingBase.java +++ b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderLivingBase.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.mixin; import com.replaymod.render.blend.BlendState; diff --git a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderManager.java b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderManager.java index 57764995d..1dfb808c5 100644 --- a/src/main/java/com/replaymod/render/blend/mixin/MixinRenderManager.java +++ b/src/main/java/com/replaymod/render/blend/mixin/MixinRenderManager.java @@ -1,4 +1,4 @@ -//#if MC>=10800 +//#if MC>=10800 && MC<11900 package com.replaymod.render.blend.mixin; import com.replaymod.render.blend.BlendState; diff --git a/src/main/java/com/replaymod/render/capturer/IrisODSFrameCapturer.java b/src/main/java/com/replaymod/render/capturer/IrisODSFrameCapturer.java index a1d310554..6671d80a0 100644 --- a/src/main/java/com/replaymod/render/capturer/IrisODSFrameCapturer.java +++ b/src/main/java/com/replaymod/render/capturer/IrisODSFrameCapturer.java @@ -1,7 +1,6 @@ //#if MC>=11600 package com.replaymod.render.capturer; -import com.mojang.blaze3d.platform.GlStateManager; import com.replaymod.render.RenderSettings; import com.replaymod.render.frame.CubicOpenGlFrame; import com.replaymod.render.frame.ODSOpenGlFrame; @@ -16,18 +15,13 @@ import java.util.HashMap; import java.util.Map; -import static com.replaymod.core.versions.MCVer.popMatrix; -import static com.replaymod.core.versions.MCVer.pushMatrix; -import static com.replaymod.core.versions.MCVer.resizeMainWindow; -import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; -import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT; - public class IrisODSFrameCapturer implements FrameCapturer { public static final String SHADER_PACK_NAME = "assets/replaymod/iris/ods"; public static IrisODSFrameCapturer INSTANCE; private final CubicPboOpenGlFrameCapturer left, right; private final String prevShaderPack; + private final boolean prevShadersEnabled; private int direction; private boolean isLeftEye; @@ -67,13 +61,16 @@ public RenderSettings getRenderSettings() { right = new CubicStereoFrameCapturer(worldRenderer, fakeInfo, frameSize); INSTANCE = this; - prevShaderPack = Iris.getIrisConfig().getShaderPackName().orElse(null); - setShaderPack(SHADER_PACK_NAME); + IrisConfig irisConfig = Iris.getIrisConfig(); + prevShaderPack = irisConfig.getShaderPackName().orElse(null); + prevShadersEnabled = irisConfig.areShadersEnabled(); + setShaderPack(SHADER_PACK_NAME, true); } - private static void setShaderPack(String name) { + private static void setShaderPack(String name, boolean enabled) { IrisConfig irisConfig = Iris.getIrisConfig(); irisConfig.setShaderPackName(name); + irisConfig.setShadersEnabled(enabled); try { irisConfig.save(); Iris.reload(); @@ -121,7 +118,7 @@ public void close() throws IOException { left.close(); right.close(); INSTANCE = null; - setShaderPack(prevShaderPack); + setShaderPack(prevShaderPack, prevShadersEnabled); } private class CubicStereoFrameCapturer extends CubicPboOpenGlFrameCapturer { @@ -131,25 +128,8 @@ public CubicStereoFrameCapturer(WorldRenderer worldRenderer, RenderInfo renderIn @Override protected OpenGlFrame renderFrame(int frameId, float partialTicks, CubicOpenGlFrameCapturer.Data captureData) { - resizeMainWindow(mc, getFrameWidth(), getFrameHeight()); - - pushMatrix(); - frameBuffer().beginWrite(true); - - GlStateManager.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT - //#if MC>=11400 - , false - //#endif - ); - GlStateManager.enableTexture(); - direction = captureData.ordinal(); - worldRenderer.renderWorld(partialTicks, null); - - frameBuffer().endWrite(); - popMatrix(); - - return captureFrame(frameId, captureData); + return super.renderFrame(frameId, partialTicks, captureData); } } } diff --git a/src/main/java/com/replaymod/render/capturer/ODSFrameCapturer.java b/src/main/java/com/replaymod/render/capturer/ODSFrameCapturer.java index 4df2fa18b..925257fe8 100644 --- a/src/main/java/com/replaymod/render/capturer/ODSFrameCapturer.java +++ b/src/main/java/com/replaymod/render/capturer/ODSFrameCapturer.java @@ -1,6 +1,5 @@ package com.replaymod.render.capturer; -import com.mojang.blaze3d.platform.GlStateManager; import com.replaymod.render.rendering.Channel; import de.johni0702.minecraft.gui.utils.EventRegistrations; import com.replaymod.render.RenderSettings; @@ -20,12 +19,6 @@ import java.util.HashMap; import java.util.Map; -import static com.replaymod.core.versions.MCVer.popMatrix; -import static com.replaymod.core.versions.MCVer.pushMatrix; -import static com.replaymod.core.versions.MCVer.resizeMainWindow; -import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; -import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT; - public class ODSFrameCapturer implements FrameCapturer { private static final Identifier vertexResource = new Identifier("replaymod", "shader/ods.vert"); private static final Identifier fragmentResource = new Identifier("replaymod", "shader/ods.frag"); @@ -166,25 +159,8 @@ public CubicStereoFrameCapturer(WorldRenderer worldRenderer, RenderInfo renderIn @Override protected OpenGlFrame renderFrame(int frameId, float partialTicks, CubicOpenGlFrameCapturer.Data captureData) { - resizeMainWindow(mc, getFrameWidth(), getFrameHeight()); - - pushMatrix(); - frameBuffer().beginWrite(true); - - GlStateManager.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT - //#if MC>=11400 - , false - //#endif - ); - GlStateManager.enableTexture(); - directionVariable.set(captureData.ordinal()); - worldRenderer.renderWorld(partialTicks, null); - - frameBuffer().endWrite(); - popMatrix(); - - return captureFrame(frameId, captureData); + return super.renderFrame(frameId, partialTicks, captureData); } } } diff --git a/src/main/java/com/replaymod/render/gui/GuiExportFailed.java b/src/main/java/com/replaymod/render/gui/GuiExportFailed.java index de5e9c0e3..56f9229f2 100644 --- a/src/main/java/com/replaymod/render/gui/GuiExportFailed.java +++ b/src/main/java/com/replaymod/render/gui/GuiExportFailed.java @@ -90,6 +90,7 @@ public GuiExportFailed(FFmpegWriter.FFmpegStartupException e, Consumer openURL(URI.create(LINK))); + private final GuiButton copyToClipboardButton = new GuiButton() + .setI18nLabel("chat.copy") + .setSize(100, 20) + .onClick(() -> setClipboardString(LINK)); + private final GuiButton backButton = new GuiButton() + .setI18nLabel("gui.back") + .setSize(100, 20); + private final GuiPanel buttons = new GuiPanel() + .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER).setSpacing(4)) + .addElements(null, openLinkButton, copyToClipboardButton, backButton); + + { + setBackground(Background.DIRT); + setTitle(new GuiLabel().setI18nText("replaymod.gui.rendering.error.title")); + setLayout(new VerticalLayout(VerticalLayout.Alignment.CENTER).setSpacing(30)); + addElements(new VerticalLayout.Data(0.5), message, link, buttons); + } + + public GuiNoFfmpeg(Runnable goBack) { + backButton.onClick(goBack); + } +} diff --git a/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java b/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java index a019b5657..86ca19dcc 100644 --- a/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java +++ b/src/main/java/com/replaymod/render/gui/GuiRenderQueue.java @@ -37,7 +37,6 @@ import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; import de.johni0702.minecraft.gui.utils.lwjgl.ReadablePoint; import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.screen.NoticeScreen; import net.minecraft.util.crash.CrashReport; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.tuple.Pair; @@ -181,17 +180,7 @@ private static void processQueue(AbstractGuiScreen container, ReplayHandler r videoRenderer.renderVideo(); } catch (FFmpegWriter.NoFFmpegException e) { LOGGER.error("Rendering video:", e); - NoticeScreen errorScreen = new NoticeScreen( - //#if MC>=11400 - container::display, - new TranslatableText("replaymod.gui.rendering.error.title"), - new TranslatableText("replaymod.gui.rendering.error.message") - //#else - //$$ I18n.format("replaymod.gui.rendering.error.title"), - //$$ I18n.format("replaymod.gui.rendering.error.message") - //#endif - ); - mc.openScreen(errorScreen); + mc.openScreen(new GuiNoFfmpeg(container::display).toMinecraft()); return; } catch (FFmpegWriter.FFmpegStartupException e) { int jobsToSkip = jobsDone; @@ -228,8 +217,8 @@ public static void processMultipleReplays( ReplayHandler replayHandler; ReplayFile replayFile = null; try { - replayFile = mod.getCore().openReplay(next.getKey().toPath()); - replayHandler = mod.startReplay(replayFile, true, false); + replayFile = mod.getCore().files.open(next.getKey().toPath()); + replayHandler = mod.startReplay(replayFile, false, false); } catch (IOException e) { Utils.error(LOGGER, container, CrashReport.create(e, "Opening replay"), () -> {}); container.display(); // Re-show the queue popup and the new error popup diff --git a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java index 38d80a27c..170aa21aa 100644 --- a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java +++ b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java @@ -30,7 +30,6 @@ import de.johni0702.minecraft.gui.utils.lwjgl.Color; import de.johni0702.minecraft.gui.utils.lwjgl.Dimension; import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; -import net.minecraft.client.gui.screen.NoticeScreen; import net.minecraft.client.resource.language.I18n; import net.minecraft.util.crash.CrashReport; @@ -149,6 +148,9 @@ public void run() { public final GuiCheckbox nametagCheckbox = new GuiCheckbox() .setI18nLabel("replaymod.gui.rendersettings.nametags"); + public final GuiCheckbox alphaCheckbox = new GuiCheckbox() + .setI18nLabel("replaymod.gui.rendersettings.includealpha"); + public final GuiPanel stabilizePanel = new GuiPanel().setLayout(new HorizontalLayout().setSpacing(10)); public final GuiCheckbox stabilizeYaw = new GuiCheckbox(stabilizePanel) .setI18nLabel("replaymod.gui.yaw"); @@ -188,7 +190,7 @@ public void run() { .setSize(200, 20).setValues(RenderSettings.AntiAliasing.values()).setSelected(RenderSettings.AntiAliasing.NONE); public final GuiPanel advancedPanel = new GuiPanel().setLayout(new VerticalLayout().setSpacing(15)) - .addElements(null, nametagCheckbox, new GuiPanel().setLayout( + .addElements(null, nametagCheckbox, alphaCheckbox, new GuiPanel().setLayout( new GridLayout().setCellsEqualSize(false).setColumns(2).setSpacingX(5).setSpacingY(15)) .addElements(new GridLayout.Data(0, 0.5), new GuiLabel().setI18nText("replaymod.gui.rendersettings.stabilizecamera"), stabilizePanel, @@ -240,17 +242,7 @@ public void run() { videoRenderer.renderVideo(); } catch (FFmpegWriter.NoFFmpegException e) { LOGGER.error("Rendering video:", e); - NoticeScreen errorScreen = new NoticeScreen( - //#if MC>=11400 - getScreen()::display, - new TranslatableText("replaymod.gui.rendering.error.title"), - new TranslatableText("replaymod.gui.rendering.error.message") - //#else - //$$ I18n.format("replaymod.gui.rendering.error.title"), - //$$ I18n.format("replaymod.gui.rendering.error.message") - //#endif - ); - getMinecraft().openScreen(errorScreen); + getMinecraft().openScreen(new GuiNoFfmpeg(getScreen()::display).toMinecraft()); } catch (FFmpegWriter.FFmpegStartupException e) { GuiExportFailed.tryToRecover(e, newSettings -> { // Update settings with fixed ffmpeg arguments @@ -533,6 +525,7 @@ public void load(RenderSettings settings) { } outputFileButton.setLabel(this.outputFile.getName()); nametagCheckbox.setChecked(settings.isRenderNameTags()); + alphaCheckbox.setChecked(settings.isIncludeAlphaChannel()); stabilizeYaw.setChecked(settings.isStabilizeYaw()); stabilizePitch.setChecked(settings.isStabilizePitch()); stabilizeRoll.setChecked(settings.isStabilizeRoll()); @@ -571,6 +564,7 @@ public RenderSettings save(boolean serialize) { bitRateField.getInteger() << (10 * bitRateUnit.getSelected()), serialize && !userDefinedOutputFileName ? getParentFile(outputFile) : outputFile, nametagCheckbox.isChecked(), + alphaCheckbox.isChecked(), stabilizeYaw.isChecked() && (serialize || stabilizeYaw.isEnabled()), stabilizePitch.isChecked() && (serialize || stabilizePitch.isEnabled()), stabilizeRoll.isChecked() && (serialize || stabilizeRoll.isEnabled()), diff --git a/src/main/java/com/replaymod/render/gui/GuiVideoRenderer.java b/src/main/java/com/replaymod/render/gui/GuiVideoRenderer.java index bc69f0616..cd9c62687 100644 --- a/src/main/java/com/replaymod/render/gui/GuiVideoRenderer.java +++ b/src/main/java/com/replaymod/render/gui/GuiVideoRenderer.java @@ -141,7 +141,7 @@ public GuiVideoRenderer(VideoRenderer renderer) { @Override public void tick() { - long current = System.currentTimeMillis(); + long current = System.nanoTime() / 1_000_000; //first, update the total render time (only if rendering is not paused and has already started) if(!renderer.isPaused() && renderer.getFramesDone() > 0 && prevTime > -1) { diff --git a/src/main/java/com/replaymod/render/gui/progress/VirtualWindow.java b/src/main/java/com/replaymod/render/gui/progress/VirtualWindow.java new file mode 100644 index 000000000..6f2b2bc09 --- /dev/null +++ b/src/main/java/com/replaymod/render/gui/progress/VirtualWindow.java @@ -0,0 +1,148 @@ +package com.replaymod.render.gui.progress; + +import com.replaymod.render.hooks.MinecraftClientExt; +import com.replaymod.render.mixin.MainWindowAccessor; +import de.johni0702.minecraft.gui.function.Closeable; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.Framebuffer; +import net.minecraft.client.util.Window; + +//#if MC>=11700 +//$$ import net.minecraft.client.gl.WindowFramebuffer; +//#endif + +public class VirtualWindow implements Closeable { + private final MinecraftClient mc; + private final Window window; + private final MainWindowAccessor acc; + + private final Framebuffer guiFramebuffer; + private boolean isBound; + private int framebufferWidth, framebufferHeight; + + private int gameWidth, gameHeight; + + + public VirtualWindow(MinecraftClient mc) { + this.mc = mc; + this.window = mc.getWindow(); + this.acc = (MainWindowAccessor) (Object) this.window; + + framebufferWidth = acc.getFramebufferWidth(); + framebufferHeight = acc.getFramebufferHeight(); + + //#if MC>=11700 + //$$ guiFramebuffer = new WindowFramebuffer(framebufferWidth, framebufferHeight); + //#else + guiFramebuffer = new Framebuffer(framebufferWidth, framebufferHeight, true + //#if MC>=11400 + , false + //#endif + ); + //#endif + + MinecraftClientExt.get(mc).setWindowDelegate(this); + } + + @Override + public void close() { + guiFramebuffer.delete(); + + MinecraftClientExt.get(mc).setWindowDelegate(null); + } + + public void bind() { + gameWidth = acc.getFramebufferWidth(); + gameHeight = acc.getFramebufferHeight(); + acc.setFramebufferWidth(framebufferWidth); + acc.setFramebufferHeight(framebufferHeight); + applyScaleFactor(); + isBound = true; + } + + public void unbind() { + acc.setFramebufferWidth(gameWidth); + acc.setFramebufferHeight(gameHeight); + applyScaleFactor(); + isBound = false; + } + + public void beginWrite() { + guiFramebuffer.beginWrite(true); + } + + public void endWrite() { + guiFramebuffer.endWrite(); + } + + public void flip() { + guiFramebuffer.draw(framebufferWidth, framebufferHeight); + + //#if MC>=11500 + window.swapBuffers(); + //#else + //#if MC>=11400 + //$$ window.setFullscreen(false); + //#else + //#if MC>=10800 + //$$ mc.updateDisplay(); + //#else + //$$ mc.resetSize(); + //#endif + //#endif + //#endif + } + + /** + * Updates the size of the window's framebuffer. Must only be called while this window is bound. + */ + public void onResolutionChanged(int newWidth, int newHeight) { + if (newWidth == 0 || newHeight == 0) { + // These can be zero on Windows if minimized. + // Creating zero-sized framebuffers however will throw an error, so we never want to switch to zero values. + return; + } + + if (framebufferWidth == newWidth && framebufferHeight == newHeight) { + return; // size is unchanged, nothing to do + } + + framebufferWidth = newWidth; + framebufferHeight = newHeight; + + //#if MC>=11400 + guiFramebuffer.resize(newWidth, newHeight + //#if MC>=11400 + , false + //#endif + ); + //#else + //$$ guiFramebuffer.createBindFramebuffer(newWidth, newHeight); + //#endif + + applyScaleFactor(); + if (mc.currentScreen != null) { + mc.currentScreen.resize(mc, window.getScaledWidth(), window.getScaledHeight()); + } + } + + private void applyScaleFactor() { + //#if MC>=11400 + window.setScaleFactor(window.calculateScaleFactor(mc.options.guiScale, mc.forcesUnicodeFont())); + //#else + //$$ // Nothing to do, ScaledResolution re-computes the scale factor every time it is created + //#endif + } + + public int getFramebufferWidth() { + return framebufferWidth; + } + + public int getFramebufferHeight() { + return framebufferHeight; + } + + public boolean isBound() { + return isBound; + } +} diff --git a/src/main/java/com/replaymod/render/hooks/EntityRendererHandler.java b/src/main/java/com/replaymod/render/hooks/EntityRendererHandler.java index 8dbd50f7d..ee68b87e5 100644 --- a/src/main/java/com/replaymod/render/hooks/EntityRendererHandler.java +++ b/src/main/java/com/replaymod/render/hooks/EntityRendererHandler.java @@ -8,6 +8,7 @@ import com.replaymod.render.capturer.CaptureData; import com.replaymod.render.capturer.RenderInfo; import com.replaymod.render.capturer.WorldRenderer; +import com.replaymod.render.mixin.GameRendererAccessor; import com.replaymod.replay.ReplayModReplay; import de.johni0702.minecraft.gui.utils.EventRegistrations; import net.minecraft.client.MinecraftClient; @@ -81,11 +82,16 @@ public void renderWorld(float partialTicks, long finishTimeNano) { //#endif if (mc.world != null && mc.player != null) { + GameRendererAccessor gameRenderer = (GameRendererAccessor) mc.gameRenderer; Screen orgScreen = mc.currentScreen; boolean orgPauseOnLostFocus = mc.options.pauseOnLostFocus; + boolean orgRenderHand = gameRenderer.getRenderHand(); try { mc.currentScreen = null; // do not want to render the current screen (that'd just be the progress gui) mc.options.pauseOnLostFocus = false; // do not want the pause menu to open if the window is unfocused + if (omnidirectional) { + gameRenderer.setRenderHand(false); // makes no sense, we wouldn't even know where to put it + } //#if MC>=11400 mc.gameRenderer.render(partialTicks, finishTimeNano, true); @@ -100,6 +106,7 @@ public void renderWorld(float partialTicks, long finishTimeNano) { } finally { mc.currentScreen = orgScreen; mc.options.pauseOnLostFocus = orgPauseOnLostFocus; + gameRenderer.setRenderHand(orgRenderHand); } } diff --git a/src/main/java/com/replaymod/render/hooks/MinecraftClientExt.java b/src/main/java/com/replaymod/render/hooks/MinecraftClientExt.java new file mode 100644 index 000000000..6b4295bb5 --- /dev/null +++ b/src/main/java/com/replaymod/render/hooks/MinecraftClientExt.java @@ -0,0 +1,12 @@ +package com.replaymod.render.hooks; + +import com.replaymod.render.gui.progress.VirtualWindow; +import net.minecraft.client.MinecraftClient; + +public interface MinecraftClientExt { + void setWindowDelegate(VirtualWindow window); + + static MinecraftClientExt get(MinecraftClient mc) { + return (MinecraftClientExt) mc; + } +} diff --git a/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java b/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java new file mode 100644 index 000000000..032359453 --- /dev/null +++ b/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java @@ -0,0 +1 @@ +// 1.18+ diff --git a/src/main/java/com/replaymod/render/mixin/GameRendererAccessor.java b/src/main/java/com/replaymod/render/mixin/GameRendererAccessor.java new file mode 100644 index 000000000..e74e16bc8 --- /dev/null +++ b/src/main/java/com/replaymod/render/mixin/GameRendererAccessor.java @@ -0,0 +1,13 @@ +package com.replaymod.render.mixin; + +import net.minecraft.client.render.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(GameRenderer.class) +public interface GameRendererAccessor { + @Accessor + boolean getRenderHand(); + @Accessor + void setRenderHand(boolean value); +} diff --git a/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java b/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java index 335c9908e..6bfa8e678 100644 --- a/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java +++ b/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java @@ -3,6 +3,7 @@ import net.minecraft.client.util.Window; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; @Mixin(Window.class) public interface MainWindowAccessor { @@ -14,4 +15,6 @@ public interface MainWindowAccessor { int getFramebufferHeight(); @Accessor void setFramebufferHeight(int value); + @Invoker + void invokeOnFramebufferSizeChanged(long window, int width, int height); } diff --git a/src/main/java/com/replaymod/render/mixin/MixinParticleManager.java b/src/main/java/com/replaymod/render/mixin/MixinParticleManager.java index 456dceac3..98eafe377 100644 --- a/src/main/java/com/replaymod/render/mixin/MixinParticleManager.java +++ b/src/main/java/com/replaymod/render/mixin/MixinParticleManager.java @@ -3,7 +3,6 @@ //#if MC>=10904 import com.replaymod.core.versions.MCVer; import com.replaymod.render.blend.BlendState; -import com.replaymod.render.blend.exporters.ParticlesExporter; import com.replaymod.render.hooks.EntityRendererHandler; import net.minecraft.client.particle.Particle; import net.minecraft.client.particle.ParticleManager; @@ -12,6 +11,11 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; +//#if MC>=11900 +//#else +import com.replaymod.render.blend.exporters.ParticlesExporter; +//#endif + //#if MC>=11500 import net.minecraft.client.render.VertexConsumer; import net.minecraft.util.math.Quaternion; @@ -52,10 +56,12 @@ private void buildOrientedGeometry(Particle particle, VertexConsumer vertexConsu } private void buildGeometry(Particle particle, VertexConsumer vertexConsumer, Camera camera, float partialTicks) { + //#if MC<11900 BlendState blendState = BlendState.getState(); if (blendState != null) { blendState.get(ParticlesExporter.class).onRender(particle, partialTicks); } + //#endif particle.buildGeometry(vertexConsumer, camera, partialTicks); } //#else diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java b/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java index a11818c43..fdbdd8263 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_BlockOnChunkRebuilds.java @@ -24,7 +24,20 @@ public abstract class Mixin_BlockOnChunkRebuilds implements ForceChunkLoadingHook.IBlockOnChunkRebuilds { @Shadow @Final private Queue threadBuffers; + //#if MC>=11800 + //$$ @org.spongepowered.asm.mixin.Unique + //$$ private boolean upload() { + //$$ boolean anything = false; + //$$ Runnable runnable; + //$$ while ((runnable = this.uploadQueue.poll()) != null) { + //$$ runnable.run(); + //$$ anything = true; + //$$ } + //$$ return anything; + //$$ } + //#else @Shadow public abstract boolean upload(); + //#endif @Shadow @Final private TaskExecutor mailbox; diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java index b43d7bd7c..288724960 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyColorSky.java @@ -20,7 +20,16 @@ public abstract class Mixin_ChromaKeyColorSky { @Shadow @Final private MinecraftClient client; - //#if MC>=11400 || 10710>=MC + //#if MC>=11800 + //$$ @Inject( + //#if MC>=11802 + //$$ method = "renderSky(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/util/math/Matrix4f;FLnet/minecraft/client/render/Camera;ZLjava/lang/Runnable;)V", + //#else + //$$ method = "renderSky(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/util/math/Matrix4f;FLjava/lang/Runnable;)V", + //#endif + //$$ at = @At(value = "INVOKE", target = "Ljava/lang/Runnable;run()V", remap = false, shift = At.Shift.AFTER), + //$$ cancellable = true) + //#elseif MC>=11400 || 10710>=MC @Inject(method = "renderSky", at = @At("HEAD"), cancellable = true) //#else //$$ @Inject(method = "renderSky(FI)V", at = @At("HEAD"), cancellable = true) diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyDisableFog.java b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyDisableFog.java index 48719c54b..948038e9f 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyDisableFog.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyDisableFog.java @@ -1,5 +1,6 @@ package com.replaymod.render.mixin; +import com.mojang.blaze3d.platform.GlStateManager; import com.replaymod.core.versions.MCVer; import com.replaymod.render.hooks.EntityRendererHandler; import net.minecraft.client.render.BackgroundRenderer; @@ -20,6 +21,17 @@ void replayModRender_onSetupFog(CallbackInfo ci) { ((EntityRendererHandler.IEntityRenderer) MCVer.getMinecraft().gameRenderer).replayModRender_getHandler(); if (handler == null) return; if (handler.getSettings().getChromaKeyingColor() != null) { + // Starting with 1.15, fog is no longer enabled in this method but is instead managed by the RenderLayer + // system (and with 1.17, they are enabled permanently / depend only on the shader). Therefore, cancelling + // this method is no longer sufficient, and we additionally also need to set the start value to get rid of + // fog (this doesn't hurt on 1.14 either). + // Note: This only becomes noticeable with Sodium because Vanilla would already set the start to max for + // unrelated reasons. But Sodium does some math which gives wrong results if end isn't greater than + // start, as would be the case in these cases. Sodium doing math is also the reason we don't set start + // equal to end (that'll result in undefined behavior because it sticks those into a smoothstep on old + // versions), and we don't set it to MAX_VALUE because that also gives wrong results. + GlStateManager.fogStart(1E10F); + GlStateManager.fogEnd(2E10F); ci.cancel(); } } diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java index 1d22d82ae..d619f02c1 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_ChromaKeyForceSky.java @@ -29,12 +29,10 @@ public abstract class Mixin_ChromaKeyForceSky { //#if MC>=11500 @ModifyConstant(method = "render", constant = @Constant(intValue = 4)) - //#else - //#if MC>=11400 + //#elseif MC>=11400 //$$ @ModifyConstant(method = "renderCenter", constant = @Constant(intValue = 4)) //#else - //$$ @ModifyConstant(method = "updateCameraAndRender(FJ)V", constant = @Constant(intValue = 4)) - //#endif + //$$ @ModifyConstant(method = "renderWorldPass", constant = @Constant(intValue = 4)) //#endif private int forceSkyWhenChromaKeying(int value) { EntityRendererHandler handler = ((EntityRendererHandler.IEntityRenderer) this.client.gameRenderer).replayModRender_getHandler(); diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_LoadIrisOdsShaderPack.java b/src/main/java/com/replaymod/render/mixin/Mixin_LoadIrisOdsShaderPack.java index d8756e7b3..56d4564b2 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_LoadIrisOdsShaderPack.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_LoadIrisOdsShaderPack.java @@ -4,7 +4,6 @@ import com.replaymod.render.capturer.IrisODSFrameCapturer; import net.coderbot.iris.Iris; import net.fabricmc.loader.api.FabricLoader; -import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Pseudo; import org.spongepowered.asm.mixin.injection.At; @@ -15,14 +14,14 @@ @Pseudo @Mixin(value = Iris.class, remap = false) public class Mixin_LoadIrisOdsShaderPack { - @Redirect(method = "loadExternalShaderpack", at = @At(value = "FIELD", opcode = Opcodes.GETSTATIC, target = "Lnet/coderbot/iris/Iris;SHADERPACKS_DIRECTORY:Ljava/nio/file/Path;")) + @Redirect(method = "loadExternalShaderpack", at = @At(value = "INVOKE", target = "Lnet/coderbot/iris/Iris;getShaderpacksDirectory()Ljava/nio/file/Path;")) private static Path loadReplayModOdsPack(String name) { if (IrisODSFrameCapturer.INSTANCE != null && IrisODSFrameCapturer.SHADER_PACK_NAME.equals(name)) { return FabricLoader.getInstance().getModContainer("replaymod") .orElseThrow(() -> new RuntimeException("Failed to get mod container for ReplayMod")) .getRootPath(); } else { - return Iris.SHADERPACKS_DIRECTORY; + return Iris.getShaderpacksDirectory(); } } } diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Camera.java b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Camera.java index fb58ab874..b9726a4c2 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Camera.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Camera.java @@ -2,23 +2,40 @@ import com.replaymod.render.hooks.EntityRendererHandler; import net.minecraft.client.render.GameRenderer; -import net.minecraft.util.math.Matrix4f; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.ModifyArg; @Mixin(GameRenderer.class) public abstract class Mixin_Omnidirectional_Camera implements EntityRendererHandler.IEntityRenderer { - @Redirect(method = "method_22973", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/math/Matrix4f;viewboxMatrix(DFFF)Lnet/minecraft/util/math/Matrix4f;")) - private Matrix4f replayModRender_perspective$0(double fovY, float aspect, float zNear, float zFar) { - return replayModRender_perspective((float) fovY, aspect, zNear, zFar); + private static final String METHOD = "getBasicProjectionMatrix"; + //#if MC>=11903 + //$$ private static final String TARGET = "Lorg/joml/Matrix4f;setPerspective(FFFF)Lorg/joml/Matrix4f;"; + //$$ private static final boolean TARGET_REMAP = false; + //$$ private static final float OMNIDIRECTIONAL_FOV = (float) Math.PI / 2; + //#else + private static final String TARGET = "Lnet/minecraft/util/math/Matrix4f;viewboxMatrix(DFFF)Lnet/minecraft/util/math/Matrix4f;"; + private static final boolean TARGET_REMAP = true; + private static final float OMNIDIRECTIONAL_FOV = 90; + //#endif + + @ModifyArg(method = METHOD, at = @At(value = "INVOKE", target = TARGET, remap = TARGET_REMAP), index = 0) + //#if MC>=11903 + //$$ private float replayModRender_perspective_fov(float fovY) { + //#else + private double replayModRender_perspective_fov(double fovY) { + //#endif + return isOmnidirectional() ? OMNIDIRECTIONAL_FOV : fovY; + } + + @ModifyArg(method = METHOD, at = @At(value = "INVOKE", target = TARGET, remap = TARGET_REMAP), index = 1) + private float replayModRender_perspective_aspect(float aspect) { + return isOmnidirectional() ? 1 : aspect; } - private Matrix4f replayModRender_perspective(float fovY, float aspect, float zNear, float zFar) { - if (replayModRender_getHandler() != null && replayModRender_getHandler().omnidirectional) { - fovY = 90; - aspect = 1; - } - return Matrix4f.viewboxMatrix(fovY, aspect, zNear, zFar); + @Unique + private boolean isOmnidirectional() { + return replayModRender_getHandler() != null && replayModRender_getHandler().omnidirectional; } } diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_DisableFrustumCulling.java b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_DisableFrustumCulling.java deleted file mode 100644 index f174e0a39..000000000 --- a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_DisableFrustumCulling.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.replaymod.render.mixin; - -import com.replaymod.core.versions.MCVer; -import com.replaymod.render.hooks.EntityRendererHandler; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; - -//#if MC>=10800 -import net.minecraft.client.render.Frustum; -//#else -//$$ import net.minecraft.client.renderer.culling.Frustrum; -//#endif - -//#if MC>=10800 -@Mixin(Frustum.class) -//#else -//$$ @Mixin(Frustrum.class) -//#endif -public abstract class Mixin_Omnidirectional_DisableFrustumCulling { - //#if MC>=11500 - @Inject(method = "isAnyCornerVisible", at = @At("HEAD"), cancellable = true) - //#else - //$$ @Inject(method = "intersects", at = @At("HEAD"), cancellable = true) - //#endif - public void intersects(CallbackInfoReturnable ci) { - EntityRendererHandler handler = ((EntityRendererHandler.IEntityRenderer) MCVer.getMinecraft().gameRenderer).replayModRender_getHandler(); - if (handler != null && handler.omnidirectional) { - // Note the following used to be true but for simplicity non-ODS omnidirectional is the same now too. - // Normally the camera is always facing the direction of the omnidirectional image face that is currently - // getting rendered. With ODS however, the camera is always facing forwards and the turning happens in the - // vertex shader (non-trivial due to stereo). As such, all chunks need to be rendered all the time for ODS. - ci.setReturnValue(true); - } - } -} diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Rotation.java b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Rotation.java index 6107028ec..e7d567c71 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Rotation.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_Rotation.java @@ -83,6 +83,8 @@ private void replayModRender_setupCubicFrameRotation( //#else //$$ GL11.glRotatef(angle, x, y, 0); //#endif + + getMinecraft().worldRenderer.scheduleTerrainUpdate(); } //#if MC<11500 //$$ if (getHandler() != null && getHandler().omnidirectional) { diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_SkipHand.java b/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_SkipHand.java deleted file mode 100644 index 939f6ec86..000000000 --- a/src/main/java/com/replaymod/render/mixin/Mixin_Omnidirectional_SkipHand.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.replaymod.render.mixin; - -import com.replaymod.render.hooks.EntityRendererHandler; -import net.minecraft.client.render.Camera; -import net.minecraft.client.render.GameRenderer; -import net.minecraft.client.util.math.MatrixStack; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(GameRenderer.class) -public abstract class Mixin_Omnidirectional_SkipHand implements EntityRendererHandler.IEntityRenderer { - @Inject(method = "renderHand", at = @At("HEAD"), cancellable = true) - private void replayModRender_renderSpectatorHand( - //#if MC>=11500 - MatrixStack matrixStack, - //#endif - //#if MC>=11400 - Camera camera, - //#endif - float partialTicks, - //#if MC<11400 - //$$ int renderPass, - //#endif - CallbackInfo ci - ) { - EntityRendererHandler handler = replayModRender_getHandler(); - if (handler != null && handler.omnidirectional) { - // No spectator hands during 360° view, we wouldn't even know where to put it - ci.cancel(); - } - } -} diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringGuiRendering.java b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringGuiRendering.java index 4e380f62f..d23765fa8 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringGuiRendering.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringGuiRendering.java @@ -1,25 +1,35 @@ package com.replaymod.render.mixin; import com.replaymod.render.hooks.EntityRendererHandler; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.render.GameRenderer; import org.lwjgl.opengl.GL11; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.ModifyArg; +//#if MC>=11500 @Mixin(GameRenderer.class) +//#elseif MC>=11400 +//$$ @Mixin(net.minecraft.client.util.Window.class) +//#else +//$$ @Mixin(EntityRenderer.class) +//#endif public abstract class Mixin_PreserveDepthDuringGuiRendering { @ModifyArg( - //#if MC>=11400 + //#if MC>=11500 method = "render", at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/systems/RenderSystem;clear(IZ)V"), index = 0 + //#elseif MC>=11400 + //$$ method = "method_4493", + //$$ at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/platform/GlStateManager;clear(IZ)V"), index = 0 //#else //$$ method = "setupOverlayRendering", //$$ at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GlStateManager;clear(I)V"), index = 0 //#endif ) private int replayModRender_skipClearWhenRecordingDepth(int mask) { - EntityRendererHandler handler = ((EntityRendererHandler.IEntityRenderer) this).replayModRender_getHandler(); + EntityRendererHandler handler = ((EntityRendererHandler.IEntityRenderer) MinecraftClient.getInstance().gameRenderer).replayModRender_getHandler(); if (handler != null && handler.getSettings().isDepthMap()) { mask = mask & ~GL11.GL_DEPTH_BUFFER_BIT; } diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java index 5edd5e298..948ab355f 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_PreserveDepthDuringHandRendering.java @@ -10,8 +10,18 @@ @Mixin(GameRenderer.class) public abstract class Mixin_PreserveDepthDuringHandRendering { @ModifyArg( + //#if MC>=11400 method = "renderWorld", + //#else + //$$ method = "renderWorldPass", + //#endif + //#if MC>=11500 at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/systems/RenderSystem;clear(IZ)V"), + //#elseif MC>=11400 + //$$ at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/platform/GlStateManager;clear(IZ)V", ordinal = 1), + //#else + //$$ at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GlStateManager;clear(I)V", ordinal = 1), + //#endif index = 0 ) private int replayModRender_skipClearWhenRecordingDepth(int mask) { diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_Stereoscopic_Camera.java b/src/main/java/com/replaymod/render/mixin/Mixin_Stereoscopic_Camera.java index 945ac1075..885608e34 100644 --- a/src/main/java/com/replaymod/render/mixin/Mixin_Stereoscopic_Camera.java +++ b/src/main/java/com/replaymod/render/mixin/Mixin_Stereoscopic_Camera.java @@ -13,7 +13,7 @@ @Mixin(GameRenderer.class) public abstract class Mixin_Stereoscopic_Camera implements EntityRendererHandler.IEntityRenderer { - @Inject(method = "method_22973", at = @At("RETURN"), cancellable = true) + @Inject(method = "getBasicProjectionMatrix", at = @At("RETURN"), cancellable = true) private void replayModRender_setupStereoscopicProjection(CallbackInfoReturnable ci) { if (replayModRender_getHandler() != null) { Matrix4f offset; diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_SuppressFramebufferResizeDuringRender.java b/src/main/java/com/replaymod/render/mixin/Mixin_SuppressFramebufferResizeDuringRender.java new file mode 100644 index 000000000..707f69527 --- /dev/null +++ b/src/main/java/com/replaymod/render/mixin/Mixin_SuppressFramebufferResizeDuringRender.java @@ -0,0 +1,39 @@ +package com.replaymod.render.mixin; + +import com.replaymod.render.gui.progress.VirtualWindow; +import com.replaymod.render.hooks.MinecraftClientExt; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.util.Window; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(MinecraftClient.class) +public class Mixin_SuppressFramebufferResizeDuringRender implements MinecraftClientExt { + + @Unique + private VirtualWindow windowDelegate; + + @Override + public void setWindowDelegate(VirtualWindow window) { + this.windowDelegate = window; + } + + //#if MC>=11400 + @Inject(method = "onResolutionChanged", at = @At("HEAD"), cancellable = true) + //#else + //$$ @Inject(method = "resize", at = @At("HEAD"), cancellable = true) + //#endif + private void suppressResizeDuringRender(CallbackInfo ci) { + VirtualWindow delegate = this.windowDelegate; + if (delegate != null && delegate.isBound()) { + Window window = ((MinecraftClient) (Object) this).getWindow(); + delegate.onResolutionChanged(window.getFramebufferWidth(), window.getFramebufferHeight()); + ci.cancel(); + } + } +} diff --git a/src/main/java/com/replaymod/render/mixin/Mixin_WindowsWorkaroundForTinyEXRNatives.java b/src/main/java/com/replaymod/render/mixin/Mixin_WindowsWorkaroundForTinyEXRNatives.java deleted file mode 100644 index 3df81c08e..000000000 --- a/src/main/java/com/replaymod/render/mixin/Mixin_WindowsWorkaroundForTinyEXRNatives.java +++ /dev/null @@ -1,81 +0,0 @@ -//#if MC>=11400 -package com.replaymod.render.mixin; - -import org.lwjgl.system.Library; -import org.lwjgl.system.Platform; -import org.lwjgl.util.tinyexr.TinyEXR; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.ModifyArg; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.function.Consumer; -import java.util.regex.Pattern; - -/** - * It appears like natives on Windows cannot be loaded if one of their dependencies has already been loaded by a - * different class loader. In our case we cannot load tinyexr (on the knot class loader) because lwjgl has already - * been loaded on the system class loader. - * - * If we force the tinyexr native to load on the system class loader (by calling `Library.loadSystem(absPath)`), - * it'll load but we'll get an error when we call any of the native methods. - * - * We can't really load TinyEXR itself via the system class loader because Java does not provide any methods for - * modifying the system class path at runtime and we'd have to use JVM-specific hacks. - * - * Strangely, if we use System.loadLibrary instead of System.load, then it all just works. This mixin implements - * that workaround by finding MC's natives folder, extracting the dll from our jar into that folder and then replacing - * the context class passed to Library.loadSystem (which it uses to find dlls in jars) with Library (which is on the - * system class loader) so it cannot find the dll in our jar and falls back to using System.loadLibrary. - */ -@Mixin(value = TinyEXR.class, remap = false) -public class Mixin_WindowsWorkaroundForTinyEXRNatives { - private static final String LOAD_SYSTEM_CONSUMERS = "Lorg/lwjgl/system/Library;loadSystem(Ljava/util/function/Consumer;Ljava/util/function/Consumer;Ljava/lang/Class;Ljava/lang/String;)V"; - - @ModifyArg(method = "", at = @At(value = "INVOKE", target = LOAD_SYSTEM_CONSUMERS)) - private static Class uglyWindowsHacks(Consumer load, Consumer loadLibrary, Class context, String name) throws IOException { - if (Platform.get() != Platform.WINDOWS) { - return context; // works out of the box on linux - } - - name = System.mapLibraryName(name); - - URL libURL = context.getClassLoader().getResource(name); - if (libURL == null) { - throw new UnsatisfiedLinkError("Failed to locate library: " + name); - } - - String lwjglLibName = Library.JNI_LIBRARY_NAME; - if (!lwjglLibName.endsWith(".dll")) { - lwjglLibName = System.mapLibraryName(lwjglLibName); - } - - String paths = System.getProperty("java.library.path"); - Path nativesDir = null; - for (String dir : Pattern.compile(File.pathSeparator).split(paths)) { - Path path = Paths.get(dir); - if (Files.isReadable(path.resolve(lwjglLibName))) { - nativesDir = path; - break; - } - } - if (nativesDir == null) { - throw new UnsatisfiedLinkError("Failed to locate natives folder in " + paths); - } - - Path libPath = nativesDir.resolve(name); - try (InputStream source = libURL.openStream()) { - Files.copy(source, libPath, StandardCopyOption.REPLACE_EXISTING); - } - - return Library.class; - } -} -//#endif diff --git a/src/main/java/com/replaymod/render/processor/OpenGlToBitmapProcessor.java b/src/main/java/com/replaymod/render/processor/OpenGlToBitmapProcessor.java index 6cb60f5e4..8740716d9 100644 --- a/src/main/java/com/replaymod/render/processor/OpenGlToBitmapProcessor.java +++ b/src/main/java/com/replaymod/render/processor/OpenGlToBitmapProcessor.java @@ -2,42 +2,25 @@ import com.replaymod.render.frame.OpenGlFrame; import com.replaymod.render.frame.BitmapFrame; +import com.replaymod.render.utils.ByteBufferPool; +import de.johni0702.minecraft.gui.utils.lwjgl.Dimension; import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; import java.nio.ByteBuffer; -public class OpenGlToBitmapProcessor extends AbstractFrameProcessor { +import static com.replaymod.render.utils.Utils.openGlBytesToBitmap; - private byte[] row, rowSwap; +public class OpenGlToBitmapProcessor extends AbstractFrameProcessor { @Override public BitmapFrame process(OpenGlFrame rawFrame) { - // Flip whole image in place - ReadableDimension size = rawFrame.getSize(); + int width = size.getWidth(); + int height = size.getHeight(); int bpp = rawFrame.getBytesPerPixel(); - int rowSize = size.getWidth() * bpp; - if (row == null || row.length < rowSize) { - row = new byte[rowSize]; - rowSwap = new byte[rowSize]; - } - ByteBuffer buffer = rawFrame.getByteBuffer(); - int rows = size.getHeight(); - byte[] row = this.row; - byte[] rowSwap = this.rowSwap; - for (int i = 0; i < rows / 2; i++) { - int from = rowSize * i; - int to = rowSize * (rows - i - 1); - buffer.position(from); - buffer.get(row); - buffer.position(to); - buffer.get(rowSwap); - buffer.position(to); - buffer.put(row); - buffer.position(from); - buffer.put(rowSwap); - } - buffer.rewind(); - return new BitmapFrame(rawFrame.getFrameId(), size, bpp, buffer); + ByteBuffer result = ByteBufferPool.allocate(width * height * bpp); + openGlBytesToBitmap(rawFrame, 0, 0, result, width); + ByteBufferPool.release(rawFrame.getByteBuffer()); + return new BitmapFrame(rawFrame.getFrameId(), new Dimension(width, height), bpp, result); } } diff --git a/src/main/java/com/replaymod/render/rendering/FrameConsumer.java b/src/main/java/com/replaymod/render/rendering/FrameConsumer.java index fd74586c1..ae654ca07 100644 --- a/src/main/java/com/replaymod/render/rendering/FrameConsumer.java +++ b/src/main/java/com/replaymod/render/rendering/FrameConsumer.java @@ -7,4 +7,6 @@ public interface FrameConsumer

extends Closeable { void consume(Map channels); + boolean isParallelCapable(); + } diff --git a/src/main/java/com/replaymod/render/rendering/Pipeline.java b/src/main/java/com/replaymod/render/rendering/Pipeline.java index cf7f057d5..8696fe297 100644 --- a/src/main/java/com/replaymod/render/rendering/Pipeline.java +++ b/src/main/java/com/replaymod/render/rendering/Pipeline.java @@ -10,6 +10,7 @@ import net.minecraft.util.crash.CrashReport; import org.lwjgl.glfw.GLFW; +import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; @@ -25,8 +26,6 @@ public class Pipeline implements Runnable { private final FrameCapturer capturer; private final FrameProcessor processor; private final GlToAbsoluteDepthProcessor depthProcessor; - private int consumerNextFrame; - private final Object consumerLock = new Object(); private final FrameConsumer

consumer; private volatile boolean abort; @@ -35,7 +34,7 @@ public Pipeline(WorldRenderer worldRenderer, FrameCapturer capturer, FramePro this.worldRenderer = worldRenderer; this.capturer = capturer; this.processor = processor; - this.consumer = consumer; + this.consumer = new ParallelSafeConsumer<>(consumer); float near = 0.05f; float far = getMinecraft().options.viewDistance * 16 * 4; @@ -44,7 +43,6 @@ public Pipeline(WorldRenderer worldRenderer, FrameCapturer capturer, FramePro @Override public synchronized void run() { - consumerNextFrame = 0; int processors = Runtime.getRuntime().availableProcessors(); int processThreads = Math.max(1, processors - 2); // One processor for the main thread and one for ffmpeg, sorry OS :( ExecutorService processService = new ThreadPoolExecutor(processThreads, processThreads, @@ -106,7 +104,6 @@ public ProcessTask(Map rawChannels) { @Override public void run() { try { - Integer frameId = null; Map processedChannels = new HashMap<>(); for (Map.Entry entry : rawChannels.entrySet()) { P processedFrame = processor.process(entry.getValue()); @@ -114,27 +111,57 @@ public void run() { depthProcessor.process((BitmapFrame) processedFrame); } processedChannels.put(entry.getKey(), processedFrame); - frameId = processedFrame.getFrameId(); } - if (frameId == null) { + if (processedChannels.isEmpty()) { return; } - synchronized (consumerLock) { - while (consumerNextFrame != frameId) { + consumer.consume(processedChannels); + } catch (Throwable t) { + CrashReport crashReport = CrashReport.create(t, "Processing frame"); + MCVer.getMinecraft().setCrashReport(crashReport); + } + } + } + + private static class ParallelSafeConsumer

implements FrameConsumer

{ + private final FrameConsumer

inner; + + private int nextFrame; + private final Object lock = new Object(); + + private ParallelSafeConsumer(FrameConsumer

inner) { + this.inner = inner; + } + + @Override + public void consume(Map channels) { + if (inner.isParallelCapable()) { + inner.consume(channels); + } else { + int frameId = channels.values().iterator().next().getFrameId(); + synchronized (lock) { + while (nextFrame != frameId) { try { - consumerLock.wait(); + lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } - consumer.consume(processedChannels); - consumerNextFrame++; - consumerLock.notifyAll(); + inner.consume(channels); + nextFrame++; + lock.notifyAll(); } - } catch (Throwable t) { - CrashReport crashReport = CrashReport.create(t, "Processing frame"); - MCVer.getMinecraft().setCrashReport(crashReport); } } + + @Override + public boolean isParallelCapable() { + return true; + } + + @Override + public void close() throws IOException { + inner.close(); + } } } diff --git a/src/main/java/com/replaymod/render/rendering/Pipelines.java b/src/main/java/com/replaymod/render/rendering/Pipelines.java index e012fb414..bc65fba25 100644 --- a/src/main/java/com/replaymod/render/rendering/Pipelines.java +++ b/src/main/java/com/replaymod/render/rendering/Pipelines.java @@ -128,6 +128,11 @@ public void consume(Map channels) { @Override public void close() { } + + @Override + public boolean isParallelCapable() { + return true; + } }; return new Pipeline<>(worldRenderer, capturer, new DummyProcessor<>(), consumer); } diff --git a/src/main/java/com/replaymod/render/rendering/VideoRenderer.java b/src/main/java/com/replaymod/render/rendering/VideoRenderer.java index dfa7e096e..6d554e28c 100644 --- a/src/main/java/com/replaymod/render/rendering/VideoRenderer.java +++ b/src/main/java/com/replaymod/render/rendering/VideoRenderer.java @@ -6,9 +6,9 @@ import com.replaymod.core.utils.WrappedTimer; import com.replaymod.core.versions.MCVer; import com.replaymod.pathing.player.AbstractTimelinePlayer; -import com.replaymod.pathing.player.ReplayTimer; import com.replaymod.pathing.properties.TimestampProperty; import com.replaymod.render.CameraPathExporter; +import com.replaymod.render.EXRWriter; import com.replaymod.render.PNGWriter; import com.replaymod.render.RenderSettings; import com.replaymod.render.ReplayModRender; @@ -19,9 +19,9 @@ import com.replaymod.render.frame.BitmapFrame; import com.replaymod.render.gui.GuiRenderingDone; import com.replaymod.render.gui.GuiVideoRenderer; +import com.replaymod.render.gui.progress.VirtualWindow; import com.replaymod.render.hooks.ForceChunkLoadingHook; import com.replaymod.render.metadata.MetadataInjector; -import com.replaymod.render.mixin.MainWindowAccessor; import com.replaymod.render.mixin.WorldRendererAccessor; import com.replaymod.render.utils.FlawlessFrames; import com.replaymod.replay.ReplayHandler; @@ -32,7 +32,6 @@ import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; import net.minecraft.client.MinecraftClient; import com.mojang.blaze3d.platform.GLX; -import net.minecraft.client.gl.Framebuffer; import net.minecraft.client.sound.PositionedSoundInstance; import net.minecraft.client.util.Window; import net.minecraft.sound.SoundEvent; @@ -43,7 +42,6 @@ import org.lwjgl.glfw.GLFW; //#if MC>=11700 -//$$ import net.minecraft.client.gl.WindowFramebuffer; //$$ import net.minecraft.client.render.DiffuseLighting; //$$ import net.minecraft.util.math.Matrix4f; //#endif @@ -58,7 +56,6 @@ //#endif //#if MC>=11400 -import com.replaymod.render.EXRWriter; import net.minecraft.client.gui.screen.Screen; import java.util.concurrent.CompletableFuture; //#else @@ -113,14 +110,12 @@ public class VideoRenderer implements RenderInfo { private int framesDone; private int totalFrames; + private final VirtualWindow guiWindow = new VirtualWindow(mc); private final GuiVideoRenderer gui; private boolean paused; private boolean cancelled; private volatile Throwable failureCause; - private Framebuffer guiFramebuffer; - private int displayWidth, displayHeight; - public VideoRenderer(RenderSettings settings, ReplayHandler replayHandler, Timeline timeline) throws IOException { this.settings = settings; this.replayHandler = replayHandler; @@ -134,23 +129,27 @@ public VideoRenderer(RenderSettings settings, ReplayHandler replayHandler, Timel } else { FrameConsumer frameConsumer; if (settings.getEncodingPreset() == RenderSettings.EncodingPreset.EXR) { - //#if MC>=11400 - frameConsumer = new EXRWriter(settings.getOutputFile().toPath()); - //#else - //$$ throw new UnsupportedOperationException("EXR requires LWJGL3"); - //#endif + frameConsumer = EXRWriter.create(settings.getOutputFile().toPath(), settings.isIncludeAlphaChannel()); } else if (settings.getEncodingPreset() == RenderSettings.EncodingPreset.PNG) { - frameConsumer = new PNGWriter(settings.getOutputFile().toPath()); + frameConsumer = new PNGWriter(settings.getOutputFile().toPath(), settings.isIncludeAlphaChannel()); } else { frameConsumer = new FFmpegWriter(this); } ffmpegWriter = frameConsumer instanceof FFmpegWriter ? (FFmpegWriter) frameConsumer : null; FrameConsumer previewingFrameConsumer = new FrameConsumer() { + private int lastFrameId = -1; + @Override public void consume(Map channels) { BitmapFrame bgra = channels.get(Channel.BRGA); if (bgra != null) { - gui.updatePreview(bgra.getByteBuffer(), bgra.getSize()); + synchronized (this) { + int frameId = bgra.getFrameId(); + if (lastFrameId < frameId) { + lastFrameId = frameId; + gui.updatePreview(bgra.getByteBuffer(), bgra.getSize()); + } + } } frameConsumer.consume(channels); } @@ -159,6 +158,11 @@ public void consume(Map channels) { public void close() throws IOException { frameConsumer.close(); } + + @Override + public boolean isParallelCapable() { + return frameConsumer.isParallelCapable(); + } }; this.renderingPipeline = Pipelines.newPipeline(settings.getRenderMethod(), this, previewingFrameConsumer); } @@ -244,11 +248,7 @@ public boolean renderVideo() throws Throwable { @Override public float updateForNextFrame() { // because the jGui lib uses Minecraft's displayWidth and displayHeight values, update these temporarily - MainWindowAccessor acc = (MainWindowAccessor) (Object) mc.getWindow(); - int displayWidthBefore = acc.getFramebufferWidth(); - int displayHeightBefore = acc.getFramebufferHeight(); - acc.setFramebufferWidth(displayWidth); - acc.setFramebufferHeight(displayHeight); + guiWindow.bind(); if (!settings.isHighPerformance() || framesDone % fps == 0) { while (drawGui() && paused) { @@ -289,8 +289,7 @@ public float updateForNextFrame() { } // change Minecraft's display size back - acc.setFramebufferWidth(displayWidthBefore); - acc.setFramebufferHeight(displayHeightBefore); + guiWindow.unbind(); if (cameraPathExporter != null) { cameraPathExporter.recordFrame(timer.tickDelta); @@ -361,22 +360,9 @@ private void setup() { cameraPathExporter.setup(totalFrames); } - updateDisplaySize(); - gui.toMinecraft().init(mc, mc.getWindow().getScaledWidth(), mc.getWindow().getScaledHeight()); forceChunkLoadingHook = new ForceChunkLoadingHook(mc.worldRenderer); - - // Set up our own framebuffer to render the GUI to - //#if MC>=11700 - //$$ guiFramebuffer = new WindowFramebuffer(displayWidth, displayHeight); - //#else - guiFramebuffer = new Framebuffer(displayWidth, displayHeight, true - //#if MC>=11400 - , false - //#endif - ); - //#endif } private void finish() { @@ -386,6 +372,8 @@ private void finish() { // Tear down of the timeline player might only happen the next tick after it was cancelled timelinePlayer.onTick(); + guiWindow.close(); + // FBOs are always used in 1.14+ //#if MC<11400 //$$ if (!OpenGlHelper.isFramebufferEnabled()) { @@ -425,7 +413,7 @@ private void finish() { } // Finally, resize the Minecraft framebuffer to the actual width/height of the window - resizeMainWindow(mc, displayWidth, displayHeight); + resizeMainWindow(mc, guiWindow.getFramebufferWidth(), guiWindow.getFramebufferHeight()); } private void executeTaskQueue() { @@ -483,20 +471,6 @@ public boolean drawGui() { return false; } - // Resize the GUI framebuffer if the display size changed - if (displaySizeChanged()) { - updateDisplaySize(); - //#if MC>=11400 - guiFramebuffer.resize(displayWidth, displayHeight - //#if MC>=11400 - , false - //#endif - ); - //#else - //$$ guiFramebuffer.createBindFramebuffer(mc.displayWidth, mc.displayHeight); - //#endif - } - pushMatrix(); GlStateManager.clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT //#if MC>=11400 @@ -504,7 +478,7 @@ public boolean drawGui() { //#endif ); GlStateManager.enableTexture(); - guiFramebuffer.beginWrite(true); + guiWindow.beginWrite(); //#if MC>=11500 RenderSystem.clear(256, MinecraftClient.IS_SYSTEM_MAC); @@ -560,8 +534,8 @@ public boolean drawGui() { //#endif //#if MC>=11400 - int mouseX = (int) mc.mouse.getX() * window.getScaledWidth() / displayWidth; - int mouseY = (int) mc.mouse.getY() * window.getScaledHeight() / displayHeight; + int mouseX = (int) mc.mouse.getX() * window.getScaledWidth() / Math.max(window.getWidth(), 1); + int mouseY = (int) mc.mouse.getY() * window.getScaledHeight() / Math.max(window.getHeight(), 1); if (mc.getOverlay() != null) { Screen orgScreen = mc.currentScreen; @@ -591,31 +565,12 @@ public boolean drawGui() { //$$ gui.toMinecraft().drawScreen(mouseX, mouseY, 0); //#endif - guiFramebuffer.endWrite(); + guiWindow.endWrite(); popMatrix(); pushMatrix(); - guiFramebuffer.draw(displayWidth, displayHeight); + guiWindow.flip(); popMatrix(); - //#if MC>=11500 - window.swapBuffers(); - //#else - //#if MC>=11400 - //$$ window.setFullscreen(false); - //#else - //$$ // if not in high performance mode, update the gui size if screen size changed - //$$ // otherwise just swap the progress gui to screen - //$$ if (settings.isHighPerformance()) { - //$$ Display.update(); - //$$ } else { - //#if MC>=10800 - //$$ mc.updateDisplay(); - //#else - //$$ mc.resetSize(); - //#endif - //$$ } - //#endif - //#endif //#if MC>=11400 if (mc.mouse.isCursorLocked()) { mc.mouse.unlockCursor(); @@ -630,22 +585,6 @@ public boolean drawGui() { } while (true); } - private boolean displaySizeChanged() { - int realWidth = mc.getWindow().getWidth(); - int realHeight = mc.getWindow().getHeight(); - if (realWidth == 0 || realHeight == 0) { - // These can be zero on Windows if minimized. - // Creating zero-sized framebuffers however will throw an error, so we never want to switch to zero values. - return false; - } - return displayWidth != realWidth || displayHeight != realHeight; - } - - private void updateDisplaySize() { - displayWidth = mc.getWindow().getWidth(); - displayHeight = mc.getWindow().getHeight(); - } - public int getFramesDone() { return framesDone; } @@ -712,7 +651,7 @@ public static String[] checkCompat(RenderSettings settings) { return new String[] { "Rendering is not supported with your Sodium version.", "It is missing support for the FREX Flawless Frames API.", - "Either update to the latest version or uninstall Sodium before rendering!", + "Either use the Sodium build from replaymod.com or uninstall Sodium before rendering!", }; } //#if MC>=11700 diff --git a/src/main/java/com/replaymod/render/utils/Lwjgl3Loader.java b/src/main/java/com/replaymod/render/utils/Lwjgl3Loader.java new file mode 100644 index 000000000..8c832acd2 --- /dev/null +++ b/src/main/java/com/replaymod/render/utils/Lwjgl3Loader.java @@ -0,0 +1,132 @@ +package com.replaymod.render.utils; + +import com.replaymod.core.ReplayMod; +import com.replaymod.render.rendering.Frame; +import com.replaymod.render.rendering.FrameConsumer; +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.ProtectionDomain; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +public class Lwjgl3Loader extends URLClassLoader { + static { registerAsParallelCapable(); } + private static Path tempJarFile; + private static Lwjgl3Loader instance; + + private final Set implClasses = new CopyOnWriteArraySet<>(); + + private Lwjgl3Loader(Path jarFile) throws IOException, ReflectiveOperationException { + super(new URL[] { jarFile.toUri().toURL() }, Lwjgl3Loader.class.getClassLoader()); + + // Need to use a different directory for natives than MC because native files can only be loaded once + Path nativesDir = ReplayMod.instance.folders.getCacheFolder().resolve("lwjgl-natives"); + + Class configClass = Class.forName("org.lwjgl.system.Configuration", true, this); + Object extractDirField = configClass.getField("SHARED_LIBRARY_EXTRACT_DIRECTORY").get(null); + Method setMethod = configClass.getMethod("set", Object.class); + setMethod.invoke(extractDirField, nativesDir.toAbsolutePath().toString()); + } + + private boolean canBeSharedWithMc(String name) { + if (name.startsWith("org.lwjgl.")) { + return false; // MC may have a different version + } + for (String implClass : implClasses) { + if (name.startsWith(implClass)) { + return false; // depends on above lwjgl + } + } + return true; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!canBeSharedWithMc(name)) { + synchronized (getClassLoadingLock(name)) { + Class cls = findLoadedClass(name); + if (cls == null) { + cls = findClass(name); + } + if (resolve) { + resolveClass(cls); + } + return cls; + } + } else { + return super.loadClass(name, resolve); + } + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + try { + return super.findClass(name); + } catch (ClassNotFoundException e) { + String path = name.replace('.', '/').concat(".class"); + URL url = getParent().getResource(path); + if (url == null) { + throw e; + } + try { + byte[] bytes = IOUtils.toByteArray(url); + return defineClass(name, bytes, 0, bytes.length, (ProtectionDomain) null); + } catch (IOException e1) { + throw new ClassNotFoundException(name, e1); + } + } + } + + private static synchronized Path getJarFile() throws IOException { + if (tempJarFile == null) { + Path jarFile = Files.createTempFile("replaymod-lwjgl", ".jar"); + jarFile.toFile().deleteOnExit(); + try (InputStream in = Lwjgl3Loader.class.getResourceAsStream("lwjgl.jar")) { + if (in == null) { + throw new IOException("Failed to find embedded lwjgl.jar file."); + } + Files.copy(in, jarFile, REPLACE_EXISTING); + } + tempJarFile = jarFile; + } + return tempJarFile; + } + + public static synchronized Lwjgl3Loader instance() { + if (instance == null) { + try { + instance = new Lwjgl3Loader(getJarFile()); + } catch (IOException | ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + return instance; + } + + @SuppressWarnings("unchecked") + public static

FrameConsumer

createFrameConsumer( + Class> implClass, + Class[] parameterTypes, + Object[] args + ) { + try { + Lwjgl3Loader loader = instance(); + loader.implClasses.add(implClass.getName()); + Class realClass = Class.forName(implClass.getName(), true, loader); + Constructor constructor = realClass.getConstructor(parameterTypes); + return (FrameConsumer

) constructor.newInstance(args); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/replaymod/render/utils/RenderJob.java b/src/main/java/com/replaymod/render/utils/RenderJob.java index 5203d254f..3554930f6 100644 --- a/src/main/java/com/replaymod/render/utils/RenderJob.java +++ b/src/main/java/com/replaymod/render/utils/RenderJob.java @@ -1,12 +1,12 @@ package com.replaymod.render.utils; -import com.google.common.base.Optional; import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.replaymod.render.RenderSettings; +import com.replaymod.replaystudio.lib.guava.base.Optional; import com.replaymod.replaystudio.pathing.PathingRegistry; import com.replaymod.replaystudio.pathing.path.Timeline; import com.replaymod.replaystudio.pathing.serialize.TimelineSerialization; @@ -81,10 +81,14 @@ public static List readQueue(ReplayFile replayFile) throws IOExceptio } try (InputStream in = optIn.get(); InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { - return new GsonBuilder() + List jobs = new GsonBuilder() .registerTypeAdapter(Timeline.class, new TimelineTypeAdapter()) .create() .fromJson(reader, new TypeToken>(){}.getType()); + if (jobs == null) { + jobs = new ArrayList<>(); + } + return jobs; } } } diff --git a/src/main/java/com/replaymod/replay/FullReplaySender.java b/src/main/java/com/replaymod/replay/FullReplaySender.java index c4cc978ef..9c9621e0f 100644 --- a/src/main/java/com/replaymod/replay/FullReplaySender.java +++ b/src/main/java/com/replaymod/replay/FullReplaySender.java @@ -44,8 +44,6 @@ import net.minecraft.network.packet.s2c.play.ScreenHandlerPropertyUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.HealthUpdateS2CPacket; import net.minecraft.network.packet.s2c.login.LoginSuccessS2CPacket; -import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; -import net.minecraft.network.packet.s2c.play.PaintingSpawnS2CPacket; import net.minecraft.network.packet.s2c.play.ParticleS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerAbilitiesS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerPositionLookS2CPacket; @@ -55,9 +53,25 @@ import net.minecraft.network.packet.s2c.play.StatisticsS2CPacket; import net.minecraft.text.Text; import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; +//#if MC>=11903 +//$$ import net.minecraft.network.packet.s2c.play.ProfilelessChatMessageS2CPacket; +//#endif + +//#if MC==11901 || MC==11902 +//$$ import net.minecraft.network.packet.s2c.play.MessageHeaderS2CPacket; +//#endif + +//#if MC>=11900 +//$$ import net.minecraft.network.packet.s2c.play.ChatMessageS2CPacket; +//#else +import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; +import net.minecraft.network.packet.s2c.play.PaintingSpawnS2CPacket; +//#endif + //#if MC>=11600 //#else //$$ import net.minecraft.network.packet.s2c.play.EntitySpawnGlobalS2CPacket; @@ -124,6 +138,9 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import static com.replaymod.core.versions.MCVer.*; import static com.replaymod.replaystudio.util.Utils.readInt; @@ -258,6 +275,8 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend /** * Whether to allow (process) the next player movement packet. + * + * Must only be accessed from the main thread. */ protected boolean allowMovement; @@ -348,6 +367,7 @@ public void terminateReplay() { return; } terminate = true; + syncSender.shutdown(); events.unregister(); try { channelInactive(ctx); @@ -402,59 +422,19 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) super.channelRead(ctx, p); } - // If we do not give minecraft time to tick, there will be dead entity artifacts left in the world - // Therefore we have to remove all loaded, dead entities manually if we are in sync mode. - // We do this after every SpawnX packet and after the destroy entities packet. - if (!asyncMode && mc.world != null) { - if (p instanceof PlayerSpawnS2CPacket - || p instanceof EntitySpawnS2CPacket - || p instanceof MobSpawnS2CPacket - //#if MC<11600 - //$$ || p instanceof EntitySpawnGlobalS2CPacket - //#endif - || p instanceof PaintingSpawnS2CPacket - || p instanceof ExperienceOrbSpawnS2CPacket - || p instanceof EntitiesDestroyS2CPacket) { - ClientWorld world = mc.world; - //#if MC>=11700 - //$$ // From the looks of it, this has now been resolved (thanks to EntityChangeListener) - //#elseif MC>=11400 - // Note: Not sure if it's still required but there's this really handy method anyway - world.finishRemovingEntities(); - //#else - //$$ Iterator iter = world.loadedEntityList.iterator(); - //$$ while (iter.hasNext()) { - //$$ Entity entity = iter.next(); - //$$ if (entity.isDead) { - //$$ int chunkX = entity.chunkCoordX; - //$$ int chunkY = entity.chunkCoordZ; - //$$ - //#if MC>=11400 - //$$ if (entity.addedToChunk && world.getChunkProvider().provideChunk(chunkX, chunkY, false, false) != null) { - //#else - //#if MC>=10904 - //$$ if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) { - //#else - //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) { - //#endif - //#endif - //$$ world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity); - //$$ } - //$$ - //$$ iter.remove(); - //$$ world.onEntityRemoved(entity); - //$$ } - //$$ - //$$ } - //#endif - } - } + maybeRemoveDeadEntities(p); //#if MC>=11400 if (p instanceof ChunkDataS2CPacket) { Runnable doLightUpdates = () -> { - if (mc.world != null) { - LightingProvider provider = mc.world.getChunkManager().getLightingProvider(); + ClientWorld world = mc.world; + if (world != null) { + //#if MC>=11800 + //$$ while (!world.hasNoChunkUpdaters()) { + //$$ world.runQueuedChunkUpdates(); + //$$ } + //#endif + LightingProvider provider = world.getChunkManager().getLightingProvider(); while (provider.hasUpdates()) { provider.doLightUpdates(Integer.MAX_VALUE, true, true); } @@ -497,6 +477,71 @@ private Packet deserializePacket(byte[] bytes) throws IOException, IllegalAccess return p; } + // If we do not give minecraft time to tick, there will be dead entity artifacts left in the world + // Therefore we have to remove all loaded, dead entities manually if we are in sync mode. + // We do this after every SpawnX packet and after the destroy entities packet. + private void maybeRemoveDeadEntities(Packet packet) { + if (asyncMode) { + return; // MC should have enough time to tick + } + + boolean relevantPacket = packet instanceof PlayerSpawnS2CPacket + || packet instanceof EntitySpawnS2CPacket + //#if MC<11900 + || packet instanceof MobSpawnS2CPacket + || packet instanceof PaintingSpawnS2CPacket + //#endif + //#if MC<11600 + //$$ || packet instanceof EntitySpawnGlobalS2CPacket + //#endif + || packet instanceof ExperienceOrbSpawnS2CPacket + || packet instanceof EntitiesDestroyS2CPacket; + if (!relevantPacket) { + return; // don't want to do it too often, only when there's likely to be a dead entity + } + + mc.send(() -> { + ClientWorld world = mc.world; + if (world != null) { + removeDeadEntities(world); + } + }); + } + + private void removeDeadEntities(ClientWorld world) { + //#if MC>=11700 + //$$ // From the looks of it, this has now been resolved (thanks to EntityChangeListener) + //#elseif MC>=11400 + // Note: Not sure if it's still required but there's this really handy method anyway + world.finishRemovingEntities(); + //#else + //$$ Iterator iter = world.loadedEntityList.iterator(); + //$$ while (iter.hasNext()) { + //$$ Entity entity = iter.next(); + //$$ if (entity.isDead) { + //$$ int chunkX = entity.chunkCoordX; + //$$ int chunkY = entity.chunkCoordZ; + //$$ + //#if MC>=11400 + //$$ if (entity.addedToChunk && world.getChunkProvider().provideChunk(chunkX, chunkY, false, false) != null) { + //#else + //#if MC>=10904 + //$$ if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) { + //#else + //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) { + //#endif + //#endif + //$$ world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity); + //$$ } + //$$ + //$$ iter.remove(); + //$$ world.onEntityRemoved(entity); + //$$ } + //$$ + //$$ } + //#endif + } + /** * Process a packet and return the result. * @param p The packet to process @@ -557,6 +602,12 @@ protected Packet processPacket(Packet p) throws Exception { //#else //$$ String channelName = packet.getChannelName(); //#endif + String channelNameStr = channelName.toString(); + + if (channelNameStr.startsWith("fabric-screen-handler-api-v")) { + return null; // we do not want to show modded screens which got opened for the recording player + } + // On 1.14+ there's a dedicated OpenWrittenBookS2CPacket now //#if MC<11400 //#if MC>=11400 @@ -598,24 +649,33 @@ protected Packet processPacket(Packet p) throws Exception { if(p instanceof GameJoinS2CPacket) { GameJoinS2CPacket packet = (GameJoinS2CPacket) p; int entId = packet.getEntityId(); - allowMovement = true; + schedulePacketHandler(() -> allowMovement = true); actualID = entId; entId = -1789435; // Camera entity id should be negative which is an invalid id and can't be used by servers //#if MC>=11400 p = new GameJoinS2CPacket( entId, + //#if MC>=11800 + //$$ packet.hardcore(), + //#endif GameMode.SPECTATOR, //#if MC>=11600 GameMode.SPECTATOR, //#endif + //#if MC<11800 //#if MC>=11500 packet.getSha256Seed(), //#endif false, + //#endif //#if MC>=11600 //#if MC>=11603 packet.getDimensionIds(), + //#if MC>=11800 + //$$ packet.registryManager(), + //#else (net.minecraft.util.registry.DynamicRegistryManager.Impl) packet.getRegistryManager(), + //#endif packet.getDimensionType(), //#else //$$ packet.method_29443(), @@ -626,11 +686,17 @@ protected Packet processPacket(Packet p) throws Exception { //#else //$$ packet.getDimension(), //#endif + //#if MC>=11800 + //$$ packet.sha256Seed(), + //#endif 0, // max players (has no getter -> never actually used) //#if MC<11600 //$$ packet.getGeneratorType(), //#endif packet.getViewDistance(), + //#if MC>=11800 + //$$ packet.simulationDistance(), + //#endif packet.hasReducedDebugInfo() //#if MC>=11500 , packet.showsDeathScreen() @@ -639,6 +705,9 @@ protected Packet processPacket(Packet p) throws Exception { , packet.isDebugWorld() , packet.isFlatWorld() //#endif + //#if MC>=11900 + //$$ , java.util.Optional.empty() + //#endif ); //#else //#if MC>=10800 @@ -690,11 +759,18 @@ protected Packet processPacket(Packet p) throws Exception { GameMode.SPECTATOR, respawn.isDebugWorld(), respawn.isFlatWorld(), - respawn.isWritingErrorSkippable() + //#if MC>=11903 + //$$ (byte) 0 + //#else + false + //#endif //#else //$$ respawn.getGeneratorType(), //$$ GameMode.SPECTATOR //#endif + //#if MC>=11900 + //$$ , java.util.Optional.empty() + //#endif ); //#else //#if MC>=10809 @@ -711,7 +787,7 @@ protected Packet processPacket(Packet p) throws Exception { //#endif //#endif - allowMovement = true; + schedulePacketHandler(() -> allowMovement = true); } if(p instanceof PlayerPositionLookS2CPacket) { @@ -727,8 +803,6 @@ protected Packet processPacket(Packet p) throws Exception { if(replayHandler.shouldSuppressCameraMovements()) return null; - CameraEntity cent = replayHandler.getCameraEntity(); - //#if MC>=10800 //#if MC>=11400 for (PlayerPositionLookS2CPacket.Flag relative : ppl.getFlags()) { @@ -750,28 +824,30 @@ protected Packet processPacket(Packet p) throws Exception { } //#endif - if(cent != null) { - if(!allowMovement && !((Math.abs(cent.getX() - ppl.getX()) > TP_DISTANCE_LIMIT) || - (Math.abs(cent.getZ() - ppl.getZ()) > TP_DISTANCE_LIMIT))) { - return null; - } else { - allowMovement = false; - } - } - - new Runnable() { + schedulePacketHandler(new Runnable() { @Override @SuppressWarnings("unchecked") public void run() { + // FIXME: world shouldn't ever be null at this point, now that we use the packet queue + // probably fine to remove on the next non-patch version (don't want to break stuff now) if (mc.world == null || !mc.isOnThread()) { ReplayMod.instance.runLater(this); return; } CameraEntity cent = replayHandler.getCameraEntity(); + if (!allowMovement && !((Math.abs(cent.getX() - ppl.getX()) > TP_DISTANCE_LIMIT) || + (Math.abs(cent.getZ() - ppl.getZ()) > TP_DISTANCE_LIMIT))) { + return; + } else { + allowMovement = false; + } cent.setCameraPosition(ppl.getX(), ppl.getY(), ppl.getZ()); + cent.setCameraRotation(ppl.getYaw(), ppl.getPitch(), cent.roll); } - }.run(); + }); + + return null; } if(p instanceof GameStateChangeS2CPacket) { @@ -800,13 +876,27 @@ public void run() { } } + //#if MC>=11903 + //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket || p instanceof ProfilelessChatMessageS2CPacket) { + //#elseif MC==11901 || MC==11902 + //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket || p instanceof MessageHeaderS2CPacket) { + //#elseif MC>=11900 + //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket) { + //#else if (p instanceof GameMessageS2CPacket) { + //#endif if (!ReplayModReplay.instance.getCore().getSettingsRegistry().get(Setting.SHOW_CHAT)) { return null; } } - return asyncMode ? processPacketAsync(p) : processPacketSync(p); + if (asyncMode) { + return processPacketAsync(p); + } else { + Packet fp = p; + mc.send(() -> processPacketSync(fp)); + return p; + } } @Override @@ -1067,6 +1157,10 @@ protected Packet processPacketAsync(Packet p) { // Synchronous packet processing // ///////////////////////////////////////////////////////// + // Even in sync mode, we send from another thread because mods may rely on that + private final ExecutorService syncSender = Executors.newSingleThreadExecutor(runnable -> + new Thread(runnable, "replaymod-sync-sender")); + /** * Sends all packets until the specified timestamp is reached (inclusive). * If the timestamp is smaller than the last packet sent, the replay is restarted from the beginning. @@ -1075,6 +1169,36 @@ protected Packet processPacketAsync(Packet p) { @Override public void sendPacketsTill(int timestamp) { Preconditions.checkState(!asyncMode, "This method cannot be used in async mode. Use jumpToTime(int) instead."); + + // Submit our target to the sender thread and track its progress + AtomicBoolean doneSending = new AtomicBoolean(); + syncSender.submit(() -> { + try { + doSendPacketsTill(timestamp); + } finally { + doneSending.set(true); + } + }); + + // Drain the task queue while we are sending (in case a mod blocks the io thread waiting for the main thread) + while (!doneSending.get()) { + executeTaskQueue(); + + // Wait until the sender thread has made progress + try { + //noinspection BusyWait + Thread.sleep(0, 100_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + + // Everything has been sent, drain the queue one last time + executeTaskQueue(); + } + + private void doSendPacketsTill(int timestamp) { try { while (ctx == null && !terminate) { // Make sure channel is ready Thread.sleep(10); @@ -1094,7 +1218,7 @@ public void sendPacketsTill(int timestamp) { loginPhase = true; startFromBeginning = false; nextPacket = null; - replayHandler.restartedReplay(); + ReplayMod.instance.runSync(replayHandler::restartedReplay); } if (replayIn == null) { @@ -1141,7 +1265,46 @@ public void sendPacketsTill(int timestamp) { } } - protected Packet processPacketSync(Packet p) { + private void executeTaskQueue() { + //#if MC>=11400 + ((MCVer.MinecraftMethodAccessor) mc).replayModExecuteTaskQueue(); + //#else + //$$ java.util.Queue> scheduledTasks = ((MinecraftAccessor) mc).getScheduledTasks(); + //$$ + //$$ // Live-lock detection: if we already hold the lock, then the sender thread will never be able to queue its + //$$ // tasks + //$$ if (Thread.holdsLock(scheduledTasks)) { + //$$ throw new IllegalStateException("Task queue already locked. " + + //$$ "You may want to use `Scheduler.runLaterWithoutLock` to run while the lock is not taken."); + //$$ } + //$$ + //$$ //noinspection SynchronizationOnLocalVariableOrMethodParameter + //$$ synchronized (scheduledTasks) { + //$$ while (!scheduledTasks.isEmpty()) { + //$$ scheduledTasks.poll().run(); + //$$ } + //$$ } + //#endif + ReplayMod.instance.runTasks(); + } + + /** + * Runs the given runnable on the main thread as if it was a packet handler. + * Note that the packet handler queue has different behavior than the standard ReplayMod queue. + */ + private void schedulePacketHandler(Runnable runnable) { + if (mc.isOnThread()) { + runnable.run(); + } else { + //#if MC>=11400 + mc.execute(runnable); + //#else + //$$ mc.addScheduledTask(runnable); + //#endif + } + } + + protected void processPacketSync(Packet p) { //#if MC>=10904 if (p instanceof UnloadChunkS2CPacket) { UnloadChunkS2CPacket packet = (UnloadChunkS2CPacket) p; @@ -1194,15 +1357,7 @@ protected Packet processPacketSync(Packet p) { for (Entity entity : entitiesInChunk) { // Skip interpolation of position updates coming from server // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) - // Needs to be called at least 4 times thanks to - // EntityOtherPlayerMP#otherPlayerMPPosRotationIncrements (max vanilla value is 3) - for (int i = 0; i < 4; i++) { - //#if MC>=11400 - entity.tick(); - //#else - //$$ entity.onUpdate(); - //#endif - } + forcePositionForVehicleAndSelf(entity); // Check whether the entity has left the chunk //#if MC>=11700 @@ -1270,7 +1425,26 @@ protected Packet processPacketSync(Packet p) { } } } - return p; // During synchronous playback everything is sent normally + } + + private void forcePositionForVehicleAndSelf(Entity entity) { + Entity vehicle = entity.getVehicle(); + if (vehicle != null) { + forcePositionForVehicleAndSelf(vehicle); + } + + // Skip interpolation of position updates coming from server + // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) + int ticks = 0; + Vec3d prevPos; + do { + prevPos = entity.getPos(); + if (vehicle != null) { + entity.tickRiding(); + } else { + entity.tick(); + } + } while (prevPos.squaredDistanceTo(entity.getPos()) > 0.0001 && ticks++ < 100); } private static final class PacketData { diff --git a/src/main/java/com/replaymod/replay/InputReplayTimer.java b/src/main/java/com/replaymod/replay/InputReplayTimer.java index 394fdd50b..0b3e8b4a3 100644 --- a/src/main/java/com/replaymod/replay/InputReplayTimer.java +++ b/src/main/java/com/replaymod/replay/InputReplayTimer.java @@ -8,6 +8,10 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.render.RenderTickCounter; +//#if MC>=11802 +//$$ import net.minecraft.client.gui.screen.DownloadingTerrainScreen; +//#endif + //#if MC>=11400 import org.lwjgl.glfw.GLFW; //#else @@ -111,6 +115,15 @@ public InputReplayTimer(RenderTickCounter wrapped, ReplayModReplay mod) { //#endif //$$ } //#endif + + //#if MC>=11802 + //$$ // As of 1.18.2, this screen always stays open for at least two seconds, and requires ticking to close. + //$$ // Thanks, but we'll have none of that (at least while in a replay). + //$$ if (mc.currentScreen instanceof DownloadingTerrainScreen) { + //$$ mc.currentScreen.close(); + //$$ } + //#endif + } //#if MC>=11600 return ticksThisFrame; diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 5f5d74629..6592b47d3 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -1,4 +1,4 @@ -//#if MC>=10904 +//#if MC>=10800 package com.replaymod.replay; import com.google.common.util.concurrent.FutureCallback; @@ -78,7 +78,11 @@ protected void dispatch(com.replaymod.replaystudio.protocol.Packet packet) { wrappedBuf.writerIndex(size); PacketByteBuf packetByteBuf = new PacketByteBuf(wrappedBuf); + //#if MC>=10809 Packet mcPacket; + //#else + //$$ Packet mcPacket; + //#endif //#if MC>=11700 //$$ mcPacket = NetworkState.PLAY.getPacketHandler(NetworkSide.CLIENTBOUND, packet.getId(), packetByteBuf); //#elseif MC>=11500 @@ -137,13 +141,13 @@ public ListenableFuture initialize(Consumer progress) { LOGGER.info("Initialized quick replay sender in " + (System.currentTimeMillis() - start) + "ms"); } catch (Throwable e) { LOGGER.error("Initializing quick replay sender:", e); - mod.getCore().runLater(() -> { + mod.getCore().runLaterWithoutLock(() -> { mod.getCore().printWarningToChat("Error initializing quick replay sender: %s", e.getLocalizedMessage()); promise.setException(e); }); return; } - mod.getCore().runLater(() -> promise.set(null)); + mod.getCore().runLaterWithoutLock(() -> promise.set(null)); }).start(); return promise; } diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index adac3806a..800377b6f 100644 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -69,7 +69,7 @@ //$$ import io.netty.channel.ChannelOutboundHandlerAdapter; //#endif -//#if MC<10904 +//#if MC<10800 //$$ import de.johni0702.minecraft.gui.element.GuiLabel; //$$ import de.johni0702.minecraft.gui.popup.GuiInfoPopup; //$$ import de.johni0702.minecraft.gui.utils.Colors; @@ -119,11 +119,11 @@ public class ReplayHandler { * Decodes and sends packets into channel. */ private final FullReplaySender fullReplaySender; - //#if MC>=10904 + //#if MC>=10800 private final QuickReplaySender quickReplaySender; private boolean quickMode = false; //#else - //$$ private static final String QUICK_MODE_MIN_MC = "1.9.4"; + //$$ private static final String QUICK_MODE_MIN_MC = "1.8"; //#endif /** @@ -160,7 +160,7 @@ public ReplayHandler(ReplayFile replayFile, boolean asyncMode) throws IOExceptio markers = replayFile.getMarkers().or(Collections.emptySet()); fullReplaySender = new FullReplaySender(this, replayFile, false); - //#if MC>=10904 + //#if MC>=10800 quickReplaySender = new QuickReplaySender(ReplayModReplay.instance, replayFile); //#endif @@ -206,7 +206,7 @@ public void endReplay() throws IOException { ReplayClosingCallback.EVENT.invoker().replayClosing(this); fullReplaySender.terminateReplay(); - //#if MC>=10904 + //#if MC>=10800 if (quickMode) { quickReplaySender.unregister(); } @@ -295,6 +295,11 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) { networkManager, mc, null + //#if MC>=11903 + //$$ , null + //$$ , false + //$$ , null + //#endif //#if MC>=11400 , it -> {} //#endif @@ -308,7 +313,7 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) { //$$ channel = new EmbeddedChannel(dummyHandler); //$$ channel.pipeline().remove(dummyHandler); //#endif - //#if MC>=10904 + //#if MC>=10800 channel.pipeline().addLast("ReplayModReplay_quickReplaySender", quickReplaySender); //#endif channel.pipeline().addLast("ReplayModReplay_replaySender", fullReplaySender); @@ -329,7 +334,7 @@ public Restrictions getRestrictions() { } public ReplaySender getReplaySender() { - //#if MC>=10904 + //#if MC>=10800 return quickMode ? quickReplaySender : fullReplaySender; //#else //$$ return fullReplaySender; @@ -340,7 +345,7 @@ public GuiReplayOverlay getOverlay() { return overlay; } - //#if MC>=10904 + //#if MC>=10800 public void ensureQuickModeInitialized(Runnable andThen) { if (Utils.ifMinimalModeDoPopup(overlay, () -> {})) return; ListenableFuture future = quickReplaySender.getInitializationPromise(); @@ -534,7 +539,11 @@ public void moveCameraToTargetPosition() { } public void doJump(int targetTime, boolean retainCameraPosition) { - //#if MC>=10904 + if (!getReplaySender().isAsyncMode()) { + return; // path playback, rendering, etc. -> no jumping allowed + } + + //#if MC>=10800 if (getReplaySender() == quickReplaySender) { // Always round to full tick targetTime = targetTime + targetTime % 50; @@ -603,6 +612,7 @@ public void doJump(int targetTime, boolean retainCameraPosition) { // Render our please-wait-screen GuiScreen guiScreen = new GuiScreen(); guiScreen.setBackground(AbstractGuiScreen.Background.DIRT); + guiScreen.setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER)); guiScreen.addElements(new HorizontalLayout.Data(0.5), new GuiLabel().setI18nText("replaymod.gui.pleasewait")); @@ -699,6 +709,13 @@ public void doJump(int targetTime, boolean retainCameraPosition) { //#else //$$ .processReceivedPackets(); //#endif + + // If the packets we just sent somehow caused the client to disconnect, then the above connection tick + // call will have unloaded the world, and we'll have to abort what we were doing. + if (mc.world == null) { + return; + } + for (Entity entity : mc.world.getEntities()) { skipTeleportInterpolation(entity); entity.lastRenderX = entity.prevX = entity.getX(); diff --git a/src/main/java/com/replaymod/replay/ReplayModReplay.java b/src/main/java/com/replaymod/replay/ReplayModReplay.java index 05113411e..cb225447a 100644 --- a/src/main/java/com/replaymod/replay/ReplayModReplay.java +++ b/src/main/java/com/replaymod/replay/ReplayModReplay.java @@ -159,7 +159,7 @@ public CameraController apply(@Nullable CameraEntity cameraEntity) { } public void startReplay(File file) throws IOException { - startReplay(core.openReplay(file.toPath())); + startReplay(core.files.open(file.toPath())); } public void startReplay(ReplayFile replayFile) throws IOException { diff --git a/src/main/java/com/replaymod/replay/camera/CameraEntity.java b/src/main/java/com/replaymod/replay/camera/CameraEntity.java index ba1b59e38..19ca60cd1 100644 --- a/src/main/java/com/replaymod/replay/camera/CameraEntity.java +++ b/src/main/java/com/replaymod/replay/camera/CameraEntity.java @@ -22,7 +22,6 @@ import net.minecraft.client.network.AbstractClientPlayerEntity; import net.minecraft.client.network.ClientPlayNetworkHandler; import net.minecraft.entity.Entity; -import net.minecraft.entity.LivingEntity; import net.minecraft.entity.mob.MobEntity; import net.minecraft.entity.decoration.ItemFrameEntity; import net.minecraft.entity.player.PlayerEntity; @@ -44,7 +43,11 @@ //#if MC>=11400 import net.minecraft.client.world.ClientWorld; import net.minecraft.fluid.Fluid; +//#if MC>=11802 +//$$ import net.minecraft.tag.TagKey; +//#else import net.minecraft.tag.Tag; +//#endif import net.minecraft.util.hit.BlockHitResult; import net.minecraft.util.hit.HitResult; //#else @@ -70,6 +73,7 @@ //$$ import net.minecraft.stats.RecipeBook; //#endif //#endif +import net.minecraft.util.Arm; import net.minecraft.util.Hand; //#endif @@ -249,14 +253,7 @@ public void setCameraPosRot(Entity to) { this.lastRenderX = to.lastRenderX; this.lastRenderY = to.lastRenderY + yOffset; this.lastRenderZ = to.lastRenderZ; - if (to instanceof LivingEntity) { - LivingEntity toLiving = (LivingEntity) to; - this.headYaw = toLiving.headYaw; - this.prevHeadYaw = toLiving.prevHeadYaw; - } else { - this.headYaw = to.yaw; - this.prevHeadYaw = to.prevYaw; - } + this.wrapArmYaw(); updateBoundingBox(); } @@ -265,7 +262,7 @@ public void setCameraPosRot(Entity to) { public float getYaw(float tickDelta) { Entity view = this.client.getCameraEntity(); if (view != null && view != this) { - return this.prevHeadYaw + (this.headYaw - this.prevHeadYaw) * tickDelta; + return this.prevYaw + (this.yaw - this.prevYaw) * tickDelta; } return super.getYaw(tickDelta); } @@ -362,9 +359,20 @@ public boolean isInsideWall() { //#if MC>=11400 @Override - public boolean isSubmergedIn(Tag fluid) { + public boolean isSubmergedIn( + //#if MC>=11802 + //$$ TagKey fluid + //#else + Tag fluid + //#endif + ) { return falseUnlessSpectating(entity -> entity.isSubmergedIn(fluid)); } + + @Override + public float getUnderwaterVisibility() { + return falseUnlessSpectating(__ -> true) ? super.getUnderwaterVisibility() : 1f; + } //#else //#if MC>=10800 //$$ @Override @@ -465,8 +473,8 @@ public boolean isInvisible() { @Override public Identifier getSkinTexture() { Entity view = this.client.getCameraEntity(); - if (view != this && view instanceof PlayerEntity) { - return Utils.getResourceLocationForPlayerUUID(view.getUuid()); + if (view != this && view instanceof AbstractClientPlayerEntity) { + return ((AbstractClientPlayerEntity) view).getSkinTexture(); } return super.getSkinTexture(); } @@ -491,6 +499,17 @@ public boolean isPartVisible(PlayerModelPart modelPart) { } //#endif + //#if MC>=10904 + @Override + public Arm getMainArm() { + Entity view = this.client.getCameraEntity(); + if (view != this && view instanceof PlayerEntity) { + return ((PlayerEntity) view).getMainArm(); + } + return super.getMainArm(); + } + //#endif + @Override public float getHandSwingProgress(float renderPartialTicks) { Entity view = this.client.getCameraEntity(); @@ -540,7 +559,11 @@ public boolean isUsingItem() { //#if MC>=11400 @Override + //#if MC>=11900 + //$$ public void onEquipStack(EquipmentSlot slot, ItemStack stack, ItemStack itemStack) { + //#else protected void onEquipStack(ItemStack itemStack_1) { + //#endif // Suppress equip sounds } //#endif @@ -681,6 +704,7 @@ private void syncInventory() { //#if MC>=10904 cameraA.setItemStackMainHand(viewPlayerA != null ? viewPlayerA.getItemStackMainHand() : empty); this.preferredHand = viewPlayer != null ? viewPlayer.preferredHand : Hand.MAIN_HAND; + this.activeItemStack = viewPlayer != null ? viewPlayer.getActiveItem() : empty; cameraA.setActiveItemStackUseCount(viewPlayerA != null ? viewPlayerA.getActiveItemStackUseCount() : 0); //#else //$$ cameraA.setItemInUse(viewPlayerA != null ? viewPlayerA.getItemInUse() : empty); @@ -707,7 +731,35 @@ private void updateArmYawAndPitch() { this.lastRenderYaw = this.renderYaw; this.lastRenderPitch = this.renderPitch; this.renderPitch = this.renderPitch + (this.pitch - this.renderPitch) * 0.5f; - this.renderYaw = this.renderYaw + (this.headYaw - this.renderYaw) * 0.5f; + this.renderYaw = this.renderYaw + wrapDegrees(this.yaw - this.renderYaw) * 0.5f; + this.wrapArmYaw(); + } + + /** + * Minecraft renders the arm offset based on the difference between {@link #yaw} and {@link #renderYaw}. It does not + * wrap around the difference though, so if {@link #yaw} just wrapped around from 350 to 10 but {@link #renderYaw} + * is still at 355, then the difference will be inappropriately large. To fix this, we always wrap the + * {@link #renderYaw} such that it is no more than 180 degrees away from {@link #yaw}, even if that requires going + * outside the normal range. + */ + private void wrapArmYaw() { + this.renderYaw = wrapDegreesTo(this.renderYaw, this.yaw); + this.lastRenderYaw = wrapDegreesTo(this.lastRenderYaw, this.renderYaw); + } + + private static float wrapDegreesTo(float value, float towardsValue) { + while (towardsValue - value < -180) { + value -= 360; + } + while (towardsValue - value >= 180) { + value += 360; + } + return value; + } + + private static float wrapDegrees(float value) { + value %= 360; + return wrapDegreesTo(value, 0); } public boolean canSpectate(Entity e) { diff --git a/src/main/java/com/replaymod/replay/camera/ClassicCameraController.java b/src/main/java/com/replaymod/replay/camera/ClassicCameraController.java index 316b4e250..26c5dfb0c 100644 --- a/src/main/java/com/replaymod/replay/camera/ClassicCameraController.java +++ b/src/main/java/com/replaymod/replay/camera/ClassicCameraController.java @@ -9,9 +9,9 @@ // TODO: Marius is responsible for this. Please, someone clean it up. public class ClassicCameraController implements CameraController { - private static final double SPEED_CHANGE = 0.5; - private static final double LOWER_SPEED = 2; - private static final double UPPER_SPEED = 20; + private static final double LOWER_SPEED = 0.2; + private static final double UPPER_SPEED = 200; + private static final double SPEED_CHANGE = (UPPER_SPEED - LOWER_SPEED) / 20000; private final CameraEntity camera; diff --git a/src/main/java/com/replaymod/replay/camera/VanillaCameraController.java b/src/main/java/com/replaymod/replay/camera/VanillaCameraController.java index 10354be0a..6561436ba 100644 --- a/src/main/java/com/replaymod/replay/camera/VanillaCameraController.java +++ b/src/main/java/com/replaymod/replay/camera/VanillaCameraController.java @@ -9,7 +9,7 @@ * Camera controller performing vanilla creative-like camera movements. */ public class VanillaCameraController implements CameraController { - private static final int MAX_SPEED = 1000; + private static final int MAX_SPEED = 2000; private static final int MIN_SPEED = -1000; private static final Vector3f[] DIRECTIONS = new Vector3f[]{ diff --git a/src/main/java/com/replaymod/replay/ext/EntityExt.java b/src/main/java/com/replaymod/replay/ext/EntityExt.java new file mode 100644 index 000000000..5f6e4ce46 --- /dev/null +++ b/src/main/java/com/replaymod/replay/ext/EntityExt.java @@ -0,0 +1,9 @@ +package com.replaymod.replay.ext; + +public interface EntityExt { + float replaymod$getTrackedYaw(); + void replaymod$setTrackedYaw(float value); + + float replaymod$getTrackedPitch(); + void replaymod$setTrackedPitch(float value); +} diff --git a/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java b/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java index 14bef7b15..7a91d189f 100644 --- a/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java +++ b/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java @@ -3,6 +3,7 @@ import com.replaymod.core.ReplayMod; import com.replaymod.core.events.KeyBindingEventCallback; import com.replaymod.core.events.KeyEventCallback; +import com.replaymod.core.gui.common.GuiWindow; import com.replaymod.core.versions.MCVer.Keyboard; import com.replaymod.replay.ReplayHandler; import com.replaymod.replay.ReplayModReplay; @@ -31,6 +32,9 @@ public class GuiReplayOverlay extends AbstractGuiOverlay { private final ReplayModReplay mod = ReplayModReplay.instance; + public final GuiReplayOverlayKt kt = new GuiReplayOverlayKt(); + public final GuiWindow guiWindow = new GuiWindow(this, kt.getWindow()); + public final GuiPanel topPanel = new GuiPanel(this) .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.LEFT).setSpacing(5)); public final GuiButton playPauseButton = new GuiButton() { @@ -57,7 +61,7 @@ public GuiElement getTooltip(RenderInfo renderInfo) { * when they're active. */ public final GuiPanel statusIndicatorPanel = new GuiPanel(this).setSize(100, 16) - .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.RIGHT).setSpacing(5)); + .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.LEFT).setSpacing(5)); private final EventHandler eventHandler = new EventHandler(); private boolean hidden; @@ -78,8 +82,11 @@ protected void layout(GuiReplayOverlay container, int width, int height) { pos(topPanel, 10, 10); size(topPanel, width - 20, 20); - pos(statusIndicatorPanel, width / 2, height - 21); + pos(statusIndicatorPanel, 5, height - 21); width(statusIndicatorPanel, width / 2 - 5); + + pos(guiWindow, 0, 0); + size(guiWindow, width, height); } }); diff --git a/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java b/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java index bd889eb09..9a2dec3c1 100644 --- a/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java +++ b/src/main/java/com/replaymod/replay/gui/screen/GuiReplayViewer.java @@ -49,6 +49,8 @@ import java.io.FileFilter; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -95,8 +97,10 @@ public void run() { List selected = list.getSelected(); if (selected.size() == 1) { + File file = selected.get(0).file; + LOGGER.info("Opening replay in viewer: " + file); try { - mod.startReplay(selected.get(0).file); + mod.startReplay(file); } catch (IOException e) { e.printStackTrace(); } @@ -118,7 +122,7 @@ public void run() { @Override public void run() { try { - File folder = mod.getCore().getReplayFolder().toFile(); + File folder = mod.getCore().folders.getReplayFolder().toFile(); MCVer.openFile(folder); } catch (IOException e) { @@ -130,8 +134,8 @@ public void run() { public final GuiButton renameButton = new GuiButton().onClick(new Runnable() { @Override public void run() { - final File file = list.getSelected().get(0).file; - String name = Utils.fileNameToReplayName(file.getName()); + final Path path = list.getSelected().get(0).file.toPath(); + String name = Utils.fileNameToReplayName(path.getFileName().toString()); final GuiTextField nameField = new GuiTextField().setSize(200, 20).setFocused(true).setText(name); final GuiYesNoPopup popup = GuiYesNoPopup.open(GuiReplayViewer.this, new GuiLabel().setI18nText("replaymod.gui.viewer.rename.name").setColor(Colors.BLACK), @@ -147,16 +151,16 @@ public void run() { } }).onTextChanged(obj -> { popup.getYesButton().setEnabled(!nameField.getText().isEmpty() - && !new File(file.getParentFile(), Utils.replayNameToFileName(nameField.getText())).exists()); + && Files.notExists(Utils.replayNameToPath(path.getParent(), nameField.getText()))); }); popup.onAccept(() -> { // Sanitize their input String newName = nameField.getText().trim(); // This file is what they want - File targetFile = new File(file.getParentFile(), Utils.replayNameToFileName(newName)); + Path targetPath = Utils.replayNameToPath(path.getParent(), newName); try { // Finally, try to move it - FileUtils.moveFile(file, targetFile); + Files.move(path, targetPath); } catch (IOException e) { // We failed (might also be their OS) e.printStackTrace(); @@ -221,7 +225,7 @@ public GuiReplayViewer(ReplayModReplay mod) { this.mod = mod; try { - list.setFolder(mod.getCore().getReplayFolder().toFile()); + list.setFolder(mod.getCore().folders.getReplayFolder().toFile()); } catch (IOException e) { throw new CrashException(CrashReport.create(e, "Getting replay folder")); } @@ -370,7 +374,7 @@ public GuiReplayList(GuiContainer container) { Arrays.sort(files, Comparator.comparingLong(f -> lastModified.computeIfAbsent(f, File::lastModified)).reversed()); for (final File file : files) { if (Thread.interrupted()) break; - try (ReplayFile replayFile = ReplayMod.instance.openReplay(file.toPath())) { + try (ReplayFile replayFile = ReplayMod.instance.files.open(file.toPath())) { final Image thumb = Optional.ofNullable(replayFile.getThumbBytes().orNull()).flatMap(stream -> { try (InputStream in = stream) { return Optional.of(Image.read(in)); diff --git a/src/main/java/com/replaymod/replay/handler/GuiHandler.java b/src/main/java/com/replaymod/replay/handler/GuiHandler.java index 2bc2f79d7..a8fec2a30 100644 --- a/src/main/java/com/replaymod/replay/handler/GuiHandler.java +++ b/src/main/java/com/replaymod/replay/handler/GuiHandler.java @@ -64,11 +64,11 @@ private void injectIntoIngameMenu(Screen guiScreen, Collection=11600 - final TranslatableText BUTTON_OPTIONS = new TranslatableText("menu.options"); - final TranslatableText BUTTON_EXIT_SERVER = new TranslatableText("menu.disconnect"); - final TranslatableText BUTTON_ADVANCEMENTS = new TranslatableText("gui.advancements"); - final TranslatableText BUTTON_STATS = new TranslatableText("gui.stats"); - final TranslatableText BUTTON_OPEN_TO_LAN = new TranslatableText("menu.shareToLan"); + final Text BUTTON_OPTIONS = new TranslatableText("menu.options"); + final Text BUTTON_EXIT_SERVER = new TranslatableText("menu.disconnect"); + final Text BUTTON_ADVANCEMENTS = new TranslatableText("gui.advancements"); + final Text BUTTON_STATS = new TranslatableText("gui.stats"); + final Text BUTTON_OPEN_TO_LAN = new TranslatableText("menu.shareToLan"); //#else //#if MC>=11400 //$$ final String BUTTON_OPTIONS = I18n.translate("menu.options"); @@ -138,7 +138,7 @@ private void injectIntoIngameMenu(Screen guiScreen, Collection=11400 + //#if MC>=11400 && MC<11901 } else if (id.equals(BUTTON_OPTIONS)) { //#if MC>=11400 b.setWidth(204); @@ -195,7 +195,8 @@ private void moveAllButtonsInRect( buttons.stream() .filter(button -> button.x <= xEnd && button.x + button.getWidth() >= xStart) .filter(button -> button.y <= yEnd && button.y + button.getHeight() >= yStart) - .forEach(button -> button.y += moveBy); + // FIXME remap bug: needs the {} to recognize the setter (it also doesn't understand +=) + .forEach(button -> { button.y = button.y + moveBy; }); } { on(InitScreenCallback.EVENT, (screen, buttons) -> ensureReplayStopped(screen)); } @@ -417,6 +418,9 @@ public InjectedButton(Screen guiScreen, int buttonId, int x, int y, int width, i //#if MC>=11400 , self -> onClick.accept((InjectedButton) self) //#endif + //#if MC>=11903 + //$$ , DEFAULT_NARRATION_SUPPLIER + //#endif ); this.guiScreen = guiScreen; this.id = buttonId; diff --git a/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java b/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java new file mode 100644 index 000000000..ba16ed59b --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/ClientWorldAccessor.java @@ -0,0 +1,13 @@ +package com.replaymod.replay.mixin; + +import net.minecraft.client.world.ClientWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ClientWorld.class) +public interface ClientWorldAccessor { + //#if MC>=11800 + //$$ @Accessor + //$$ net.minecraft.world.EntityList getEntityList(); + //#endif +} diff --git a/src/main/java/com/replaymod/replay/mixin/MixinGuiSpectator.java b/src/main/java/com/replaymod/replay/mixin/MixinGuiSpectator.java index 9d938f7e5..2257eceb7 100644 --- a/src/main/java/com/replaymod/replay/mixin/MixinGuiSpectator.java +++ b/src/main/java/com/replaymod/replay/mixin/MixinGuiSpectator.java @@ -18,7 +18,7 @@ public abstract class MixinGuiSpectator { //$$ @Inject(method = "func_175260_a", at = @At("HEAD"), cancellable = true) //#endif public void isInReplay( - //#if MC>=11400 + //#if MC>=11400 && MC<11802 double i, //#else //$$ int i, diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java b/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java new file mode 100644 index 000000000..be37d3e27 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java @@ -0,0 +1 @@ +// 1.19+ only diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java b/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java new file mode 100644 index 000000000..464bb2001 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java @@ -0,0 +1 @@ +// 1.18+ only diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java b/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java new file mode 100644 index 000000000..fca4cd6cf --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java @@ -0,0 +1 @@ +// 1.12.2 and below diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_MoveRealmsButton.java b/src/main/java/com/replaymod/replay/mixin/Mixin_MoveRealmsButton.java index 3066899f2..2ccc1c9b3 100644 --- a/src/main/java/com/replaymod/replay/mixin/Mixin_MoveRealmsButton.java +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_MoveRealmsButton.java @@ -11,11 +11,13 @@ @Mixin(TitleScreen.class) public abstract class Mixin_MoveRealmsButton { - @ModifyArg( - method = "init", - at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/screen/Screen;init(Lnet/minecraft/client/MinecraftClient;II)V"), - index = 2 - ) + //#if MC>=11901 + //$$ private static final String REALMS_INIT = "Lnet/minecraft/client/realms/gui/screen/RealmsNotificationsScreen;init(Lnet/minecraft/client/MinecraftClient;II)V"; + //#else + private static final String REALMS_INIT = "Lnet/minecraft/client/gui/screen/Screen;init(Lnet/minecraft/client/MinecraftClient;II)V"; + //#endif + + @ModifyArg(method = "init", at = @At(value = "INVOKE", target = REALMS_INIT), index = 2) private int adjustRealmsButton(int height) { String setting = ReplayMod.instance.getSettingsRegistry().get(Setting.MAIN_MENU_BUTTON); if (MainMenuButtonPosition.valueOf(setting) == MainMenuButtonPosition.BIG) { diff --git a/src/main/java/com/replaymod/replay/mixin/Mixin_ShowSpectatedHand_Iris.java b/src/main/java/com/replaymod/replay/mixin/Mixin_ShowSpectatedHand_Iris.java new file mode 100644 index 000000000..48e8c8a35 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/Mixin_ShowSpectatedHand_Iris.java @@ -0,0 +1,35 @@ +//#if MC>=11400 +package com.replaymod.replay.mixin; + +import com.replaymod.replay.camera.CameraEntity; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.network.ClientPlayerInteractionManager; +import net.minecraft.world.GameMode; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import static com.replaymod.core.versions.MCVer.getMinecraft; + +@Pseudo +@Mixin(targets = "net.coderbot.iris.pipeline.HandRenderer", remap = false) +public abstract class Mixin_ShowSpectatedHand_Iris { + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/network/ClientPlayerInteractionManager;getCurrentGameMode()Lnet/minecraft/world/GameMode;", + remap = true + ) + ) + private GameMode getGameMode(ClientPlayerInteractionManager interactionManager) { + ClientPlayerEntity camera = getMinecraft().player; + if (camera instanceof CameraEntity) { + // alternative doesn't really matter, the caller only checks for equality to SPECTATOR + return camera.isSpectator() ? GameMode.SPECTATOR : GameMode.SURVIVAL; + } + return interactionManager.getCurrentGameMode(); + } +} +//#endif diff --git a/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java new file mode 100644 index 000000000..7b5642eb8 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_EntityExt.java @@ -0,0 +1,43 @@ +package com.replaymod.replay.mixin.entity_tracking; + +import com.replaymod.replay.ext.EntityExt; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(Entity.class) +public abstract class Mixin_EntityExt implements EntityExt { + + @Shadow + public float yaw; + + @Shadow + public float pitch; + + @Unique + private float trackedYaw = Float.NaN; + + @Unique + private float trackedPitch = Float.NaN; + + @Override + public float replaymod$getTrackedYaw() { + return !Float.isNaN(this.trackedYaw) ? this.trackedYaw : this.yaw; + } + + @Override + public float replaymod$getTrackedPitch() { + return !Float.isNaN(this.trackedPitch) ? this.trackedPitch : this.pitch; + } + + @Override + public void replaymod$setTrackedYaw(float value) { + this.trackedYaw = value; + } + + @Override + public void replaymod$setTrackedPitch(float value) { + this.trackedPitch = value; + } +} diff --git a/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java new file mode 100644 index 000000000..2ffd980a3 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/entity_tracking/Mixin_FixPartialUpdates.java @@ -0,0 +1,109 @@ +package com.replaymod.replay.mixin.entity_tracking; + +import com.replaymod.replay.ext.EntityExt; +import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.entity.Entity; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Every Entity on the client has at least two position/rotation states: The local one and the server ("tracked") one. + * When receiving an update from the server, the server one needs to be updated and the local one will then usually be + * interpolated to the server one within a few ticks. + * + * Minecraft however incorrectly implements the server rotation update for position-only update packets by + * interpolating to the local rotation, which might not yet match the server rotation, rather than to the previously + * received server rotation. + * Similarly, with 1.15 and later, it incorrectly implements the server position update for rotation-only update packets + * by interpolating to the local position rather than the server one. + * + * Each of these will cause the client position to be in an incorrect state until the next update packed for the + * respective rotation/position of that entity. + * + * This mixin fixes those two issues by redirecting to the server rotation/position respectively. + * Minecraft does not currently even track the server rotation, so we need to do that as well. + */ +@Mixin(ClientPlayNetworkHandler.class) +public class Mixin_FixPartialUpdates { + + // + // Use correct rotation for position-only updates + // + + //#if MC>=11700 + //$$ @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getYaw()F")) + //#else + @Redirect(method = "onEntityUpdate", at = @At(value = "FIELD", target = "Lnet/minecraft/entity/Entity;yaw:F", opcode = Opcodes.GETFIELD)) + //#endif + private float getTrackedYaw(Entity instance) { + return ((EntityExt) instance).replaymod$getTrackedYaw(); + } + + //#if MC>=11700 + //$$ @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getPitch()F")) + //#else + @Redirect(method = "onEntityUpdate", at = @At(value = "FIELD", target = "Lnet/minecraft/entity/Entity;pitch:F", opcode = Opcodes.GETFIELD)) + //#endif + private float getTrackedPitch(Entity instance) { + return ((EntityExt) instance).replaymod$getTrackedPitch(); + } + + //#if MC>=11500 + // + // Use correct position for rotation-only updates + // + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getX()D")) + private double getTrackedX(Entity instance) { + return instance.getTrackedPosition().getX(); + } + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getY()D")) + private double getTrackedY(Entity instance) { + return instance.getTrackedPosition().getY(); + } + + @Redirect(method = "onEntityUpdate", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;getZ()D")) + private double getTrackedZ(Entity instance) { + return instance.getTrackedPosition().getZ(); + } + //#endif + + // + // Track server rotation + // + + private static final String ENTITY_UPDATE = "Lnet/minecraft/entity/Entity;updateTrackedPositionAndAngles(DDDFFIZ)V"; + + @Unique + private Entity entity; + + @ModifyVariable(method = { "onEntityUpdate", "onEntityPosition" }, at = @At(value = "INVOKE", target = ENTITY_UPDATE), ordinal = 0) + private Entity captureEntity(Entity entity) { + return this.entity = entity; + } + + @Inject(method = { "onEntityUpdate", "onEntityPosition" }, at = @At("RETURN")) + private void resetEntityField(CallbackInfo ci) { + this.entity = null; + } + + @ModifyArg(method = { "onEntityUpdate", "onEntityPosition" }, at = @At(value = "INVOKE", target = ENTITY_UPDATE), index = 3) + private float captureTrackedYaw(float value) { + ((EntityExt) this.entity).replaymod$setTrackedYaw(value); + return value; + } + + @ModifyArg(method = { "onEntityUpdate", "onEntityPosition" }, at = @At(value = "INVOKE", target = ENTITY_UPDATE), index = 4) + private float captureTrackedPitch(float value) { + ((EntityExt) this.entity).replaymod$setTrackedPitch(value); + return value; + } +} diff --git a/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForMovement.java b/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForMovement.java new file mode 100644 index 000000000..1e8f9e2b1 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForMovement.java @@ -0,0 +1,38 @@ +package com.replaymod.replay.mixin.world_border; + +import com.replaymod.core.versions.MCVer; +import com.replaymod.replay.ReplayHandler; +import com.replaymod.replay.ReplayModReplay; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** + * Normally Minecraft's world border movement is based off real time; + * this redirect ensures that it is synced with the time in the Replay instead. + */ +//#if MC>=11400 +// FIXME: preprocessor should be able to remap between fabric and forge +//#if FABRIC +@Mixin(targets = "net.minecraft.world.border.WorldBorder.MovingArea") +//#else +//$$ @Mixin(targets = "net.minecraft.world.border.WorldBorder.MovingBorderInfo") +//#endif +//#else +//$$ @Mixin(net.minecraft.world.border.WorldBorder.class) +//#endif +public class Mixin_UseReplayTime_ForMovement { + + //#if MC>=11400 + @Redirect(method = "*", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Util;getMeasuringTimeMs()J")) + //#else + //$$ @Redirect(method = "*", at = @At(value = "INVOKE", target = "Ljava/lang/System;currentTimeMillis()J")) + //#endif + private long getWorldBorderTime() { + ReplayHandler replayHandler = ReplayModReplay.instance.getReplayHandler(); + if (replayHandler != null) { + return replayHandler.getReplaySender().currentTimeStamp(); + } + return MCVer.milliTime(); + } +} diff --git a/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForTexture.java b/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForTexture.java new file mode 100644 index 000000000..9abf28c43 --- /dev/null +++ b/src/main/java/com/replaymod/replay/mixin/world_border/Mixin_UseReplayTime_ForTexture.java @@ -0,0 +1,29 @@ +package com.replaymod.replay.mixin.world_border; + +import com.replaymod.core.versions.MCVer; +import com.replaymod.replay.ReplayHandler; +import com.replaymod.replay.ReplayModReplay; +import net.minecraft.client.render.WorldRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** + * Normally Minecraft's world border texture animation is based off real time; + * this redirect ensures that it is synced with the time in the Replay instead. + */ +@Mixin(WorldRenderer.class) +public class Mixin_UseReplayTime_ForTexture { + //#if MC>=11400 + @Redirect(method = "*", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Util;getMeasuringTimeMs()J")) + //#else + //$$ @Redirect(method = "*", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;getSystemTime()J")) + //#endif + private long getWorldBorderTime() { + ReplayHandler replayHandler = ReplayModReplay.instance.getReplayHandler(); + if (replayHandler != null) { + return replayHandler.getReplaySender().currentTimeStamp(); + } + return MCVer.milliTime(); + } +} diff --git a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java index aaa839e82..d5276558c 100644 --- a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java +++ b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java @@ -5,6 +5,8 @@ import com.replaymod.core.ReplayMod; import com.replaymod.core.SettingsRegistry; import com.replaymod.core.events.SettingsChangedCallback; +import com.replaymod.simplepathing.gui.KeyframeState; +import com.replaymod.simplepathing.gui.KeyframeType; import de.johni0702.minecraft.gui.utils.EventRegistrations; import com.replaymod.core.versions.MCVer.Keyboard; import com.replaymod.replay.ReplayHandler; @@ -15,7 +17,6 @@ import com.replaymod.replay.gui.overlay.GuiReplayOverlay; import com.replaymod.replaystudio.pathing.PathingRegistry; import com.replaymod.replaystudio.pathing.change.Change; -import com.replaymod.replaystudio.pathing.path.Keyframe; import com.replaymod.replaystudio.pathing.path.Timeline; import com.replaymod.replaystudio.pathing.serialize.TimelineSerialization; import com.replaymod.replaystudio.replay.ReplayFile; @@ -45,6 +46,14 @@ public class ReplayModSimplePathing extends EventRegistrations implements Module public KeyBindingRegistry.Binding keyTimeKeyframe; public KeyBindingRegistry.Binding keySyncTime; + public KeyBindingRegistry.Binding keyRotationMode; + public KeyBindingRegistry.Binding keyPlusX; + public KeyBindingRegistry.Binding keyPlusY; + public KeyBindingRegistry.Binding keyPlusZ; + public KeyBindingRegistry.Binding keyMinusX; + public KeyBindingRegistry.Binding keyMinusY; + public KeyBindingRegistry.Binding keyMinusZ; + public static Logger LOGGER = LogManager.getLogger(); private GuiPathing guiPathing; @@ -91,7 +100,7 @@ public void registerKeyBindings(KeyBindingRegistry registry) { if (guiPathing != null) guiPathing.clearKeyframesButtonPressed(); }, true); keySyncTime = core.getKeyBindingRegistry().registerRepeatedKeyBinding("replaymod.input.synctimeline", Keyboard.KEY_V, () -> { - if (guiPathing != null) guiPathing.syncTimeButtonPressed(); + if (guiPathing != null) guiPathing.kt.syncTimeButtonPressed(); }, true); SettingsRegistry settingsRegistry = core.getSettingsRegistry(); keySyncTime.registerAutoActivationSupport(settingsRegistry.get(Setting.AUTO_SYNC), active -> { @@ -99,22 +108,43 @@ public void registerKeyBindings(KeyBindingRegistry registry) { settingsRegistry.save(); }); core.getKeyBindingRegistry().registerRaw(Keyboard.KEY_DELETE, () -> - guiPathing != null && guiPathing.deleteButtonPressed()); + guiPathing != null && guiPathing.kt.deleteButtonPressed()); keyPositionKeyframe = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.positionkeyframe", Keyboard.KEY_I, () -> { - if (guiPathing != null) guiPathing.toggleKeyframe(SPPath.POSITION, false); + if (guiPathing != null) guiPathing.kt.toggleKeyframe(KeyframeType.POSITION); }, true); core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.positiononlykeyframe", 0, () -> { - if (guiPathing != null) guiPathing.toggleKeyframe(SPPath.POSITION, true); + if (guiPathing != null) guiPathing.kt.toggleKeyframe(KeyframeType.POSITION, true); }, true); keyTimeKeyframe = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.timekeyframe", Keyboard.KEY_O, () -> { - if (guiPathing != null) guiPathing.toggleKeyframe(SPPath.TIME, false); + if (guiPathing != null) guiPathing.kt.toggleKeyframe(KeyframeType.TIME); }, true); core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.bothkeyframes", 0, () -> { if (guiPathing != null) { - guiPathing.toggleKeyframe(SPPath.TIME, false); - guiPathing.toggleKeyframe(SPPath.POSITION, false); + guiPathing.kt.toggleKeyframe(KeyframeType.TIME); + guiPathing.kt.toggleKeyframe(KeyframeType.POSITION); } }, true); + + keyRotationMode = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.rotation", Keyboard.KEY_LALT, () -> {}, true); + keyPlusX = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.px", Keyboard.KEY_RIGHT, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getPlusXButton().activate(); + }, true); + keyPlusY = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.py", Keyboard.KEY_PAGE_UP, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getPlusYButton().activate(); + }, true); + keyPlusZ = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.pz", Keyboard.KEY_UP, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getPlusZButton().activate(); + }, true); + keyMinusX = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.mx", Keyboard.KEY_LEFT, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getMinusXButton().activate(); + }, true); + keyMinusY = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.my", Keyboard.KEY_PAGE_DOWN, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getMinusYButton().activate(); + }, true); + keyMinusZ = core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.offset.mz", Keyboard.KEY_DOWN, () -> { + if (guiPathing != null) guiPathing.kt.getPositionOffsetPanel().getMinusZButton().activate(); + }, true); + core.getKeyBindingRegistry().registerRaw(Keyboard.KEY_Z, () -> { if (Screen.hasControlDown() && currentTimeline != null) { Timeline timeline = currentTimeline.getTimeline(); @@ -188,7 +218,6 @@ private void onReplayClosing() { private void onReplayClosed() { currentTimeline = null; guiPathing = null; - selectedPath = null; } private GuiReplayOverlay getReplayOverlay() { @@ -197,31 +226,25 @@ private GuiReplayOverlay getReplayOverlay() { private SPTimeline currentTimeline; - private SPPath selectedPath; - private long selectedTime; - + @Deprecated public SPPath getSelectedPath() { - if (getReplayOverlay().timeline.getSelectedMarker() != null) { - selectedPath = null; - selectedTime = 0; - } - return selectedPath; + return this.guiPathing == null ? null : this.guiPathing.kt.getState().getSelectedPath(); } + @Deprecated public long getSelectedTime() { - return selectedTime; + return this.guiPathing == null ? 0 : this.guiPathing.kt.getState().getSelectedTime(); } - public boolean isSelected(Keyframe keyframe) { - return getSelectedPath() != null && currentTimeline.getKeyframe(selectedPath, selectedTime) == keyframe; + public boolean isSelected(SPPath path, long time) { + if (this.guiPathing == null) return false; + return this.guiPathing.kt.getState().isSelected(path, time); } + @Deprecated public void setSelected(SPPath path, long time) { - selectedPath = path; - selectedTime = time; - if (selectedPath != null) { - getReplayOverlay().timeline.setSelectedMarker(null); - } + if (this.guiPathing == null) return; + this.guiPathing.kt.getState().setSelected(path, time); } public void setCurrentTimeline(SPTimeline newTimeline) { @@ -229,13 +252,17 @@ public void setCurrentTimeline(SPTimeline newTimeline) { } private void setCurrentTimeline(SPTimeline newTimeline, boolean save) { - selectedPath = null; currentTimeline = newTimeline; if (!save) { lastTimeline = newTimeline; lastChange = newTimeline.getTimeline().peekUndoStack(); } updateDefaultInterpolatorType(); + if (this.guiPathing != null) { + KeyframeState state = this.guiPathing.kt.getState(); + state.getSelection().set(KeyframeState.Selection.EMPTY); + state.update(); + } } public void clearCurrentTimeline() { diff --git a/src/main/java/com/replaymod/simplepathing/SPTimeline.java b/src/main/java/com/replaymod/simplepathing/SPTimeline.java index 561122d49..96a831208 100644 --- a/src/main/java/com/replaymod/simplepathing/SPTimeline.java +++ b/src/main/java/com/replaymod/simplepathing/SPTimeline.java @@ -307,6 +307,23 @@ public void removeTimeKeyframe(long time) { timeline.pushChange(change); } + public Change removeKeyframe(SPPath spPath, long time) { + LOGGER.debug("Removing {} keyframe at {}", spPath, time); + + Path path = getPath(spPath); + Keyframe keyframe = path.getKeyframe(time); + + Preconditions.checkState(keyframe != null, "No keyframe at that time"); + + Change change = create(path, keyframe); + change.apply(timeline); + + Change specPosUpdate = updateSpectatorPositions(); + specPosUpdate.apply(timeline); + + return CombinedChange.createFromApplied(change, specPosUpdate); + } + public Change setInterpolatorToDefault(long time) { LOGGER.debug("Setting interpolator of position keyframe at {} to the default", time); diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java index a99435286..d8f556cda 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java @@ -101,7 +101,7 @@ protected boolean canSave() { long newTime = (timeMin * 60 + timeSec) * 1000 + timeMSec; - if (newTime < 0 || newTime > guiPathing.timeline.getLength()) { + if (newTime < 0 || newTime > guiPathing.kt.getTimeline().getLengthMillis()) { return false; } return newTime == keyframe.getTime() || path.getKeyframe(newTime) == null; diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java b/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java deleted file mode 100644 index da17ba14b..000000000 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java +++ /dev/null @@ -1,408 +0,0 @@ -package com.replaymod.simplepathing.gui; - -import com.replaymod.core.ReplayMod; -import com.replaymod.core.versions.MCVer; -import com.replaymod.pathing.properties.CameraProperties; -import com.replaymod.pathing.properties.SpectatorProperty; -import com.replaymod.pathing.properties.TimestampProperty; -import com.replaymod.replay.ReplayModReplay; -import com.replaymod.replay.gui.overlay.GuiMarkerTimeline; -import com.replaymod.replaystudio.pathing.change.Change; -import com.replaymod.replaystudio.pathing.path.Keyframe; -import com.replaymod.replaystudio.pathing.path.Path; -import com.replaymod.replaystudio.pathing.path.PathSegment; -import com.replaymod.replaystudio.pathing.property.Property; -import com.replaymod.simplepathing.ReplayModSimplePathing; -import com.replaymod.simplepathing.SPTimeline; -import com.replaymod.simplepathing.SPTimeline.SPPath; -import de.johni0702.minecraft.gui.GuiRenderer; -import de.johni0702.minecraft.gui.element.advanced.AbstractGuiTimeline; -import de.johni0702.minecraft.gui.function.Draggable; -import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector2f; -import net.minecraft.client.render.BufferBuilder; -import net.minecraft.client.render.Tessellator; -import net.minecraft.client.render.VertexFormats; -import org.apache.commons.lang3.tuple.Pair; -import de.johni0702.minecraft.gui.utils.lwjgl.Point; -import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; -import de.johni0702.minecraft.gui.utils.lwjgl.ReadablePoint; -import org.lwjgl.opengl.GL11; - -import java.util.Comparator; -import java.util.Optional; - -import static com.replaymod.core.versions.MCVer.emitLine; -import static de.johni0702.minecraft.gui.versions.MCVer.popScissorState; -import static de.johni0702.minecraft.gui.versions.MCVer.pushScissorState; -import static de.johni0702.minecraft.gui.versions.MCVer.setScissorDisabled; - -//#if MC>=11700 -//$$ import com.mojang.blaze3d.systems.RenderSystem; -//$$ import net.minecraft.client.render.GameRenderer; -//#endif - -public class GuiKeyframeTimeline extends AbstractGuiTimeline implements Draggable { - protected static final int KEYFRAME_SIZE = 5; - protected static final int KEYFRAME_TEXTURE_X = 74; - protected static final int KEYFRAME_TEXTURE_Y = 20; - private static final int DOUBLE_CLICK_INTERVAL = 250; - private static final int DRAGGING_THRESHOLD = KEYFRAME_SIZE; - - private final GuiPathing gui; - - /** - * The keyframe (time on timeline) that was last clicked on using the left mouse button. - */ - private long lastClickedKeyframe; - - /** - * Path of {@link #lastClickedKeyframe}. - */ - private SPPath lastClickedPath; - - /** - * The time at which {@link #lastClickedKeyframe} was updated. - * According to {@link MCVer#milliTime()}. - */ - private long lastClickedTime; - - /** - * Whether to handle dragging events. - */ - private boolean dragging; - - /** - * Whether we have surpassed the initial threshold and are actually dragging the keyframe. - */ - private boolean actuallyDragging; - - /** - * Where the mouse was when {@link #dragging} started. - */ - private int draggingStartX; - - /** - * Change caused by dragging. Whenever the user moves the keyframe further, the previous change is undone - * and a new one is created. This way when the mouse is released, only one change is in the undo history. - */ - private Change draggingChange; - - public GuiKeyframeTimeline(GuiPathing gui) { - this.gui = gui; - } - - @Override - protected void drawTimelineCursor(GuiRenderer renderer, ReadableDimension size) { - ReplayModSimplePathing mod = gui.getMod(); - - int width = size.getWidth(); - int visibleWidth = width - BORDER_LEFT - BORDER_RIGHT; - int startTime = getOffset(); - int visibleTime = (int) (getZoom() * getLength()); - int endTime = getOffset() + visibleTime; - - renderer.bindTexture(ReplayMod.TEXTURE); - - SPTimeline timeline = mod.getCurrentTimeline(); - - timeline.getTimeline().getPaths().stream().flatMap(path -> path.getKeyframes().stream()).forEach(keyframe -> { - if (keyframe.getTime() >= startTime && keyframe.getTime() <= endTime) { - double relativeTime = keyframe.getTime() - startTime; - int positonX = BORDER_LEFT + (int) (relativeTime / visibleTime * visibleWidth) - KEYFRAME_SIZE / 2; - int u = KEYFRAME_TEXTURE_X + (mod.isSelected(keyframe) ? KEYFRAME_SIZE : 0); - int v = KEYFRAME_TEXTURE_Y; - if (keyframe.getValue(CameraProperties.POSITION).isPresent()) { - if (keyframe.getValue(SpectatorProperty.PROPERTY).isPresent()) { - v += 2 * KEYFRAME_SIZE; - } - renderer.drawTexturedRect(positonX, BORDER_TOP, u, v, KEYFRAME_SIZE, KEYFRAME_SIZE); - } - Optional timeProperty = keyframe.getValue(TimestampProperty.PROPERTY); - if (timeProperty.isPresent()) { - v += KEYFRAME_SIZE; - renderer.drawTexturedRect(positonX, BORDER_TOP + KEYFRAME_SIZE, u, v, KEYFRAME_SIZE, KEYFRAME_SIZE); - - GuiMarkerTimeline replayTimeline = gui.overlay.timeline; - GuiKeyframeTimeline keyframeTimeline = this; - - ReadableDimension replayTimelineSize = replayTimeline.getLastSize(); - ReadableDimension keyframeTimelineSize = this.getLastSize(); - if (replayTimelineSize == null || keyframeTimelineSize == null) { - return; - } - - // Determine absolute positions for both timelines - Point replayTimelinePos = new Point(0, 0); - Point keyframeTimelinePos = new Point(0, 0); - replayTimeline.getContainer().convertFor(replayTimeline, replayTimelinePos); - keyframeTimeline.getContainer().convertFor(keyframeTimeline, keyframeTimelinePos); - replayTimelinePos.setLocation(-replayTimelinePos.getX(), -replayTimelinePos.getY()); - keyframeTimelinePos.setLocation(-keyframeTimelinePos.getX(), -keyframeTimelinePos.getY()); - - int replayTimelineLeft = replayTimelinePos.getX(); - int replayTimelineRight = replayTimelinePos.getX() + replayTimelineSize.getWidth(); - int replayTimelineTop = replayTimelinePos.getY(); - int replayTimelineBottom = replayTimelinePos.getY() + replayTimelineSize.getHeight(); - int replayTimelineWidth = replayTimelineRight - replayTimelineLeft - BORDER_LEFT - BORDER_RIGHT; - - int keyframeTimelineLeft = keyframeTimelinePos.getX(); - int keyframeTimelineTop = keyframeTimelinePos.getY(); - - float positionXReplayTimeline = BORDER_LEFT + timeProperty.get() / (float) replayTimeline.getLength() * replayTimelineWidth; - float positionXKeyframeTimeline = positonX + KEYFRAME_SIZE / 2f; - - final int color = 0xff0000ff; - Tessellator tessellator = Tessellator.getInstance(); - BufferBuilder buffer = tessellator.getBuffer(); - buffer.begin(GL11.GL_LINE_STRIP, VertexFormats.POSITION_COLOR); - - // Start just below the top border of the replay timeline - Vector2f p1 = new Vector2f(replayTimelineLeft + positionXReplayTimeline, replayTimelineTop + BORDER_TOP); - // Draw vertically over the replay timeline, including its bottom border - Vector2f p2 = new Vector2f(replayTimelineLeft + positionXReplayTimeline, replayTimelineBottom); - // Now for the important part: connecting to the keyframe timeline - Vector2f p3 = new Vector2f(keyframeTimelineLeft + positionXKeyframeTimeline, keyframeTimelineTop); - // And finally another vertical bit (the timeline is already crammed enough, so only the border) - Vector2f p4 = new Vector2f(keyframeTimelineLeft + positionXKeyframeTimeline, keyframeTimelineTop + BORDER_TOP); - - emitLine(buffer, p1, p2, color); - emitLine(buffer, p2, p3, color); - emitLine(buffer, p3, p4, color); - - //#if MC>=11700 - //$$ RenderSystem.setShader(GameRenderer::getRenderTypeLinesShader); - //#else - GL11.glEnable(GL11.GL_LINE_SMOOTH); - GL11.glDisable(GL11.GL_TEXTURE_2D); - //#endif - pushScissorState(); - setScissorDisabled(); - GL11.glLineWidth(2); - tessellator.draw(); - popScissorState(); - //#if MC<11700 - GL11.glEnable(GL11.GL_TEXTURE_2D); - GL11.glDisable(GL11.GL_LINE_SMOOTH); - //#endif - } - } - }); - - // Draw colored quads on spectator path segments - for (PathSegment segment : timeline.getPositionPath().getSegments()) { - if (segment.getInterpolator() == null - || !segment.getInterpolator().getKeyframeProperties().contains(SpectatorProperty.PROPERTY)) { - continue; // Not a spectator segment - } - drawQuadOnSegment(renderer, visibleWidth, segment, BORDER_TOP + 1, 0xFF0088FF); - } - - // Draw red quads on time path segments that would require time going backwards - for (PathSegment segment : timeline.getTimePath().getSegments()) { - long startTimestamp = segment.getStartKeyframe().getValue(TimestampProperty.PROPERTY).orElseThrow(IllegalStateException::new); - long endTimestamp = segment.getEndKeyframe().getValue(TimestampProperty.PROPERTY).orElseThrow(IllegalStateException::new); - if (endTimestamp >= startTimestamp) { - continue; // All is fine, time is not moving backwards - } - drawQuadOnSegment(renderer, visibleWidth, segment, BORDER_TOP + KEYFRAME_SIZE + 1, 0xFFFF0000); - } - - super.drawTimelineCursor(renderer, size); - } - - private void drawQuadOnSegment(GuiRenderer renderer, int visibleWidth, PathSegment segment, int y, int color) { - int startTime = getOffset(); - int visibleTime = (int) (getZoom() * getLength()); - int endTime = getOffset() + visibleTime; - - long startFrameTime = segment.getStartKeyframe().getTime(); - long endFrameTime = segment.getEndKeyframe().getTime(); - if (startFrameTime >= endTime || endFrameTime <= startTime) { - return; // Segment out of display range - } - - double relativeStart = startFrameTime - startTime; - double relativeEnd = endFrameTime - startTime; - int startX = BORDER_LEFT + Math.max(0, (int) (relativeStart / visibleTime * visibleWidth) + KEYFRAME_SIZE / 2 + 1); - int endX = BORDER_LEFT + Math.min(visibleWidth, (int) (relativeEnd / visibleTime * visibleWidth) - KEYFRAME_SIZE / 2); - if (startX < endX) { - renderer.drawRect(startX + 1, y, endX - startX - 2, KEYFRAME_SIZE - 2, color); - } - } - - /** - * Returns the keyframe at the specified position. - * @param position The raw position - * @return Pair of path id and keyframe or null when no keyframe was clicked - */ - private Pair getKeyframe(ReadablePoint position) { - int time = getTimeAt(position.getX(), position.getY()); - if (time != -1) { - Point mouse = new Point(position); - getContainer().convertFor(this, mouse); - int mouseY = mouse.getY(); - if (mouseY > BORDER_TOP && mouseY < BORDER_TOP + 2 * KEYFRAME_SIZE) { - SPPath path; - if (mouseY <= BORDER_TOP + KEYFRAME_SIZE) { - // Position keyframe - path = SPPath.POSITION; - } else { - // Time keyframe - path = SPPath.TIME; - } - int visibleTime = (int) (getZoom() * getLength()); - int tolerance = visibleTime * KEYFRAME_SIZE / (getLastSize().getWidth() - BORDER_LEFT - BORDER_RIGHT) / 2; - Optional keyframe = gui.getMod().getCurrentTimeline().getPath(path).getKeyframes().stream() - .filter(k -> Math.abs(k.getTime() - time) <= tolerance) - .sorted(Comparator.comparing(k -> Math.abs(k.getTime() - time))) - .findFirst(); - return Pair.of(path, keyframe.map(Keyframe::getTime).orElse(null)); - } - } - return Pair.of(null, null); - } - - @Override - public boolean mouseClick(ReadablePoint position, int button) { - int time = getTimeAt(position.getX(), position.getY()); - Pair pathKeyframePair = getKeyframe(position); - if (pathKeyframePair.getRight() != null) { - SPPath path = pathKeyframePair.getLeft(); - // Clicked on keyframe - long keyframeTime = pathKeyframePair.getRight(); - if (button == 0) { // Left click - long now = MCVer.milliTime(); - if (lastClickedKeyframe == keyframeTime) { - // Clicked the same keyframe again, potentially a double click - if (now - lastClickedTime < DOUBLE_CLICK_INTERVAL) { - // Yup, double click, open the edit keyframe gui - gui.openEditKeyframePopup(path, keyframeTime); - return true; - } - } - // Not a double click, just update the click time and selection - lastClickedTime = now; - lastClickedKeyframe = keyframeTime; - lastClickedPath = path; - gui.getMod().setSelected(lastClickedPath, lastClickedKeyframe); - // We might be dragging - draggingStartX = position.getX(); - dragging = true; - } else if (button == 1) { // Right click - Keyframe keyframe = gui.getMod().getCurrentTimeline().getKeyframe(path, keyframeTime); - for (Property property : keyframe.getProperties()) { - applyPropertyToGame(property, keyframe); - } - } - return true; - } else if (time != -1) { - // Clicked on timeline but not on any keyframe - if (button == 0) { // Left click - setCursorPosition(time); - gui.getMod().setSelected(null, 0); - } else if (button == 1) { // Right click - if (pathKeyframePair.getLeft() != null) { - // Apply the value of the clicked path at the clicked position - Path path = gui.getMod().getCurrentTimeline().getPath(pathKeyframePair.getLeft()); - path.getKeyframes().stream().flatMap(k -> k.getProperties().stream()).distinct().forEach( - p -> applyPropertyToGame(p, path, time)); - } - } - return true; - } - // Missed timeline - return false; - } - - // Helper method because generics cannot be defined on blocks - private void applyPropertyToGame(Property property, Path path, long time) { - Optional value = path.getValue(property, time); - if (value.isPresent()) { - property.applyToGame(value.get(), ReplayModReplay.instance.getReplayHandler()); - } - } - - // Helper method because generics cannot be defined on blocks - private void applyPropertyToGame(Property property, Keyframe keyframe) { - Optional value = keyframe.getValue(property); - if (value.isPresent()) { - property.applyToGame(value.get(), ReplayModReplay.instance.getReplayHandler()); - } - } - - @Override - public boolean mouseDrag(ReadablePoint position, int button, long timeSinceLastCall) { - if (!dragging) { - if (button == 0) { - // Left click, the user might try to move the cursor by clicking and holding - int time = getTimeAt(position.getX(), position.getY()); - if (time != -1) { - // and they are still on the timeline, so update the time appropriately - setCursorPosition(time); - return true; - } - } - return false; - } - - if (!actuallyDragging) { - // Check if threshold has been passed by now - if (Math.abs(position.getX() - draggingStartX) >= DRAGGING_THRESHOLD) { - actuallyDragging = true; - } - } - if (actuallyDragging) { - if (!gui.loadEntityTracker(() -> mouseDrag(position, button, timeSinceLastCall))) return true; - // Threshold passed - SPTimeline timeline = gui.getMod().getCurrentTimeline(); - Point mouse = new Point(position); - getContainer().convertFor(this, mouse); - int mouseX = mouse.getX(); - int width = getLastSize().getWidth(); - int bodyWidth = width - BORDER_LEFT - BORDER_RIGHT; - double segmentLength = getLength() * getZoom(); - double segmentTime = segmentLength * (mouseX - BORDER_LEFT) / bodyWidth; - int newTime = Math.min(Math.max((int) Math.round(getOffset() + segmentTime), 0), getLength()); - if (newTime < 0) { - return true; - } - - // If there already is a keyframe at the target time, then increase the time by one until there is none - while (timeline.getKeyframe(lastClickedPath, newTime) != null) { - newTime++; - } - - // First undo any previous changes - if (draggingChange != null) { - draggingChange.undo(timeline.getTimeline()); - } - - // Move keyframe to new position and - // store change for later undoing / pushing to history - draggingChange = timeline.moveKeyframe(lastClickedPath, lastClickedKeyframe, newTime); - - // Selected keyframe has been replaced - gui.getMod().setSelected(lastClickedPath, newTime); - } - return true; - } - - @Override - public boolean mouseRelease(ReadablePoint position, int button) { - if (dragging) { - if (actuallyDragging) { - gui.getMod().getCurrentTimeline().getTimeline().pushChange(draggingChange); - draggingChange = null; - actuallyDragging = false; - } - dragging = false; - return true; - } - return false; - } - - @Override - protected GuiKeyframeTimeline getThis() { - return this; - } -} diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java b/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java index d4031bbd4..f92691ea6 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiPathing.java @@ -3,20 +3,15 @@ import com.google.common.base.Preconditions; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import com.replaymod.core.ReplayMod; import com.replaymod.core.utils.Result; import com.replaymod.core.utils.Utils; import com.replaymod.pathing.gui.GuiKeyframeRepository; -import com.replaymod.pathing.player.RealtimeTimelinePlayer; import com.replaymod.pathing.properties.CameraProperties; import com.replaymod.pathing.properties.SpectatorProperty; import com.replaymod.pathing.properties.TimestampProperty; -import com.replaymod.render.gui.GuiRenderQueue; -import com.replaymod.render.gui.GuiRenderSettings; import com.replaymod.replay.ReplayHandler; -import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replay.gui.overlay.GuiReplayOverlay; import com.replaymod.replaystudio.pathing.path.Keyframe; import com.replaymod.replaystudio.pathing.path.Path; @@ -26,53 +21,23 @@ import com.replaymod.simplepathing.ReplayModSimplePathing; import com.replaymod.simplepathing.SPTimeline; import com.replaymod.simplepathing.SPTimeline.SPPath; -import com.replaymod.simplepathing.Setting; -import de.johni0702.minecraft.gui.GuiRenderer; -import de.johni0702.minecraft.gui.RenderInfo; import de.johni0702.minecraft.gui.container.GuiContainer; -import de.johni0702.minecraft.gui.container.GuiPanel; -import de.johni0702.minecraft.gui.container.GuiScreen; -import de.johni0702.minecraft.gui.element.GuiButton; -import de.johni0702.minecraft.gui.element.GuiElement; -import de.johni0702.minecraft.gui.element.GuiHorizontalScrollbar; import de.johni0702.minecraft.gui.element.GuiLabel; -import de.johni0702.minecraft.gui.element.GuiTooltip; import de.johni0702.minecraft.gui.element.advanced.GuiProgressBar; -import de.johni0702.minecraft.gui.element.advanced.GuiTimelineTime; -import de.johni0702.minecraft.gui.layout.CustomLayout; -import de.johni0702.minecraft.gui.layout.HorizontalLayout; -import de.johni0702.minecraft.gui.layout.VerticalLayout; import de.johni0702.minecraft.gui.popup.AbstractGuiPopup; -import de.johni0702.minecraft.gui.popup.GuiInfoPopup; import de.johni0702.minecraft.gui.popup.GuiYesNoPopup; import de.johni0702.minecraft.gui.utils.Colors; -import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension; -import de.johni0702.minecraft.gui.utils.lwjgl.ReadablePoint; -import de.johni0702.minecraft.gui.utils.lwjgl.WritablePoint; -import net.minecraft.client.resource.language.I18n; import net.minecraft.util.crash.CrashReport; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -//#if MC>=11400 -//#else -//$$ import org.lwjgl.input.Keyboard; -//#if MC>=10800 -//$$ import net.minecraftforge.fml.common.Loader; -//#else -//$$ import cpw.mods.fml.common.Loader; -//#endif -//#endif - import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.util.Collections; -import java.util.concurrent.CancellationException; import java.util.function.Consumer; import static com.replaymod.core.utils.Utils.error; -import static com.replaymod.core.versions.MCVer.*; import static com.replaymod.simplepathing.ReplayModSimplePathing.LOGGER; @@ -82,148 +47,12 @@ public class GuiPathing { private static final Logger logger = LogManager.getLogger(); - public final GuiButton playPauseButton = new GuiButton() { - @Override - public GuiElement getTooltip(RenderInfo renderInfo) { - GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); - if (tooltip != null) { - if (player.isActive()) { - tooltip.setI18nText("replaymod.gui.ingame.menu.pausepath"); - } else if (Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)) { - tooltip.setI18nText("replaymod.gui.ingame.menu.playpathfromstart"); - } else { - tooltip.setI18nText("replaymod.gui.ingame.menu.playpath"); - } - } - return tooltip; - } - }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); - - public final GuiButton renderButton = new GuiButton().onClick(new Runnable() { - @Override - public void run() { - abortPathPlayback(); - GuiScreen screen = GuiRenderSettings.createBaseScreen(); - new GuiRenderQueue(screen, replayHandler, () -> preparePathsForPlayback(false)) { - @Override - protected void close() { - super.close(); - getMinecraft().openScreen(null); - } - }.open(); - screen.display(); - } - }).setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setSpriteUV(40, 0) - .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.renderpath")); - - public final GuiButton positionKeyframeButton = new GuiButton() { - @Override - public GuiElement getTooltip(RenderInfo renderInfo) { - GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); - if (tooltip != null) { - String label; - if (getSpriteUV().getY() == 40) { // Add keyframe - if (getSpriteUV().getX() == 0) { // Position - label = "replaymod.gui.ingame.menu.addposkeyframe"; - } else { // Spectator - label = "replaymod.gui.ingame.menu.addspeckeyframe"; - } - } else { // Remove keyframe - if (getSpriteUV().getX() == 0) { // Position - label = "replaymod.gui.ingame.menu.removeposkeyframe"; - } else { // Spectator - label = "replaymod.gui.ingame.menu.removespeckeyframe"; - } - } - tooltip.setText(I18n.translate(label) + " (" + mod.keyPositionKeyframe.getBoundKey() + ")"); - } - return tooltip; - } - }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); - - public final GuiButton timeKeyframeButton = new GuiButton() { - @Override - public GuiElement getTooltip(RenderInfo renderInfo) { - GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); - if (tooltip != null) { - String label; - if (getSpriteUV().getY() == 80) { // Add time keyframe - label = "replaymod.gui.ingame.menu.addtimekeyframe"; - } else { // Remove time keyframe - label = "replaymod.gui.ingame.menu.removetimekeyframe"; - } - tooltip.setText(I18n.translate(label) + " (" + mod.keyTimeKeyframe.getBoundKey() + ")"); - } - return tooltip; - } - }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); - - public final GuiKeyframeTimeline timeline = new GuiKeyframeTimeline(this){ - @Override - public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { - if (player.isActive()) { - setCursorPosition((int) player.getTimePassed()).ensureCursorVisibleWithPadding(); - } - super.draw(renderer, size, renderInfo); - } - }.setSize(Integer.MAX_VALUE, 20).setMarkers(); - - public final GuiHorizontalScrollbar scrollbar = new GuiHorizontalScrollbar().setSize(Integer.MAX_VALUE, 9); - {scrollbar.onValueChanged(new Runnable() { - @Override - public void run() { - timeline.setOffset((int) (scrollbar.getPosition() * timeline.getLength())); - timeline.setZoom(scrollbar.getZoom()); - } - }).setZoom(0.1);} - - public final GuiTimelineTime timelineTime = new GuiTimelineTime() - .setTimeline(timeline); - - public final GuiButton zoomInButton = new GuiButton().setSize(9, 9).onClick(new Runnable() { - @Override - public void run() { - zoomTimeline(2d / 3d); - } - }).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setSpriteUV(40, 20) - .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.zoomin")); - - public final GuiButton zoomOutButton = new GuiButton().setSize(9, 9).onClick(new Runnable() { - @Override - public void run() { - zoomTimeline(3d / 2d); - } - }).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setSpriteUV(40, 30) - .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.zoomout")); - - public final GuiPanel zoomButtonPanel = new GuiPanel() - .setLayout(new VerticalLayout(VerticalLayout.Alignment.CENTER).setSpacing(2)) - .addElements(null, zoomInButton, zoomOutButton); - - public final GuiPanel timelinePanel = new GuiPanel().setSize(Integer.MAX_VALUE, 40) - .setLayout(new CustomLayout() { - @Override - protected void layout(GuiPanel container, int width, int height) { - pos(zoomButtonPanel, width - width(zoomButtonPanel), 10); - pos(timelineTime, 0, 2); - size(timelineTime, x(zoomButtonPanel), 8); - pos(timeline, 0, y(timelineTime) + height(timelineTime)); - size(timeline, x(zoomButtonPanel) - 2, 20); - pos(scrollbar, 0, y(timeline) + height(timeline) + 1); - size(scrollbar, x(zoomButtonPanel) - 2, 9); - } - }).addElements(null, timeline, timelineTime, scrollbar, zoomButtonPanel); - - public final GuiPanel panel = new GuiPanel() - .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER).setSpacing(5)) - .addElements(new HorizontalLayout.Data(0.5), - playPauseButton, renderButton, positionKeyframeButton, timeKeyframeButton, timelinePanel); - private final ReplayMod core; private final ReplayModSimplePathing mod; private final ReplayHandler replayHandler; public final GuiReplayOverlay overlay; - private final RealtimeTimelinePlayer player; + + public final GuiPathingKt kt; // Whether any error which occured during entity tracker loading has already been shown to the user private boolean errorShown; @@ -236,164 +65,13 @@ public GuiPathing(final ReplayMod core, final ReplayModSimplePathing mod, final this.mod = mod; this.replayHandler = replayHandler; this.overlay = replayHandler.getOverlay(); - this.player = new RealtimeTimelinePlayer(replayHandler); - - timeline.setLength(core.getSettingsRegistry().get(Setting.TIMELINE_LENGTH) * 1000); - - playPauseButton.setSpriteUV(new ReadablePoint() { - @Override - public int getX() { - return 0; - } - - @Override - public int getY() { - return player.isActive() ? 20 : 0; - } - - @Override - public void getLocation(WritablePoint dest) { - dest.setLocation(getX(), getY()); - } - }).onClick(new Runnable() { - @Override - public void run() { - if (player.isActive()) { - player.getFuture().cancel(false); - } else { - boolean ignoreTimeKeyframes = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT); - - Timeline timeline = preparePathsForPlayback(ignoreTimeKeyframes).okOrElse(err -> { - GuiInfoPopup.open(overlay, err); - return null; - }); - if (timeline == null) return; - - Path timePath = new SPTimeline(timeline).getTimePath(); - timePath.setActive(!ignoreTimeKeyframes); - - // Start from cursor time unless the control key is pressed (then start from beginning) - int startTime = Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)? 0 : GuiPathing.this.timeline.getCursorPosition(); - ListenableFuture future = player.start(timeline, startTime); - overlay.setCloseable(false); - overlay.setMouseVisible(true); - core.printInfoToChat("replaymod.chat.pathstarted"); - Futures.addCallback(future, new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - if (future.isCancelled()) { - core.printInfoToChat("replaymod.chat.pathinterrupted"); - } else { - core.printInfoToChat("replaymod.chat.pathfinished"); - } - overlay.setCloseable(true); - } - - @Override - public void onFailure(Throwable t) { - if (!(t instanceof CancellationException)) { - t.printStackTrace(); - } - overlay.setCloseable(true); - } - }); - } - } - }); - - positionKeyframeButton.setSpriteUV(new ReadablePoint() { - @Override - public int getX() { - SPPath keyframePath = mod.getSelectedPath(); - long keyframeTime = mod.getSelectedTime(); - if (keyframePath != SPPath.POSITION) { - // No keyframe or wrong path - keyframeTime = timeline.getCursorPosition(); - keyframePath = mod.getCurrentTimeline().isPositionKeyframe(keyframeTime) ? SPPath.POSITION : null; - } - if (keyframePath != SPPath.POSITION) { - return replayHandler.isCameraView() ? 0 : 40; - } else { - return mod.getCurrentTimeline().isSpectatorKeyframe(keyframeTime) ? 40 : 0; - } - } - - @Override - public int getY() { - SPPath keyframePath = mod.getSelectedPath(); - if (keyframePath != SPPath.POSITION) { - // No keyframe selected but there might be one at exactly the position of the cursor - keyframePath = mod.getCurrentTimeline().isPositionKeyframe(timeline.getCursorPosition()) ? SPPath.POSITION : null; - } - return keyframePath == SPPath.POSITION ? 60 : 40; - } - - @Override - public void getLocation(WritablePoint dest) { - dest.setLocation(getX(), getY()); - } - }).onClick(new Runnable() { - @Override - public void run() { - toggleKeyframe(SPPath.POSITION, false); - } - }); - - timeKeyframeButton.setSpriteUV(new ReadablePoint() { - @Override - public int getX() { - return 0; - } - - @Override - public int getY() { - SPPath keyframePath = mod.getSelectedPath(); - if (keyframePath != SPPath.TIME) { - // No keyframe selected but there might be one at exactly the position of the cursor - keyframePath = mod.getCurrentTimeline().isTimeKeyframe(timeline.getCursorPosition()) ? SPPath.TIME : null; - } - return keyframePath == SPPath.TIME ? 100 : 80; - } - - @Override - public void getLocation(WritablePoint dest) { - dest.setLocation(getX(), getY()); - } - }).onClick(new Runnable() { - @Override - public void run() { - toggleKeyframe(SPPath.TIME, false); - } - }); - - overlay.addElements(null, panel); - overlay.setLayout(new CustomLayout(overlay.getLayout()) { - @Override - protected void layout(GuiReplayOverlay container, int width, int height) { - checkForAutoSync(); - pos(panel, 10, y(overlay.topPanel) + height(overlay.topPanel) + 3); - size(panel, width - 20, 40); - } - }); + this.kt = new GuiPathingKt(this, replayHandler); startLoadingEntityTracker(); } - private void abortPathPlayback() { - if (!player.isActive()) { - return; - } - - ListenableFuture future = player.getFuture(); - if (!future.isDone() && !future.isCancelled()) { - future.cancel(false); - } - // Tear down of the player might only happen the next tick after it was cancelled - player.onTick(); - } - public void keyframeRepoButtonPressed() { - abortPathPlayback(); + kt.abortPathPlayback(); try { GuiKeyframeRepository gui = new GuiKeyframeRepository( mod.getCurrentTimeline(), replayHandler.getReplayFile(), mod.getCurrentTimeline().getTimeline()); @@ -429,62 +107,6 @@ public void clearKeyframesButtonPressed() { }); } - private int prevSpeed = -1; - private int prevTime = -1; - private void checkForAutoSync() { - if (!mod.keySyncTime.isAutoActivating()) { - prevSpeed = -1; - prevTime = -1; - return; - } - - int speed = overlay.speedSlider.getValue(); - if (prevSpeed != speed && prevSpeed != -1) { - syncTimeButtonPressed(); - } - prevSpeed = speed; - - int time = replayHandler.getReplaySender().currentTimeStamp(); - if (prevTime != time && prevTime != -1 && !player.isActive()) { - syncTimeButtonPressed(); - } - prevTime = time; - } - - public void syncTimeButtonPressed() { - // Current replay time - int time = replayHandler.getReplaySender().currentTimeStamp(); - // Position of the cursor - int cursor = timeline.getCursorPosition(); - // Get the last time keyframe before the cursor - mod.getCurrentTimeline().getTimePath().getKeyframes().stream() - .filter(it -> it.getTime() <= cursor).reduce((__, last) -> last).ifPresent(keyframe -> { - // Cursor position at the keyframe - int keyframeCursor = (int) keyframe.getTime(); - // Replay time at the keyframe - // This is a keyframe from the time path, so it _should_ always have a time property - int keyframeTime = keyframe.getValue(TimestampProperty.PROPERTY).get(); - // Replay time passed - int timePassed = time - keyframeTime; - // Speed (set to 1 when shift is held) - double speed = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) ? 1 : replayHandler.getOverlay().getSpeedSliderValue(); - // Cursor time passed - int cursorPassed = (int) (timePassed / speed); - // Move cursor to new position - timeline.setCursorPosition(keyframeCursor + cursorPassed).ensureCursorVisibleWithPadding(); - // Deselect keyframe to allow the user to add a new one right away - mod.setSelected(null, 0); - }); - } - - public boolean deleteButtonPressed() { - if (mod.getSelectedPath() != null) { - toggleKeyframe(mod.getSelectedPath(), false); - return true; - } - return false; - } - private void startLoadingEntityTracker() { Preconditions.checkState(entityTrackerFuture == null); // Start loading entity tracker @@ -514,7 +136,7 @@ private void startLoadingEntityTracker() { }).start(); } - private Result preparePathsForPlayback(boolean ignoreTimeKeyframes) { + Result preparePathsForPlayback(boolean ignoreTimeKeyframes) { SPTimeline spTimeline = mod.getCurrentTimeline(); String[] errors = validatePathsForPlayback(spTimeline, ignoreTimeKeyframes); @@ -569,10 +191,6 @@ private String[] validatePathsForPlayback(SPTimeline timeline, boolean ignoreTim return null; } - public void zoomTimeline(double factor) { - scrollbar.setZoom(scrollbar.getZoom() * factor); - } - public boolean loadEntityTracker(Runnable thenRun) { if (entityTracker == null && !errorShown) { LOGGER.debug("Entity tracker not yet loaded, delaying..."); @@ -611,65 +229,6 @@ public void onFailure(@Nonnull Throwable t) { return true; } - /** - * Called when either one of the property buttons is pressed. - * @param path {@code TIME} for the time property button, {@code POSITION} for the place property button - * @param neverSpectator when true, will insert a position keyframe even when currently spectating an entity - */ - public void toggleKeyframe(SPPath path, boolean neverSpectator) { - LOGGER.debug("Updating keyframe on path {}" + path); - if (!loadEntityTracker(() -> toggleKeyframe(path, neverSpectator))) return; - - int time = timeline.getCursorPosition(); - SPTimeline timeline = mod.getCurrentTimeline(); - - if (timeline.getPositionPath().getKeyframes().isEmpty() && - timeline.getTimePath().getKeyframes().isEmpty() && - time > 1000) { - String text = I18n.translate("replaymod.gui.ingame.first_keyframe_not_at_start_warning"); - GuiInfoPopup.open(overlay, text.split("\\\\n")); - } - - switch (path) { - case TIME: - if (mod.getSelectedPath() == path) { - LOGGER.debug("Selected keyframe is time keyframe -> removing keyframe"); - timeline.removeTimeKeyframe(mod.getSelectedTime()); - mod.setSelected(null, 0); - } else if (timeline.isTimeKeyframe(time)) { - LOGGER.debug("Keyframe at cursor position is time keyframe -> removing keyframe"); - timeline.removeTimeKeyframe(time); - mod.setSelected(null, 0); - } else { - LOGGER.debug("No time keyframe found -> adding new keyframe"); - timeline.addTimeKeyframe(time, replayHandler.getReplaySender().currentTimeStamp()); - mod.setSelected(path, time); - } - break; - case POSITION: - if (mod.getSelectedPath() == path) { - LOGGER.debug("Selected keyframe is position keyframe -> removing keyframe"); - timeline.removePositionKeyframe(mod.getSelectedTime()); - mod.setSelected(null, 0); - } else if (timeline.isPositionKeyframe(time)) { - LOGGER.debug("Keyframe at cursor position is position keyframe -> removing keyframe"); - timeline.removePositionKeyframe(time); - mod.setSelected(null, 0); - } else { - LOGGER.debug("No position keyframe found -> adding new keyframe"); - CameraEntity camera = replayHandler.getCameraEntity(); - int spectatedId = -1; - if (!replayHandler.isCameraView() && !neverSpectator) { - spectatedId = replayHandler.getOverlay().getMinecraft().getCameraEntity().getEntityId(); - } - timeline.addPositionKeyframe(time, camera.getX(), camera.getY(), camera.getZ(), - camera.yaw, camera.pitch, camera.roll, spectatedId); - mod.setSelected(path, time); - } - break; - } - } - public ReplayModSimplePathing getMod() { return mod; } diff --git a/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java b/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java index 50782934c..bc2b71104 100644 --- a/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java +++ b/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java @@ -180,7 +180,7 @@ private void renderCameraPath(MatrixStack matrixStack) { GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); GL11.glEnable(GL11.GL_DEPTH_TEST); - int time = guiPathing.timeline.getCursorPosition(); + long time = guiPathing.kt.getTimeline().getCursor().getPositionMillis(); Optional entityId = path.getValue(SpectatorProperty.PROPERTY, time); if (entityId.isPresent()) { // Spectating an entity @@ -266,7 +266,7 @@ private void drawPoint(Vector3f view, Vector3f pos, Keyframe keyframe) { float posY = 0f; float size = 10f / ReplayMod.TEXTURE_SIZE; - if (mod.isSelected(keyframe)) { + if (mod.isSelected(SPTimeline.SPPath.POSITION, keyframe.getTime())) { posY += size; } diff --git a/src/main/kotlin/com/replaymod/core/gui/common/GuiWindow.kt b/src/main/kotlin/com/replaymod/core/gui/common/GuiWindow.kt new file mode 100644 index 000000000..2e5d748ee --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/GuiWindow.kt @@ -0,0 +1,76 @@ +package com.replaymod.core.gui.common + +import de.johni0702.minecraft.gui.GuiRenderer +import de.johni0702.minecraft.gui.RenderInfo +import de.johni0702.minecraft.gui.container.GuiContainer +import de.johni0702.minecraft.gui.element.AbstractGuiElement +import de.johni0702.minecraft.gui.function.Clickable +import de.johni0702.minecraft.gui.function.Draggable +import de.johni0702.minecraft.gui.function.Scrollable +import de.johni0702.minecraft.gui.function.Typeable +import de.johni0702.minecraft.gui.utils.lwjgl.Dimension +import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension +import de.johni0702.minecraft.gui.utils.lwjgl.ReadablePoint +import gg.essential.elementa.components.Window +import gg.essential.universal.UMatrixStack + +class GuiWindow(parent: GuiContainer<*>, val window: Window) : AbstractGuiElement(parent), Clickable, Draggable, Scrollable, Typeable { + private var dragging = false + + override fun getThis(): GuiWindow = this + override fun calcMinSize(): ReadableDimension = Dimension() + + override fun draw(renderer: GuiRenderer, size: ReadableDimension, renderInfo: RenderInfo) { + window.draw(UMatrixStack()) + } + + private fun hasComponent(pos: ReadablePoint): Boolean = + window.hoveredFloatingComponent != null || window.hitTest(pos.x.toFloat(), pos.y.toFloat()) != window + + override fun mouseClick(position: ReadablePoint, button: Int): Boolean { + return if (hasComponent(position)) { + window.mouseClick(position.x.toDouble(), position.y.toDouble(), button) + true + } else { + false + }.also { dragging = it } + } + + override fun mouseDrag(position: ReadablePoint, button: Int, timeSinceLastCall: Long): Boolean { + return dragging + } + + override fun mouseRelease(position: ReadablePoint, button: Int): Boolean { + return if (dragging) { + dragging = false + window.mouseRelease() + true + } else { + false + } + } + + override fun scroll(position: ReadablePoint, dWheel: Int): Boolean { + return if (hasComponent(position)) { + window.mouseScroll(dWheel / 120.0) + true + } else { + false + } + } + + override fun typeKey( + mousePosition: ReadablePoint?, + keyCode: Int, + keyChar: Char, + ctrlDown: Boolean, + shiftDown: Boolean + ): Boolean { + return if (window.focusedComponent != null) { + window.keyType(keyChar, keyCode) + true + } else { + false + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UI4Slice.kt b/src/main/kotlin/com/replaymod/core/gui/common/UI4Slice.kt new file mode 100644 index 000000000..ddcac634b --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UI4Slice.kt @@ -0,0 +1,97 @@ +package com.replaymod.core.gui.common + +import com.replaymod.core.gui.common.UITexture.TextureData +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.shader.BlendState +import net.minecraft.util.Identifier +import java.awt.Color + +class UI4Slice( + private val texture: Identifier, + private var textureData: State, +) : UIComponent() { + + constructor(texture: Identifier, textureData: TextureData) : this(texture, BasicState(textureData)) + + fun bindTextureData(textureData: State) { + this.textureData = textureData + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft().toDouble() + val right = getRight().toDouble() + val top = getTop().toDouble() + val bottom = getBottom().toDouble() + + UGraphics.bindTexture(0, texture) + draw(matrixStack, left, right, top, bottom, getColor(), textureData.get()) + + super.draw(matrixStack) + } + + companion object { + fun draw( + matrixStack: UMatrixStack, + l: Double, + r: Double, + t: Double, + b: Double, + color: Color, + data: TextureData, + ) { + val oldBlendState = BlendState.active() + BlendState.NORMAL.activate() + + UGraphics.enableAlpha() + + val red = color.red.toFloat() / 255f + val green = color.green.toFloat() / 255f + val blue = color.blue.toFloat() / 255f + val alpha = color.alpha.toFloat() / 255f + val buffer = UGraphics.getFromTessellator() + + buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + + fun UGraphics.texS(u: Double, v: Double) = tex(u / data.textureWidth, v / data.textureHeight) + + fun drawTexturedRect(x: Double, y: Double, u: Double, v: Double, width: Double, height: Double) { + buffer.pos(matrixStack, x, y + height, 0.0) + .texS(u, v + height) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x + width, y + height, 0.0) + .texS(u + width, v + height) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x + width, y, 0.0) + .texS(u + width, v) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x, y, 0.0) + .texS(u, v) + .color(red, green, blue, alpha) + .endVertex() + } + + with(data) { + val wh = (r - l) / 2 + val hh = (b - t) / 2 + + drawTexturedRect(l, t, lt, tt, wh, hh) + drawTexturedRect(l, t + hh, lt, bt - hh, wh, hh) + drawTexturedRect(l + wh, t, rt - wh, tt, wh, hh) + drawTexturedRect(l + wh, t + hh, rt - wh, bt - hh, wh, hh) + } + + buffer.drawDirect() + + oldBlendState.activate() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UI9Slice.kt b/src/main/kotlin/com/replaymod/core/gui/common/UI9Slice.kt new file mode 100644 index 000000000..64cf0c581 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UI9Slice.kt @@ -0,0 +1,222 @@ +package com.replaymod.core.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.shader.BlendState +import net.minecraft.util.Identifier +import java.awt.Color + +class UI9Slice( + private val texture: Identifier, + private var textureData: State, +) : UIComponent() { + + constructor(texture: Identifier, textureData: TextureData) : this(texture, BasicState(textureData)) + + fun bindTextureData(textureData: State) { + this.textureData = textureData + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft().toDouble() + val right = getRight().toDouble() + val top = getTop().toDouble() + val bottom = getBottom().toDouble() + + UGraphics.bindTexture(0, texture) + draw(matrixStack, left, right, top, bottom, getColor(), textureData.get()) + + super.draw(matrixStack) + } + + companion object { + fun draw( + matrixStack: UMatrixStack, + l: Double, + r: Double, + t: Double, + b: Double, + color: Color, + data: TextureData, + ) { + val oldBlendState = BlendState.active() + BlendState.NORMAL.activate() + + UGraphics.enableAlpha() + + val red = color.red.toFloat() / 255f + val green = color.green.toFloat() / 255f + val blue = color.blue.toFloat() / 255f + val alpha = color.alpha.toFloat() / 255f + val buffer = UGraphics.getFromTessellator() + + buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + + fun UGraphics.texS(u: Double, v: Double) = tex(u / data.textureWidth, v / data.textureHeight) + + fun drawTexturedRect(x: Double, y: Double, u: Double, v: Double, width: Double, height: Double) { + buffer.pos(matrixStack, x, y + height, 0.0) + .texS(u, v + height) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x + width, y + height, 0.0) + .texS(u + width, v + height) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x + width, y, 0.0) + .texS(u + width, v) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, x, y, 0.0) + .texS(u, v) + .color(red, green, blue, alpha) + .endVertex() + } + + with(data) { + // Corners + run { + drawTexturedRect(l, t, lt, tt, ls, ts) // Top left + drawTexturedRect(r - rs, t, rt - rs, tt, rs, ts) // Top right + drawTexturedRect(l, b - bs, lt, bt - bs, ls, bs) // Bottom left + drawTexturedRect(r - rs, b - bs, rt - rs, bt - bs, rs, bs) // Bottom right + } + + // Top and bottom edge + run { + var x0 = l + ls + val xMax = r - rs + while (x0 < xMax) { + val w = (xMax - x0).coerceAtMost(ws) + drawTexturedRect(x0, t, lt + ls, tt, w, ts) // Top + drawTexturedRect(x0, b - bs, lt + ls, bt - bs, w, bs) // Bottom + x0 += w + } + } + + // Left and right edge + run { + var y0 = t + ts + val yMax = b - bs + while (y0 < yMax) { + val h = (yMax - y0).coerceAtMost(hs) + drawTexturedRect(l, y0, lt, tt + ts, ls, h) // Left + drawTexturedRect(r - rs, y0, rt - rs, tt + ts, rs, h) // Right + y0 += h + } + } + + // Center + run { + var x0 = l + ls + val xMax = r - rs + while (x0 < xMax) { + val w = (xMax - x0).coerceAtMost(ws) + + var y0 = t + ts + val yMax = b - bs + while (y0 < yMax) { + val h = (yMax - y0).coerceAtMost(hs) + drawTexturedRect(x0, y0, lt + ls, tt + ts, w, h) + y0 += h + } + + x0 += w + } + } + } + + buffer.drawDirect() + + oldBlendState.activate() + } + } + + data class TextureData( + val leftTexture: Double, + val topTexture: Double, + val rightTexture: Double, + val bottomTexture: Double, + val leftSlice: Double, + val topSlice: Double, + val rightSlice: Double, + val bottomSlice: Double, + val textureWidth: Int = 256, + val textureHeight: Int = 256, + ) { + val lt = leftTexture + val tt = topTexture + val rt = rightTexture + val bt = bottomTexture + val wt = rightTexture - leftTexture + val ht = bottomTexture - topTexture + val ls = leftSlice + val ts = topSlice + val rs = rightSlice + val bs = bottomSlice + val ws = wt - leftSlice - rightSlice + val hs = ht - leftSlice - bottomSlice + + fun offset(x: Int, y: Int) = offset(x.toDouble(), y.toDouble()) + + fun offset(x: Double, y: Double) = TextureData( + leftTexture + x, + topTexture + y, + rightTexture + x, + bottomTexture + y, + leftSlice, + topSlice, + rightSlice, + bottomSlice, + textureWidth, + textureHeight + ) + + companion object { + fun ofSize( + width: Int, + height: Int, + border: Int, + ) = ofSize(width, height, border, border, border, border) + + fun ofSize( + width: Int, + height: Int, + leftSlice: Int, + topSlice: Int, + rightSlice: Int, + bottomSlice: Int, + ) = ofSize( + width.toDouble(), + height.toDouble(), + leftSlice.toDouble(), + topSlice.toDouble(), + rightSlice.toDouble(), + bottomSlice.toDouble() + ) + + fun ofSize( + width: Double, + height: Double, + leftSlice: Double, + topSlice: Double, + rightSlice: Double, + bottomSlice: Double, + ) = TextureData( + 0.0, + 0.0, + width, + height, + leftSlice, + topSlice, + rightSlice, + bottomSlice + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UIButton.kt b/src/main/kotlin/com/replaymod/core/gui/common/UIButton.kt new file mode 100644 index 000000000..c3c42fd04 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UIButton.kt @@ -0,0 +1,135 @@ +package com.replaymod.core.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIImage +import gg.essential.elementa.components.UIText +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.utils.invisible +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import net.minecraft.client.MinecraftClient +import net.minecraft.client.sound.PositionedSoundInstance +import net.minecraft.util.Identifier +import java.awt.Color +import java.awt.image.BufferedImage +import java.util.concurrent.CompletableFuture + +class UIButton : UIComponent() { + + private val hovered = BasicState(false) + private val enabled = BasicState(true) + + private val background = enabled.zip(hovered).map { (enabled, hovered) -> + val offset = when { + !enabled -> 0 + hovered -> 2 + else -> 1 + } + UITexture.TextureData.ofSize(0, 46 + offset * 20, 200, 20) + } + + init { + onMouseEnter { hovered.set(true) } + onMouseLeave { hovered.set(false) } + + onMouseClick { + playClickSound() + } + + constrain { + width = 200.pixels + height = 20.pixels + color = Color.WHITE.invisible().toConstraint() + } + } + + fun label(text: String) = label { + bindText(BasicState(text)) + } + + fun label(configure: UIText.() -> Unit) = apply { + val component = UIText() childOf this + component.constrain { + x = CenterConstraint() + y = CenterConstraint() + color = enabled.zip(hovered).map { (enabled, hovered) -> when { + !enabled -> Color(160, 160, 160) + hovered -> Color(255, 255, 160) + else -> Color(224, 224, 224) + } }.toConstraint() + } + component.configure() + } + + fun image(imageFuture: CompletableFuture, configure: UIImage.() -> Unit = {}) = apply { + val component = UIImage(imageFuture) childOf this + component.constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent + height = 100.percent + } + component.configure() + } + + fun texture( + texture: Identifier, + data: UITexture.TextureData = UITexture.TextureData.full(), + configure: UITexture.() -> Unit = {}, + ) = texture(texture, BasicState(data), configure) + + fun texture(texture: Identifier, data: State, configure: UITexture.() -> Unit = {}) = apply { + val component = UITexture(texture, data) childOf this + component.constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent + height = 100.percent + } + component.configure() + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft().toDouble() + val right = getRight().toDouble() + val top = getTop().toDouble() + val bottom = getBottom().toDouble() + + UGraphics.bindTexture(0, WIDGETS_TEXTURE) + UI4Slice.draw(matrixStack, left, right, top, bottom, Color.WHITE, background.get()) + + UIBlock.drawBlock(matrixStack, getColor(), left + 1, top + 1, right - 1, bottom - 1) + + super.draw(matrixStack) + } + + companion object { + //#if MC>=10900 + private val BUTTON_SOUND = net.minecraft.sound.SoundEvents.UI_BUTTON_CLICK + //#else + //$$ private val BUTTON_SOUND = ResourceLocation("gui.button.press") + //#endif + + val WIDGETS_TEXTURE = Identifier("textures/gui/widgets.png") + + fun playClickSound() { + val mc = MinecraftClient.getInstance() + //#if MC>=11400 + mc.soundManager.play(PositionedSoundInstance.master(BUTTON_SOUND, 1.0f)) + //#elseif MC>=10904 + //$$ mc.getSoundHandler().playSound(PositionedSoundRecord.getMasterRecord(BUTTON_SOUND, 1.0F)); + //#elseif MC>=10800 + //$$ mc.getSoundHandler().playSound(PositionedSoundRecord.create(BUTTON_SOUND, 1.0F)); + //#else + //$$ mc.getSoundHandler().playSound(PositionedSoundRecord.createPositionedSoundRecord(BUTTON_SOUND, 1.0F)); + //#endif + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UICheckbox.kt b/src/main/kotlin/com/replaymod/core/gui/common/UICheckbox.kt new file mode 100644 index 000000000..ef78dd35a --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UICheckbox.kt @@ -0,0 +1,75 @@ +package com.replaymod.core.gui.common + +import com.replaymod.core.gui.utils.Resources +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.utils.invisible +import gg.essential.elementa.utils.withAlpha +import gg.essential.universal.UMatrixStack +import java.awt.Color + +class UICheckbox : UIComponent() { + + private val hovered = BasicState(false) + private val enabled = BasicState(true) + val checked = BasicState(false) + + var isChecked: Boolean + get() = checked.get() + set(value) = checked.set(value) + + private val checkmark by UITexture(Resources.icon("checkmark"), UITexture.TextureData.full()).constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 9.pixels + height = 9.pixels + color = checked.zip(enabled).map { (checked, enabled) -> + if (enabled) { + Color.WHITE.withAlpha(if (checked) 1f else 0f) + } else { + if (checked) Color.GRAY else Color.DARK_GRAY + } + }.toConstraint() + } childOf this + + init { + onMouseEnter { hovered.set(true) } + onMouseLeave { hovered.set(false) } + + onMouseClick { + if (!enabled.get()) return@onMouseClick + UIButton.playClickSound() + checked.set { !it } + } + + constrain { + width = 9.pixels + height = 9.pixels + color = Color.WHITE.invisible().toConstraint() + } + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft().toDouble() + val right = getRight().toDouble() + val top = getTop().toDouble() + val bottom = getBottom().toDouble() + + // Outline + val outlineColor = if (hovered.get() && enabled.get()) Color.WHITE else Color.BLACK + UIBlock.drawBlock(matrixStack, outlineColor, left, top, right, bottom) + + // Background + val backgroundColor = Color.DARK_GRAY.darker() + UIBlock.drawBlock(matrixStack, backgroundColor, left + 1, top + 1, right - 1, bottom - 1) + + // Checkmark + super.draw(matrixStack) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UITexture.kt b/src/main/kotlin/com/replaymod/core/gui/common/UITexture.kt new file mode 100644 index 000000000..f9c2a7ee8 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UITexture.kt @@ -0,0 +1,120 @@ +package com.replaymod.core.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.universal.UGraphics +import gg.essential.universal.UMatrixStack +import gg.essential.universal.shader.BlendState +import net.minecraft.util.Identifier +import java.awt.Color + +class UITexture( + private val texture: Identifier, + private var textureData: State, +) : UIComponent() { + + constructor(texture: Identifier, textureData: TextureData) : this(texture, BasicState(textureData)) + + fun bindTextureData(textureData: State) { + this.textureData = textureData + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft().toDouble() + val right = getRight().toDouble() + val top = getTop().toDouble() + val bottom = getBottom().toDouble() + + UGraphics.bindTexture(0, texture) + draw(matrixStack, left, right, top, bottom, getColor(), textureData.get()) + + super.draw(matrixStack) + } + + companion object { + fun draw( + matrixStack: UMatrixStack, + l: Double, + r: Double, + t: Double, + b: Double, + color: Color, + data: TextureData, + ) { + val oldBlendState = BlendState.active() + BlendState.NORMAL.activate() + + UGraphics.enableAlpha() + + val buffer = UGraphics.getFromTessellator() + buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR) + with(data) { + with(color) { + fun UGraphics.texS(u: Double, v: Double) = tex(u / textureWidth, v / textureHeight) + + buffer.pos(matrixStack, l, b, 0.0) + .texS(lt, bt) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, r, b, 0.0) + .texS(rt, bt) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, r, t, 0.0) + .texS(rt, tt) + .color(red, green, blue, alpha) + .endVertex() + buffer.pos(matrixStack, l, t, 0.0) + .texS(lt, tt) + .color(red, green, blue, alpha) + .endVertex() + } + } + buffer.drawDirect() + + oldBlendState.activate() + } + } + + data class TextureData( + val leftTexture: Double, + val topTexture: Double, + val rightTexture: Double, + val bottomTexture: Double, + val textureWidth: Int = 256, + val textureHeight: Int = 256, + ) { + val lt = leftTexture + val tt = topTexture + val rt = rightTexture + val bt = bottomTexture + + fun offset(x: Int, y: Int) = offset(x.toDouble(), y.toDouble()) + + fun offset(x: Double, y: Double) = TextureData( + leftTexture + x, + topTexture + y, + rightTexture + x, + bottomTexture + y, + textureWidth, + textureHeight + ) + + companion object { + fun full() = ofSize(256, 256) + + fun ofSize(width: Int, height: Int) = ofSize(0, 0, width, height) + + fun ofSize(width: Double, height: Double) = ofSize(0.0, 0.0, width, height) + + fun ofSize(left: Int, top: Int, width: Int, height: Int) = + ofSize(left.toDouble(), top.toDouble(), width.toDouble(), height.toDouble()) + + fun ofSize(left: Double, top: Double, width: Double, height: Double) = + TextureData(left, top, left + width, top + height) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/UITooltip.kt b/src/main/kotlin/com/replaymod/core/gui/common/UITooltip.kt new file mode 100644 index 000000000..4efc170c1 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/UITooltip.kt @@ -0,0 +1,67 @@ +package com.replaymod.core.gui.common + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.GradientComponent.Companion.drawGradientBlock +import gg.essential.elementa.components.GradientComponent.GradientDirection.TOP_TO_BOTTOM +import gg.essential.elementa.components.UIBlock.Companion.drawBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIText +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.dsl.* +import gg.essential.universal.UMatrixStack +import java.awt.Color + +class UITooltip : UIComponent() { + + init { + constrain { + width = ChildBasedMaxSizeConstraint() + 8.pixels + height = ChildBasedSizeConstraint() + 8.pixels + } + } + + val content by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = ChildBasedMaxSizeConstraint() + height = ChildBasedSizeConstraint() + } childOf this + + fun addLine(text: String = "", configure: UIText.() -> Unit = {}) = apply { + val component = UIText(text).constrain { + y = SiblingConstraint(padding = 3f) + } childOf content + component.configure() + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val l = getLeft().toDouble() + val r = getRight().toDouble() + val t = getTop().toDouble() + val b = getBottom().toDouble() + + // Draw background + drawBlock(matrixStack, BACKGROUND_COLOR, l + 1, t, r - 1, b) // Top to bottom + drawBlock(matrixStack, BACKGROUND_COLOR, l, t + 1, l + 1, b - 1) // Left pixel row + drawBlock(matrixStack, BACKGROUND_COLOR, r - 1, t + 1, r, b - 1) // Right pixel row + + // Draw the border, it gets darker from top to bottom + drawBlock(matrixStack, BORDER_LIGHT, l + 1, t + 1, r - 1, t + 2) // Top border + drawBlock(matrixStack, BORDER_DARK, l + 1, b - 2, r - 1, b - 1) // Bottom border + drawGradientBlock(matrixStack, l + 1, t + 2, l + 2, b - 2, BORDER_LIGHT, BORDER_DARK, TOP_TO_BOTTOM) // Left border + drawGradientBlock(matrixStack, r - 2, t + 2, r - 1, b - 2, BORDER_LIGHT, BORDER_DARK, TOP_TO_BOTTOM) // Right border + + super.draw(matrixStack) + } + + companion object { + private val BACKGROUND_COLOR = Color(16, 0, 16, 240) + private val BORDER_LIGHT = Color(80, 0, 255, 80) + private val BORDER_DARK = Color(40, 0, 127, 80) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/elementa/AbstractTextInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/elementa/AbstractTextInput.kt new file mode 100644 index 000000000..9a27b9d88 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/elementa/AbstractTextInput.kt @@ -0,0 +1,1014 @@ +/** + * Based on Elementa's AbstractTextInput but with more stuff exposed and certain changes so we can get it to behave just + * like we need it to: + * - Add `isCursorAtAbsoluteStart` + * - Remove the `private` from TextOperation implementations' fields + * - Remove the `protected` from `activateAction` + * + * MIT License + * + * Copyright (c) 2021 ReplayMod contributors + * Copyright (c) 2021 Sk1er LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.replaymod.core.gui.common.elementa + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.elementa.utils.getStringSplitToWidth +import gg.essential.universal.UDesktop +import gg.essential.universal.UKeyboard +import gg.essential.universal.UMatrixStack +import net.minecraft.SharedConstants +import java.awt.Color +import java.util.* +import kotlin.math.abs + +abstract class AbstractTextInput( + var placeholder: String, + var shadow: Boolean, + protected val selectionBackgroundColor: Color, + protected val selectionForegroundColor: Color, + protected val allowInactiveSelection: Boolean, + protected val inactiveSelectionBackgroundColor: Color, + protected val inactiveSelectionForegroundColor: Color, + protected val cursorColor: Color +) : UIComponent() { + protected var active = false + var lineHeight = 9f + set(value) { + cursorComponent.setHeight(value.pixels()) + this.setHeight(value.pixels()) + field = value + } + protected var updateAction: (text: String) -> Unit = {} + var activateAction: (text: String) -> Unit = {} + + protected val textualLines = mutableListOf(TextualLine("", 0..0)) + protected val visualLines = mutableListOf(VisualLine("", 0)) + + protected var verticalScrollingOffset = 0f + protected var targetVerticalScrollingOffset = 0f + protected var horizontalScrollingOffset = 0f + protected var cursorNeedsRefocus = false + + protected var lastSelectionMoveTimestamp = System.currentTimeMillis() + protected var selectionMode = SelectionMode.None + protected var initiallySelectedLine = -1 + protected var initiallySelectedWord = LinePosition(0, 0, true) to LinePosition(0, 0, true) + + protected val undoStack = ArrayDeque() + protected val redoStack = ArrayDeque() + + protected var cursorComponent: UIComponent = UIBlock(Color(255, 255, 255, 0)).constrain { + y = CenterConstraint() - 0.5f.pixels() + width = 1.pixel() + height = lineHeight.pixels() + } childOf this + + protected var cursor = LinePosition(0, 0, isVisual = true) + set(value) { + field = value.toVisualPos() + } + + protected var otherSelectionEnd = LinePosition(0, 0, isVisual = true) + set(value) { + field = value.toVisualPos() + } + + enum class SelectionMode { + None, + Character, + Word, + Line, + } + + init { + + setHeight(lineHeight.pixels()) + + onKeyType { typedChar, keyCode -> + if (!active) return@onKeyType + + if (keyCode == UKeyboard.KEY_ESCAPE) { + releaseWindowFocus() + } else if (UKeyboard.isKeyComboCtrlA(keyCode)) { + selectAll() + } else if (UKeyboard.isKeyComboCtrlC(keyCode) && hasSelection()) { + copySelection() + } else if (UKeyboard.isKeyComboCtrlX(keyCode) && hasSelection()) { + copySelection() + deleteSelection() + } else if (UKeyboard.isKeyComboCtrlV(keyCode)) { + commitTextAddition(UDesktop.getClipboardString()) + } else if (UKeyboard.isKeyComboCtrlZ(keyCode)) { + if (undoStack.isEmpty()) + return@onKeyType + val operationToUndo = undoStack.pop() + operationToUndo.undo() + redoStack.push(operationToUndo) + } else if (UKeyboard.isKeyComboCtrlShiftZ(keyCode) || UKeyboard.isKeyComboCtrlY(keyCode)) { + if (redoStack.isEmpty()) + return@onKeyType + val operationToRedo = redoStack.pop() + operationToRedo.redo() + undoStack.push(operationToRedo) + } else if (SharedConstants.isValidChar(typedChar)) { // Most of the ASCII characters + commitTextAddition(typedChar.toString()) + } else if (keyCode == UKeyboard.KEY_LEFT) { + val holdingShift = UKeyboard.isShiftKeyDown() + val holdingCtrl = UKeyboard.isCtrlKeyDown() + + val newCursorPosition = when { + holdingCtrl -> getNearestWordBoundary(cursor, Direction.Left) + hasSelection() -> if (holdingShift) cursor.offsetColumn(-1) else selectionStart() + else -> cursor.offsetColumn(-1) + } + + if (!holdingShift) { + setCursorPosition(newCursorPosition) + return@onKeyType + } + + cursor = newCursorPosition + cursorNeedsRefocus = true + } else if (keyCode == UKeyboard.KEY_RIGHT) { + val holdingShift = UKeyboard.isShiftKeyDown() + val holdingCtrl = UKeyboard.isCtrlKeyDown() + + val newCursorPosition = when { + holdingCtrl -> getNearestWordBoundary(cursor, Direction.Right) + hasSelection() -> if (holdingShift) cursor.offsetColumn(1) else selectionEnd() + else -> cursor.offsetColumn(1) + } + + if (!holdingShift) { + setCursorPosition(newCursorPosition) + return@onKeyType + } + + cursor = newCursorPosition + cursorNeedsRefocus = true + } else if (keyCode == UKeyboard.KEY_UP) { + val newVisualPos = if (cursor.line == 0) { + LinePosition(0, 0, isVisual = true) + } else { + val (currX, currY) = cursor.toScreenPos() + screenPosToVisualPos(currX, currY - lineHeight) + } + + if (UKeyboard.isShiftKeyDown()) { + cursor = newVisualPos + cursorNeedsRefocus = true + } else { + setCursorPosition(newVisualPos) + } + } else if (keyCode == UKeyboard.KEY_DOWN) { + val newVisualPos = if (cursor.line == visualLines.lastIndex) { + LinePosition(visualLines.lastIndex, visualLines.last().length, isVisual = true) + } else { + val (currX, currY) = cursor.toScreenPos() + screenPosToVisualPos(currX, currY + lineHeight) + } + + if (UKeyboard.isShiftKeyDown()) { + cursor = newVisualPos + cursorNeedsRefocus = true + } else { + setCursorPosition(newVisualPos) + } + } else if (keyCode == UKeyboard.KEY_BACKSPACE) { + if (hasSelection()) { + deleteSelection() + } else if (!cursor.isAtAbsoluteStart) { + val startPos = if (UKeyboard.isCtrlKeyDown()) { + getNearestWordBoundary(cursor, Direction.Left) + } else cursor.offsetColumn(-1).toTextualPos() + val endPos = cursor.toTextualPos() + + commitTextRemoval(startPos, endPos, selectAfterUndo = false) + } + } else if (keyCode == UKeyboard.KEY_DELETE) { + if (hasSelection()) { + deleteSelection() + } else if (!cursor.isAtAbsoluteEnd) { + val startPos = cursor.toTextualPos() + val endPos = if (UKeyboard.isCtrlKeyDown()) { + getNearestWordBoundary(cursor, Direction.Right) + } else cursor.offsetColumn(1).toTextualPos() + + commitTextRemoval(startPos, endPos, selectAfterUndo = false) + } + } else if (keyCode == UKeyboard.KEY_HOME) { + if (UKeyboard.isShiftKeyDown()) { + cursor = cursor.withColumn(0) + cursorNeedsRefocus = true + } else { + setCursorPosition(cursor.withColumn(0)) + } + } else if (keyCode == UKeyboard.KEY_END) { + cursor.withColumn(visualLines[cursor.line].length).also { + if (UKeyboard.isShiftKeyDown()) { + cursor = it + cursorNeedsRefocus = true + } else { + setCursorPosition(it) + } + } + } else if (keyCode == UKeyboard.KEY_ENTER) { // Enter + onEnterPressed() + } + } + + onMouseScroll { + val heightDifference = getHeight() - visualLines.size * lineHeight + if (heightDifference > 0) + return@onMouseScroll + targetVerticalScrollingOffset = + (targetVerticalScrollingOffset + it.delta.toFloat() * lineHeight).coerceIn(heightDifference, 0f) + it.stopPropagation() + } + + onMouseClick { event -> + if (!active || event.mouseButton != 0) + return@onMouseClick + + val clickedVisualPos = screenPosToVisualPos(event.relativeX, event.relativeY) + + var clickCount = event.clickCount % 3 + if (clickCount == 0 && clickedVisualPos.line != cursor.line) + clickCount = 1 + else if (clickCount == 2 && cursor != clickedVisualPos) + clickCount = 1 + + when (clickCount) { + 0 -> { + selectionMode = SelectionMode.Line + otherSelectionEnd = clickedVisualPos.withColumn(visualLines[cursor.line].length) + initiallySelectedLine = cursor.line + } + 1 -> { + selectionMode = SelectionMode.Character + setCursorPosition(clickedVisualPos) + } + 2 -> { + selectionMode = SelectionMode.Word + cursor = getNearestWordBoundary( + clickedVisualPos, + Direction.Left + ) + cursorNeedsRefocus = true + otherSelectionEnd = getNearestWordBoundary( + clickedVisualPos, + Direction.Right + ) + initiallySelectedWord = cursor to otherSelectionEnd + } + } + } + + onMouseDrag { mouseX, mouseY, mouseButton -> + if (mouseButton != 0 || selectionMode == SelectionMode.None) + return@onMouseDrag + + val draggedVisualPos = screenPosToVisualPos(mouseX, mouseY) + + when (selectionMode) { + SelectionMode.Character -> otherSelectionEnd = draggedVisualPos + SelectionMode.Line -> if (initiallySelectedLine < draggedVisualPos.line) { + cursor = LinePosition(initiallySelectedLine, 0, isVisual = true) + otherSelectionEnd = draggedVisualPos.withColumn(visualLines[draggedVisualPos.line].length) + } else { + cursor = draggedVisualPos.withColumn(0) + otherSelectionEnd = LinePosition( + initiallySelectedLine, + visualLines[initiallySelectedLine].length, + isVisual = true + ) + } + SelectionMode.Word -> when { + draggedVisualPos < initiallySelectedWord.first -> { + cursor = getNearestWordBoundary( + draggedVisualPos, + Direction.Left + ) + otherSelectionEnd = initiallySelectedWord.second + } + draggedVisualPos > initiallySelectedWord.second -> { + cursor = initiallySelectedWord.first + otherSelectionEnd = getNearestWordBoundary( + draggedVisualPos, + Direction.Right + ) + } + else -> { + cursor = initiallySelectedWord.first + otherSelectionEnd = initiallySelectedWord.second + } + } + SelectionMode.None -> { + } + } + + val currentTime = System.currentTimeMillis() + if (currentTime - lastSelectionMoveTimestamp > 50) { + if (mouseY <= 0) { + targetVerticalScrollingOffset = + (targetVerticalScrollingOffset + lineHeight * getTextScale()).coerceAtMost(0f) + lastSelectionMoveTimestamp = currentTime + } else if (mouseY >= getHeight()) { + val heightDifference = getHeight() - visualLines.size * lineHeight * getTextScale() + targetVerticalScrollingOffset = + (targetVerticalScrollingOffset - lineHeight * getTextScale()).coerceIn(0f, heightDifference) + lastSelectionMoveTimestamp = currentTime + } else if (mouseX <= 0) { + scrollIntoView(draggedVisualPos.offsetColumn(-1)) + lastSelectionMoveTimestamp = currentTime + } else if (mouseX >= getWidth()) { + scrollIntoView(draggedVisualPos.offsetColumn(1)) + lastSelectionMoveTimestamp = currentTime + } + } + } + + onMouseRelease { + selectionMode = SelectionMode.None + } + + onFocus { + setActive(true) + } + + onFocusLost { + setActive(false) + } + + cursorComponent.animateAfterUnhide { + setColorAnimation(Animations.OUT_CIRCULAR, 0.5f, cursorColor.toConstraint()) + onComplete { + if (!active) return@onComplete + cursorComponent.animate { + setColorAnimation(Animations.IN_CIRCULAR, 0.5f, Color(255, 255, 255, 0).toConstraint()) + onComplete { + if (active) animateCursor() + } + } + } + } + + enableEffect(ScissorEffect()) + } + + override fun draw(matrixStack: UMatrixStack) { + cursorComponent.setHeight( + (lineHeight * getTextScale()).pixels() + ) + super.draw(matrixStack) + } + + abstract fun getText(): String + fun setText(text: String) { + val absoluteStart = LinePosition(0, 0, isVisual = true) + val replaceTextOperation = ReplaceTextOperation( + AddTextOperation(text, absoluteStart), + RemoveTextOperation( + absoluteStart, + LinePosition(visualLines.lastIndex, visualLines.last().length, isVisual = true), + selectAfterUndo = true + ) + ) + commitTextOperation(replaceTextOperation) + } + + protected abstract fun scrollIntoView(pos: LinePosition) + protected abstract fun screenPosToVisualPos(x: Float, y: Float): LinePosition + protected abstract fun recalculateDimensions() + protected abstract fun textToLines(text: String): List + protected abstract fun onEnterPressed() + + fun setActive(isActive: Boolean) = apply { + active = isActive + + if (isActive) { + cursorComponent.unhide() + animateCursor() + } else { + cursorComponent.setColor(Color(255, 255, 255, 0).toConstraint()) + if (hasText() && (!allowInactiveSelection || !hasSelection())) { + setCursorPosition(LinePosition(visualLines.lastIndex, visualLines.last().length, isVisual = true)) + } + } + } + + fun isActive() = active + + fun onUpdate(listener: (text: String) -> Unit) = apply { + updateAction = listener + } + + fun onActivate(listener: (text: String) -> Unit) = apply { + activateAction = listener + } + + protected open fun commitTextOperation(operation: TextOperation) { + operation.redo() + undoStack.push(operation) + redoStack.clear() + } + + protected fun commitTextAddition(newText: String) { + val addTextOperation = AddTextOperation(newText, cursor) + + if (hasSelection()) { + val removeTextOperation = RemoveTextOperation(selectionStart(), selectionEnd(), selectAfterUndo = true) + val replaceTextOperation = ReplaceTextOperation(addTextOperation, removeTextOperation) + commitTextOperation(replaceTextOperation) + return + } + + commitTextOperation(addTextOperation) + } + + protected fun addText(newText: String, position: LinePosition) { + val textPos = position.toTextualPos() + val textualLine = textualLines[textPos.line] + + val lines = textToLines(newText) + when { + lines.isEmpty() -> { + return + } + lines.size == 1 -> { + textualLine.addTextAt(lines.first(), textPos.column) + } + else -> { + val newTextualLines = lines.drop(1).map { TextualLine(it) } + + if (textPos.column < textualLine.text.length) { + val textAfterInsertion = textualLine.text.substring(textPos.column) + textualLine.text = textualLine.text.substring(0, textPos.column) + lines.first() + newTextualLines.last().text += textAfterInsertion + } else { + textualLine.addTextAt(lines.first(), textPos.column) + } + + textualLines.addAll(textPos.line + 1, newTextualLines) + } + } + + recalculateAllVisualLines() + setCursorPosition(textPos.offsetColumn(newText.length).toVisualPos()) + + updateAction(getText()) + } + + protected open fun recalculateVisualLinesFor(textualLineIndex: Int) { + val textualLine = textualLines[textualLineIndex] + val firstVisualIndex = textualLine.visualIndices.first + repeat(textualLine.visualIndices.count()) { + if (firstVisualIndex < visualLines.size) + visualLines.removeAt(firstVisualIndex) + } + val splitLines = splitTextForWrapping(textualLine.text, getWidth()) + + visualLines.addAll(firstVisualIndex, splitLines.map { VisualLine(it, textualLineIndex) }) + textualLine.visualIndices = firstVisualIndex until firstVisualIndex + splitLines.size + } + + // TODO: This probably isn't necessary. Remove when feeling not lazy :) + protected open fun recalculateAllVisualLines() { + visualLines.clear() + + for ((index, textualLine) in textualLines.withIndex()) { + val splitLines = splitTextForWrapping(textualLine.text, getWidth()) + textualLine.visualIndices = visualLines.size..visualLines.size + splitLines.size + visualLines.addAll(splitLines.map { VisualLine(it, index) }) + } + } + + // TODO: Look into optimization of this algorithm + protected open fun splitTextForWrapping(text: String, maxLineWidth: Float): List { + return getStringSplitToWidth(text, maxLineWidth, getTextScale(), processColorCodes = false) + } + + protected fun commitTextRemoval(startPos: LinePosition, endPos: LinePosition, selectAfterUndo: Boolean) { + val removeTextOperation = RemoveTextOperation(startPos, endPos, selectAfterUndo) + commitTextOperation(removeTextOperation) + } + + private fun removeText(startPos: LinePosition, endPos: LinePosition) { + val textualStartPos = startPos.toTextualPos() + val textualEndPos = endPos.toTextualPos() + + val startTextualLine = textualLines[textualStartPos.line] + val endTextualLine = textualLines[textualEndPos.line] + + startTextualLine.text = startTextualLine.text.substring( + 0, + textualStartPos.column + ) + endTextualLine.text.substring(textualEndPos.column) + + val firstItemToDelete = textualStartPos.line + 1 + repeat(textualEndPos.line - firstItemToDelete + 1) { + textualLines.removeAt(firstItemToDelete) + } + + recalculateAllVisualLines() + + val heightDifference = getHeight() - visualLines.size * lineHeight + if (verticalScrollingOffset < heightDifference) + targetVerticalScrollingOffset = heightDifference.coerceAtMost(0f) + + updateAction(getText()) + } + + private fun setCursorPosition(newPosition: LinePosition) { + newPosition.toVisualPos().run { + cursor = this + otherSelectionEnd = this + cursorNeedsRefocus = true + } + } + + protected open fun getTextBetween(startPos: LinePosition, endPos: LinePosition): String { + val textStart = startPos.toTextualPos() + val textEnd = endPos.toTextualPos() + + return if (textStart.line == textEnd.line) { + textualLines[textStart.line].text.substring(textStart.column, textEnd.column) + } else { + val lines = mutableListOf() + lines.add(textualLines[textStart.line].text.substring(textStart.column)) + + for (i in textStart.line + 1 until textEnd.line) + lines.add(textualLines[i].text) + + lines.add(textualLines[textEnd.line].text.substring(0, textEnd.column)) + lines.joinToString("\n") + } + } + + protected open fun selectAll() { + cursor = LinePosition(0, 0, isVisual = true) + otherSelectionEnd = LinePosition(visualLines.size - 1, visualLines.last().length, isVisual = true) + } + + protected open fun hasSelection() = cursor != otherSelectionEnd + protected open fun selectionStart() = minOf(cursor, otherSelectionEnd) + protected open fun selectionEnd() = maxOf(cursor, otherSelectionEnd) + protected open fun getSelection() = selectionStart() to selectionEnd() + + protected open fun deleteSelection() { + if (!hasSelection()) + return + + commitTextRemoval(selectionStart(), selectionEnd(), selectAfterUndo = true) + } + + protected open fun copySelection() { + val (visualSelectionStart, visualSelectionEnd) = getSelection() + if (visualSelectionStart == visualSelectionEnd) + return + + UDesktop.setClipboardString(getTextBetween(visualSelectionStart, visualSelectionEnd)) + } + + protected open fun charBefore(pos: LinePosition) = pos.toTextualPos().let { + when { + it.isAtAbsoluteStart -> null + it.isAtLineStart -> '\n' + else -> textualLines[it.line].text[it.column - 1] + } + } + + protected open fun charAfter(pos: LinePosition) = pos.toTextualPos().let { + when { + it.isAtAbsoluteEnd -> null + it.isAtLineEnd -> '\n' + else -> textualLines[it.line].text[it.column] + } + } + + enum class Direction { + Left, + Right + } + + protected open fun isBreakingCharacter(ch: Char): Boolean { + return !ch.isLetterOrDigit() && ch != '_' + } + + protected open fun getNearestWordBoundary(pos: LinePosition, direction: Direction): LinePosition { + /* + * Algorithm: + * 1. If we can't go further in the specified direction, return pos + * 2. First, ignore all breaking characters until a non-breaking character is found + * or the beginning is reached + * 3. Consume until a breaking character is found or the beginning is reached + * 4. If our direction is left and we are at the end of a visual line, and we are not + * at the last line, return the position at the beginning of the next visual line + * 5. if our direction is right and we are at the beginning of a visual + * line, and we are not the first line, return the position at the end of the + * last visual line + * 6. Return the position + * + * Other conditions: + * - If a newline is encountered, one of the following actions happens: + * - If this is the first character, return the position past that newline + * - Otherwise, return the position before that newline + * - If our direction is left and we are at the end of a visual line, and we are not + * at the last line, return the position at the beginning of the next visual line + * - If our direction is right and we are at the beginning of a visual line, and we + * are not the first line, return the position at the end of the last visual line + */ + + // Step 1 + val atEndOfDirection = if (direction == Direction.Left) pos::isAtAbsoluteStart else pos::isAtAbsoluteEnd + if (atEndOfDirection()) + return pos + + var textualPos = pos.toTextualPos() + val columnOffset = if (direction == Direction.Left) -1 else 1 + val nextChar = if (direction == Direction.Left) ::charBefore else ::charAfter + + if (direction == Direction.Left && textualPos.isAtLineStart) { + val previousLine = textualLines[textualPos.line - 1] + return LinePosition(textualPos.line - 1, previousLine.length, isVisual = false) + } else if (direction == Direction.Right && textualPos.isAtLineEnd) { + return LinePosition(textualPos.line + 1, 0, isVisual = false) + } + + var ch = nextChar(textualPos) + + // Step 2 + while (!atEndOfDirection() && ch?.let(::isBreakingCharacter) == true) { + textualPos = textualPos.offsetColumn(columnOffset) + ch = nextChar(textualPos) + if (ch == '\n') + return textualPos + } + + // Step 3 + while (!atEndOfDirection() && ch?.let(::isBreakingCharacter) == false) { + textualPos = textualPos.offsetColumn(columnOffset) + ch = nextChar(textualPos) + if (ch == '\n') + return textualPos + } + + // Note that if we go into either of the if cases below, we will end up returning + // a visual position rather than a textual position. This is intentional, as a + // textual position cannot distinguish a visual end of line from a visual start + // of line (in other words, if you call `.toTextualPos()` on a visual EOL on line 5 + // and on a visual start of line on line 6, they will give you the same position) + // + // In order to distinguish this, if either of these are true, we have to return a + // visual position. Fortunately, because of the way we handle visual vs textual + // lines, this does not cause a problem + val visualPos = textualPos.toVisualPos() + if (direction == Direction.Left && visualPos.isAtLineEnd && !visualPos.isInLastLine) { // Step 4 + textualPos = LinePosition(visualPos.line + 1, 0, isVisual = true) + } else if (direction == Direction.Right && visualPos.isAtLineStart && !visualPos.isInFirstLine) { // Step 5 + textualPos = LinePosition(visualPos.line - 1, visualLines[visualPos.line - 1].text.length, isVisual = true) + } + + // Step 6 + return textualPos + } + + protected open fun animateCursor() { + if (!active) return + + cursorComponent.animate { + setColorAnimation(Animations.OUT_CIRCULAR, 0.5f, cursorColor.toConstraint()) + onComplete { + if (!active) return@onComplete + cursorComponent.animate { + setColorAnimation(Animations.IN_CIRCULAR, 0.5f, Color(255, 255, 255, 0).toConstraint()) + onComplete { + if (active) animateCursor() + } + } + } + } + } + + protected open fun hasText() = textualLines.size > 1 || textualLines[0].text.isNotEmpty() + + @Deprecated(UMatrixStack.Compat.DEPRECATED, ReplaceWith("drawUnselectedText(matrixStack, text, left, row)")) + protected open fun drawUnselectedText(text: String, left: Float, row: Int) = + drawUnselectedText(UMatrixStack.Compat.get(), text, left, row) + + @Suppress("DEPRECATION") + protected fun drawUnselectedTextCompat(matrixStack: UMatrixStack, text: String, left: Float, row: Int) = + UMatrixStack.Compat.runLegacyMethod(matrixStack) { drawUnselectedText(text, left, row) } + + protected open fun drawUnselectedText(matrixStack: UMatrixStack, text: String, left: Float, row: Int) { + // TODO: Shadow color + getFontProvider().drawString( + matrixStack, + text, + getColor(), + left - horizontalScrollingOffset, + getTop() + ((lineHeight * row + 1) * getTextScale()) + verticalScrollingOffset, + 10f, + getTextScale(), + shadow = false + ) + } + + @Deprecated(UMatrixStack.Compat.DEPRECATED, ReplaceWith("drawSelectedText(matrixStack, text, left, right, row)")) + protected open fun drawSelectedText(text: String, left: Float, right: Float, row: Int) = + drawSelectedText(UMatrixStack.Compat.get(), text, left, right, row) + + @Suppress("DEPRECATION") + protected fun drawSelectedTextCompat(matrixStack: UMatrixStack, text: String, left: Float, right: Float, row: Int) = + UMatrixStack.Compat.runLegacyMethod(matrixStack) { drawSelectedText(text, left, right, row) } + + protected open fun drawSelectedText(matrixStack: UMatrixStack, text: String, left: Float, right: Float, row: Int) { + UIBlock.drawBlock( + matrixStack, + if (active) selectionBackgroundColor else inactiveSelectionBackgroundColor, + left.toDouble() - horizontalScrollingOffset, + getTop().toDouble() + (lineHeight * row * getTextScale()) + verticalScrollingOffset, + right.toDouble() - horizontalScrollingOffset, + getTop().toDouble() + (lineHeight * ((row + 1) * getTextScale())) + verticalScrollingOffset + ) + if (text.isNotEmpty()) { + getFontProvider().drawString( + matrixStack, + text, + if (active) selectionForegroundColor else inactiveSelectionForegroundColor, + left - horizontalScrollingOffset, + getTop() + ((lineHeight * row + 1) * getTextScale()) + verticalScrollingOffset, + 10f, + getTextScale(), + shadow = false + ) + } + } + + val isCursorAtAbsoluteStart: Boolean + get() = cursor.isAtAbsoluteStart + + override fun animationFrame() { + super.animationFrame() + + val diff = (targetVerticalScrollingOffset - verticalScrollingOffset) * 0.1f + if (abs(diff) < .25f) + verticalScrollingOffset = targetVerticalScrollingOffset + verticalScrollingOffset += diff + + recalculateDimensions() + + if (cursorNeedsRefocus) { + scrollIntoView(cursor) + cursorNeedsRefocus = false + } + } + + protected inner class LinePosition(val line: Int, val column: Int, val isVisual: Boolean) : + Comparable { + val isAtLineStart: Boolean get() = column == 0 + val isAtLineEnd: Boolean get() = column == lines[line].length + + val isInFirstLine: Boolean get() = line == 0 + val isInLastLine: Boolean get() = line == lines.lastIndex + + val isAtAbsoluteStart: Boolean get() = isInFirstLine && isAtLineStart + val isAtAbsoluteEnd: Boolean get() = isInLastLine && isAtLineEnd + + private val lines: List = if (isVisual) visualLines else textualLines + + fun offsetColumn(amount: Int) = when { + amount > 0 -> offsetColumnPositive(amount, this) + amount < 0 -> offsetColumnNegative(-amount, this) + else -> this + } + + private tailrec fun offsetColumnNegative(amount: Int, pos: LinePosition): LinePosition { + if (amount == 0 || pos.isAtAbsoluteStart) + return pos + + return offsetColumnNegative(amount - 1, complexOffsetColumnNegative(pos)) + } + + private fun complexOffsetColumnNegative(pos: LinePosition): LinePosition { + if (!pos.isVisual) + return simpleOffsetColumnNegative(pos) + if (!pos.isAtLineStart) + return simpleOffsetColumnNegative(pos) + + val currentLine = visualLines[pos.line] + val previousLine = visualLines[pos.line - 1] + if (currentLine.textIndex != previousLine.textIndex) + return simpleOffsetColumnNegative(pos) + if (previousLine.text.last() != ' ') + return simpleOffsetColumnNegative(pos) + return LinePosition(pos.line - 1, previousLine.length - 1, isVisual = true) + } + + private fun simpleOffsetColumnNegative(pos: LinePosition) = if (pos.column == 0) { + LinePosition(pos.line - 1, pos.lines[pos.line - 1].length, pos.isVisual) + } else { + pos.withColumn(pos.column - 1) + } + + private tailrec fun offsetColumnPositive(amount: Int, pos: LinePosition): LinePosition { + if (amount == 0 || pos.isAtAbsoluteEnd) + return pos + + return offsetColumnPositive(amount - 1, complexOffsetColumnPositive(pos)) + } + + private fun complexOffsetColumnPositive(pos: LinePosition): LinePosition { + if (!pos.isVisual) + return simpleOffsetColumnPositive(pos) + + val currentLine = visualLines[pos.line] + if (pos.column < currentLine.length - 1) + return simpleOffsetColumnPositive(pos) + if (pos.line == visualLines.lastIndex) + return LinePosition(pos.line, currentLine.length, isVisual = true) + if (pos.column == currentLine.length - 1 && currentLine.text.last() != ' ') + return simpleOffsetColumnPositive(pos) + + val nextLine = visualLines[pos.line + 1] + if (currentLine.textIndex == nextLine.textIndex) + return LinePosition(pos.line + 1, 0, isVisual = true) + return simpleOffsetColumnPositive(pos) + } + + private fun simpleOffsetColumnPositive(pos: LinePosition) = if (pos.column >= pos.lines[pos.line].length) { + if (pos.line == pos.lines.lastIndex) { + LinePosition(pos.lines.lastIndex, pos.lines.last().length, pos.isVisual) + } else { + LinePosition(pos.line + 1, 0, pos.isVisual) + } + } else { + pos.withColumn(pos.column + 1) + } + + fun withColumn(newColumn: Int) = LinePosition(line, newColumn, isVisual) + + fun toTextualPos(): LinePosition { + if (!isVisual) + return this + + val visualLine = visualLines[line] + val textualLine = textualLines[visualLine.textIndex] + var totalVisualLength = 0 + + for (i in textualLine.visualIndices.first until line) + totalVisualLength += visualLines[i].length + + return LinePosition(visualLine.textIndex, totalVisualLength + column, isVisual = false) + } + + fun toVisualPos(): LinePosition { + if (isVisual) + return this + + val textualLine = textualLines[line] + var lengthRemaining = column + + for (visualLineIndex in textualLine.visualIndices) { + val visualLine = visualLines[visualLineIndex] + if (visualLine.length >= lengthRemaining) + return LinePosition(visualLineIndex, lengthRemaining, isVisual = true) + + lengthRemaining -= visualLine.length + } + + println("toTextualPos: Unexpected end of function") + return LinePosition(0, 0, isVisual = true) + } + + fun toScreenPos(): Pair { + val visualPos = toVisualPos() + val x = visualLines[visualPos.line].text.substring(0, visualPos.column) + .width(getTextScale()) - horizontalScrollingOffset + val y = (lineHeight * visualPos.line * getTextScale()) + verticalScrollingOffset + return x to y + } + + override operator fun compareTo(other: LinePosition): Int { + val thisVisual = toVisualPos() + val otherVisual = other.toVisualPos() + + return when { + thisVisual.line < otherVisual.line -> -1 + thisVisual.line > otherVisual.line -> 1 + thisVisual.column < otherVisual.column -> -1 + thisVisual.column > otherVisual.column -> 1 + else -> 0 + } + } + + override fun equals(other: Any?) = + other is LinePosition && line == other.line && column == other.column && isVisual == other.isVisual + + override fun hashCode(): Int { + var result = line + result = 31 * result + column + result = 31 * result + isVisual.hashCode() + return result + } + + override fun toString() = "LinePosition(line=$line, column=$column, isVisual=$isVisual)" + } + + protected open inner class Line(var text: String) { + val length: Int get() = text.length + } + + protected inner class TextualLine(text: String, var visualIndices: IntRange = 0..0) : Line(text) { + fun addTextAt(newText: String, column: Int) { + if (column >= text.length) { + text += newText + } else { + text = text.substring(0, column) + newText + text.substring(column) + } + } + + override fun toString() = "TextualLine(text=$text, visualIndices=$visualIndices)" + } + + protected inner class VisualLine(text: String, val textIndex: Int) : Line(text) { + override fun toString() = "VisualLine(text=$text, textIndex=$textIndex)" + } + + protected abstract inner class TextOperation { + abstract fun redo() + abstract fun undo() + } + + protected inner class AddTextOperation(val newText: String, val startPos: LinePosition) : + TextOperation() { + override fun redo() { + addText(newText, startPos) + } + + override fun undo() { + removeText(startPos, startPos.offsetColumn(newText.length)) + setCursorPosition(startPos.toVisualPos()) + } + } + + protected inner class RemoveTextOperation( + val startPos: LinePosition, val endPos: LinePosition, val selectAfterUndo: Boolean + ) : TextOperation() { + val text = getTextBetween(startPos, endPos) + + override fun redo() { + val textualStartPos = startPos.toTextualPos() + removeText(textualStartPos, endPos) + setCursorPosition(textualStartPos) + } + + override fun undo() { + addText(text, startPos) + if (selectAfterUndo) { + cursor = startPos + otherSelectionEnd = endPos + cursorNeedsRefocus = true + } + } + } + + protected inner class ReplaceTextOperation( + val addTextOperation: AddTextOperation, + val removeTextOperation: RemoveTextOperation + ) : TextOperation() { + override fun redo() { + removeTextOperation.redo() + addTextOperation.redo() + } + + override fun undo() { + addTextOperation.undo() + removeTextOperation.undo() + } + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/common/elementa/UIScrollComponent.kt b/src/main/kotlin/com/replaymod/core/gui/common/elementa/UIScrollComponent.kt new file mode 100644 index 000000000..c6f354a9f --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/elementa/UIScrollComponent.kt @@ -0,0 +1,802 @@ +/** + * Based on Elementa's ScrollComponent but with more stuff exposed and certain changes so we can get it to behave just + * like we need it to: + * - New scrollTo method + * - Exposed onScroll method + * - Fix scrollbars when size or content size changes + * - Fix NaN as scrollPercentage when the offset range is zero in width + * - Ignore components going into the negative when calculating actual width/height, fixes scrollbar when zoomed out + * + * MIT License + * + * Copyright (c) 2021 ReplayMod contributors + * Copyright (c) 2021 Sk1er LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.replaymod.core.gui.common.elementa + +import gg.essential.elementa.components.* + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.* +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.elementa.utils.bindLast +import gg.essential.universal.UKeyboard +import gg.essential.universal.UMatrixStack +import gg.essential.universal.UMouse +import gg.essential.universal.UResolution +import java.awt.Color +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.abs +import kotlin.math.max + +/** + * Basic scroll component that will only draw what is currently visible. + * + * Also prevents scrolling past what should be reasonable. + */ +class UIScrollComponent @JvmOverloads constructor( + emptyString: String = "", + private val innerPadding: Float = 0f, + private val scrollIconColor: Color = Color.WHITE, + private val horizontalScrollEnabled: Boolean = false, + private val verticalScrollEnabled: Boolean = true, + private val horizontalScrollOpposite: Boolean = false, + private val verticalScrollOpposite: Boolean = false, + private val pixelsPerScroll: Float = 15f, + private val scrollAcceleration: Float = 1.0f, + customScissorBoundingBox: UIComponent? = null +) : UIContainer() { + private var animationFPS: Int? = null + + private val actualHolder = UIContainer().constrain { + x = innerPadding.pixels() + y = innerPadding.pixels() + width = RelativeConstraint(1f) - innerPadding.pixels() + height = RelativeConstraint(1f) + } + + //Exposed so its position and value can be adjusted by user + val emptyText = UIWrappedText(emptyString, centered = true).constrain { + x = CenterConstraint() + y = SiblingConstraint() + 4.pixels() + } + + private val scrollSVGComponent = getScrollImage().constrain { + width = 24.pixels() + height = 24.pixels() + + color = scrollIconColor.toConstraint() + } + + var horizontalOffset = innerPadding + private set + var verticalOffset = innerPadding + private set + + private var horizontalScrollBarGrip: UIComponent? = null + private var horizontalHideScrollWhenUseless = false + private var verticalScrollBarGrip: UIComponent? = null + private var verticalHideScrollWhenUseless = false + + private var horizontalDragBeginPos = -1f + private var verticalDragBeginPos = -1f + + private val horizontalScrollAdjustEvents: MutableList<(Float, Float) -> Unit> = + mutableListOf(::updateScrollBar.bindLast(true)) + private val verticalScrollAdjustEvents: MutableList<(Float, Float) -> Unit> = + mutableListOf(::updateScrollBar.bindLast(false)) + private var needsUpdate = true + + private var isAutoScrolling = false + private var autoScrollBegin: Pair = -1f to -1f + private var currentScrollAcceleration: Float = 1.0f + + val allChildren = CopyOnWriteArrayList() + + /** + * Difference between the ScrollComponent's width and its contents' width. + * Will not be less than zero, even if the contents' width is less than the + * component's width. + */ + val horizontalOverhang: Float + get() = max(0f, calculateActualWidth() - getWidth()) + + /** + * Difference between the ScrollComponent's height and its contents' height. + * Will not be less than zero, even if the contents' height is less than the + * component's height. + */ + val verticalOverhang: Float + get() = max(0f, calculateActualHeight() - getHeight()) + + init { + this.constrain { + width = ScrollChildConstraint() coerceAtMost 100.percentOfWindow() + height = ScrollChildConstraint() coerceAtMost 100.percentOfWindow() + } + + if (!horizontalScrollEnabled && !verticalScrollEnabled) + throw IllegalArgumentException("ScrollComponent must have at least one direction of scrolling enabled") + + super.addChild(actualHolder) + actualHolder.addChild(emptyText) + this.enableEffects(ScissorEffect(customScissorBoundingBox)) + emptyText.setFontProvider(getFontProvider()) + super.addChild(scrollSVGComponent) + scrollSVGComponent.hide(instantly = true) + + onMouseScroll { + if (UKeyboard.isShiftKeyDown() && horizontalScrollEnabled) { + onScroll(it.delta.toFloat(), isHorizontal = true) + } else if (!UKeyboard.isShiftKeyDown() && verticalScrollEnabled) { + onScroll(it.delta.toFloat(), isHorizontal = false) + } + + it.stopPropagation() + } + + onMouseClick { event -> + onClick(event.relativeX, event.relativeY, event.mouseButton) + } + } + + private var lastHorizontalRange = 0f..0f + private var lastVerticalRange = 0f..0f + private var lastActualWidth = 0f + private var lastActualHeight = 0f + + override fun draw(matrixStack: UMatrixStack) { + val horizontalRange = calculateOffsetRange(isHorizontal = true) + val verticalRange = calculateOffsetRange(isHorizontal = false) + val actualWidth = calculateActualWidth() + val actualHeight = calculateActualHeight() + if (lastHorizontalRange != horizontalRange + || lastVerticalRange != verticalRange + || lastActualWidth != actualWidth + || lastActualHeight != actualHeight + ) { + lastHorizontalRange = horizontalRange + lastVerticalRange = verticalRange + lastActualWidth = actualWidth + lastActualHeight = actualHeight + needsUpdate = true + } + + if (needsUpdate) { + needsUpdate = false + + // Recalculate our scroll box and move the content inside if needed. + actualHolder.animate { + horizontalOffset = + if (horizontalRange.isEmpty()) innerPadding else horizontalOffset.coerceIn(horizontalRange) + verticalOffset = if (verticalRange.isEmpty()) innerPadding else verticalOffset.coerceIn(verticalRange) + + setXAnimation(Animations.IN_SIN, 0.1f, horizontalOffset.pixels()) + setYAnimation(Animations.IN_SIN, 0.1f, verticalOffset.pixels()) + } + // Run our scroll adjust event, normally updating [scrollBarGrip] + var percent = abs(horizontalOffset) / horizontalRange + var percentageOfParent = this.getWidth() / actualWidth + horizontalScrollAdjustEvents.forEach { it(percent, percentageOfParent) } + + percent = abs(verticalOffset) / verticalRange + percentageOfParent = this.getHeight() / actualHeight + verticalScrollAdjustEvents.forEach { it(percent, percentageOfParent) } + } + + super.draw(matrixStack) + } + + override fun afterInitialization() { + super.afterInitialization() + + animationFPS = Window.of(this).animationFPS + } + + /** + * Sets the text that appears when no items are shown + */ + fun setEmptyText(text: String) { + emptyText.setText(text) + } + + fun addScrollAdjustEvent( + isHorizontal: Boolean, + event: (scrollPercentage: Float, percentageOfParent: Float) -> Unit + ) { + if (isHorizontal) horizontalScrollAdjustEvents.add(event) else verticalScrollAdjustEvents.add(event) + } + + @JvmOverloads + fun setHorizontalScrollBarComponent(component: UIComponent, hideWhenUseless: Boolean = false) { + setScrollBarComponent(component, hideWhenUseless, isHorizontal = true) + } + + @JvmOverloads + fun setVerticalScrollBarComponent(component: UIComponent, hideWhenUseless: Boolean = false) { + setScrollBarComponent(component, hideWhenUseless, isHorizontal = false) + } + + /** + * A scroll bar component is an optional component that can visually display the scroll status + * of this scroll component (just like any scroll bar). + * + * The utility here is that it is automatically updated by this component with no extra work on the user-end. + * + * The hierarchy for this scrollbar grip component must be as follows: + * - Have a containing parent being the full height range of this scroll bar. + * + * [component]'s mouse events will all be overridden by this action. + * + * If [hideWhenUseless] is enabled, [component] will have [hide] called on it when the scrollbar is full height + * and dragging it would do nothing. + */ + fun setScrollBarComponent(component: UIComponent, hideWhenUseless: Boolean, isHorizontal: Boolean) { + if (isHorizontal) { + horizontalScrollBarGrip = component + horizontalHideScrollWhenUseless = hideWhenUseless + } else { + verticalScrollBarGrip = component + verticalHideScrollWhenUseless = hideWhenUseless + } + + component.onMouseScroll { + if (isHorizontal && horizontalScrollEnabled && UKeyboard.isShiftKeyDown()) { + onScroll(it.delta.toFloat(), isHorizontal = true) + } else if (!isHorizontal && verticalScrollEnabled) { + onScroll(it.delta.toFloat(), isHorizontal = false) + } + + it.stopPropagation() + } + + component.onMouseClick { event -> + if (isHorizontal) { + horizontalDragBeginPos = event.relativeX + } else { + verticalDragBeginPos = event.relativeY + } + } + + component.onMouseDrag { mouseX, mouseY, _ -> + if (isHorizontal) { + if (horizontalDragBeginPos == -1f) + return@onMouseDrag + updateGrip(component, mouseX, isHorizontal = true) + } else { + if (verticalDragBeginPos == -1f) + return@onMouseDrag + updateGrip(component, mouseY, isHorizontal = false) + } + } + + component.onMouseRelease { + if (isHorizontal) { + horizontalDragBeginPos = -1f + } else { + verticalDragBeginPos = -1f + } + } + + needsUpdate = true + } + + fun scrollToLeft(smoothScroll: Boolean = true) { + scrollTo(horizontalOffset = Float.POSITIVE_INFINITY, smoothScroll = smoothScroll) + } + + fun scrollToRight(smoothScroll: Boolean = true) { + scrollTo(horizontalOffset = Float.NEGATIVE_INFINITY, smoothScroll = smoothScroll) + } + + fun scrollToTop(smoothScroll: Boolean = true) { + scrollTo(verticalOffset = Float.POSITIVE_INFINITY, smoothScroll = smoothScroll) + } + + fun scrollToBottom(smoothScroll: Boolean = true) { + scrollTo(verticalOffset = Float.NEGATIVE_INFINITY, smoothScroll = smoothScroll) + } + + fun scrollTo( + horizontalOffset: Float = this.horizontalOffset, + verticalOffset: Float = this.verticalOffset, + smoothScroll: Boolean = true + ) { + val horizontalRange = calculateOffsetRange(isHorizontal = true) + val verticalRange = calculateOffsetRange(isHorizontal = false) + this.horizontalOffset = if (horizontalRange.isEmpty()) innerPadding else horizontalOffset.coerceIn(horizontalRange) + this.verticalOffset = if (verticalRange.isEmpty()) innerPadding else verticalOffset.coerceIn(verticalRange) + + if (smoothScroll) { + needsUpdate = true + return + } + + actualHolder.setX(this.horizontalOffset.pixels()) + actualHolder.setY(this.verticalOffset.pixels()) + val horizontalFraction = abs(horizontalOffset) / horizontalRange + val verticalFraction = abs(verticalOffset) / verticalRange + horizontalScrollAdjustEvents.forEach { it(horizontalFraction, this.getWidth() / calculateActualWidth()) } + verticalScrollAdjustEvents.forEach { it(verticalFraction, this.getHeight() / calculateActualHeight()) } + } + + fun filterChildren(filter: (component: UIComponent) -> Boolean) { + actualHolder.children.clear() + actualHolder.children.addAll(allChildren.filter(filter).ifEmpty { listOf(emptyText) }) + actualHolder.children.forEach { it.parent = actualHolder } + + needsUpdate = true + } + + @JvmOverloads + fun > sortChildren(descending: Boolean = false, comparator: (UIComponent) -> T) { + if (descending) { + actualHolder.children.sortByDescending(comparator) + } else { + actualHolder.children.sortBy(comparator) + } + } + + fun sortChildren(comparator: Comparator) { + actualHolder.children.sortWith(comparator) + } + + private fun updateGrip(component: UIComponent, mouseCoord: Float, isHorizontal: Boolean) { + if (isHorizontal) { + val minCoord = component.parent.getLeft() + val maxCoord = component.parent.getRight() + val dragDelta = mouseCoord - horizontalDragBeginPos + + horizontalOffset = if (horizontalScrollOpposite) { + val newPos = maxCoord - component.getRight() - dragDelta + val percentage = newPos / (maxCoord - minCoord) + + percentage * calculateActualWidth() + } else { + val newPos = component.getLeft() + dragDelta - minCoord + val percentage = newPos / (maxCoord - minCoord) + + -(percentage * calculateActualWidth()) + } + } else { + val minCoord = component.parent.getTop() + val maxCoord = component.parent.getBottom() + val dragDelta = mouseCoord - verticalDragBeginPos + + verticalOffset = if (verticalScrollOpposite) { + val newPos = maxCoord - component.getBottom() - dragDelta + val percentage = newPos / (maxCoord - minCoord) + + percentage * calculateActualHeight() + } else { + val newPos = component.getTop() + dragDelta - minCoord + val percentage = newPos / (maxCoord - minCoord) + + -(percentage * calculateActualHeight()) + } + } + + needsUpdate = true + } + + fun onScroll(delta: Float, isHorizontal: Boolean) { + if (isHorizontal) { + horizontalOffset += delta * pixelsPerScroll * currentScrollAcceleration + } else { + verticalOffset += delta * pixelsPerScroll * currentScrollAcceleration + } + + currentScrollAcceleration = + (currentScrollAcceleration + (scrollAcceleration - 1.0f) * 0.15f).coerceIn(0f, scrollAcceleration) + + needsUpdate = true + } + + private fun updateScrollBar(scrollPercentage: Float, percentageOfParent: Float, isHorizontal: Boolean) { + val component = if (isHorizontal) { + horizontalScrollBarGrip ?: return + } else { + verticalScrollBarGrip ?: return + } + + val clampedPercentage = percentageOfParent.coerceAtMost(1f) + + if ((isHorizontal && horizontalHideScrollWhenUseless) || (!isHorizontal && verticalHideScrollWhenUseless)) { + if (clampedPercentage == 1f) { + Window.enqueueRenderOperation(component::hide) + return + } else { + Window.enqueueRenderOperation(component::unhide) + } + } + + if (isHorizontal) { + component.setWidth(RelativeConstraint(clampedPercentage)) + } else { + component.setHeight(RelativeConstraint(clampedPercentage)) + } + + component.animate { + if (isHorizontal) { + setXAnimation( + Animations.IN_SIN, 0.1f, basicXConstraint { component -> + val offset = (component.parent.getWidth() - component.getWidth()) * scrollPercentage + + if (horizontalScrollOpposite) component.parent.getRight() - component.getHeight() - offset + else component.parent.getLeft() + offset + } + ) + } else { + setYAnimation( + Animations.IN_SIN, 0.1f, basicYConstraint { component -> + val offset = (component.parent.getHeight() - component.getHeight()) * scrollPercentage + + if (verticalScrollOpposite) component.parent.getBottom() - component.getHeight() - offset + else component.parent.getTop() + offset + } + ) + } + } + } + + private fun calculateActualWidth(): Float { + if (actualHolder.children.isEmpty()) return 0f + + return actualHolder.children.let { c -> + c.maxOf { it.getRight() } - c.minOf { it.getLeft() }.coerceAtLeast(actualHolder.getLeft()) + } + } + + private fun calculateActualHeight(): Float { + if (actualHolder.children.isEmpty()) return 0f + + return actualHolder.children.let { c -> + c.maxOf { it.getBottom() } - c.minOf { it.getTop() }.coerceAtLeast(actualHolder.getTop()) + } + } + + private fun calculateOffsetRange(isHorizontal: Boolean): ClosedFloatingPointRange { + return if (isHorizontal) { + val actualWidth = calculateActualWidth() + val maxNegative = this.getWidth() - actualWidth - innerPadding + if (horizontalScrollOpposite) (-innerPadding)..-maxNegative else maxNegative..(innerPadding) + } else { + val actualHeight = calculateActualHeight() + val maxNegative = this.getHeight() - actualHeight - innerPadding + if (verticalScrollOpposite) (-innerPadding)..-maxNegative else maxNegative..(innerPadding) + } + } + + private fun onClick(mouseX: Float, mouseY: Float, mouseButton: Int) { + if (isAutoScrolling) { + isAutoScrolling = false + scrollSVGComponent.hide() + return + } + + if (mouseButton == 2) { + // Middle click, begin the auto scroll + isAutoScrolling = true + autoScrollBegin = mouseX to mouseY + + scrollSVGComponent.constrain { + x = (mouseX - 12).pixels() + y = (mouseY - 12).pixels() + } + + scrollSVGComponent.unhide(useLastPosition = false) + } + } + + override fun animationFrame() { + super.animationFrame() + + currentScrollAcceleration = + (currentScrollAcceleration - ((scrollAcceleration - 1.0f) / (animationFPS ?: 244).toFloat())) + .coerceAtLeast(1.0f) + + if (!isAutoScrolling) return + + if (horizontalScrollEnabled) { + val xBegin = autoScrollBegin.first + getLeft() + val currentX = UMouse.Scaled.x + + if (currentX in getLeft()..getRight()) { + val deltaX = currentX - xBegin + val percentX = deltaX / (-getWidth() / 2) + horizontalOffset += (percentX.toFloat() * 5f) + needsUpdate = true + } + } + + if (verticalScrollEnabled) { + val yBegin = autoScrollBegin.second + getTop() + val currentY = UMouse.Scaled.y + + if (currentY in getTop()..getBottom()) { + val deltaY = currentY - yBegin + val percentY = deltaY / (-getHeight() / 2) + verticalOffset += (percentY.toFloat() * 5f) + needsUpdate = true + } + } + + needsUpdate = true + } + + override fun addChild(component: UIComponent) = apply { + actualHolder.removeChild(emptyText) + + actualHolder.addChild(component) + allChildren.add(component) + + needsUpdate = true + } + + override fun insertChildAt(component: UIComponent, index: Int) = apply { + if (index < 0 || index > allChildren.size) { + println("Bad index given to insertChildAt (index: $index, children size: ${allChildren.size}") + return@apply + } + + actualHolder.removeChild(emptyText) + + component.parent = actualHolder + actualHolder.children.add(index, component) + allChildren.add(index, component) + + needsUpdate = true + } + + override fun insertChildBefore(newComponent: UIComponent, targetComponent: UIComponent) = apply { + val indexOfExisting = allChildren.indexOf(targetComponent) + if (indexOfExisting == -1) { + println("targetComponent given to insertChildBefore is not a child of this component") + return@apply + } + + insertChildAt(newComponent, indexOfExisting) + } + + override fun insertChildAfter(newComponent: UIComponent, targetComponent: UIComponent) = apply { + val indexOfExisting = allChildren.indexOf(targetComponent) + if (indexOfExisting == -1) { + println("targetComponent given to insertChildAfter is not a child of this component") + return@apply + } + + insertChildAt(newComponent, indexOfExisting + 1) + } + + override fun replaceChild(newComponent: UIComponent, componentToReplace: UIComponent) = apply { + val indexOfExisting = allChildren.indexOf(componentToReplace) + if (indexOfExisting == -1) { + println("componentToReplace given to replaceChild is not a child of this component") + return@apply + } + + actualHolder.removeChild(emptyText) + + actualHolder.children.removeAt(indexOfExisting) + allChildren.removeAt(indexOfExisting) + + newComponent.parent = actualHolder + actualHolder.children.add(indexOfExisting, newComponent) + allChildren.add(indexOfExisting, newComponent) + + needsUpdate = true + } + + override fun removeChild(component: UIComponent) = apply { + if (component == scrollSVGComponent) { + super.removeChild(component) + return@apply + } + + actualHolder.removeChild(component) + allChildren.remove(component) + + if (allChildren.isEmpty()) + actualHolder.addChild(emptyText) + + needsUpdate = true + } + + override fun clearChildren() = apply { + allChildren.clear() + actualHolder.clearChildren() + actualHolder.addChild(emptyText) + + needsUpdate = true + } + + override fun alwaysDrawChildren(): Boolean { + return true + } + + override fun childrenOfType(clazz: Class): List { + return actualHolder.childrenOfType(clazz) + } + + override fun mouseClick(mouseX: Double, mouseY: Double, button: Int) { + actualHolder.mouseClick(mouseX, mouseY, button) + } + + override fun hitTest(x: Float, y: Float): UIComponent { + return actualHolder.hitTest(x, y) + } + + fun searchAndInsert(components: List, comparison: (UIComponent) -> Int) { + if (components.isEmpty()) return + + actualHolder.children.remove(emptyText) + val searchIndex = actualHolder.children.binarySearch(comparison = comparison) + + components.forEach { it.parent = actualHolder } + allChildren.addAll(components) + actualHolder.children.addAll( + if (searchIndex >= 0) searchIndex else -(searchIndex + 1), + components + ) + + needsUpdate = true + } + + fun setChildren(components: List) = apply { + actualHolder.children.clear() + actualHolder.children.addAll(components.ifEmpty { listOf(emptyText) }) + actualHolder.children.forEach { it.parent = actualHolder } + + allChildren.clear() + allChildren.addAll(actualHolder.children) + + needsUpdate = true + } + + private fun ClosedFloatingPointRange.width() = abs(this.start - this.endInclusive) + private fun ClosedFloatingPointRange.width() = abs(this.start - this.endInclusive) + private operator fun Float.div(range: ClosedFloatingPointRange): Float { + val width = range.width() + return if (width == 0f) 0f else this / width + } + + class DefaultScrollBar(isHorizontal: Boolean) : UIComponent() { + val grip: UIComponent + + init { + if (isHorizontal) { + constrain { + y = 2.pixels(alignOpposite = true) + width = 100.percent() + height = 10.pixels() + } + + val container = UIContainer().constrain { + x = 2.pixels() + y = CenterConstraint() + width = RelativeConstraint() - 4.pixels() + height = 4.pixels() + } childOf this + + grip = UIBlock(Color(70, 70, 70)).constrain { + x = 0.pixels(alignOpposite = true) + y = CenterConstraint() + width = 30.pixels() + height = 3.pixels() + } childOf container + } else { + constrain { + x = 2.pixels(alignOpposite = true) + width = 10.pixels() + height = 100.percent() + } + + val container = UIContainer().constrain { + x = CenterConstraint() + y = 2.pixels() + width = 4.pixels() + height = RelativeConstraint() - 4.pixels() + } childOf this + + grip = UIBlock(Color(70, 70, 70)).constrain { + x = CenterConstraint() + y = 0.pixels(alignOpposite = true) + width = 3.pixels() + height = 30.pixels() + } childOf container + } + } + } + + /** + * Constrains a scroll component to either the widest/tallest child or to the sum of their widths/heights and + * padding. If [horizontalScrollEnabled] or [verticalScrollEnabled] are true, they will change the constraint to + * use the total of their children's corresponding measurements and padding. Otherwise, it will use the maximum + * measurement for the corresponding direction. + * + * This is the default width and height constraint for scroll components. + * + * @param padding Pixels of padding to add to each component + */ + inner class ScrollChildConstraint(val padding: Float = 0f) : WidthConstraint, HeightConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + override fun getWidthImpl(component: UIComponent): Float { + if (horizontalScrollEnabled) { + val holder = constrainTo ?: actualHolder + var totalPadding = (holder.children.size - 1) * padding + holder.children.forEach { child -> + if (child.constraints.y is PaddingConstraint) { + totalPadding += (child.constraints.y as PaddingConstraint).getVerticalPadding(child) + } + } + return (holder.children + .sumOf { it.getHeight().toDouble() } + totalPadding).toFloat() + + } else { + return (constrainTo ?: actualHolder).children.maxByOrNull { + if(it.constraints.x is PaddingConstraint) + return@maxByOrNull it.getWidth() + (it.constraints.x as PaddingConstraint).getHorizontalPadding(it) + it.getWidth() }?.getWidth() ?: 0f + } + } + + override fun getHeightImpl(component: UIComponent): Float { + if (verticalScrollEnabled) { + val holder = constrainTo ?: actualHolder + var totalPadding = (holder.children.size - 1) * padding + holder.children.forEach { child -> + if (child.constraints.y is PaddingConstraint) { + totalPadding += (child.constraints.y as PaddingConstraint).getVerticalPadding(child) + } + } + return (holder.children + .sumOf { it.getHeight().toDouble() } + totalPadding).toFloat() + } else { + return (constrainTo ?: actualHolder).children.maxByOrNull { + if(it.constraints.y is PaddingConstraint) + return@maxByOrNull it.getHeight() + (it.constraints.y as PaddingConstraint).getVerticalPadding(it) + it.getHeight() }?.getHeight() ?: 0f + } + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + when (type) { + ConstraintType.WIDTH -> visitor.visitChildren(ConstraintType.WIDTH) + ConstraintType.HEIGHT -> visitor.visitChildren(ConstraintType.HEIGHT) + else -> throw IllegalArgumentException(type.prettyName) + } + } + + } + + companion object { + + fun getScrollImage(): UIImage { + return UIImage.ofResourceCached("/svg/scroll.png") + } + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/common/elementa/UITextInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/elementa/UITextInput.kt new file mode 100644 index 000000000..f37c546f6 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/elementa/UITextInput.kt @@ -0,0 +1,176 @@ +/** + * Based on Elementa's UITextInput but with more stuff exposed and certain changes so we can get it to behave just + * like we need it to: + * - No changes except it is using our AbstractTextInput (which has changes) + * + * MIT License + * + * Copyright (c) 2021 ReplayMod contributors + * Copyright (c) 2021 Sk1er LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.replaymod.core.gui.common.elementa + +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.dsl.* +import gg.essential.universal.UMatrixStack +import java.awt.Color + +open class UITextInput @JvmOverloads constructor( + placeholder: String = "", + shadow: Boolean = true, + selectionBackgroundColor: Color = Color.WHITE, + selectionForegroundColor: Color = Color(64, 139, 229), + allowInactiveSelection: Boolean = false, + inactiveSelectionBackgroundColor: Color = Color(176, 176, 176), + inactiveSelectionForegroundColor: Color = Color.WHITE, + cursorColor: Color = Color.WHITE +) : AbstractTextInput( + placeholder, + shadow, + selectionBackgroundColor, + selectionForegroundColor, + allowInactiveSelection, + inactiveSelectionBackgroundColor, + inactiveSelectionForegroundColor, + cursorColor = Color.WHITE +) { + protected var minWidth: WidthConstraint? = null + protected var maxWidth: WidthConstraint? = null + + protected val placeholderWidth = placeholder.width() + + fun setMinWidth(constraint: WidthConstraint) = apply { + minWidth = constraint + } + + fun setMaxWidth(constraint: WidthConstraint) = apply { + maxWidth = constraint + } + + override fun getText() = textualLines.first().text + + protected open fun getTextForRender(): String = getText() + + protected open fun setCursorPos() { + cursorComponent.unhide() + val (cursorPosX, _) = cursor.toScreenPos() + cursorComponent.setX((cursorPosX).pixels()) + } + + override fun textToLines(text: String): List { + return listOf(text.replace('\n', ' ')) + } + + override fun scrollIntoView(pos: LinePosition) { + val column = pos.column + val lineText = getTextForRender() + if (column < 0 || column > lineText.length) + return + + val widthBeforePosition = lineText.substring(0, column).width(getTextScale()) + + when { + getTextForRender().width(getTextScale()) < getWidth() -> { + horizontalScrollingOffset = 0f + } + horizontalScrollingOffset > widthBeforePosition -> { + horizontalScrollingOffset = widthBeforePosition + } + widthBeforePosition - horizontalScrollingOffset > getWidth() -> { + horizontalScrollingOffset = widthBeforePosition - getWidth() + } + } + } + + override fun screenPosToVisualPos(x: Float, y: Float): LinePosition { + val targetXPos = x + horizontalScrollingOffset + var currentX = 0f + + val line = getTextForRender() + + for (i in line.indices) { + val charWidth = line[i].width(getTextScale()) + if (currentX + (charWidth / 2) >= targetXPos) return LinePosition(0, i, isVisual = true) + currentX += charWidth + } + + return LinePosition(0, line.length, isVisual = true) + } + + override fun recalculateDimensions() { + if (minWidth != null && maxWidth != null) { + val width = if (!hasText() && !this.active) { + placeholderWidth + } else { + getTextForRender().width(getTextScale()) + 1 /* cursor */ + } + setWidth(width.pixels().coerceIn(minWidth!!, maxWidth!!)) + } + } + + override fun splitTextForWrapping(text: String, maxLineWidth: Float): List { + return listOf(text) + } + + override fun onEnterPressed() { + activateAction(getText()) + } + + override fun draw(matrixStack: UMatrixStack) { + beforeDrawCompat(matrixStack) + + if (!active && !hasText()) { + getFontProvider().drawString(matrixStack, placeholder, getColor(), getLeft(), getTop(), 10f, getTextScale()) + return super.draw(matrixStack) + } + + val lineText = getTextForRender() + + if (hasSelection()) { + var currentX = getLeft() + cursorComponent.hide(instantly = true) + + if (!selectionStart().isAtLineStart) { + val preSelectionText = lineText.substring(0, selectionStart().column) + drawUnselectedTextCompat(matrixStack, preSelectionText, currentX, row = 0) + currentX += preSelectionText.width(getTextScale()) + } + + val selectedText = lineText.substring(selectionStart().column, selectionEnd().column) + val selectedTextWidth = selectedText.width(getTextScale()) + drawSelectedTextCompat(matrixStack, selectedText, currentX, currentX + selectedTextWidth, row = 0) + currentX += selectedTextWidth + + if (!selectionEnd().isAtLineEnd) { + drawUnselectedTextCompat(matrixStack, lineText.substring(selectionEnd().column), currentX, row = 0) + } + } else { + cursorComponent.setY(basicYConstraint { + getTop() + }) + setCursorPos() + + drawUnselectedTextCompat(matrixStack, lineText, getLeft(), 0) + } + + super.draw(matrixStack) + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIAdvancedTextInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIAdvancedTextInput.kt new file mode 100644 index 000000000..2aa176d48 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIAdvancedTextInput.kt @@ -0,0 +1,85 @@ +package com.replaymod.core.gui.common.input + +import com.replaymod.core.gui.common.elementa.UITextInput +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels + +open class UIAdvancedTextInput( + private val preferReplace: Boolean = true, + private val alignRight: Boolean = true, +) : UITextInput() { + + override fun afterInitialization() { + super.afterInitialization() + + if (alignRight) { + constrain { + x = 2.pixels(alignOpposite = true) + } + } + } + + protected open fun shouldReplaceExistingText(add: AddTextOperation) = preferReplace + protected open fun fixText(): TextOperation? = null + + override fun commitTextOperation(operation: TextOperation) { + var op = operation + + if (op is AddTextOperation && shouldReplaceExistingText(op)) { + op = CompoundOperation(op, with(op) { + applied { RemoveTextOperation(endPos, endPos.offsetColumn(newText.length), false) } + }) + } + + op.applied { + val fix = fixText() ?: return + if (fix !is NoOperation) { + op = CompoundOperation(op, fix) + } + } + + super.commitTextOperation(op) + } + + private inner class CompoundOperation( + val inner: TextOperation, + val fix: TextOperation, + ) : TextOperation() { + override fun redo() { + inner.redo() + + val orgCursor = cursor + val orgOtherSelectionEnd = otherSelectionEnd + fix.redo() + cursor = orgCursor.coerceInBounds() + otherSelectionEnd = orgOtherSelectionEnd.coerceInBounds() + } + + override fun undo() { + fix.undo() + inner.undo() + } + } + + protected inner class NoOperation : TextOperation() { + override fun redo() = Unit + override fun undo() = Unit + } + + private inline fun TextOperation?.applied(block: () -> T): T { + this?.redo() + try { + return block() + } finally { + this?.undo() + } + } + + private fun LinePosition.coerceInBounds(): LinePosition { + val lines = if (isVisual) visualLines else textualLines + val line = line.coerceIn(0, lines.lastIndex) + return LinePosition(line, column.coerceIn(0, lines[line].length), isVisual) + } + + protected val AddTextOperation.endPos get() = startPos.offsetColumn(newText.length) +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIDecimalInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIDecimalInput.kt new file mode 100644 index 000000000..76ed4c92b --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIDecimalInput.kt @@ -0,0 +1,70 @@ +package com.replaymod.core.gui.common.input + +import java.math.BigDecimal +import java.math.MathContext +import java.text.DecimalFormat + +class UIDecimalInput( + precision: Int = 5, +) : UIAdvancedTextInput() { + private val mathContext = MathContext(precision) + private val format = DecimalFormat("0." + "0".repeat(precision)).apply { + minimumFractionDigits = precision + maximumFractionDigits = precision + } + + private fun BigDecimal.formatString() = format.format(this) + + private fun String.parseDecimal(): BigDecimal? = try { + BigDecimal(this, mathContext) + } catch (e: NumberFormatException) { + null + } + + var value: BigDecimal + get() = getText().parseDecimal() ?: BigDecimal.ZERO + set(value) { + val str = value.formatString() + if (getText() != str) { + setText(str) + } + } + + override fun shouldReplaceExistingText(add: AddTextOperation): Boolean = + super.shouldReplaceExistingText(add) && + (add.newText.length != 1 || getText()[add.startPos.column] != '.' || add.newText == ".") + + override fun fixText(): TextOperation? { + val unformatted = getText() + val duration = getText().parseDecimal() ?: return null + val formatted = duration.formatString() + + if (formatted != unformatted) { + val start = LinePosition(0, 0, false) + return ReplaceTextOperation( + AddTextOperation(formatted, start), + RemoveTextOperation(start, LinePosition(0, unformatted.length, false), false) + ) + } + + return NoOperation() + } + + override fun commitTextOperation(operation: TextOperation) { + var op = operation + + // If they try to delete the separator, move the cursor before it instead + if (op is RemoveTextOperation && op.text == ".") { + cursor = op.startPos + otherSelectionEnd = cursor + return + } + + // If they add the final digit before a separator, move the cursor past the separator as well + if (op is AddTextOperation && getText().drop(op.endPos.column).startsWith(".")) { + op = AddTextOperation(op.newText + ".", op.startPos) + } + + super.commitTextOperation(op) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIExpressionInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIExpressionInput.kt new file mode 100644 index 000000000..f068f26e7 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIExpressionInput.kt @@ -0,0 +1,52 @@ +package com.replaymod.core.gui.common.input + +import com.replaymod.core.gui.common.elementa.UITextInput +import com.udojava.evalex.Expression +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.toConstraint +import java.awt.Color +import java.math.MathContext + +class UIExpressionInput : UITextInput() { + val value: Double? + get() = try { + expression.eval()?.toDouble() + } catch (e: Expression.ExpressionException) { + null + } catch (e: ArithmeticException) { + null + } catch (e: NumberFormatException) { + null + } + + val expression: Expression + get() = Expression(getText(), MathContext.DECIMAL64) + + val valid: State = BasicState(false) + + init { + constrain { + color = valid.map { if (it) Color.WHITE else Color.RED }.toConstraint() + } + + onUpdate { + valid.set(value != null) + } + } + + override fun afterInitialization() { + // Right-align + constrain { + x = 2.pixels(alignOpposite = true) + } + + super.afterInitialization() + } + + companion object { + val expressionChars = "+-*/%".toSet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputField.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputField.kt new file mode 100644 index 000000000..874a9e97d --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputField.kt @@ -0,0 +1,82 @@ +package com.replaymod.core.gui.common.input + +import com.replaymod.core.gui.common.elementa.UITextInput +import com.replaymod.core.gui.utils.enableTabFocusChange +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.OutlineEffect +import gg.essential.elementa.events.UIClickEvent +import gg.essential.elementa.state.BasicState +import java.awt.Color + +class UIInputField( + val input: T, +) : UIContainer() { + + private val focused = BasicState(false) + + val background by UIBlock(Color.BLACK).constrain { + width = 100.percent + height = 100.percent + }.effect(OutlineEffect(Color.WHITE, 1f, drawInsideChildren = true).bindColor(focused.map { focused -> + if (focused) { + Color.WHITE + } else { + Color(160, 160, 160) + } + })).onMouseClick { event -> + for (listener in input.mouseClickListeners) { + input.listener(event.projectOnto(input)) + } + } childOf this + + init { + constrain { + width = 200.pixels + height = 20.pixels + } + + input.constrain { + x = 3.pixels + y = CenterConstraint() + }.apply { + setMinWidth(1.pixel) + setMaxWidth(100.percent - 6.pixels) + enableTabFocusChange() + }.onFocus { + focused.set(true) + }.onFocusLost { + focused.set(false) + }.onMouseClick { event -> + input.grabWindowFocus() + + if (!input.isActive()) { + Window.enqueueRenderOperation { + if (input.isActive()) { + for (listener in input.mouseClickListeners) { + listener(event) + } + } + } + } + } childOf this + } + + companion object { + fun text( + placeholder: String = "", + ): UIInputField = UIInputField(UITextInput( + placeholder, + )) + } + + private fun UIClickEvent.projectOnto(component: UIComponent) = copy( + absoluteX = absoluteX.coerceIn(component.getLeft(), component.getRight()), + absoluteY = absoluteY.coerceIn(component.getTop(), component.getBottom()), + currentTarget = component, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputOrExpressionField.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputOrExpressionField.kt new file mode 100644 index 000000000..589d2374f --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIInputOrExpressionField.kt @@ -0,0 +1,83 @@ +package com.replaymod.core.gui.common.input + +import com.replaymod.core.gui.common.elementa.UITextInput +import com.replaymod.core.gui.utils.hiddenChildOf +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.dsl.* +import java.math.BigDecimal +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +class UIInputOrExpressionField( + field: UIInputField, + getValue: T.() -> String, + setValue: T.(value: Double) -> Unit, + forwardActivation: Boolean = true, +) : UIContainer() { + + val input: T by field::input + + val inputField: UIInputField = field.constrain { + width = 100.percent + height = 100.percent + } childOf this + + val expressionField by UIInputField(UIExpressionInput()).constrain { + width = 100.percent + height = 100.percent + } hiddenChildOf this + + init { + // Switch from regular input to expression when certain keys are pressed (special case negative numbers) + input.keyTypedListeners.add(0) { typedChar, keyCode -> + if (typedChar in UIExpressionInput.expressionChars && (typedChar != '-' || !input.isCursorAtAbsoluteStart)) { + inputField.hide(instantly = true) + expressionField.unhide() + + with (expressionField.input) { + setText(inputField.input.getValue()) + grabWindowFocus() + setActive(true) + keyTypedListeners.forEach { it(typedChar, keyCode) } + } + } + } + + // Switch from expression to regular input when Enter is pressed (and the expression is valid) + expressionField.input.onActivate { + val value = expressionField.input.value ?: return@onActivate + + expressionField.hide(instantly = true) + inputField.unhide() + + with(inputField.input) { + setValue(value) + grabWindowFocus() + if (forwardActivation) { + activateAction(getText()) + } + } + } + + // Also switch back when the expression field looses focus, but do not apply the result in that case + expressionField.input.onFocusLost { + expressionField.hide(instantly = true) + inputField.unhide() + } + } + + companion object { + fun forTimeInput(input: UITimeInput = UITimeInput()) = UIInputOrExpressionField( + UIInputField(input), + { "%.3f".format(Locale.ROOT, value.toDouble(TimeUnit.SECONDS)) }, + { value = Duration.seconds(it) }, + ) + + fun forDecimalInput(input: UIDecimalInput = UIDecimalInput()) = UIInputOrExpressionField( + UIInputField(input), + { getText() }, + { value = BigDecimal(it) }, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UIIntegerInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UIIntegerInput.kt new file mode 100644 index 000000000..a51d43186 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UIIntegerInput.kt @@ -0,0 +1,51 @@ +package com.replaymod.core.gui.common.input + +import java.util.* + +class UIIntegerInput( + preferReplace: Boolean = true, + alignRight: Boolean = true, + private val fixedDigits: Int? = null, +) : UIAdvancedTextInput( + preferReplace, + alignRight, +) { + var value: Int + get() = getText().toIntOrNull() ?: 0 + set(value) { + val str = "%0${fixedDigits ?: 1}d".format(Locale.ROOT, value) + if (getText() != str) { + setText(str) + } + } + + override fun shouldReplaceExistingText(add: AddTextOperation): Boolean = + super.shouldReplaceExistingText(add) && add.newText != "-" + + override fun fixText(): TextOperation? { + val text = getText() + + val validValue = text.toIntOrNull() != null + || text.isEmpty() // allow field to be completely emptied + || (fixedDigits == null && text == "-") // allow typing of negative numbers into empty field + if (!validValue) { + return null + } + + val targetLen = fixedDigits + if (targetLen != null) { + val newLen = text.length + val endOfLine = LinePosition(0, newLen, false) + when { + newLen < targetLen -> return AddTextOperation("0".repeat(targetLen - newLen), endOfLine) + newLen > targetLen -> return RemoveTextOperation( + endOfLine.offsetColumn(targetLen - newLen), + endOfLine, + false + ) + } + } + + return NoOperation() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/input/UITimeInput.kt b/src/main/kotlin/com/replaymod/core/gui/common/input/UITimeInput.kt new file mode 100644 index 000000000..b51758304 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/input/UITimeInput.kt @@ -0,0 +1,79 @@ +package com.replaymod.core.gui.common.input + +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class UITimeInput( + private val minuteDigits: Int? = 3, +) : UIAdvancedTextInput() { + + private fun Duration.formatString() = "%0${minuteDigits}d:%02d:%03d".format( + Locale.ROOT, + inWholeMinutes, + inWholeSeconds % 60, + inWholeMilliseconds % 1000 + ) + + private fun String.parseDuration(): Duration? = this + .split(":") + .also { if (it.size != 3) return null } + .zip(listOf(minuteDigits ?: 1, 2, 3)) + .map { (str, len) -> str.padEnd(len, '0').toIntOrNull() ?: return null } + .zip(listOf(DurationUnit.MINUTES, TimeUnit.SECONDS, TimeUnit.MILLISECONDS)) + .map { (int, unit) -> int.toDuration(unit) } + .reduce(Duration::plus) + + var value: Duration + get() = getText().parseDuration() ?: Duration.ZERO + set(value) { + val str = value.formatString() + if (getText() != str) { + setText(str) + } + } + + override fun fixText(): TextOperation? { + val unformatted = getText() + val duration = getText().parseDuration() ?: return null + val formatted = duration.formatString() + + if (formatted != unformatted) { + val start = LinePosition(0, 0, false) + return ReplaceTextOperation( + AddTextOperation(formatted, start), + RemoveTextOperation(start, LinePosition(0, unformatted.length, false), false) + ) + } + + return NoOperation() + } + + override fun commitTextOperation(operation: TextOperation) { + var op = operation + + // If they try to delete the separator, move the cursor before it instead + if (op is RemoveTextOperation && op.text == ":") { + cursor = op.startPos + otherSelectionEnd = cursor + return + } + + // If they add the final digit before a separator, move the cursor past the separator as well + if (op is AddTextOperation && getText().drop(op.endPos.column).startsWith(":")) { + op = AddTextOperation(op.newText + ":", op.startPos) + } + + // Replace any non-digits with the separator character, this allows us to skip the separator with any key + if (op is AddTextOperation && !getText().all { it.isDigit() || it == ':' }) { + op = AddTextOperation( + op.newText.map { if (it.isDigit()) it else ':' }.joinToString(), + op.startPos + ) + } + + super.commitTextOperation(op) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UIFlatScrollBar.kt b/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UIFlatScrollBar.kt new file mode 100644 index 000000000..229bd8323 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UIFlatScrollBar.kt @@ -0,0 +1,47 @@ +package com.replaymod.core.gui.common.scrollbar + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.dsl.childOf +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.percent +import gg.essential.elementa.dsl.provideDelegate +import gg.essential.elementa.utils.withAlpha +import gg.essential.universal.UMatrixStack +import java.awt.Color + +class UIFlatScrollBar(transparent: Boolean) : UIBlock(Color.BLACK.withAlpha(if(transparent) 0.5f else 1f)) { + val grip by Grip().constrain { + width = 100.percent + height = 100.percent + } childOf this + + init { + constrain { + width = 100.percent + height = 100.percent + } + } + + class Grip : UIComponent() { + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val l = getLeft().toDouble() + val r = getRight().toDouble() + val t = getTop().toDouble() + val b = getBottom().toDouble() + + // Slider + drawBlock(matrixStack, Color.LIGHT_GRAY, l, t, r, b) + + // Right shadow + drawBlock(matrixStack, Color.GRAY, r - 1, t, r, b) + + // Bottom shadow + drawBlock(matrixStack, Color.GRAY, l, b - 1, r, b) + + super.draw(matrixStack) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UITexturedScrollBar.kt b/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UITexturedScrollBar.kt new file mode 100644 index 000000000..25104f1ce --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/scrollbar/UITexturedScrollBar.kt @@ -0,0 +1,33 @@ +package com.replaymod.core.gui.common.scrollbar + +import com.replaymod.core.gui.common.UI9Slice +import com.replaymod.core.gui.common.timeline.UITimeline.Companion.TEXTURE +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.dsl.* + +class UITexturedScrollBar : UIContainer() { + val background by UI9Slice(TEXTURE, UI9Slice.TextureData.ofSize(64, 9, 2).offset(0, 7)).constrain { + width = 100.percent + height = 100.percent + } childOf this + + val inner by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent - 2.pixels + height = 100.percent - 2.pixels + } childOf background + + val grip by UI9Slice(TEXTURE, UI9Slice.TextureData.ofSize(62, 7, 2)).constrain { + width = 100.percent + height = 100.percent + } childOf inner + + init { + constrain { + width = 100.percent + height = 100.percent + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/state.kt b/src/main/kotlin/com/replaymod/core/gui/common/state.kt new file mode 100644 index 000000000..af2491896 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/state.kt @@ -0,0 +1,39 @@ +package com.replaymod.core.gui.common + +import gg.essential.elementa.state.State + +fun , T> S.lazy() = LazyState(this) +fun , T> S.bounded(constrain: (value: T) -> T) = BoundedState(this, constrain) + +open class DelegatingState, T>(val inner: S) : State() { + init { + @Suppress("LeakingThis") + inner.onSetValue(this::notifyListeners) + } + + protected open fun notifyListeners(value: T) { + super.set(value) + } + + override fun get(): T = inner.get() + override fun set(value: T) = inner.set(value) +} + +class LazyState, T>(inner: S) : DelegatingState(inner) { + private var dirty = false + + override fun notifyListeners(value: T) { + dirty = true + } + + fun flush() { + if (!dirty) return + super.notifyListeners(get()) + } +} + +class BoundedState, T>(inner: S, private val constrain: (value: T) -> T) : DelegatingState(inner) { + override fun notifyListeners(value: T) = super.notifyListeners(constrain(value)) + override fun get(): T = constrain(super.get()) + override fun set(value: T) = super.set(constrain(value)) +} diff --git a/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimeline.kt b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimeline.kt new file mode 100644 index 000000000..0dc1c42f2 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimeline.kt @@ -0,0 +1,153 @@ +package com.replaymod.core.gui.common.timeline + +import com.replaymod.core.gui.common.UI9Slice +import com.replaymod.core.gui.common.elementa.UIScrollComponent +import com.replaymod.core.gui.utils.actualHorizontalOffset +import com.replaymod.core.gui.utils.addTooltip +import com.replaymod.core.gui.utils.pollingState +import com.replaymod.core.gui.utils.selfOrParentOfType +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.ConstraintType +import gg.essential.elementa.constraints.WidthConstraint +import gg.essential.elementa.constraints.XConstraint +import gg.essential.elementa.constraints.resolution.ConstraintVisitor +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State +import gg.essential.elementa.state.pixels +import gg.essential.universal.UKeyboard +import net.minecraft.util.Identifier +import kotlin.math.pow +import kotlin.time.Duration + +class UITimeline : UIContainer() { + val length = BasicState(Duration.ZERO) + val offset = BasicState(Duration.ZERO) + val zoom = BasicState(1f) + private val widthOfView = pollingState(1f) { content.getWidth() } + private val widthOfContent = widthOfView.zip(zoom).map { (viewWidth, zoom) -> viewWidth / zoom } + val unit = length.zip(widthOfContent).map { (length, width) -> length / width.toDouble() } + + val lengthMillis get() = length.get().inWholeMilliseconds + + val background by UI9Slice(TEXTURE, UI9Slice.TextureData.ofSize(64, 22, 5, 5, 5, 5).offset(0, 16)).constrain { + width = 100.percent + height = 100.percent + } childOf this + + val content by UIScrollComponent(horizontalScrollEnabled = true, verticalScrollEnabled = false).constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent - 6.pixels + height = 100.percent - 6.pixels + } childOf this + + val contentSize by UIContainer().constrain { + width = widthOfContent.pixels() + } childOf content + + val indicators by UITimelineIndicators(this).constrain { + y = 0.pixels(alignOpposite = true) + width = widthOfContent.pixels() + height = 6.pixels + } + + val cursor by UITimelineCursor(this) childOf content + + init { + addTooltip { + addLine { + bindText(pollingState("") { + val time = getTimeAt(getMousePosition().first) + "%02d:%02d".format(time.inWholeMinutes, time.inWholeSeconds % 60) + }) + } + } + } + + fun getTimeAt(mouseX: Float): Duration { + val width = content.getWidth() + val innerX = (mouseX - content.getLeft()).coerceIn(0f, width) - content.actualHorizontalOffset + return Duration.milliseconds((unit.get() * innerX.toDouble()).inWholeMilliseconds) + } + + fun enableIndicators() = apply { + content.insertChildAt(indicators, 0) + } + + fun enableZooming() = apply { + content.mouseScrollListeners.clear() + onMouseScroll { event -> + event.stopImmediatePropagation() + + if (UKeyboard.isCtrlKeyDown()) { + val (absoluteMouseX, _) = getMousePosition() + val mouseX = absoluteMouseX - content.getLeft() + val fixedPos = mouseX - content.horizontalOffset + val oldZoom = zoom.get() + val newZoom = (oldZoom * (1.2).pow(-event.delta).toFloat()).coerceIn(0.001f, 1f) + val zoomedPos = fixedPos * oldZoom / newZoom + val newOffset = mouseX - zoomedPos + zoom.set(newZoom) + content.onWindowResize() + content.scrollTo(horizontalOffset = newOffset, smoothScroll = false) + } else { + content.onScroll(event.delta.toFloat(), isHorizontal = true) + } + } + } + + class Constraint(value: Duration) : XConstraint, WidthConstraint { + override var cachedValue = 0f + override var recalculate = true + override var constrainTo: UIComponent? = null + + private var valueState: State = BasicState(value) + + var value: Duration + get() = valueState.get() + set(value) { + valueState.set(value) + } + + fun bindValue(newState: State) = apply { + valueState = newState + } + + override fun getXPositionImpl(component: UIComponent): Float = getImpl(component, true) + override fun getWidthImpl(component: UIComponent): Float = getImpl(component, false) + + private fun getImpl(component: UIComponent, absolute: Boolean): Float { + val targetComponent = constrainTo ?: component.parent + val targetTimeline = targetComponent.selfOrParentOfType() + ?: throw IllegalStateException("$targetComponent needs to be the child of a timeline") + val targetContent = targetTimeline.content + val unit = targetTimeline.unit.get() + + val value = this.valueState.get() + + val offset = (value / unit).toFloat() + return if (absolute) { + targetContent.getLeft() + targetContent.actualHorizontalOffset + offset + } else { + offset + } + } + + override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) { + /** Can only visit the direct parent, but that's of no use to us and generates misleading errors. + when (type) { + ConstraintType.X -> visitor.visitParent(ConstraintType.X) + ConstraintType.WIDTH -> visitor.visitParent(ConstraintType.WIDTH) + else -> throw IllegalArgumentException(type.prettyName) + } + */ + } + } + + companion object { + internal val TEXTURE = Identifier("jgui", "gui.png") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineCursor.kt b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineCursor.kt new file mode 100644 index 000000000..fc5da8ab1 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineCursor.kt @@ -0,0 +1,47 @@ +package com.replaymod.core.gui.common.timeline + +import com.replaymod.core.gui.common.UITexture +import com.replaymod.core.gui.common.bounded +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import kotlin.time.Duration + +class UITimelineCursor(val timeline: UITimeline) : UIContainer() { + val position = BasicState(Duration.ZERO).bounded { it.coerceIn(Duration.ZERO..timeline.length.get()) } + + val positionMillis get() = position.get().inWholeMilliseconds + + private val pin by UITexture(UITimeline.TEXTURE, UITexture.TextureData.ofSize(5, 4).offset(64, 0)).constrain { + width = 5.pixels + height = 4.pixels + } childOf this + + private val needle by UITexture(UITimeline.TEXTURE, UITexture.TextureData.ofSize(5, 11).offset(64, 4)).constrain { + y = 4.pixels + width = 5.pixels + height = 100.percent - 4.pixels + } childOf this + + init { + constrain { + x = UITimeline.Constraint(Duration.ZERO).bindValue(position) - (2.5).pixels + width = 5.pixels + height = 100.percent + } + } + + override fun isPointInside(x: Float, y: Float): Boolean = false // the cursor should never obstruct clicks, etc. + + fun ensureVisibleWithPadding() = ensureVisible(padding = timeline.content.getWidth() / 10) + + fun ensureVisible(padding: Float = 0f) = apply { + val scroller = timeline.content + val position = getLeft() - parent.getLeft() + if (position - padding + scroller.horizontalOffset < 0) { + scroller.scrollTo(horizontalOffset = -(position - padding), smoothScroll = false) + } else if (position + padding + scroller.horizontalOffset > scroller.getWidth()) { + scroller.scrollTo(horizontalOffset = -(position + padding - scroller.getWidth()), smoothScroll = false) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineIndicators.kt b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineIndicators.kt new file mode 100644 index 000000000..bc15238aa --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineIndicators.kt @@ -0,0 +1,71 @@ +package com.replaymod.core.gui.common.timeline + +import com.replaymod.core.gui.utils.actualHorizontalOffset +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.universal.UMatrixStack +import java.awt.Color +import kotlin.time.Duration + +class UITimelineIndicators(val timeline: UITimeline) : UIComponent() { + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val height = getHeight() + val bottom = getBottom() + + val unit = timeline.unit.get() + val visibleLength = unit * timeline.content.getWidth().toDouble() + val offset = unit * -timeline.content.actualHorizontalOffset.toDouble() + val interval = getInterval() + val smallInterval = interval / 5 + var time = interval * (offset / interval).toInt() + var counter = 0 + while (time <= offset + visibleLength) { + if (time >= offset) { + val big = counter % 5 == 0 + val color = if (big) Color.LIGHT_GRAY else Color.WHITE + val x = UITimeline.Constraint(time).getXPosition(this) - 0.5 + val h = height / if (big) 1.0 else 2.0 + UIBlock.drawBlockSized(matrixStack, color, x, bottom - h, 1.0, h) + } + counter++ + time += smallInterval + } + + super.draw(matrixStack) + } + + internal fun getInterval(): Duration { + val width = timeline.content.getWidth().toDouble() // Width of the drawn timeline + val segmentLength = timeline.unit.get() * width // Length of the drawn timeline + val minDistance = MIN_DISTANCE * if (timeline.length.get() > Duration.Companion.hours(1)) 1.2 else 1.0 + val maxIndicators = width / minDistance // Max. amount of indicators that can fit in the timeline + val minInterval = segmentLength / maxIndicators // Min. interval between those indicators + return SNAP_TO.firstOrNull { it > minInterval } ?: SNAP_TO.last() // find next greater snap, fallback to max one + } + + companion object { + private const val MIN_DISTANCE = 40 + + private val SNAP_TO = listOf( + Duration.seconds(1), + Duration.seconds(2), + Duration.seconds(5), + Duration.seconds(10), + Duration.seconds(15), + Duration.seconds(20), + Duration.seconds(30), + Duration.minutes(1), + Duration.minutes(2), + Duration.minutes(5), + Duration.minutes(10), + Duration.minutes(15), + Duration.minutes(30), + Duration.hours(1), + Duration.hours(2), + Duration.hours(5), + Duration.hours(10), + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineTime.kt b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineTime.kt new file mode 100644 index 000000000..06fee96b9 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/common/timeline/UITimelineTime.kt @@ -0,0 +1,42 @@ +package com.replaymod.core.gui.common.timeline + +import com.replaymod.core.gui.utils.actualHorizontalOffset +import gg.essential.elementa.UIComponent +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.width +import gg.essential.universal.UMatrixStack +import kotlin.time.Duration + +class UITimelineTime(val timeline: UITimeline) : UIComponent() { + override fun draw(matrixStack: UMatrixStack) { + beforeDraw(matrixStack) + + val left = getLeft() + val right = getRight() + val top = getTop() + val fontProvider = getFontProvider() + val color = getColor() + + val unit = timeline.unit.get() + val visibleLength = unit * timeline.content.getWidth().toDouble() + val offset = unit * -timeline.content.actualHorizontalOffset.toDouble() + val interval = timeline.indicators.getInterval() + var time = interval * (offset / interval).toInt() + while (time <= offset + visibleLength) { + if (time >= offset) { + val str = if (timeline.length.get() > Duration.hours(1)) { + "%02d:%02d:%02d".format(time.inWholeHours, time.inWholeMinutes % 60, time.inWholeSeconds % 60) + } else { + "%02d:%02d".format(time.inWholeMinutes, time.inWholeSeconds % 60) + } + val halfStrWidth = str.width(fontProvider = fontProvider) / 2 + val x = (UITimeline.Constraint(time).boundTo(timeline).getXPosition(this) - 0.5f) + .coerceIn(left + halfStrWidth, right - halfStrWidth) + fontProvider.drawString(matrixStack, str, color, x - halfStrWidth, top, 1f, 1f) + } + time += interval + } + + super.draw(matrixStack) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/Axis.kt b/src/main/kotlin/com/replaymod/core/gui/utils/Axis.kt new file mode 100644 index 000000000..31aa524d5 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/Axis.kt @@ -0,0 +1,19 @@ +package com.replaymod.core.gui.utils + +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector3f +import java.awt.Color + +enum class Axis( + val color: Color, +) { + X(Color.RED), + Y(Color.GREEN), + Z(Color.BLUE), + ; + + fun toVector3f() = when (this) { + X -> Vector3f(1f, 0f, 0f) + Y -> Vector3f(0f, 1f, 0f) + Z -> Vector3f(0f, 0f, 1f) + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/boxSelect.kt b/src/main/kotlin/com/replaymod/core/gui/utils/boxSelect.kt new file mode 100644 index 000000000..5f127d53d --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/boxSelect.kt @@ -0,0 +1,155 @@ +package com.replaymod.core.gui.utils + +import com.replaymod.core.gui.utils.Box.Companion.toBox +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.Window +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.elementa.utils.withAlpha +import gg.essential.universal.UMatrixStack +import gg.essential.universal.UResolution +import java.awt.Color +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +interface BoxSelectable + +/** + * Enables box selections to be draw anywhere within this component (usually the window). + * This is safe to call multiple times on the same component (e.g. for the window by independent code). + */ +fun T.onBoxSelection(onChange: (selected: List, final: Boolean) -> Unit) = apply { + var currentBoxSelect: BoxSelect? = null + + onLeftClick { event -> + event.stopPropagation() + currentBoxSelect = BoxSelect(event.absoluteX to event.absoluteY) + } + onMouseDrag { mouseX, mouseY, _ -> + val boxSelect = currentBoxSelect ?: return@onMouseDrag + + boxSelect.second = (mouseX + getLeft()) to (mouseY + getTop()) + val box = boxSelect.box + + if (!boxSelect.passedThreshold) { + if (box.width < 3 && box.height < 3) { + return@onMouseDrag + } else { + boxSelect.passedThreshold = true + + if (effects.find { it is BoxSelectionEffect } == null) { + enableEffect(BoxSelectionEffect(boxSelect)) + } + } + } + onChange(findAllInBox(box), false) + } + onMouseRelease { + val boxSelect = currentBoxSelect?.also { currentBoxSelect = null } ?: return@onMouseRelease + if (!boxSelect.passedThreshold) { + return@onMouseRelease + } + removeEffect() + onChange(findAllInBox(boxSelect.box), true) + } +} + +private data class BoxSelect( + var first: Pair, + var second: Pair = first, + var passedThreshold: Boolean = false, +) { + val box: Box + get() { + val (x1, y1) = first + val (x2, y2) = second + return Box.fromBounds(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) + } +} + +/** Box around some center point (x, y). Note that (x, y) is **not** the top left point here, it is the center. */ +private data class Box(val x: Float, val y: Float, val width: Float, val height: Float) { + val left get() = x - width / 2 + val top get() = y - height / 2 + val right get() = x + width / 2 + val bottom get() = y + height / 2 + + private fun intersectsX(other: Box) = abs(this.x - other.x) * 2 < this.width + other.width + private fun intersectsY(other: Box) = abs(this.y - other.y) * 2 < this.height + other.height + fun intersects(other: Box) = intersectsX(other) && intersectsY(other) + + companion object { + fun fromBounds(left: Float, top: Float, right: Float, bottom: Float): Box { + val width = right - left + val height = bottom - top + return Box(left + width / 2, top + height / 2, width, height) + } + + fun UIComponent.toBox() = fromBounds(getLeft(), getTop(), getRight(), getBottom()) + } +} + +private val dummyMatrixStack = UMatrixStack() + +private fun UIComponent.findAllInBox(box: Box): List { + val parentWindow = Window.of(this) + val result = mutableListOf() + + fun UIComponent.collect() { + if (!box.intersects(this.toBox())) { + return + } + + if (this is BoxSelectable) { + result.add(this) + } + + // Window.isAreaVisible uses the scissor effect to determine whether an area is visible + val scissors = effects.filterIsInstance() + scissors.forEach { it.beforeDraw(dummyMatrixStack) } + + for (child in children) { + if (alwaysDrawChildren() || parentWindow.isAreaVisible( + child.getLeft().toDouble(), + child.getTop().toDouble(), + child.getRight().toDouble(), + child.getBottom().toDouble() + ) + ) { + child.collect() + } + } + + scissors.forEach { it.afterDraw(dummyMatrixStack) } + } + collect() + + return result +} + +/** + * Draws a highlight and border for the given box selection on top of a component. + */ +private class BoxSelectionEffect(val boxSelect: BoxSelect) : Effect() { + override fun afterDraw(matrixStack: UMatrixStack) { + val box = boxSelect.box + val l = box.left.toDouble() + val t = box.top.toDouble() + val r = box.right.toDouble() + val b = box.bottom.toDouble() + val w = box.width.toDouble() + val h = box.height.toDouble() + val d = 1.0 / UResolution.scaleFactor // a single (real) pixel + + // Background + UIBlock.drawBlock(matrixStack, Color.YELLOW.withAlpha(0.3f), l, t, r, b) + + // Outline + UIBlock.drawBlockSized(matrixStack, Color.YELLOW, l, t, d, h) // left + UIBlock.drawBlockSized(matrixStack, Color.YELLOW, l, t, w, d) // top + UIBlock.drawBlockSized(matrixStack, Color.YELLOW, r, t, d, h) // right + UIBlock.drawBlockSized(matrixStack, Color.YELLOW, l, b, w, d) // bottom + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/events.kt b/src/main/kotlin/com/replaymod/core/gui/utils/events.kt new file mode 100644 index 000000000..8d8abda75 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/events.kt @@ -0,0 +1,61 @@ +package com.replaymod.core.gui.utils + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.events.UIClickEvent +import gg.essential.elementa.utils.ObservableClearEvent +import gg.essential.elementa.utils.ObservableRemoveEvent +import java.util.Observer + +fun T.onAnimationFrame(block: T.() -> Unit) = apply { + enableEffect(object : Effect() { + override fun animationFrame() = block() + }) +} + +fun T.onLeftClick(clickCount: Int? = null, handler: T.(event: UIClickEvent) -> Unit) = apply { + onMouseClick { if (it.mouseButton == 0 && (clickCount == null || it.clickCount == clickCount)) handler(it) } +} + +fun T.onRightClick(clickCount: Int? = null, handler: T.(event: UIClickEvent) -> Unit) = apply { + onMouseClick { if (it.mouseButton == 1 && (clickCount == null || it.clickCount == clickCount)) handler(it) } +} + +fun T.onMiddleClick(clickCount: Int? = null, handler: T.(event: UIClickEvent) -> Unit) = apply { + onMouseClick { if (it.mouseButton == 2 && (clickCount == null || it.clickCount == clickCount)) handler(it) } +} + +private fun T.onMouse(mouseButton: Int, handler: T.(mouseX: Float, mouseY: Float) -> Unit) = apply { + var dragging = false + onMouseClick { + if (it.mouseButton == mouseButton) { + dragging = true + handler(it.absoluteX, it.absoluteY) + } + } + onMouseDrag { mouseX, mouseY, _ -> if (dragging) handler(getLeft() + mouseX, getTop() + mouseY) } + onMouseRelease { dragging = false } +} + +fun T.onLeftMouse(handler: T.(mouseX: Float, mouseY: Float) -> Unit) = onMouse(0, handler) +fun T.onRightMouse(handler: T.(mouseX: Float, mouseY: Float) -> Unit) = onMouse(1, handler) +fun T.onMiddleMouse(handler: T.(mouseX: Float, mouseY: Float) -> Unit) = onMouse(2, handler) + +// Elementa has no unmount event, so instead we listen for changes to the children list of all our parents. +fun UIComponent.onRemoved(listener: () -> Unit): () -> Unit { + if (parent == this) { + return {} + } + val parentUnregister = parent.onRemoved(listener) + + val observer = Observer { _, event -> + if (event is ObservableClearEvent<*> || event is ObservableRemoveEvent<*> && event.element.value == this) { + listener() + } + } + parent.children.addObserver(observer) + return { + parent.children.deleteObserver(observer) + parentUnregister() + } +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/extensions.kt b/src/main/kotlin/com/replaymod/core/gui/utils/extensions.kt new file mode 100644 index 000000000..314383ff1 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/extensions.kt @@ -0,0 +1,25 @@ +package com.replaymod.core.gui.utils + +import com.replaymod.core.gui.common.elementa.UIScrollComponent +import gg.essential.elementa.UIComponent +import gg.essential.elementa.dsl.* + +infix fun T.hiddenChildOf(parent: UIComponent) = (this childOf parent).also { this.hide(true) } + +inline fun UIComponent.parentOfType(): T? = + if (hasParent && parent != this) parent.selfOrParentOfType() else null + +inline fun UIComponent.selfOrParentOfType(): T? { + var component: UIComponent = this + while (component.hasParent && component.parent != component) { + if (component is T) { + return component + } + component = component.parent + } + return null +} + +/** Like [UIScrollComponent.horizontalOffset] but including any animations. */ +val UIScrollComponent.actualHorizontalOffset + get() = children.first().getLeft() - getLeft() diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/focus.kt b/src/main/kotlin/com/replaymod/core/gui/utils/focus.kt new file mode 100644 index 000000000..ec2f89336 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/focus.kt @@ -0,0 +1,81 @@ +package com.replaymod.core.gui.utils + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.effects.Effect +import gg.essential.universal.UKeyboard + +/** Marks components which can receive focus via [enableTabFocusChange]. */ +private class CanReceiveFocus : Effect() + +/** Limits (tab) focus changes to stay within the children of the decorated component. */ +class FocusTrap : Effect() + +/** + * Enables tab-behavior for this component. Specifically this allows the user to switch away from this component to + * instead focus the next component by pressing Tab (with Shift to go backwards) and in turn allows this component to + * receive focus from another tab-enabled component. + * + * Use [FocusTrap] to limit tab switching to specific sub-tree of the component hierarchy. + */ +fun T.enableTabFocusChange() = apply { + enableEffect(CanReceiveFocus()) + onKeyType { _, keyCode -> + if (keyCode == UKeyboard.KEY_TAB) { + val backwards = UKeyboard.isShiftKeyDown() + val nextComponent = parent.findNextFocusableComponent(this, backwards) ?: return@onKeyType + nextComponent.grabWindowFocus() + } + } +} + +/** + * Returns the next focusable component after the given [fromComponent] as returned by a depth-first search of the + * entire component hierarchy (but without actually running the entire search, and wrapping around). + * If this or a parent component has the [FocusTrap] effect, then the search will stay within the sub-tree with the + * respective component at its root. + * + * When [backwards] is `true`, the entire search is performed in reverse, returning the previous focusable component. + * + * When [fromComponent] is given, then this will recursively search up the tree if the next element cannot be found in + * this subtree. If [fromComponent] is `null` (searching a sibling sub-tree of the original sub-tree), then `null` is + * return if the next element cannot be found in this subtree. + * + * If the search is unsuccessful (because there is no focusable component other than [fromComponent]), then `null` is + * returned. + */ +private fun UIComponent.findNextFocusableComponent(fromComponent: UIComponent?, backwards: Boolean): UIComponent? { + fun findNextInRange(range: IntProgression): UIComponent? { + for (index in range) { + val child = children[index] + return if (child.effects.any { it is CanReceiveFocus }) { + child + } else { + child.findNextFocusableComponent(null, backwards) ?: continue + } + } + return null + } + + if (fromComponent != null) { + val fromIndex = children.indexOf(fromComponent) + val higherRange = (fromIndex + 1)..children.lastIndex + val lowerRange = 0 until fromIndex + + // first check siblings in the respective direction + findNextInRange(if (backwards) lowerRange.reversed() else higherRange)?.let { return it } + + // then go one level up and check siblings there, recursively + if (parent != this && effects.none { it is FocusTrap }) { + parent.findNextFocusableComponent(this, backwards)?.let { return it } + } + + // and finally, back on this level, check the other half of siblings + findNextInRange(if (backwards) higherRange.reversed() else lowerRange)?.let { return it } + } else { + // check all children in the respective order + findNextInRange(if (backwards) children.indices.reversed() else children.indices)?.let { return it } + } + + // looped all the way round and still haven't found anything + return null +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/resources.kt b/src/main/kotlin/com/replaymod/core/gui/utils/resources.kt new file mode 100644 index 000000000..142d277b4 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/resources.kt @@ -0,0 +1,10 @@ +package com.replaymod.core.gui.utils + +import com.replaymod.core.ReplayMod +import net.minecraft.util.Identifier + +object Resources { + private const val namespace = ReplayMod.MOD_ID + + fun icon(name: String) = Identifier(namespace, "icons/$name.png") +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/state.kt b/src/main/kotlin/com/replaymod/core/gui/utils/state.kt new file mode 100644 index 000000000..25e7b5b22 --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/state.kt @@ -0,0 +1,56 @@ +package com.replaymod.core.gui.utils + +import gg.essential.elementa.UIComponent +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.State + +fun State.onSetValueAndNow(block: (value: T) -> Unit) = onSetValue(block).also { block(get()) } + +fun UIComponent.pollingState(initialValue: T? = null, getter: () -> T): State { + val state = BasicState(initialValue ?: getter()) + enableEffect(object : Effect() { + override fun animationFrame() { + state.set(getter()) + } + }) + return state +} + +fun T.bindStateTransition( + state: State, + update: T.(done: () -> Unit, oldState: S, newState: S) -> Unit, +) = apply { + state.bindTransition { done, oldState, newState -> update(done, oldState, newState) } +} + +fun State.bindTransition(update: (done: () -> Unit, oldState: S, newState: S) -> Unit) { + var activeState = get() + var pendingState: S? = null + var inProgress = false + + fun onStateChanged(newState: S) { + if (inProgress) { + pendingState = newState + return + } + + if (newState == activeState) { + return + } + + val oldState = activeState + activeState = newState + + inProgress = true + update({ + inProgress = false + pendingState?.also { pendingState = null }?.let(::onStateChanged) + }, oldState, newState) + } + onSetValue(::onStateChanged) +} + +fun State.zip(b: State, c: State): State> = zip(b).zip(c).map { values -> + Triple(values.first.first, values.first.second, values.second) +} diff --git a/src/main/kotlin/com/replaymod/core/gui/utils/tooltip.kt b/src/main/kotlin/com/replaymod/core/gui/utils/tooltip.kt new file mode 100644 index 000000000..d3f22ddaa --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/gui/utils/tooltip.kt @@ -0,0 +1,52 @@ +package com.replaymod.core.gui.utils + +import com.replaymod.core.gui.common.UITooltip +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.MousePositionConstraint +import gg.essential.elementa.dsl.* + +fun T.addTooltip(configure: UITooltip.() -> Unit) = addTooltip(UITooltip().apply(configure)) + +fun T.addTooltip(tooltip: UIComponent) = apply { + tooltip.constrain { + // Slightly right of the cursor but never off-screen + x = (MousePositionConstraint() + 8.pixels) + .coerceAtMost(100.percentOfWindow - basicXConstraint { tooltip.getWidth() }) + // Slightly below the cursor except when there is insufficient space, then slightly above it + y = basicYConstraint { + val mouseY = MousePositionConstraint().getYPosition(it) + val idealY = mouseY + 8 + val height = tooltip.getHeight() + if (idealY + height <= 100.percentOfWindow.getYPosition(it)) { + idealY + } else { + mouseY - 8 - height + } + } + } + + var unregister: (() -> Unit)? = null + + onMouseEnter { + Window.enqueueRenderOperation { + tooltip childOf Window.of(this) + tooltip.setFloating(true) + val unregisterOnRemoved = this.onRemoved { + unregister?.invoke() + unregister = null + } + unregister = { + unregisterOnRemoved.invoke() + Window.enqueueRenderOperation { + tooltip.setFloating(false) + tooltip.hide(true) + } + } + } + } + onMouseLeave { + unregister?.invoke() + unregister = null + } +} diff --git a/src/main/kotlin/com/replaymod/core/utils/extensions.kt b/src/main/kotlin/com/replaymod/core/utils/extensions.kt new file mode 100644 index 000000000..61c5c246f --- /dev/null +++ b/src/main/kotlin/com/replaymod/core/utils/extensions.kt @@ -0,0 +1,28 @@ +package com.replaymod.core.utils + +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector3f +import net.minecraft.client.resource.language.I18n +import java.util.* + +fun String.i18n(vararg args: String): String = I18n.translate(this, *args) + +val Optional?.orNull get() = this?.orElse(null) + +fun Pair?.transpose() = this ?: Pair(null, null) + +val org.apache.commons.lang3.tuple.Triple.kt: Triple + get() = Triple(left, middle, right) + +fun Triple.toDouble() = Triple(first.toDouble(), second.toDouble(), third.toDouble()) + +fun Triple.toVector3f() = Vector3f(first.toFloat(), second.toFloat(), third.toFloat()) + +inline fun Iterable.associateNotNull(transform: (T) -> Pair?): Map { + val destination = LinkedHashMap((this as? Collection)?.size ?: 16) + for (element in this) { + destination += transform(element) ?: continue + } + return destination +} + +fun MutableSet.toggle(element: T) = if (element in this) remove(element) else add(element) diff --git a/src/main/kotlin/com/replaymod/replay/gui/overlay/GuiReplayOverlayKt.kt b/src/main/kotlin/com/replaymod/replay/gui/overlay/GuiReplayOverlayKt.kt new file mode 100644 index 000000000..99cd67e28 --- /dev/null +++ b/src/main/kotlin/com/replaymod/replay/gui/overlay/GuiReplayOverlayKt.kt @@ -0,0 +1,34 @@ +package com.replaymod.replay.gui.overlay + +import com.replaymod.core.gui.utils.hiddenChildOf +import com.replaymod.replay.gui.overlay.panels.UIHotkeyButtonsPanel +import gg.essential.elementa.ElementaVersion +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.dsl.* + +class GuiReplayOverlayKt { + val window = Window(ElementaVersion.V1, 60) + + val bottomLeftPanel by UIContainer().constrain { + x = 6.pixels(alignOpposite = false) + y = 6.pixels(alignOpposite = true) + // Children will be next to each other + width = ChildBasedSizeConstraint() + height = ChildBasedMaxSizeConstraint() + } childOf window + + val bottomRightPanel by UIContainer().constrain { + x = 6.pixels(alignOpposite = true) + y = 6.pixels(alignOpposite = true) + // Children will be on top of each other + width = ChildBasedMaxSizeConstraint() + height = ChildBasedSizeConstraint() + } childOf window + + val hotkeyButtonsPanel by UIHotkeyButtonsPanel().apply { + toggleButton childOf bottomRightPanel + } hiddenChildOf window +} diff --git a/src/main/kotlin/com/replaymod/replay/gui/overlay/UIStatusIndicator.kt b/src/main/kotlin/com/replaymod/replay/gui/overlay/UIStatusIndicator.kt new file mode 100644 index 000000000..3fe316833 --- /dev/null +++ b/src/main/kotlin/com/replaymod/replay/gui/overlay/UIStatusIndicator.kt @@ -0,0 +1,29 @@ +package com.replaymod.replay.gui.overlay + +import com.replaymod.core.ReplayMod +import com.replaymod.core.gui.common.UITexture +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.dsl.childOf +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.dsl.provideDelegate + +class UIStatusIndicator(u: Int, v: Int) : UIContainer() { + val icon by UITexture(ReplayMod.TEXTURE, UITexture.TextureData.ofSize(16, 16).offset(u, v)).constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 16.pixels + height = 16.pixels + } childOf this + + init { + constrain { + x = SiblingConstraint(4f) + y = 0.pixels(alignOpposite = true) + width = 20.pixels + height = 20.pixels + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIHotkeyButtonsPanel.kt b/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIHotkeyButtonsPanel.kt new file mode 100644 index 000000000..29c210c7e --- /dev/null +++ b/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIHotkeyButtonsPanel.kt @@ -0,0 +1,132 @@ +package com.replaymod.replay.gui.overlay.panels + +import com.replaymod.core.KeyBindingRegistry +import com.replaymod.core.ReplayMod +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.common.scrollbar.UIFlatScrollBar +import com.replaymod.core.gui.common.UITexture +import com.replaymod.core.gui.common.elementa.UIScrollComponent +import com.replaymod.core.gui.utils.addTooltip +import com.replaymod.core.gui.utils.pollingState +import com.replaymod.core.utils.i18n +import com.replaymod.core.utils.* +import gg.essential.elementa.components.* +import gg.essential.elementa.components.input.UITextInput +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import net.minecraft.client.gui.screen.Screen +import net.minecraft.client.resource.language.I18n +import java.awt.Color + +class UIHotkeyButtonsPanel( + keyBindings: Collection = ReplayMod.instance.keyBindingRegistry.bindings.values, +) : UIToggleablePanel() { + init { + toggleButton.texture(ReplayMod.TEXTURE, UITexture.TextureData.ofSize(0, 120, 20, 20)) + + constrain { + width = 200.pixels + height = 100.percent - 80.pixels + } + } + + val scrollContainer by UIContainer().constrain { + x = CenterConstraint() + y = 4.pixels + height = 100.percent - 20.pixels - 8.pixels + width = 100.percent - 8.pixels + } childOf this + + val scrollComponent by UIScrollComponent().constrain { + width = 100.percent - 10.pixels + height = 100.percent + }.apply { + for (keyBinding in keyBindings.sortedBy { I18n.translate(it.name) }) { + addChild(Row(keyBinding)) + } + } childOf scrollContainer + + val scrollBar by UIFlatScrollBar(transparent = true).constrain { + x = 0.pixels(alignOpposite = true) + width = 6.pixels + }.apply { + scrollComponent.setVerticalScrollBarComponent(grip) + } childOf scrollContainer + + val filter by UITextInput("Click to filter").constrain { + x = 10.pixels + y = (2 + 5).pixels(alignOpposite = true) + width = 100.percent - 10.pixels - 20.pixels - 2.pixels + height = 10.pixels + }.apply { + // When the panel is opened, immediately grant focus and clear it (so we can get straight to typing) + open.onSetValue { + if (it) { + grabWindowFocus() + setText("") + } + } + }.onUpdate { search -> + scrollComponent.filterChildren { (it as Row).label.getText().contains(search, ignoreCase = true) } + }.onActivate { + val row = scrollComponent.childrenOfType().singleOrNull() ?: return@onActivate + open.set(false) + row.keyBinding.trigger() + }.onMouseClick { + grabWindowFocus() + } childOf this + + inner class Row(val keyBinding: KeyBindingRegistry.Binding) : UIContainer() { + val button by UIButton().constrain { + y = CenterConstraint() + width = ChildBasedSizeConstraint().coerceAtLeast(10.pixels) + 10.pixels + height = 20.pixels + }.label { + bindText(pollingState { if (keyBinding.isBound) keyBinding.boundKey else "" }) + constrain { + val orgColor = color + color = basicColorConstraint { + if (keyBinding.isAutoActivating) Color.GREEN else orgColor.getColor(it) + } + } + }.onMouseClick { + it.stopImmediatePropagation() + + if (keyBinding.supportsAutoActivation() && Screen.hasControlDown()) { + keyBinding.isAutoActivating = !keyBinding.isAutoActivating + } else { + keyBinding.trigger() + } + }.apply { + if (keyBinding.supportsAutoActivation()) { + addTooltip { + addLine("replaymod.gui.ingame.autoactivating".i18n()) + addLine { + bindText(pollingState { + if (keyBinding.isAutoActivating) { + "replaymod.gui.ingame.autoactivating.disable".i18n() + } else { + "replaymod.gui.ingame.autoactivating.enable".i18n() + } + }) + } + } + } + } + + val label = UIWrappedText().constrain { + x = SiblingConstraint(padding = 4f) + y = CenterConstraint() + width = FillConstraint(useSiblings = false) + }.setText(keyBinding.name.i18n()) + + init { + constrain { + width = 100.percent + height = 25.pixels + y = SiblingConstraint() + } + addChildren(button, label) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIToggleablePanel.kt b/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIToggleablePanel.kt new file mode 100644 index 000000000..33fc5eaa3 --- /dev/null +++ b/src/main/kotlin/com/replaymod/replay/gui/overlay/panels/UIToggleablePanel.kt @@ -0,0 +1,56 @@ +package com.replaymod.replay.gui.overlay.panels + +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.utils.bindStateTransition +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.constraints.animation.Animations +import gg.essential.elementa.dsl.boundTo +import gg.essential.elementa.dsl.constrain +import gg.essential.elementa.dsl.pixels +import gg.essential.elementa.dsl.provideDelegate +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.transitions.ExpandFromTransition +import gg.essential.elementa.transitions.ShrinkToTransition +import gg.essential.elementa.utils.withAlpha +import java.awt.Color + +open class UIToggleablePanel : UIBlock(Color.BLACK.withAlpha(0.6f)) { + val open = BasicState(false) + + val toggleButton by UIButton().constrain { + x = 0.pixels(alignOpposite = true) + y = SiblingConstraint(4f) + width = 20.pixels + height = 20.pixels + }.onMouseClick { + open.set { !it } + } as UIButton + + init { + constrain { + x = (-2).pixels(alignOpposite = true) boundTo toggleButton + y = (-2).pixels(alignOpposite = true) boundTo toggleButton + } + + bindStateTransition(open) { done, _, open -> + if (open) { + unhide() + ExpandFromTransition.Bottom(0.5f, Animations.IN_OUT_EXP).transition(this) { + done() + } + + // Make sure the toggle button is on top of the panel + toggleButton.setFloating(true) + } else { + ShrinkToTransition.Bottom(0.5f, Animations.IN_OUT_EXP, true).transition(this) { + hide(true) + + toggleButton.setFloating(false) + + done() + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/GuiPathingKt.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/GuiPathingKt.kt new file mode 100644 index 000000000..9ef626184 --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/GuiPathingKt.kt @@ -0,0 +1,447 @@ +package com.replaymod.simplepathing.gui + +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.replaymod.core.ReplayMod +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.common.UITexture +import com.replaymod.core.gui.common.scrollbar.UITexturedScrollBar +import com.replaymod.core.gui.common.timeline.UITimeline +import com.replaymod.core.gui.common.timeline.UITimelineTime +import com.replaymod.core.gui.utils.* +import com.replaymod.core.utils.i18n +import com.replaymod.core.versions.MCVer.Keyboard +import com.replaymod.pathing.player.RealtimeTimelinePlayer +import com.replaymod.render.gui.GuiRenderQueue +import com.replaymod.render.gui.GuiRenderSettings +import com.replaymod.replay.ReplayHandler +import com.replaymod.replaystudio.pathing.change.CombinedChange +import com.replaymod.simplepathing.SPTimeline +import com.replaymod.simplepathing.SPTimeline.SPPath +import com.replaymod.simplepathing.Setting +import com.replaymod.simplepathing.gui.panels.UIPositionKeyframePanel +import com.replaymod.simplepathing.gui.panels.UIPositionOffsetPanel +import com.replaymod.simplepathing.gui.panels.UITimeOffsetPanel +import com.replaymod.simplepathing.gui.panels.UITimePanel +import de.johni0702.minecraft.gui.popup.GuiInfoPopup +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.State +import net.minecraft.client.resource.language.I18n +import java.util.concurrent.CancellationException +import kotlin.time.Duration + +class GuiPathingKt( + val java: GuiPathing, + val replayHandler: ReplayHandler, +) { + val state = KeyframeState(java.mod, this) + private val overlay = replayHandler.overlay + private val mod = java.mod + private val core = mod.core + private val window = overlay.kt.window + private val player = RealtimeTimelinePlayer(replayHandler) + + private val isCtrlDown = window.pollingState { Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) } + private val isShiftDown = window.pollingState { Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) } + + private val playing = window.pollingState { player.isActive } + + init { + window.onAnimationFrame { state.update() } + + window.onBoxSelection { components, _ -> + state.selection.set(KeyframeState.Selection.EMPTY.mutate { + for (keyframe in components.filterIsInstance()) { + this[keyframe.type.path] += keyframe.time + } + }) + } + } + + private val secondRow by UIContainer().constrain { + x = CenterConstraint() + y = 30.pixels /* topPanel.bottom */ + 13.pixels + width = 100.percent - 20.pixels + height = 20.pixels + } childOf window + + private val playPauseButton by UIButton().constrain { + width = 20.pixels + height = 20.pixels + }.texture(ReplayMod.TEXTURE, playing.map { + UITexture.TextureData.ofSize(0, if (it) 20 else 0, 20, 20) + }).addTooltip { + addLine { + bindText(playing.zip(isCtrlDown).map { (playing, isCtrlDown) -> + when { + playing -> "replaymod.gui.ingame.menu.pausepath" + isCtrlDown -> "replaymod.gui.ingame.menu.playpathfromstart" + else -> "replaymod.gui.ingame.menu.playpath" + }.i18n() + }) + } + }.onMouseClick { + if (player.isActive) { + player.future.cancel(false) + } else { + val ignoreTimeKeyframes = isShiftDown.get() + + val timeline = java.preparePathsForPlayback(ignoreTimeKeyframes).okOrElse { err -> + GuiInfoPopup.open(overlay, *err) + null + } ?: return@onMouseClick + + val timePath = SPTimeline(timeline).timePath + timePath.isActive = !ignoreTimeKeyframes + + // Start from cursor time unless the control key is pressed (then start from beginning) + val startTime = if (isCtrlDown.get()) Duration.ZERO else this@GuiPathingKt.timeline.cursor.position.get() + val future: ListenableFuture = player.start(timeline, startTime.inWholeMilliseconds) + overlay.isCloseable = false + overlay.isMouseVisible = true + core.printInfoToChat("replaymod.chat.pathstarted") + Futures.addCallback(future, object : FutureCallback { + override fun onSuccess(result: Void?) { + if (future.isCancelled) { + core.printInfoToChat("replaymod.chat.pathinterrupted") + } else { + core.printInfoToChat("replaymod.chat.pathfinished") + } + overlay.isCloseable = true + } + + override fun onFailure(t: Throwable) { + if (t !is CancellationException) { + t.printStackTrace() + } + overlay.isCloseable = true + } + //#if MC>=11800 + //$$ }, Runnable::run) + //#else + }) + //#endif + } + } childOf secondRow + + private val renderButton by UIButton().constrain { + x = SiblingConstraint(5f) + width = 20.pixels + height = 20.pixels + }.texture(ReplayMod.TEXTURE, UITexture.TextureData.ofSize(40, 0, 20, 20)) { + }.addTooltip { + addLine("replaymod.gui.ingame.menu.renderpath".i18n()) + }.onMouseClick { + abortPathPlayback() + val screen = GuiRenderSettings.createBaseScreen() + object : GuiRenderQueue(screen, replayHandler, { java.preparePathsForPlayback(false) }) { + override fun close() { + super.close() + minecraft.openScreen(null) + } + }.open() + screen.display() + } childOf secondRow + + private data class PositionButtonType( + val type: KeyframeType = KeyframeType.POSITION, + val remove: Boolean = false, + ) + + private val positionKeyframeButtonType = window.pollingState(PositionButtonType()) { + val keyframe = state.selectedPositionKeyframes.get().values.firstOrNull() + PositionButtonType(when { + keyframe?.entityId != null -> KeyframeType.SPECTATOR + keyframe == null && !replayHandler.isCameraView -> KeyframeType.SPECTATOR + else -> KeyframeType.POSITION + }, keyframe != null) + } + + private val positionKeyframeButton by UIButton().constrain { + x = SiblingConstraint(5f) + width = 20.pixels + height = 20.pixels + }.texture(ReplayMod.TEXTURE, positionKeyframeButtonType.map { (type, remove) -> + type.buttonIcon.offset(0, if (remove) 20 else 0) + }).addTooltip { + addLine { + bindText(positionKeyframeButtonType.map { (type, remove) -> + type.tooltip(!remove).i18n() + " (" + mod.keyPositionKeyframe.boundKey + ")" + }) + } + }.onMouseClick { + toggleKeyframe(positionKeyframeButtonType.get().type) + } childOf secondRow + + private val timeKeyframePresent: State = window.pollingState(false) { + val keyframe = state.selectedTimeKeyframes.get().values.firstOrNull() + keyframe != null + } + + private val timeKeyframeButton by UIButton().constrain { + x = SiblingConstraint(5f) + width = 20.pixels + height = 20.pixels + }.texture(ReplayMod.TEXTURE, timeKeyframePresent.map { present -> + KeyframeType.TIME.buttonIcon.offset(0, if (present) 20 else 0) + }).addTooltip { + addLine { + bindText(timeKeyframePresent.map { present -> + KeyframeType.TIME.tooltip(!present).i18n() + " (" + mod.keyTimeKeyframe.boundKey + ")" + }) + } + }.onMouseClick { + toggleKeyframe(KeyframeType.TIME) + } childOf secondRow + + private val unusedButton by UIContainer().constrain { + x = SiblingConstraint(5f) + width = 25.pixels + height = 20.pixels + } childOf secondRow + + val timeline by UITimeline().constrain { + x = SiblingConstraint(5f) + width = FillConstraint(false) + height = 20.pixels + }.apply { + enableIndicators() + enableZooming() + + zoom.set(0.1f) + length.set(Duration.seconds(core.settingsRegistry.get(Setting.TIMELINE_LENGTH))) + + content.insertChildBefore(UITimelineKeyframes(state, overlay.timeline), cursor) + }.onLeftMouse { mouseX, _ -> + val time = getTimeAt(mouseX) + cursor.position.set(time) + state.selection.set(KeyframeState.Selection.EMPTY) + }.onLeftClick { + it.stopImmediatePropagation() + }.onAnimationFrame { + if (player.isActive) { + cursor.position.set(Duration.milliseconds(player.timePassed)) + cursor.ensureVisibleWithPadding() + } + } childOf secondRow + + private val belowTimeline by UIContainer().constrain { + x = 0.pixels boundTo timeline + y = SiblingConstraint(1f) boundTo timeline + width = CopyConstraintFloat() boundTo timeline + height = 9.pixels + } childOf window + + private val belowTimelineButtons by UIContainer().constrain { + width = ChildBasedSizeConstraint() + height = 100.percent + } childOf belowTimeline + + private val scrollbar by UITexturedScrollBar().constrain { + x = SiblingConstraint(2f) + width = FillConstraint(useSiblings = false) + height = 100.percent + }.apply { + timeline.content.setHorizontalScrollBarComponent(grip) + } childOf belowTimeline + + private val timelineTime by UITimelineTime(timeline).constrain { + x = 0.pixels boundTo timeline + y = SiblingConstraint(alignOpposite = true) boundTo timeline + width = CopyConstraintFloat() boundTo timeline + height = 8.pixels + } childOf window + + private val zoomInButton by UIButton().constrain { + x = SiblingConstraint(2f) + width = 9.pixels + height = 9.pixels + }.texture(ReplayMod.TEXTURE, UITexture.TextureData.ofSize(40, 20, 9, 9)) { + }.addTooltip { + addLine("replaymod.gui.ingame.menu.zoomin".i18n()) + }.onMouseClick { + timeline.zoom.set { it * 2 / 3 } + } childOf belowTimelineButtons + + private val zoomOutButton by UIButton().constrain { + x = SiblingConstraint(2f) + width = 9.pixels + height = 9.pixels + }.texture(ReplayMod.TEXTURE, UITexture.TextureData.ofSize(40, 30, 9, 9)) { + }.addTooltip { + addLine("replaymod.gui.ingame.menu.zoomout".i18n()) + }.onMouseClick { + timeline.zoom.set { it * 3 / 2 } + } childOf belowTimelineButtons + + private val positionKeyframePanel by UIPositionKeyframePanel(state).apply { + overlay.kt.bottomRightPanel.insertChildAt(toggleButton, 0) + } hiddenChildOf window + + private val timePanel by UITimePanel(state).constrain { + x = 0.pixels boundTo belowTimeline + y = SiblingConstraint(1f) boundTo belowTimeline + }.apply { + belowTimelineButtons.insertChildAt(toggleButton.constrain { + x = SiblingConstraint(2f) + }, 0) + } hiddenChildOf window + + private val timeOffsetPanel by UITimeOffsetPanel(state, timePanel).constrain { + x = 0.pixels boundTo secondRow + y = SiblingConstraint(1f) boundTo belowTimeline + width = basicWidthConstraint { timePanel.getLeft() - 5f - it.getLeft() } + } hiddenChildOf window + + val positionOffsetPanel by UIPositionOffsetPanel(window, state).apply { + toggleButton.constrain { + x = SiblingConstraint(4f) + y = 0.pixels(alignOpposite = true) + } + overlay.kt.bottomLeftPanel.insertChildAt(toggleButton, 0) + } hiddenChildOf window + + init { + val speedValue = window.pollingState { overlay.speedSlider.value } + val replayTime = window.pollingState { replayHandler.replaySender.currentTimeStamp() } + speedValue.zip(replayTime).onSetValue { + if (mod.keySyncTime.isAutoActivating && !player.isActive) { + syncTimeButtonPressed() + } + } + } + + fun abortPathPlayback() { + if (!player.isActive) { + return + } + val future = player.future + if (!future.isDone && !future.isCancelled) { + future.cancel(false) + } + // Tear down of the player might only happen the next tick after it was cancelled + player.onTick() + } + + private fun computeSyncTime(cursor: Duration): Duration? { + // Current replay time + val currentReplayTime = Duration.milliseconds(replayHandler.replaySender.currentTimeStamp()) + // Get the last time keyframe before the cursor + val (keyframeCursor, keyframe) = state.timeKeyframes.get().entries.findLast { (time, _) -> time <= cursor } + ?: return null + val keyframeReplayTime = keyframe.replayTime + // Replay time passed + val replayTimePassed = currentReplayTime - keyframeReplayTime + // Speed (set to 1 when shift is held) + val speed = if (Keyboard.isKeyDown(Keyboard.KEY_LSHIFT)) 1.0 else overlay.speedSliderValue + // Cursor time passed + val cursorPassed = replayTimePassed / speed + // Return new position + return keyframeCursor + cursorPassed + } + + fun syncTimeButtonPressed() { + // Position of the cursor + var cursor = timeline.cursor.position.get() + + + // Update cursor once + cursor = computeSyncTime(cursor) + ?: return // no keyframes before cursor, nothing we can do + + // Repeatedly update until we find a fix point + while (true) { + // If the cursor has gotten stuck before in front of all keyframes, + // let's just use the last value we got, this shouldn't happen with ordinary timelines anyway. + val updatedCursor = computeSyncTime(cursor) ?: break + + if (updatedCursor == cursor) { + // Found the fix point, we can stop now + break + } + if (updatedCursor < cursor) { + // We've gone backwards, we'll likely get stuck in a loop, so abort the whole thing + return + } + // Found a new position, take it, repeat + cursor = updatedCursor + } + + // Move cursor to new position + timeline.cursor.position.set(cursor) + timeline.cursor.ensureVisibleWithPadding() + // Deselect keyframe to allow the user to add a new one right away + state.selection.set(KeyframeState.Selection.EMPTY) + } + + @JvmOverloads + fun toggleKeyframe(type: KeyframeType, neverSpectator: Boolean = false) { + val path = type.path + val time = timeline.cursor.position.get() + val timeline = mod.currentTimeline + + if (state.keyframes.values.all { it.get().isEmpty() } && time > Duration.seconds(1)) { + val text = I18n.translate("replaymod.gui.ingame.first_keyframe_not_at_start_warning") + GuiInfoPopup.open(overlay, *text.split("\\n").toTypedArray()) + } + + val selection = state.selection.get()[path] + if (selection.isEmpty()) { + // Nothing selected, create new keyframe + + // If a keyframe is already present at this time, cannot add another one + if (time in state[path].get()) { + return + } + + when (path) { + SPPath.TIME -> { + timeline.addTimeKeyframe(time.inWholeMilliseconds, replayHandler.replaySender.currentTimeStamp()) + } + SPPath.POSITION -> { + val camera = replayHandler.cameraEntity + var spectatedId = -1 + if (!replayHandler.isCameraView && !neverSpectator) { + // FIXME preprocessor bug: there are mappings for this + //#if MC>=11700 + // spectatedId = replayHandler.overlay.minecraft.getCameraEntity()!!.id + //#else + spectatedId = replayHandler.overlay.minecraft.getCameraEntity()!!.entityId + //#endif + } + timeline.addPositionKeyframe(time.inWholeMilliseconds, + camera.x, camera.y, camera.z, + camera.yaw, camera.pitch, camera.roll, + spectatedId) + } + } + } else { + // Keyframe(s) selected, remove them + state.selection.set { it.mutate { this[path].clear() } } + val changes = selection.map { timeline.removeKeyframe(path, it.inWholeMilliseconds) } + timeline.timeline.pushChange(CombinedChange.createFromApplied(*changes.toTypedArray())) + } + + state.update() + } + + fun deleteButtonPressed(): Boolean { + val timeline = mod.currentTimeline ?: return false + + val selection = state.selection.get().toMap() + if (selection.all { it.value.isEmpty() }) { + return false + } + + state.selection.set(KeyframeState.Selection.EMPTY) + val changes = selection.flatMap { (path, keyframes) -> + keyframes.map { timeline.removeKeyframe(path, it.inWholeMilliseconds) } + } + timeline.timeline.pushChange(CombinedChange.createFromApplied(*changes.toTypedArray())) + + return true + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeState.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeState.kt new file mode 100644 index 000000000..340ddc63d --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeState.kt @@ -0,0 +1,189 @@ +package com.replaymod.simplepathing.gui + +import com.replaymod.core.gui.common.lazy +import com.replaymod.core.utils.associateNotNull +import com.replaymod.core.utils.kt +import com.replaymod.core.utils.orNull +import com.replaymod.core.utils.toDouble +import com.replaymod.pathing.properties.CameraProperties +import com.replaymod.pathing.properties.SpectatorProperty +import com.replaymod.pathing.properties.TimestampProperty +import com.replaymod.replaystudio.pathing.change.Change +import com.replaymod.simplepathing.ReplayModSimplePathing +import com.replaymod.simplepathing.SPTimeline +import gg.essential.elementa.state.BasicState +import kotlin.time.Duration + +class KeyframeState( + val mod: ReplayModSimplePathing, + val gui: GuiPathingKt, +) { + private var lastTimeline: SPTimeline? = null + private var lastChange: Change? = null + + val selection = BasicState(Selection.EMPTY).lazy() + val timeKeyframes = BasicState(emptyMap()).lazy() + val positionKeyframes = BasicState(emptyMap()).lazy() + + operator fun get(path: SPTimeline.SPPath) = when (path) { + SPTimeline.SPPath.TIME -> timeKeyframes + SPTimeline.SPPath.POSITION -> positionKeyframes + } + val keyframes = SPTimeline.SPPath.values().associateWith { get(it) } + + val selectionPositionKeyframes = selection.map { it.positionKeyframes } + val selectionTimeKeyframes = selection.map { it.timeKeyframes } + + val selectedTimeKeyframes = timeKeyframes.zip(selectionTimeKeyframes) + .map { (keyframes, selection) -> keyframes.filterKeys { it in selection } } + val selectedPositionKeyframes = positionKeyframes.zip(selectionPositionKeyframes) + .map { (keyframes, selection) -> keyframes.filterKeys { it in selection } } + + init { + selection.inner.onSetValue { + if (it != Selection.EMPTY) { + gui.java.overlay.timeline.selectedMarker = null + } + } + } + + fun update() { + if (gui.java.overlay.timeline.selectedMarker != null) { + selection.set(Selection.EMPTY) + } + + val timeline = mod.currentTimeline + val change = timeline.timeline.peekUndoStack() + if (timeline != lastTimeline || change != lastChange) { + lastTimeline = timeline + lastChange = change + + timeKeyframes.set(timeline.timePath.keyframes.associateNotNull { + val replayTime = it.getValue(TimestampProperty.PROPERTY).orNull ?: return@associateNotNull null + Duration.milliseconds(it.time) to TimeKeyframe(Duration.milliseconds(replayTime)) + }) + positionKeyframes.set(timeline.positionPath.keyframes.associateNotNull { + val position = it.getValue(CameraProperties.POSITION).orNull ?: return@associateNotNull null + val rotation = it.getValue(CameraProperties.ROTATION).orNull ?: return@associateNotNull null + val entityId = it.getValue(SpectatorProperty.PROPERTY).orNull + Duration.milliseconds(it.time) to PositionKeyframe(position.kt, rotation.kt.toDouble(), entityId) + }) + } + + selection.flush() + timeKeyframes.flush() + positionKeyframes.flush() + } + + fun refreshKeyframes() { + lastTimeline = null + lastChange = null + } + + @Deprecated("does not support multi-selection") + fun getSelectedPath(): SPTimeline.SPPath? { + val selection = selection.get() + return when { + selection.timeKeyframes.isNotEmpty() -> SPTimeline.SPPath.TIME + selection.positionKeyframes.isNotEmpty() -> SPTimeline.SPPath.POSITION + else -> null + } + } + + @Deprecated("does not support multi-selection") + fun getSelectedTime(): Long { + val selection = selection.get() + val time = selection.timeKeyframes.firstOrNull() ?: selection.positionKeyframes.firstOrNull() ?: Duration.ZERO + return time.inWholeMilliseconds + } + + @Deprecated("does not support multi-selection") + fun setSelected(path: SPTimeline.SPPath?, time: Long) { + val keyframeSet = mutableSetOf(Duration.milliseconds(time)) + selection.set(when (path) { + SPTimeline.SPPath.TIME -> Selection.EMPTY.mutate { timeKeyframes = keyframeSet } + SPTimeline.SPPath.POSITION -> Selection.EMPTY.mutate { positionKeyframes = keyframeSet } + null -> Selection.EMPTY + }) + } + + fun isSelected(path: SPTimeline.SPPath, time: Long): Boolean = + Duration.milliseconds(time) in selection.get()[path] + + data class TimeKeyframe( + val replayTime: Duration, + ) + + data class PositionKeyframe( + val position: Triple, + val rotation: Triple, + val entityId: Int?, + ) + + class Selection( + timeKeyframes: Set, + positionKeyframes: Set, + ) { + val timeKeyframes: Set = timeKeyframes.toSortedSet() + val positionKeyframes: Set = positionKeyframes.toSortedSet() + + constructor(paths: Map>) : this( + paths[SPTimeline.SPPath.TIME] ?: emptySet(), + paths[SPTimeline.SPPath.POSITION] ?: emptySet(), + ) + + fun toMap() = SPTimeline.SPPath.values().associateWith { get(it) } + + val size: Int + get() = toMap().values.sumOf { it.size } + + operator fun get(path: SPTimeline.SPPath) = when (path) { + SPTimeline.SPPath.TIME -> timeKeyframes + SPTimeline.SPPath.POSITION -> positionKeyframes + } + + inline fun mutate(block: MutableSelection.() -> Unit): Selection = + MutableSelection(this).apply(block).toSelection() + + inline fun map(func: (Duration) -> Duration): Selection = + mapWithPath { _, duration -> func(duration) } + + inline fun mapWithPath( + reverse: Boolean = false, + func: (SPTimeline.SPPath, Duration) -> Duration, + ): Selection = Selection(toMap().mapValues { (path, keyframes) -> + if (!reverse) { + keyframes.mapTo(mutableSetOf()) { func(path, it) } + } else { + keyframes.reversed().map { func(path, it) }.asReversed().toSet() + } + }) + + companion object { + fun single(path: SPTimeline.SPPath, time: Duration): Selection = Selection(mapOf(path to setOf(time))) + + @JvmField + val EMPTY = Selection(emptySet(), emptySet()) + } + } + + class MutableSelection( + var timeKeyframes: MutableSet, + var positionKeyframes: MutableSet, + ) { + constructor(selection: Selection) : this( + selection.timeKeyframes.toMutableSet(), + selection.positionKeyframes.toMutableSet(), + ) + + fun toSelection() = Selection( + timeKeyframes.toSet(), + positionKeyframes.toSet(), + ) + + operator fun get(path: SPTimeline.SPPath) = when (path) { + SPTimeline.SPPath.TIME -> timeKeyframes + SPTimeline.SPPath.POSITION -> positionKeyframes + } + } +} diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeType.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeType.kt new file mode 100644 index 000000000..12e1ca8eb --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/KeyframeType.kt @@ -0,0 +1,40 @@ +package com.replaymod.simplepathing.gui + +import com.replaymod.core.gui.common.UITexture +import com.replaymod.simplepathing.SPTimeline + +private val buttonIcon = UITexture.TextureData.ofSize(20, 20) +private val timelineIcon = UITexture.TextureData.ofSize(5, 5) + +enum class KeyframeType( + val path: SPTimeline.SPPath, + val buttonIcon: UITexture.TextureData, + val timelineIcon: UITexture.TextureData, + val tooltipAdd: String, + val tooltipRemove: String, +) { + TIME( + SPTimeline.SPPath.TIME, + buttonIcon.offset(0, 80), + timelineIcon.offset(74, 25), + "replaymod.gui.ingame.menu.addtimekeyframe", + "replaymod.gui.ingame.menu.removetimekeyframe", + ), + POSITION( + SPTimeline.SPPath.POSITION, + buttonIcon.offset(0, 40), + timelineIcon.offset(74, 20), + "replaymod.gui.ingame.menu.addposkeyframe", + "replaymod.gui.ingame.menu.removeposkeyframe", + ), + SPECTATOR( + SPTimeline.SPPath.POSITION, + buttonIcon.offset(40, 40), + timelineIcon.offset(74, 30), + "replaymod.gui.ingame.menu.addspeckeyframe", + "replaymod.gui.ingame.menu.removespeckeyframe", + ), + ; + + fun tooltip(add: Boolean) = if (add) tooltipAdd else tooltipRemove +} diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/UITimelineKeyframes.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/UITimelineKeyframes.kt new file mode 100644 index 000000000..7f009050c --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/UITimelineKeyframes.kt @@ -0,0 +1,319 @@ +package com.replaymod.simplepathing.gui + +import com.replaymod.core.ReplayMod +import com.replaymod.core.gui.common.UITexture +import com.replaymod.core.gui.common.timeline.UITimeline +import com.replaymod.core.gui.utils.* +import com.replaymod.core.utils.* +import com.replaymod.core.versions.MCVer +import com.replaymod.replay.ReplayModReplay +import com.replaymod.replay.gui.overlay.GuiMarkerTimeline +import com.replaymod.replaystudio.pathing.change.Change +import com.replaymod.simplepathing.SPTimeline +import de.johni0702.minecraft.gui.utils.lwjgl.Point +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector2f +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.effects.Effect +import gg.essential.elementa.effects.ScissorEffect +import gg.essential.universal.UGraphics +import gg.essential.universal.UKeyboard +import gg.essential.universal.UMatrixStack +import net.minecraft.client.render.Tessellator +import org.lwjgl.opengl.GL11 +import java.awt.Color +import kotlin.math.absoluteValue +import kotlin.time.Duration + +class UITimelineKeyframes( + private val state: KeyframeState, + replayTimeline: GuiMarkerTimeline, +) : UIContainer() { + private val rows = listOf(SPTimeline.SPPath.POSITION, SPTimeline.SPPath.TIME).map { path -> + Row(state, path).constrain { + y = SiblingConstraint() + width = ChildBasedRangeConstraint() + height = 5.pixels + } childOf this + } + + init { + constrain { + y = 1.pixels + width = ChildBasedMaxSizeConstraint() + height = ChildBasedSizeConstraint() + } + + val speed = pollingState { state.gui.java.overlay.speedSliderValue } + + state.timeKeyframes.zip(state.selectionTimeKeyframes, speed).onSetValueAndNow { (keyframes, selection, speed) -> + val row = rows[1] + row.clearChildren() + + var prevTime = Duration.ZERO + var prevReplayTime = Duration.ZERO + for ((time, keyframe) in keyframes) { + val replayTime = keyframe.replayTime + val lineEffect = LineToReplayTimelineEffect(replayTimeline, replayTime) + UIKeyframe(time, KeyframeType.TIME, time in selection) effect lineEffect childOf row + + val segmentSpeed = if (prevTime != time) (replayTime - prevReplayTime) / (time - prevTime) else speed + + // Draw red quads on time path segments that would require time going backwards + if (prevReplayTime > replayTime) { + UISegment(prevTime, time, Color.RED) childOf row + } else if ((segmentSpeed / speed - 1).absoluteValue > 0.01) { + // Draw white/green/yellow quads on paused/slower/faster path segments + UISegment(prevTime, time, when { + segmentSpeed == 0.0 -> Color.WHITE + segmentSpeed < speed -> Color.GREEN + else -> Color.YELLOW + }).addTooltip { + addLine("Replay Speed: %.2fx".format(segmentSpeed)) + } childOf row + } + prevTime = time + prevReplayTime = replayTime + } + } + + state.positionKeyframes.zip(state.selectionPositionKeyframes).onSetValueAndNow { (keyframes, selection) -> + val row = rows[0] + row.clearChildren() + + var prevTime = Duration.ZERO + var prevType: KeyframeType? = null + for ((time, keyframe) in keyframes) { + val type = if (keyframe.entityId != null) KeyframeType.SPECTATOR else KeyframeType.POSITION + UIKeyframe(time, type, time in selection) childOf row + + // Draw colored quads on spectator path segments + if (type == KeyframeType.SPECTATOR && prevType == type) { + UISegment(prevTime, time, Color(0x00, 0x88, 0xff)) childOf row + } + prevTime = time + prevType = type + } + } + } + + class Row(state: KeyframeState, path: SPTimeline.SPPath) : UIContainer() { + var dragging: Dragging? = null + + init { + onRightMouse { mouseX, _ -> + val time = parentOfType()!!.getTimeAt(mouseX) + val pathObj = state.mod.currentTimeline.getPath(path) + for (property in pathObj.keyframes.flatMapTo(mutableSetOf()) { it.properties }) { + val value = pathObj.getValue(property, time.inWholeMilliseconds).orNull ?: continue + property.applyToGame(value, ReplayModReplay.instance.replayHandler) + } + } + onRightClick { + it.stopImmediatePropagation() + } + + onMouseDrag { relativeMouseX, _, _ -> + val dragging = dragging ?: return@onMouseDrag + val spTimeline = state.mod.currentTimeline ?: return@onMouseDrag + + val diff = (getLeft() + relativeMouseX) - dragging.mouseX + if (!dragging.passedThreshold) { + if (diff.absoluteValue < 5) { + return@onMouseDrag + } else { + dragging.passedThreshold = true + } + } + + // First undo any previous changes + dragging.change?.undo(spTimeline.timeline) + + // Compute new time + val deltaTime = parentOfType()!!.unit.get() * diff.toDouble() + + // Move keyframe to new position and store change for later undoing / pushing to history + dragging.change = state.moveKeyframes(dragging.selection, deltaTime) + + // Refresh keyframe state (required because we do not yet commit the Change) + state.refreshKeyframes() + } + + onMouseRelease { + val dragging = dragging.also { dragging = null } ?: return@onMouseRelease + val change = dragging.change ?: return@onMouseRelease + state.mod.currentTimeline.timeline.pushChange(change) + } + } + + data class Dragging( + val selection: KeyframeState.Selection, + val mouseX: Float, + var passedThreshold: Boolean = false, + /** + * Change caused by dragging. Whenever the user moves the keyframe further, the previous change is undone + * and a new one is created. This way when the mouse is released, only one change is in the undo history. + */ + var change: Change? = null, + ) + } + + inner class UIKeyframe( + val time: Duration, + val type: KeyframeType, + val selected: Boolean, + ) : UIContainer(), BoxSelectable { + val icon by UITexture(ReplayMod.TEXTURE, type.timelineIcon.offset(if (selected) 5 else 0, 0)).constrain { + width = 100.percent + height = 100.percent + } childOf this + + init { + constrain { + x = UITimeline.Constraint(time) - (2.5).pixels + width = 5.pixels + height = 5.pixels + } + + // Will be selected on mouse release except if we ended up dragging instead + var delayedSelect: Pair? = null + onMouseRelease { + val (selection, dragging) = delayedSelect.also { delayedSelect = null } ?: return@onMouseRelease + if (!dragging.passedThreshold) { + state.selection.set(selection) + } + } + + onLeftClick(1) { event -> + event.stopImmediatePropagation() + val row = parentOfType() + val selection = state.selection.get() + when { + // When Shift or Ctrl (cause Shift moves the camera) is held + UKeyboard.isShiftKeyDown() || UKeyboard.isCtrlKeyDown() -> { + // flip the selection state of this keyframe + state.selection.set(selection.mutate { this[type.path].toggle(time) }) + row?.dragging = null + } + // If multiple keyframes are selected and this is one of them + selection.size > 1 && time in selection[type.path] -> { + // We need delay the selection in case the user wants to drag all of them around + delayedSelect = Pair( + KeyframeState.Selection.single(type.path, time), + Row.Dragging(selection, event.absoluteX).also { row?.dragging = it }, + ) + } + else -> { + // Otherwise, select this keyframe and get ready for dragging + val newSelection = KeyframeState.Selection.single(type.path, time) + state.selection.set(newSelection) + row?.dragging = Row.Dragging(newSelection, event.absoluteX) + } + } + } + onLeftClick(2) { event -> + event.stopImmediatePropagation() + state.gui.java.openEditKeyframePopup(type.path, time.inWholeMilliseconds) + } + onRightClick { event -> + event.stopImmediatePropagation() + val keyframe = state.mod.currentTimeline.getKeyframe(type.path, time.inWholeMilliseconds) + ?: return@onRightClick + for (property in keyframe.properties) { + val value = keyframe.getValue(property).orNull ?: continue + property.applyToGame(value, ReplayModReplay.instance.replayHandler) + } + } + } + } + + class UISegment(left: Duration, right: Duration, color: Color) : UIBlock(color) { + init { + constrain { + val segmentWidth = UITimeline.Constraint(right) - UITimeline.Constraint(left) + val actualWidth = basicXConstraint { it.getWidth() } + x = UITimeline.Constraint(left) + (segmentWidth - actualWidth) / 2 + y = CenterConstraint() + width = (UITimeline.Constraint(right - left) - 5.pixels).coerceAtLeast(1.pixel) + height = 3.pixel + } + } + } + + class LineToReplayTimelineEffect( + private val replayTimeline: GuiMarkerTimeline, + private val replayTime: Duration, + ) : Effect() { + private val scissorEffect by lazy { ScissorEffect(Window.of(boundComponent), scissorIntersection = false) } + + override fun afterDraw(matrixStack: UMatrixStack) { + val keyframeTimeline = boundComponent.parentOfType() + ?: throw IllegalStateException("$boundComponent needs to be the child of a timeline") + val replayTimelineSize = replayTimeline.lastSize ?: return + + // Determine absolute positions for replay timeline + val replayTimelinePos = Point(0, 0) + replayTimeline.container.convertFor(replayTimeline, replayTimelinePos) + replayTimelinePos.setLocation(-replayTimelinePos.x, -replayTimelinePos.y) + + val replayTimelineLeft = replayTimelinePos.x + val replayTimelineRight = replayTimelinePos.x + replayTimelineSize.width + val replayTimelineTop = replayTimelinePos.y + val replayTimelineBottom = replayTimelinePos.y + replayTimelineSize.height + val replayTimelineWidth = + replayTimelineRight - replayTimelineLeft - BORDER_LEFT - BORDER_RIGHT + + val positionXReplayTimeline: Float = + BORDER_LEFT + replayTime.inWholeMilliseconds / replayTimeline.length.toFloat() * replayTimelineWidth + val keyframeX = boundComponent.getLeft() + boundComponent.getWidth() / 2 + + val color = -0xffff01 + val tessellator = UGraphics.getFromTessellator() + val buffer = Tessellator.getInstance().buffer + //#if MC>=11700 + //$$ tessellator.beginWithActiveShader(UGraphics.DrawMode.LINE_STRIP, net.minecraft.client.render.VertexFormats.LINES) + //#else + tessellator.beginWithActiveShader(UGraphics.DrawMode.LINE_STRIP, UGraphics.CommonVertexFormats.POSITION_COLOR) + //#endif + + // Start just below the top border of the replay timeline + val p1 = Vector2f(replayTimelineLeft + positionXReplayTimeline, (replayTimelineTop + BORDER_TOP).toFloat()) + // Draw vertically over the replay timeline, including its bottom border + val p2 = Vector2f(replayTimelineLeft + positionXReplayTimeline, replayTimelineBottom.toFloat()) + // Now for the important part: connecting to the keyframe timeline + val p3 = Vector2f(keyframeX, keyframeTimeline.getTop()) + // And finally another vertical bit (the timeline is already crammed enough, so only the border) + val p4 = Vector2f(keyframeX, keyframeTimeline.content.getTop()) + + MCVer.emitLine(buffer, p1, p2, color) + MCVer.emitLine(buffer, p2, p3, color) + MCVer.emitLine(buffer, p3, p4, color) + + //#if MC>=11700 + //$$ com.mojang.blaze3d.systems.RenderSystem.setShader(net.minecraft.client.render.GameRenderer::getRenderTypeLinesShader) + //#else + GL11.glEnable(GL11.GL_LINE_SMOOTH) + GL11.glDisable(GL11.GL_TEXTURE_2D) + //#endif + GL11.glLineWidth(2f) + + scissorEffect.beforeDraw(matrixStack) + tessellator.drawDirect() + scissorEffect.afterDraw(matrixStack) + + //#if MC<11700 + GL11.glEnable(GL11.GL_TEXTURE_2D) + GL11.glDisable(GL11.GL_LINE_SMOOTH) + //#endif + } + + companion object { + private const val BORDER_LEFT = 4 + private const val BORDER_RIGHT = 4 + private const val BORDER_TOP = 4 + } + } +} diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/operations.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/operations.kt new file mode 100644 index 000000000..6eb4e2672 --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/operations.kt @@ -0,0 +1,71 @@ +package com.replaymod.simplepathing.gui + +import com.replaymod.core.utils.kt +import com.replaymod.core.utils.orNull +import com.replaymod.pathing.properties.CameraProperties +import com.replaymod.pathing.properties.SpectatorProperty +import com.replaymod.pathing.properties.TimestampProperty +import com.replaymod.replaystudio.pathing.change.Change +import com.replaymod.replaystudio.pathing.change.CombinedChange +import com.replaymod.simplepathing.SPTimeline +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector3f +import kotlin.time.Duration + +fun KeyframeState.moveKeyframes(keyframes: KeyframeState.Selection, deltaTime: Duration): Change { + if (deltaTime == Duration.ZERO) { + return CombinedChange.createFromApplied() + } + + val spTimeline = mod.currentTimeline ?: throw IllegalStateException() + + val changes = mutableListOf() + val newSelection = keyframes.mapWithPath(reverse = deltaTime.isPositive()) { path, oldTime -> + var newTime = (oldTime + deltaTime).coerceAtLeast(Duration.ZERO) + + // If there already is a keyframe at the target time, then increase the time by one until there is none + while (spTimeline.getKeyframe(path, newTime.inWholeMilliseconds) != null) { + newTime += Duration.milliseconds(1) + } + + changes += spTimeline.moveKeyframe(path, oldTime.inWholeMilliseconds, newTime.inWholeMilliseconds) + + newTime + } + + this.selection.set(newSelection) + + return CombinedChange.createFromApplied(*changes.toTypedArray()) +} + +fun KeyframeState.movePosKeyframes(keyframes: KeyframeState.Selection, deltaPos: Vector3f, deltaRot: Vector3f): Change { + val spTimeline = mod.currentTimeline ?: throw IllegalStateException() + + val changes = mutableListOf() + for (time in keyframes[SPTimeline.SPPath.POSITION]) { + val keyframe = spTimeline.getKeyframe(SPTimeline.SPPath.POSITION, time.inWholeMilliseconds) ?: continue + if (keyframe.getValue(SpectatorProperty.PROPERTY).isPresent) continue + val (x, y, z) = keyframe.getValue(CameraProperties.POSITION).orNull?.kt ?: continue + val (yaw, pitch, roll) = keyframe.getValue(CameraProperties.ROTATION).orNull?.kt ?: continue + changes += spTimeline.updatePositionKeyframe( + time.inWholeMilliseconds, + x + deltaPos.x, y + deltaPos.y, z + deltaPos.z, + yaw + deltaRot.x, pitch + deltaRot.y, roll + deltaRot.z + ) + } + + return CombinedChange.createFromApplied(*changes.toTypedArray()) +} + +fun KeyframeState.moveTimeKeyframes(keyframes: KeyframeState.Selection, deltaTime: Duration): Change { + val spTimeline = mod.currentTimeline ?: throw IllegalStateException() + + val changes = mutableListOf() + for (time in keyframes[SPTimeline.SPPath.TIME]) { + val keyframe = spTimeline.getKeyframe(SPTimeline.SPPath.TIME, time.inWholeMilliseconds) ?: continue + val replayTime = keyframe.getValue(TimestampProperty.PROPERTY).orNull ?: continue + val newReplayTime = (replayTime + deltaTime.inWholeMilliseconds).coerceAtLeast(0) + changes += spTimeline.updateTimeKeyframe(time.inWholeMilliseconds, newReplayTime.toInt()) + } + + return CombinedChange.createFromApplied(*changes.toTypedArray()) +} diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionKeyframePanel.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionKeyframePanel.kt new file mode 100644 index 000000000..2839276ec --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionKeyframePanel.kt @@ -0,0 +1,270 @@ +package com.replaymod.simplepathing.gui.panels + +import com.replaymod.core.gui.common.input.* +import com.replaymod.core.gui.utils.Axis +import com.replaymod.core.gui.utils.Resources +import com.replaymod.core.gui.utils.hiddenChildOf +import com.replaymod.core.gui.utils.onSetValueAndNow +import com.replaymod.core.utils.i18n +import com.replaymod.core.utils.toVector3f +import com.replaymod.core.utils.transpose +import com.replaymod.replay.gui.overlay.panels.UIToggleablePanel +import com.replaymod.replaystudio.pathing.change.Change +import com.replaymod.replaystudio.pathing.change.CombinedChange +import com.replaymod.simplepathing.SPTimeline +import com.replaymod.simplepathing.gui.KeyframeState +import com.replaymod.simplepathing.gui.moveKeyframes +import com.replaymod.simplepathing.gui.movePosKeyframes +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector3f +import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIText +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import java.util.* +import kotlin.math.* +import kotlin.time.Duration + +class UIPositionKeyframePanel( + val state: KeyframeState, +) : UIToggleablePanel() { + init { + toggleButton.texture(Resources.icon("pos_keyframe_panel")) + + constrain { + width = 230.pixels + height = 114.pixels + } + } + + val translateContainer by UIContainer().constrain { + width = 128.pixels + height = ChildBasedSizeConstraint() + } childOf this + + val translateLabel by UIContainer().constrain { + width = 100.percent + height = 20.pixels + }.addChild(UIText("Translate".i18n()).constrain { + x = CenterConstraint() + y = CenterConstraint() + }) childOf translateContainer + + val translateXYZ by UIContainer().constrain { + y = SiblingConstraint() + width = 100.percent + height = ChildBasedSizeConstraint() + } childOf translateContainer + + val translateX by FieldComponent(Axis.X) childOf translateXYZ + val translateY by FieldComponent(Axis.Y) childOf translateXYZ + val translateZ by FieldComponent(Axis.Z) childOf translateXYZ + + val rotateContainer by UIContainer().constrain { + x = SiblingConstraint() + width = 102.pixels + height = ChildBasedSizeConstraint() + } childOf this + + val rotateLabel by UIContainer().constrain { + width = 100.percent + height = 20.pixels + }.addChild(UIText("Rotate".i18n()).constrain { + x = CenterConstraint() + y = CenterConstraint() + }) childOf rotateContainer + + val rotateXYZ by UIContainer().constrain { + y = SiblingConstraint() + width = 100.percent + height = ChildBasedSizeConstraint() + } childOf rotateContainer + + val rotateX by FieldComponent(Axis.X) childOf rotateXYZ + val rotateY by FieldComponent(Axis.Y) childOf rotateXYZ + val rotateZ by FieldComponent(Axis.Z) childOf rotateXYZ + + val timeField by UIInputOrExpressionField.forTimeInput().constrain { + x = 0.pixels boundTo rotateZ.input.integer + y = SiblingConstraint(4f) + width = 60.pixels + height = 20.pixels + }.apply { + input.onActivate { apply() } + } childOf this + + val timeLabel by UIContainer().constrain { + x = 5.pixels(alignOutside = true) boundTo timeField + y = 0.pixels boundTo timeField + width = ChildBasedSizeConstraint() + height = CopyConstraintFloat() boundTo timeField + }.addChild(UIText("Video Time (min:sec:ms)".i18n()).constrain { + y = CenterConstraint() + }) childOf this + + init { + state.selectedPositionKeyframes.map { it.entries.firstOrNull() }.onSetValueAndNow { selection -> + val (time, keyframe) = selection?.toPair().transpose() + + timeField.input.value = time ?: Duration.ZERO + (keyframe?.position ?: Triple(0.0, 0.0, 0.0)).let { (x, y, z) -> + translateX.value = x + translateY.value = y + translateZ.value = z + } + (keyframe?.rotation ?: Triple(0.0, 0.0, 0.0)).let { (x, y, z) -> + rotateX.value = x + rotateY.value = y + rotateZ.value = z + } + } + } + + private fun apply() { + val selection = state.selection.get() + val (time, keyframe) = state.selectedPositionKeyframes.get().entries.firstOrNull() ?: return + val (pos, rot) = keyframe + + val timeline = state.mod.currentTimeline + val newPos = Triple(translateX.value, translateY.value, translateZ.value) + val newRot = Triple(rotateX.value, rotateY.value, rotateZ.value) + var change = state.movePosKeyframes(selection, + Vector3f.sub(newPos.toVector3f(), pos.toVector3f(), null), + Vector3f.sub(newRot.toVector3f(), rot.toVector3f(), null), + ) + + val newTime = timeField.input.value + if (newTime != time) { + change = CombinedChange.createFromApplied( + change, + state.moveKeyframes(selection, newTime - time) + ) + } + + timeline.timeline.pushChange(change) + } + + inner class FieldComponent(axis: Axis) : UIContainer() { + init { + constrain { + y = SiblingConstraint(4f) + width = 100.percent + height = 20.pixels + } + } + + val label by UIContainer().constrain { + width = 16.pixels + height = 100.percent + }.addChild(UIText(axis.toString()).constrain { + x = CenterConstraint() + y = CenterConstraint() + color = axis.color.toConstraint() + }) childOf this + + val input by DecimalInputField().constrain { + x = SiblingConstraint() + width = FillConstraint() - 6.pixels + } childOf this + + init { + input.onActivate { apply() } + } + + var value by input::value + } + + class DecimalInputField( + fractionalDigits: Int = 5, + ) : UIContainer() { + private val fractionMultiplier = (10.0).pow(fractionalDigits) + + init { + constrain { + width = 100.percent + height = 100.percent + } + } + + val expression by UIInputField(UIExpressionInput()).constrain { + width = 100.percent + height = 100.percent + } hiddenChildOf this + + val decimal by UIContainer().constrain { + width = 100.percent + height = 100.percent + } childOf this + + val integer by UIInputField(UIIntegerInput()).constrain { + width = FillConstraint() + } childOf decimal + + val dot by UIContainer().constrain { + x = SiblingConstraint() + width = 5.pixels + height = 100.percent + }.addChild(UIText(".", shadow = false).constrain { + x = CenterConstraint() + y = CenterConstraint() + }) childOf decimal + + val fraction by UIInputField(UIIntegerInput(fixedDigits = fractionalDigits)).constrain { + x = SiblingConstraint() + width = 37.pixels + } childOf decimal + + init { + val maybeSwitchToExpression: UIComponent.(typedChar: Char, keyCode: Int) -> Unit = { typedChar, keyCode -> + this as UIIntegerInput + if (typedChar in UIExpressionInput.expressionChars && (typedChar != '-' || !isCursorAtAbsoluteStart)) { + decimal.hide(instantly = true) + expression.unhide() + + with(expression.input) { + setText(integer.input.getText() + "." + fraction.input.getText()) + grabWindowFocus() + setActive(true) + keyTypedListeners.forEach { it(typedChar, keyCode) } + } + } + } + integer.input.keyTypedListeners.add(0, maybeSwitchToExpression) + fraction.input.keyTypedListeners.add(0, maybeSwitchToExpression) + + expression.input.onActivate { + // Re-assigning the value will switch back to integer+fraction fields + value = expression.input.value ?: return@onActivate + + // Forward the activation + integer.input.activateAction("") + } + expression.input.onFocusLost { + // Re-assigning the value will switch back to integer+fraction fields + value = value + } + } + + var value: Double + get() { + val int = integer.input.value + return int + (int.sign * fraction.input.value / fractionMultiplier) + } + set(value) { + integer.input.value = value.toInt() + fraction.input.value = (abs(value - value.toInt()) * fractionMultiplier).roundToInt() + + expression.hide(instantly = true) + decimal.unhide() + if (Window.ofOrNull(expression) != null && expression.input.hasFocus()) { + integer.input.grabWindowFocus() + } + } + + fun onActivate(listener: () -> Unit) { + integer.input.onActivate { listener() } + fraction.input.onActivate { listener() } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionOffsetPanel.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionOffsetPanel.kt new file mode 100644 index 000000000..9607f7efe --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UIPositionOffsetPanel.kt @@ -0,0 +1,239 @@ +package com.replaymod.simplepathing.gui.panels + +import com.replaymod.core.KeyBindingRegistry +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.common.input.UIInputOrExpressionField +import com.replaymod.core.gui.utils.* +import com.replaymod.core.utils.i18n +import com.replaymod.replay.gui.overlay.panels.UIToggleablePanel +import com.replaymod.simplepathing.gui.KeyframeState +import com.replaymod.simplepathing.gui.movePosKeyframes +import de.johni0702.minecraft.gui.utils.lwjgl.vector.Vector3f +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIText +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.CenterConstraint +import gg.essential.elementa.constraints.ChildBasedSizeConstraint +import gg.essential.elementa.constraints.SiblingConstraint +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.utils.withAlpha +import java.awt.Color +import java.math.BigDecimal + +class UIPositionOffsetPanel( + val window: Window, + val state: KeyframeState, +) : UIToggleablePanel() { + + init { + toggleButton.texture(Resources.icon("pos_offset_panel")) + + constrain { + x = 0.pixels() boundTo toggleButton + y = 2.pixels(alignOutside = true) boundTo toggleButton + width = ChildBasedSizeConstraint() + 10.pixels + height = ChildBasedSizeConstraint() + 10.pixels + } + + open.onSetValue { if (!it) keyBindsPanel.open.set(false) } + } + + private val container: UIContainer by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = basicWidthConstraint { topButtons.getWidth() } + height = ChildBasedSizeConstraint() + } childOf this + + private val translateContainer by UIContainer().constrain { + y = SiblingConstraint(3f) + width = 100.percent + height = 20.pixels + } childOf container + + private val rotateContainer by UIContainer().constrain { + y = SiblingConstraint(3f) + width = 100.percent + height = 20.pixels + } childOf container + + private val topButtons by UIContainer().constrain { + y = SiblingConstraint(5f) + width = ChildBasedSizeConstraint() + height = 20.pixels + } childOf container + + private val bottomButtons by UIContainer().constrain { + y = SiblingConstraint(3f) + width = 100.percent + height = 20.pixels + } childOf container + + private val translateField by UIInputOrExpressionField.forDecimalInput().constrain { + x = 0.pixels(alignOpposite = true) + width = 60.pixels + height = 20.pixels + }.apply { + input.value = BigDecimal(1) + input.onActivate { releaseWindowFocus() } + } childOf translateContainer + + private val translateLabel by UIText("Translate:").constrain { + x = 1.pixel(alignOutside = true) boundTo translateField + y = CenterConstraint() + } childOf translateContainer + + private val rotateField by UIInputOrExpressionField.forDecimalInput().constrain { + x = 0.pixels(alignOpposite = true) + width = 60.pixels + height = 20.pixels + }.apply { + input.value = BigDecimal(15) + input.onActivate { releaseWindowFocus() } + } childOf rotateContainer + + private val rotateLabel by UIText("Rotate:").constrain { + x = 1.pixel(alignOutside = true) boundTo rotateField + y = CenterConstraint() + } childOf rotateContainer + + private val rotateButton by UIButton().constrain { + x = SiblingConstraint(padding = 3f) + width = ChildBasedSizeConstraint() + 10.pixels + }.label("R. mode").highlightWhenActive(Color.ORANGE, state.mod.keyRotationMode) childOf topButtons + + val plusXButton by OffsetButton(Axis.X, state.mod.keyPlusX) childOf topButtons + val plusYButton by OffsetButton(Axis.Y, state.mod.keyPlusY) childOf topButtons + val plusZButton by OffsetButton(Axis.Z, state.mod.keyPlusZ) childOf topButtons + + val minusXButton by OffsetButton(Axis.X, state.mod.keyMinusX, plusXButton) childOf bottomButtons + val minusYButton by OffsetButton(Axis.Y, state.mod.keyMinusY, plusYButton) childOf bottomButtons + val minusZButton by OffsetButton(Axis.Z, state.mod.keyMinusZ, plusZButton) childOf bottomButtons + + inner class OffsetButton( + private val axis: Axis, + keyBinding: KeyBindingRegistry.Binding, + private val inverse: OffsetButton? = null, + ) : UIContainer() { + init { + constrain { + x = if (inverse != null) { + 0.pixels boundTo inverse.button + } else { + SiblingConstraint(padding = 3f) + } + width = 20.pixels + height = 20.pixels + } + } + + val button by UIButton().constrain { + width = 100.percent + height = 100.percent + }.label((if (inverse == null) "+" else "-") + axis).onLeftClick { + activate() + }.highlightWhenActive(axis.color, keyBinding) childOf this + + fun activate() { + val timeline = state.mod.currentTimeline ?: return + val vector = axis.toVector3f() + if (inverse != null) vector.scale(-1f) + val (pos, rot) = if (state.mod.keyRotationMode.isPressed) { + Vector3f() to vector + } else { + vector to Vector3f() + } + pos.scale(translateField.input.value.toFloat()) + rot.scale(rotateField.input.value.toFloat()) + val change = state.movePosKeyframes(state.selection.get(), pos, rot) + timeline.timeline.pushChange(change) + } + } + + private val keyBindsPanel by KeyBindsPanel().constrain { + x = SiblingConstraint(padding = 2f) boundTo this@UIPositionOffsetPanel + y = 0.pixels(alignOpposite = true) boundTo this@UIPositionOffsetPanel + }.apply { + toggleButton.constrain { + x = 0.pixels + y = 0.pixels + } childOf bottomButtons + } hiddenChildOf window + + private inner class KeyBindsPanel : UIToggleablePanel() { + init { + toggleButton.texture(Resources.icon("settings")) + + constrain { + width = ChildBasedSizeConstraint() + 10.pixels + height = ChildBasedSizeConstraint() + 10.pixels + } + } + + private val container by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = basicWidthConstraint { it.children.first().getWidth() } + height = ChildBasedSizeConstraint() + } childOf this + + init { + Row(state.mod.keyRotationMode).constrain { width = ChildBasedSizeConstraint(5f) } childOf container + Row(state.mod.keyPlusX) childOf container + Row(state.mod.keyMinusX) childOf container + Row(state.mod.keyPlusY) childOf container + Row(state.mod.keyMinusY) childOf container + Row(state.mod.keyPlusZ) childOf container + Row(state.mod.keyMinusZ) childOf container + } + + inner class Row(keyBinding: KeyBindingRegistry.Binding) : UIContainer() { + init { + constrain { + y = SiblingConstraint() + width = 100.percent + height = 20.pixels + } + } + + private val label by UIText().constrain { + y = CenterConstraint() + }.setText(keyBinding.name.i18n()) childOf this + + private val button by UIButton().constrain { + x = 0.pixels(alignOpposite = true) + width = 80.pixels + }.label { + bindText(pollingState { if (keyBinding.isBound) keyBinding.boundKey else "" }) + }.onLeftClick { + // TODO need to somehow get our hands on the scan code + /* + val inputCatcher by UIContainer().constrain { + width = 100.percentOfWindow + height = 100.percentOfWindow + } childOf window + + inputCatcher.setFloating(true) + */ + } childOf this + } + } + + private fun UIButton.highlightWhenActive(highlightColor: Color, keyBinding: KeyBindingRegistry.Binding) = apply { + val keyPressed = pollingState { keyBinding.isPressed } + val buttonPressed = BasicState(false) + val highlighted = keyPressed.zip(buttonPressed).map { (a, b) -> a || b } + + constrain { + color = highlighted.map { highlightColor.withAlpha(if (it) 0.5f else 0f) }.toConstraint() + } + onLeftClick { + buttonPressed.set(true) + } + onMouseRelease { + buttonPressed.set(false) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimeOffsetPanel.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimeOffsetPanel.kt new file mode 100644 index 000000000..f2f1d6cb2 --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimeOffsetPanel.kt @@ -0,0 +1,123 @@ +package com.replaymod.simplepathing.gui.panels + +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.common.UICheckbox +import com.replaymod.core.gui.common.input.* +import com.replaymod.core.gui.utils.* +import com.replaymod.core.utils.i18n +import com.replaymod.simplepathing.gui.KeyframeState +import com.replaymod.simplepathing.gui.moveKeyframes +import com.replaymod.simplepathing.gui.moveTimeKeyframes +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIText +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.utils.withAlpha +import java.awt.Color +import kotlin.time.Duration + +class UITimeOffsetPanel( + val state: KeyframeState, + timePanel: UITimePanel, +) : UIBlock(Color(32, 32, 32).withAlpha(0.6f)) { + + init { + timePanel.open.onSetValue { if (it) unhide() else hide(true) } + + constrain { + height = ChildBasedSizeConstraint() + 10.pixels + } + } + + val container by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = 100.percent - 10.pixels + height = ChildBasedSizeConstraint() + } childOf this + + val label by UIContainer().constrain { + width = 100.percent + height = 14.pixels + }.addChild(UIText("Shift time (min:sec:ms)".i18n()).constrain { + x = CenterConstraint() + y = CenterConstraint() + }) childOf container + + val row by UIContainer().constrain { + x = CenterConstraint() + y = SiblingConstraint(2f) + width = ChildBasedSizeConstraint() + height = 20.pixels + } childOf container + + val minusButton by UIButton().texture(Resources.icon("minus")).constrain { + width = 20.pixels + height = 20.pixels + }.onLeftClick { + shiftTime(-1) + } childOf row + + val plusButton by UIButton().texture(Resources.icon("plus")).constrain { + x = SiblingConstraint(3f) + width = 20.pixels + height = 20.pixels + }.onLeftClick { + shiftTime(1) + } childOf row + + val timeField by UIInputOrExpressionField.forTimeInput(UITimeInput(minuteDigits = 2)).constrain { + x = SiblingConstraint(5f) + width = 54.pixels + height = 20.pixels + }.apply { + input.value = Duration.milliseconds(100) + } childOf row + + val checkboxes by UIContainer().constrain { + x = SiblingConstraint(5f) + width = ChildBasedMaxSizeConstraint() + height = 100.percent + } childOf row + + val replayTimeCheckbox by UICheckbox().constrain { + }.apply { + isChecked = true + }.addTooltip { + addLine("Replay Time") + } childOf checkboxes + + val videoTimeCheckbox by UICheckbox().constrain { + y = 0.pixels(alignOpposite = true) + }.apply { + isChecked = true + }.addTooltip { + addLine("Video Time") + } childOf checkboxes + + private fun shiftTime(direction: Int) { + val deltaTime = timeField.input.value * direction + + if (replayTimeCheckbox.isChecked) { + if (state.selection.get().size > 0) { + val change = state.moveTimeKeyframes(state.selection.get(), deltaTime) + state.mod.currentTimeline.timeline.pushChange(change) + } else { + with(state.gui.replayHandler) { + val newTime = replaySender.currentTimeStamp() + deltaTime.inWholeMilliseconds + doJump(newTime.toInt(), true) + } + } + } + + if (videoTimeCheckbox.isChecked) { + if (state.selection.get().size > 0) { + val change = state.moveKeyframes(state.selection.get(), deltaTime) + state.mod.currentTimeline.timeline.pushChange(change) + } else { + state.gui.timeline.cursor.position.set { it + deltaTime } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimePanel.kt b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimePanel.kt new file mode 100644 index 000000000..c50fb2176 --- /dev/null +++ b/src/main/kotlin/com/replaymod/simplepathing/gui/panels/UITimePanel.kt @@ -0,0 +1,172 @@ +package com.replaymod.simplepathing.gui.panels + +import com.replaymod.core.gui.common.UIButton +import com.replaymod.core.gui.common.input.* +import com.replaymod.core.gui.utils.* +import com.replaymod.core.utils.i18n +import com.replaymod.simplepathing.gui.KeyframeState +import com.replaymod.simplepathing.gui.moveKeyframes +import com.replaymod.simplepathing.gui.moveTimeKeyframes +import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UIText +import gg.essential.elementa.components.UIWrappedText +import gg.essential.elementa.constraints.* +import gg.essential.elementa.dsl.* +import gg.essential.elementa.state.BasicState +import gg.essential.elementa.state.toConstraint +import gg.essential.elementa.utils.getStringSplitToWidth +import gg.essential.elementa.utils.withAlpha +import java.awt.Color +import kotlin.time.Duration + +class UITimePanel( + val state: KeyframeState, +) : UIBlock(Color(32, 32, 32).withAlpha(0.6f)) { + + val open = BasicState(false) + + val toggleButton by UIButton().constrain { + width = 9.pixels + height = 9.pixels + }.texture(Resources.icon("time_panel")).onMouseClick { + open.set { !it } + } as UIButton + + init { + open.onSetValue { if (it) unhide() else hide(true) } + + constrain { + width = ChildBasedSizeConstraint() + 10.pixels + height = ChildBasedSizeConstraint() + 4.pixels + } + } + + val container by UIContainer().constrain { + x = CenterConstraint() + y = CenterConstraint() + width = ChildBasedSizeConstraint() + height = ChildBasedMaxSizeConstraint() + } childOf this + + val infoContainer by UIContainer().constrain { + x = SiblingConstraint(5f) + y = CenterConstraint() + width = ChildBasedMaxSizeConstraint() + height = ChildBasedSizeConstraint() + } childOf container + + private val hasSelectedKeyframe = state.selectionTimeKeyframes.map { it.isNotEmpty() } + + val infoCurrentTime by UIText("Current Time".i18n(), shadow = false).constrain { + color = hasSelectedKeyframe.map { if (it) Color.BLACK else Color.WHITE }.toConstraint() + } childOf infoContainer + + val infoSelectedKeyframe by UIWrappedText("Selected Time Keyframe".i18n(), shadow = false).constrain { + y = SiblingConstraint(5f) + width = basicWidthConstraint { component -> + component as UIWrappedText + getStringSplitToWidth( + component.getText(), + 80f, + component.getTextScale(), + ensureSpaceAtEndOfLines = false, + fontProvider = component.getFontProvider() + ).maxOf { it.width(component.getTextScale(), component.getFontProvider()) } + } + color = hasSelectedKeyframe.map { if (it) Color.WHITE else Color.BLACK }.toConstraint() + } childOf infoContainer + + val divider by UIBlock(Color.WHITE).constrain { + x = SiblingConstraint(5f) + width = 1.pixel + height = basicHeightConstraint { inputContainer.getHeight() } + } childOf container + + val inputContainer by UIContainer().constrain { + x = SiblingConstraint(5f) + width = ChildBasedMaxSizeConstraint() + height = ChildBasedSizeConstraint() + } childOf container + + val replayTimeContainer by UIContainer().constrain { + x = 0.pixels(alignOpposite = true) + y = SiblingConstraint(4f) + width = ChildBasedSizeConstraint() + 4.pixels // FIXME that constraint is broken and ignores the last padding + height = ChildBasedMaxSizeConstraint() + } childOf inputContainer + + val videoTimeContainer by UIContainer().constrain { + x = 0.pixels(alignOpposite = true) + y = SiblingConstraint(4f) + width = ChildBasedSizeConstraint() + 4.pixels // FIXME that constraint is broken and ignores the last padding + height = ChildBasedMaxSizeConstraint() + } childOf inputContainer + + val replayTimeField by UIInputOrExpressionField.forTimeInput().constrain { + x = 0.pixels(alignOpposite = true) + width = 60.pixels + height = 20.pixels + }.apply { + input.onActivate { + val newReplayTime = input.value + val oldReplayTime = state.selectedTimeKeyframes.get().values.firstOrNull()?.replayTime + if (oldReplayTime != null) { + if (oldReplayTime != newReplayTime) { + val change = state.moveTimeKeyframes(state.selection.get(), newReplayTime - oldReplayTime) + state.mod.currentTimeline.timeline.pushChange(change) + } + } else { + state.gui.replayHandler.doJump(newReplayTime.inWholeMilliseconds.toInt(), true) + } + } + } childOf replayTimeContainer + + val replayTimeLabel by UIText("Replay Time (min:sec:ms)".i18n()).constrain { + x = SiblingConstraint(5f, alignOpposite = true) + y = CenterConstraint() + } childOf replayTimeContainer + + val videoTimeField by UIInputOrExpressionField.forTimeInput().constrain { + x = 0.pixels(alignOpposite = true) + width = 60.pixels + height = 20.pixels + }.apply { + input.onActivate { + val newVideoTime = input.value + val oldVideoTime = state.selectedTimeKeyframes.get().keys.firstOrNull() + if (oldVideoTime != null) { + if (newVideoTime != oldVideoTime) { + val timeKeyframes = KeyframeState.Selection.EMPTY.mutate { + timeKeyframes += state.selectionTimeKeyframes.get() + } + val timeline = state.mod.currentTimeline + val change = state.moveKeyframes(timeKeyframes, newVideoTime - oldVideoTime) + timeline.timeline.pushChange(change) + } + } else { + state.gui.timeline.cursor.position.set(newVideoTime) + } + } + } childOf videoTimeContainer + + val videoTimeLabel by UIText("Video Time (min:sec:ms)".i18n()).constrain { + x = SiblingConstraint(5f, alignOpposite = true) + y = CenterConstraint() + } childOf videoTimeContainer + + init { + val currentReplayTime = + pollingState { Duration.milliseconds(state.gui.replayHandler.replaySender.currentTimeStamp()) } + val currentVideoTime = state.gui.timeline.cursor.position + + state.selectedTimeKeyframes.zip(currentVideoTime.zip(currentReplayTime)).map { (selected, current) -> + selected.entries.firstOrNull()?.let { (videoTime, keyframe) -> + videoTime to keyframe.replayTime + } ?: current + }.onSetValueAndNow { (videoTime, replayTime) -> + replayTimeField.input.value = replayTime + videoTimeField.input.value = videoTime + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/replaymod/icons/checkmark.png b/src/main/resources/assets/replaymod/icons/checkmark.png new file mode 100644 index 000000000..1482fad5c Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/checkmark.png differ diff --git a/src/main/resources/assets/replaymod/icons/minus.png b/src/main/resources/assets/replaymod/icons/minus.png new file mode 100644 index 000000000..3b5f126b9 Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/minus.png differ diff --git a/src/main/resources/assets/replaymod/icons/plus.png b/src/main/resources/assets/replaymod/icons/plus.png new file mode 100644 index 000000000..578fc5128 Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/plus.png differ diff --git a/src/main/resources/assets/replaymod/icons/pos_keyframe_panel.png b/src/main/resources/assets/replaymod/icons/pos_keyframe_panel.png new file mode 100644 index 000000000..3dd8eeb04 Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/pos_keyframe_panel.png differ diff --git a/src/main/resources/assets/replaymod/icons/pos_offset_panel.png b/src/main/resources/assets/replaymod/icons/pos_offset_panel.png new file mode 100644 index 000000000..328ae255c Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/pos_offset_panel.png differ diff --git a/src/main/resources/assets/replaymod/icons/settings.license b/src/main/resources/assets/replaymod/icons/settings.license new file mode 100644 index 000000000..112732182 --- /dev/null +++ b/src/main/resources/assets/replaymod/icons/settings.license @@ -0,0 +1,22 @@ +The "settings" icon (three horizontal sliders) is based on ModMenu's settings icon. +Original: https://github.com/Prospector/ModMenu/blob/74200fb7baa697b4e865b15a707a560757a7ee8f/src/main/resources/assets/modmenu/textures/gui/configure_button.png + +Copyright (c) 2018-2020 Prospector + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main/resources/assets/replaymod/icons/settings.png b/src/main/resources/assets/replaymod/icons/settings.png new file mode 100644 index 000000000..f8e9c6710 Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/settings.png differ diff --git a/src/main/resources/assets/replaymod/icons/time_panel.png b/src/main/resources/assets/replaymod/icons/time_panel.png new file mode 100644 index 000000000..037d06dfd Binary files /dev/null and b/src/main/resources/assets/replaymod/icons/time_panel.png differ diff --git a/src/main/resources/assets/replaymod/iris/ods/shaders/gbuffers_textured_lit.vsh b/src/main/resources/assets/replaymod/iris/ods/shaders/gbuffers_textured_lit.vsh index 3b53c589f..68e7e0fb0 100644 --- a/src/main/resources/assets/replaymod/iris/ods/shaders/gbuffers_textured_lit.vsh +++ b/src/main/resources/assets/replaymod/iris/ods/shaders/gbuffers_textured_lit.vsh @@ -9,10 +9,49 @@ uniform int direction; const float eyeDistance = 0.14; +void orient(vec4 position, int orientation) { + float z; + if (orientation == 0) { // LEFT + z = position.z; + position.z = position.x; + position.x = -z; + } else if (orientation == 1) { // RIGHT + z = position.z; + position.z = -position.x; + position.x = z; + } else if (orientation == 2) { // FRONT + // No changes required + } else if (orientation == 3) { // BACK + position.x = -position.x; + position.z = -position.z; + } else if (orientation == 4) { // TOP + z = position.z; + position.z = -position.y; + position.y = z; + } else if (orientation == 5) { // BOTTOM + z = position.z; + position.z = position.y; + position.y = -z; + } +} + +void orientInverse(vec4 position, int orientation) { + if (orientation < 2) { + orient(position, 1 - orientation); // LEFT and RIGHT flip + } else if (orientation < 4) { + orient(position, orientation); // FRONT and BACK are their own inverses + } else { + orient(position, (1 - (orientation - 4)) + 4); // TOP and BOTTOM flip + } +} + void main() { // Transform to view space vec4 position = gl_ModelViewMatrix * gl_Vertex; + // Undo the camera rotation, so we always apply our stereo effect looking in the same direction + orientInverse(position, direction); + // Distort for ODS // O := The origin // P := The current vertex/point @@ -34,30 +73,8 @@ void main() { // Calculate the vector between O and T and finally move the vertex by that vector position -= vec4(distTO * sin(angOT), 0, distTO * cos(angOT), 0); - // Rotate for different cubic views - float z; - if (direction == 0) { // LEFT - z = position.z; - position.z = position.x; - position.x = -z; - } else if (direction == 1) { // RIGHT - z = position.z; - position.z = -position.x; - position.x = z; - } else if (direction == 2) { // FRONT - // No changes required - } else if (direction == 3) { // BACK - position.x = -position.x; - position.z = -position.z; - } else if (direction == 4) { // TOP - z = position.z; - position.z = -position.y; - position.y = z; - } else if (direction == 5) { // BOTTOM - z = position.z; - position.z = position.y; - position.y = -z; - } + // Rotate back into the correct cubic view + orient(position, direction); // Transform to screen space gl_Position = gl_ProjectionMatrix * position; diff --git a/src/main/resources/assets/replaymod/lang b/src/main/resources/assets/replaymod/lang index 54ab62bdb..ed16d95cd 160000 --- a/src/main/resources/assets/replaymod/lang +++ b/src/main/resources/assets/replaymod/lang @@ -1 +1 @@ -Subproject commit 54ab62bdbb646c6b4f4b6d61a26183258e5a017d +Subproject commit ed16d95cd373d4b5ddd257ea4b30790d2fcff353 diff --git a/src/main/resources/assets/replaymod/shader/ods.vert b/src/main/resources/assets/replaymod/shader/ods.vert index 431604508..6a654a6bd 100644 --- a/src/main/resources/assets/replaymod/shader/ods.vert +++ b/src/main/resources/assets/replaymod/shader/ods.vert @@ -14,10 +14,49 @@ uniform int direction; const float eyeDistance = 0.14; +void orient(vec4 position, int orientation) { + float z; + if (orientation == 0) { // LEFT + z = position.z; + position.z = position.x; + position.x = -z; + } else if (orientation == 1) { // RIGHT + z = position.z; + position.z = -position.x; + position.x = z; + } else if (orientation == 2) { // FRONT + // No changes required + } else if (orientation == 3) { // BACK + position.x = -position.x; + position.z = -position.z; + } else if (orientation == 4) { // TOP + z = position.z; + position.z = -position.y; + position.y = z; + } else if (orientation == 5) { // BOTTOM + z = position.z; + position.z = position.y; + position.y = -z; + } +} + +void orientInverse(vec4 position, int orientation) { + if (orientation < 2) { + orient(position, 1 - orientation); // LEFT and RIGHT flip + } else if (orientation < 4) { + orient(position, orientation); // FRONT and BACK are their own inverses + } else { + orient(position, (1 - (orientation - 4)) + 4); // TOP and BOTTOM flip + } +} + void main() { // Transform to view space vec4 position = gl_ModelViewMatrix * gl_Vertex; + // Undo the camera rotation, so we always apply our stereo effect looking in the same direction + orientInverse(position, direction); + // Distort for ODS // O := The origin // P := The current vertex/point @@ -39,30 +78,8 @@ void main() { // Calculate the vector between O and T and finally move the vertex by that vector position -= vec4(distTO * sin(angOT), 0, distTO * cos(angOT), 0); - // Rotate for different cubic views - float z; - if (direction == 0) { // LEFT - z = position.z; - position.z = position.x; - position.x = -z; - } else if (direction == 1) { // RIGHT - z = position.z; - position.z = -position.x; - position.x = z; - } else if (direction == 2) { // FRONT - // No changes required - } else if (direction == 3) { // BACK - position.x = -position.x; - position.z = -position.z; - } else if (direction == 4) { // TOP - z = position.z; - position.z = -position.y; - position.y = z; - } else if (direction == 5) { // BOTTOM - z = position.z; - position.z = position.y; - position.y = -z; - } + // Rotate back into the correct cubic view + orient(position, direction); // Transform to screen space gl_Position = gl_ProjectionMatrix * position; diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 6eb336ceb..ef5522eca 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -43,10 +43,14 @@ "depends": { "fabricloader": ">=0.7.0", "fabric-networking-v0": "*", - "fabric-keybindings-v0": "*", + "fabric-key-binding-api-v1": "*", "fabric-resource-loader-v0": "*" }, + "conflicts": { + "iris": "<1.1.3" + }, + "custom": { "mm:early_risers": [ "com.replaymod.core.ReplayModMMLauncher" diff --git a/src/main/resources/mixins.compat.shaders.replaymod.json b/src/main/resources/mixins.compat.shaders.replaymod.json index 163e36b0b..b2826f7f8 100644 --- a/src/main/resources/mixins.compat.shaders.replaymod.json +++ b/src/main/resources/mixins.compat.shaders.replaymod.json @@ -7,9 +7,11 @@ //#if MC>=11500 "MixinChunkVisibility", //#endif + //#if MC>=10800 && MC<11800 + "MixinShaderRenderChunk", + //#endif //#if MC>=10800 "MixinShaderEntityRenderer", - "MixinShaderRenderChunk", //#else //$$ "MixinShaders", //#endif diff --git a/src/main/resources/mixins.core.replaymod.json b/src/main/resources/mixins.core.replaymod.json index d1232a207..7e10b9c95 100644 --- a/src/main/resources/mixins.core.replaymod.json +++ b/src/main/resources/mixins.core.replaymod.json @@ -18,10 +18,12 @@ //#endif "MixinKeyboardListener", "MixinMinecraft", - "GuiMainMenuAccessor", "GuiScreenAccessor", "KeyBindingAccessor", "MinecraftAccessor", + //#if MC>=11900 + //$$ "SimpleOptionAccessor", + //#endif "TimerAccessor" ], "compatibilityLevel": "JAVA_8", diff --git a/src/main/resources/mixins.recording.replaymod.json b/src/main/resources/mixins.recording.replaymod.json index e6c9ee412..df7f09ea2 100644 --- a/src/main/resources/mixins.recording.replaymod.json +++ b/src/main/resources/mixins.recording.replaymod.json @@ -5,14 +5,20 @@ "server": [], "client": [ "AddServerScreenAccessor", + "ClientLoginNetworkHandlerAccessor", "EntityLivingBaseAccessor", "IntegratedServerAccessor", "NetworkManagerAccessor", - "SPacketSpawnMobAccessor", - "SPacketSpawnPlayerAccessor", + "MixinClientConnection", + //#if MC<11500 + //$$ "SPacketSpawnMobAccessor", + //$$ "SPacketSpawnPlayerAccessor", + //#endif "MixinServerInfo", - //#if MC>=11400 + //#if MC>=10800 "MixinDownloadingPackFinder", + //#endif + //#if MC>=11400 "MixinMouseHelper", //#endif //#if MC>=10904 diff --git a/src/main/resources/mixins.render.blend.replaymod.json b/src/main/resources/mixins.render.blend.replaymod.json index abf980ae7..156da80c1 100644 --- a/src/main/resources/mixins.render.blend.replaymod.json +++ b/src/main/resources/mixins.render.blend.replaymod.json @@ -20,11 +20,13 @@ "ItemRendererAccessor", //#endif "ParticleAccessor", + //#if MC<11900 "MixinRenderGlobal", "MixinRenderItem", "MixinRenderLivingBase", "MixinRenderManager" //#endif + //#endif ], "compatibilityLevel": "JAVA_8", "minVersion": "0.6.11", diff --git a/src/main/resources/mixins.render.replaymod.json b/src/main/resources/mixins.render.replaymod.json index 7bddb362d..2759769b1 100644 --- a/src/main/resources/mixins.render.replaymod.json +++ b/src/main/resources/mixins.render.replaymod.json @@ -4,6 +4,9 @@ "mixins": [], "server": [], "client": [ + //#if MC>=11800 + //$$ "ChunkInfoAccessor", + //#endif "Mixin_ChromaKeyColorSky", "Mixin_ChromaKeyDisableFog", "Mixin_ChromaKeyForceSky", @@ -11,15 +14,15 @@ "Mixin_HideNameTags", "Mixin_HideNameTags_LivingEntity", "Mixin_Omnidirectional_Camera", - "Mixin_Omnidirectional_DisableFrustumCulling", "Mixin_Omnidirectional_Rotation", - "Mixin_Omnidirectional_SkipHand", "Mixin_PreserveDepthDuringGuiRendering", + "Mixin_PreserveDepthDuringHandRendering", "Mixin_SkipBlockOutlinesDuringRender", "Mixin_SkipHudDuringRender", "Mixin_StabilizeCamera", "Mixin_Stereoscopic_Camera", "Mixin_Stereoscopic_HandRenderPass", + "Mixin_SuppressFramebufferResizeDuringRender", //#if MC>=11600 "Mixin_AddIrisOdsShaderUniforms", "Mixin_LoadIrisOdsShaderPack", @@ -32,10 +35,7 @@ //$$ "MixinChunkRenderWorker", //#endif //#endif - //#if MC>=11400 - "Mixin_PreserveDepthDuringHandRendering", - "Mixin_WindowsWorkaroundForTinyEXRNatives", - //#endif + "GameRendererAccessor", "MainWindowAccessor", "WorldRendererAccessor", //#if MC>=10904 diff --git a/src/main/resources/mixins.replay.replaymod.json b/src/main/resources/mixins.replay.replaymod.json index feb30fa7b..bdd258a6b 100644 --- a/src/main/resources/mixins.replay.replaymod.json +++ b/src/main/resources/mixins.replay.replaymod.json @@ -5,16 +5,30 @@ "mixins": [], "server": [], "client": [ + "entity_tracking.Mixin_EntityExt", + "entity_tracking.Mixin_FixPartialUpdates", + "world_border.Mixin_UseReplayTime_ForMovement", + "world_border.Mixin_UseReplayTime_ForTexture", "Mixin_FixNPCSkinCaching", + //#if MC>=11900 + //$$ "Mixin_AllowExpiredPlayerKeys", + //#endif + //#if MC>=11800 + //$$ "Mixin_FixEntityNotTracking", + //#endif //#if MC>=11600 "Mixin_MoveRealmsButton", //#endif //#if MC>=11400 "MixinCamera", "MixinInGameHud", + //#else + //$$ "Mixin_FixHandOffsetTickDelta", //#endif + "ClientWorldAccessor", "EntityLivingBaseAccessor", //#if MC>=11400 + "Mixin_ShowSpectatedHand_Iris", "Mixin_ShowSpectatedHand_NoOF", "Mixin_ShowSpectatedHand_OF", //#else diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta index e491ce517..6afaebbc3 100644 --- a/src/main/resources/pack.mcmeta +++ b/src/main/resources/pack.mcmeta @@ -1,14 +1,6 @@ { "pack": { "description": "ReplayMod resources", - //#if MC>=11400 "pack_format": 4 - //#else - //#if MC>=11002 - //$$ "pack_format": 2 - //#else - //$$ "pack_format": 1 - //#endif - //#endif } } diff --git a/version.txt b/version.txt index 6a6a3d8e3..a04abec91 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.6.1 +2.6.10 diff --git a/versions/1.11.2/mapping.txt b/versions/1.11.2/mapping.txt index 7f741972d..48e74eec7 100644 --- a/versions/1.11.2/mapping.txt +++ b/versions/1.11.2/mapping.txt @@ -1 +1,2 @@ net.minecraft.item.ItemStack EMPTY field_190927_a +net.minecraft.util.NonNullList withSize() func_191197_a() diff --git a/versions/1.12.2/src/main/java/com/replaymod/core/versions/Window.java b/versions/1.12.2/src/main/java/com/replaymod/core/versions/Window.java index fdcadef37..3f6206193 100644 --- a/versions/1.12.2/src/main/java/com/replaymod/core/versions/Window.java +++ b/versions/1.12.2/src/main/java/com/replaymod/core/versions/Window.java @@ -8,7 +8,6 @@ public class Window implements MainWindowAccessor { private final Minecraft mc; - private ScaledResolution scaledResolution; public Window(Minecraft mc) { this.mc = mc; @@ -47,16 +46,11 @@ public int getHeight() { } private ScaledResolution scaledResolution() { - ScaledResolution scaledResolution = this.scaledResolution; - if (scaledResolution == null) { - //#if MC>=10809 - scaledResolution = new ScaledResolution(mc); - //#else - //$$ scaledResolution = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); - //#endif - this.scaledResolution = scaledResolution; - } - return scaledResolution; + //#if MC>=10809 + return new ScaledResolution(mc); + //#else + //$$ return new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + //#endif } public int getScaledWidth() { diff --git a/versions/1.12.2/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java b/versions/1.12.2/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java index 2d91cb588..697dd6014 100644 --- a/versions/1.12.2/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java +++ b/versions/1.12.2/src/main/java/com/replaymod/render/mixin/MainWindowAccessor.java @@ -3,6 +3,7 @@ import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; @Mixin(Minecraft.class) public interface MainWindowAccessor { diff --git a/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java b/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java new file mode 100644 index 000000000..6f447aedd --- /dev/null +++ b/versions/1.12.2/src/main/java/com/replaymod/replay/mixin/Mixin_FixHandOffsetTickDelta.java @@ -0,0 +1,46 @@ +package com.replaymod.replay.mixin; + +import com.replaymod.replay.camera.CameraEntity; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.renderer.ItemRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +/** + * In 1.12.2 and below, Vanilla does not respect the tickDelta value when getting the yaw/pitch of the player for + * computing the hand offset. + * This causes the hand movement to be jittery when spectating another player in a replay. + */ +@Mixin(ItemRenderer.class) +public abstract class Mixin_FixHandOffsetTickDelta { + @Redirect(method = "rotateArm", at = @At(value = "FIELD", target = "Lnet/minecraft/client/entity/EntityPlayerSP;rotationYaw:F")) + private float getYaw( + EntityPlayerSP instance, + //#if MC<10900 + //$$ EntityPlayerSP arg, + //#endif + float tickDelta + ) { + if (instance instanceof CameraEntity) { + return instance.prevRotationYaw + (instance.rotationYaw - instance.prevRotationYaw) * tickDelta; + } else { + return instance.rotationYaw; + } + } + + @Redirect(method = "rotateArm", at = @At(value = "FIELD", target = "Lnet/minecraft/client/entity/EntityPlayerSP;rotationPitch:F")) + private float getPitch( + EntityPlayerSP instance, + //#if MC<10900 + //$$ EntityPlayerSP arg, + //#endif + float tickDelta + ) { + if (instance instanceof CameraEntity) { + return instance.prevRotationPitch + (instance.rotationPitch - instance.prevRotationPitch) * tickDelta; + } else { + return instance.rotationPitch; + } + } +} diff --git a/versions/1.12.2/src/main/resources/pack.mcmeta b/versions/1.12.2/src/main/resources/pack.mcmeta new file mode 100644 index 000000000..5259b8256 --- /dev/null +++ b/versions/1.12.2/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "ReplayMod resources", + "pack_format": 2 + } +} diff --git a/versions/1.14.4-forge/mapping.txt b/versions/1.14.4-forge/mapping.txt index f858f3e5d..a8f90916d 100644 --- a/versions/1.14.4-forge/mapping.txt +++ b/versions/1.14.4-forge/mapping.txt @@ -1,8 +1,12 @@ +net.minecraft.util.SoundEvents net.minecraft.init.SoundEvents +net.minecraft.util.SharedConstants net.minecraft.util.ChatAllowedCharacters net.minecraft.entity.player.PlayerInventory net.minecraft.entity.player.InventoryPlayer net.minecraft.potion.EffectInstance net.minecraft.potion.PotionEffect net.minecraft.client.gui.screen.AddServerScreen net.minecraft.client.gui.GuiScreenAddServer net.minecraft.resources.AbstractResourcePack net.minecraft.client.resources.AbstractResourcePack net.minecraft.resources.FolderPack net.minecraft.client.resources.FolderResourcePack +net.minecraft.resources.IResource net.minecraft.client.resources.IResource +net.minecraft.resources.IResourceManager net.minecraft.client.resources.IResourceManager net.minecraft.client.resources.DownloadingPackFinder net.minecraft.client.resources.ResourcePackRepository net.minecraft.client.renderer.chunk.ChunkRenderTask net.minecraft.client.renderer.chunk.ChunkCompileTaskGenerator net.minecraft.client.settings.AbstractOption net.minecraft.client.settings.GameSettings.Options @@ -21,12 +25,14 @@ org.lwjgl.glfw.GLFW com.replaymod.core.versions.GLFW net.minecraft.client.MainWindow com.replaymod.core.versions.Window net.minecraft.client.audio.SimpleSound net.minecraft.client.audio.PositionedSoundRecord net.minecraft.client.gui.IngameGui net.minecraft.client.gui.GuiIngame +net.minecraft.util.HandSide net.minecraft.util.EnumHandSide net.minecraft.resources.FolderPack getInputStream() getInputStreamByName() net.minecraft.client.gui.GuiYesNoCallback confirmResult() confirmClicked() net.minecraft.util.text.ITextComponent getString() getUnformattedText() net.minecraft.network.play.server.SPacketRespawn func_212643_b() getDimensionID() net.minecraft.client.Minecraft getPackFinder() getResourcePackRepository() +net.minecraft.entity.Entity getPositionVec() getPositionVector() net.minecraftforge.client.event.GuiScreenEvent.InitGuiEvent addWidget() addButton() net.minecraftforge.client.event.GuiScreenEvent.InitGuiEvent removeWidget() removeButton() @@ -180,6 +186,7 @@ net.minecraft.client.gui.screen.Screen hasShiftDown() isShiftKeyDown() net.minecraft.client.gui.screen.Screen init() setWorldAndResolution() net.minecraft.client.gui.screen.Screen minecraft mc net.minecraft.client.gui.screen.Screen net.minecraft.client.gui.GuiScreen +net.minecraft.client.gui.screen.Screen resize() onResize() net.minecraft.client.gui.screen.Screen passEvents allowUserInput net.minecraft.client.gui.screen.Screen removed() onGuiClosed() net.minecraft.client.gui.screen.Screen renderBackground() drawDefaultBackground() diff --git a/versions/1.15.2/src/main/resources/fabric.mod.json b/versions/1.15.2/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..73fe9d6ef --- /dev/null +++ b/versions/1.15.2/src/main/resources/fabric.mod.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "id": "replaymod", + "version": "${version}", + + "name": "Replay Mod", + "description": "A Mod which allows you to record, replay and share your Minecraft experience.", + "authors": [ + "CrushedPixel", + "johni0702" + ], + "contact": { + "homepage": "https://replaymod.com/", + "sources": "https://github.com/ReplayMod/ReplayMod" + }, + + "license": "GPL-3.0-or-later", + "icon": "assets/replaymod/favicon_logo.png", + + "environment": "client", + "entrypoints": { + "client": [ + "com.replaymod.core.ReplayModBackend" + ], + "modmenu": [ + "com.replaymod.core.gui.ModMenuApiImpl" + ], + "frex_flawless_frames": [ + "com.replaymod.render.utils.FlawlessFrames::registerConsumer" + ], + "preLaunch": [ + "com.replaymod.core.DummyChainLoadEntryPoint" + ], + "mm:early_risers": [ + "com.replaymod.core.ReplayModMMLauncher" + ] + }, + "mixins": [ + "mixins.jgui.json", + "mixins.nonmmlauncher.replaymod.json" + ], + + "depends": { + "fabricloader": ">=0.7.0", + "fabric-networking-v0": "*", + "fabric-keybindings-v0": "*", + "fabric-resource-loader-v0": "*" + }, + + "conflicts": { + "iris": "<1.1.3" + }, + + "custom": { + "mm:early_risers": [ + "com.replaymod.core.ReplayModMMLauncher" + ], + "modmenu:clientsideOnly": true + } +} diff --git a/versions/1.17/src/main/resources/fabric.mod.json b/versions/1.17/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..dbb995507 --- /dev/null +++ b/versions/1.17/src/main/resources/fabric.mod.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "id": "replaymod", + "version": "${version}", + + "name": "Replay Mod", + "description": "A Mod which allows you to record, replay and share your Minecraft experience.", + "authors": [ + "CrushedPixel", + "johni0702" + ], + "contact": { + "homepage": "https://replaymod.com/", + "sources": "https://github.com/ReplayMod/ReplayMod" + }, + + "license": "GPL-3.0-or-later", + "icon": "assets/replaymod/favicon_logo.png", + + "environment": "client", + "entrypoints": { + "client": [ + "com.replaymod.core.ReplayModBackend" + ], + "modmenu": [ + "com.replaymod.core.gui.ModMenuApiImpl" + ], + "frex_flawless_frames": [ + "com.replaymod.render.utils.FlawlessFrames::registerConsumer" + ], + "preLaunch": [ + "com.replaymod.core.DummyChainLoadEntryPoint" + ], + "mm:early_risers": [ + "com.replaymod.core.ReplayModMMLauncher" + ] + }, + "mixins": [ + "mixins.jgui.json", + "mixins.nonmmlauncher.replaymod.json" + ], + + "depends": { + "fabricloader": ">=0.7.0", + "fabric-networking-api-v1": "*", + "fabric-key-binding-api-v1": "*", + "fabric-resource-loader-v0": "*" + }, + + "conflicts": { + "iris": "<1.1.3" + }, + + "custom": { + "mm:early_risers": [ + "com.replaymod.core.ReplayModMMLauncher" + ], + "modmenu:clientsideOnly": true + } +} diff --git a/versions/1.18.1/.gitkeep b/versions/1.18.1/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java new file mode 100644 index 000000000..c4fbdd968 --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/ChunkInfoAccessor.java @@ -0,0 +1,12 @@ +// 1.18+ +package com.replaymod.render.mixin; + +import net.minecraft.client.render.chunk.ChunkBuilder; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(targets = "net.minecraft.client.render.WorldRenderer$ChunkInfo") +public interface ChunkInfoAccessor { + @Accessor + ChunkBuilder.BuiltChunk getChunk(); +} diff --git a/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java new file mode 100644 index 000000000..288c43171 --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/render/mixin/Mixin_ForceChunkLoading.java @@ -0,0 +1,119 @@ +package com.replaymod.render.mixin; + +import com.replaymod.render.hooks.ForceChunkLoadingHook; +import com.replaymod.render.hooks.IForceChunkLoading; +import com.replaymod.render.utils.FlawlessFrames; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.Camera; +import net.minecraft.client.render.Frustum; +import net.minecraft.client.render.GameRenderer; +import net.minecraft.client.render.LightmapTextureManager; +import net.minecraft.client.render.WorldRenderer; +import net.minecraft.client.render.chunk.ChunkBuilder; +import net.minecraft.client.render.chunk.ChunkRendererRegionBuilder; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.math.Matrix4f; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +@Mixin(WorldRenderer.class) +public abstract class Mixin_ForceChunkLoading implements IForceChunkLoading { + private ForceChunkLoadingHook replayModRender_hook; + + @Override + public void replayModRender_setHook(ForceChunkLoadingHook hook) { + this.replayModRender_hook = hook; + } + + @Shadow private ChunkBuilder chunkBuilder; + + @Shadow protected abstract void setupTerrain(Camera par1, Frustum par2, boolean par3, boolean par4); + + @Shadow private Frustum frustum; + + @Shadow private Frustum capturedFrustum; + + @Shadow @Final private MinecraftClient client; + + @Shadow @Final private ObjectArrayList chunkInfos; + + @Shadow private boolean field_34810; + + @Shadow @Final private BlockingQueue builtChunks; + + @Shadow private Future field_34808; + + @Shadow @Final private AtomicBoolean field_34809; + + @Shadow protected abstract void applyFrustum(Frustum par1); + + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/WorldRenderer;setupTerrain(Lnet/minecraft/client/render/Camera;Lnet/minecraft/client/render/Frustum;ZZ)V")) + private void forceAllChunks(MatrixStack matrices, float tickDelta, long limitTime, boolean renderBlockOutline, Camera camera, GameRenderer gameRenderer, LightmapTextureManager lightmapTextureManager, Matrix4f matrix4f, CallbackInfo ci) { + if (replayModRender_hook == null) { + return; + } + if (FlawlessFrames.hasSodium()) { + return; + } + + assert this.client.player != null; + + ChunkRendererRegionBuilder chunkRendererRegionBuilder = new ChunkRendererRegionBuilder(); + + do { + // Determine which chunks shall be visible + setupTerrain(camera, this.frustum, this.capturedFrustum != null, this.client.player.isSpectator()); + + // Wait for async processing to be complete + if (this.field_34808 != null) { + try { + this.field_34808.get(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (TimeoutException e) { + e.printStackTrace(); + } + } + + // If that async processing did change the chunk graph, we need to re-apply the frustum (otherwise this is + // only done in the next setupTerrain call, which not happen this frame) + if (this.field_34809.compareAndSet(true, false)) { + this.applyFrustum((new Frustum(frustum)).method_38557(8)); // call based on the one in setupTerrain + } + + // Schedule all chunks which need rebuilding (we schedule even important rebuilds because we wait for + // all of them anyway and this way we can take advantage of threading) + for (ChunkInfoAccessor chunkInfo : this.chunkInfos) { + ChunkBuilder.BuiltChunk builtChunk = chunkInfo.getChunk(); + if (!builtChunk.needsRebuild()) { + continue; + } + // MC sometimes schedules invalid chunks when you're outside of loaded chunks (e.g. y > 256) + if (builtChunk.shouldBuild()) { + builtChunk.scheduleRebuild(this.chunkBuilder, chunkRendererRegionBuilder); + } + builtChunk.cancelRebuild(); + } + + // Upload all chunks + this.field_34810 |= ((ForceChunkLoadingHook.IBlockOnChunkRebuilds) this.chunkBuilder).uploadEverythingBlocking(); + + // Repeat until no more updates are needed + } while (this.field_34810 || !this.builtChunks.isEmpty()); + } +} diff --git a/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java b/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java new file mode 100644 index 000000000..1d95dd1b6 --- /dev/null +++ b/versions/1.18.1/src/main/java/com/replaymod/replay/mixin/Mixin_FixEntityNotTracking.java @@ -0,0 +1,38 @@ +package com.replaymod.replay.mixin; + +import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.entity.Entity; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ClientPlayNetworkHandler.class) +public class Mixin_FixEntityNotTracking { + @ModifyVariable(method = { "onEntityPosition", "onEntity", "onEntityPassengersSet" }, at = @At("RETURN"), ordinal = 0) + private Entity updatePositionIfNotTracked$0(Entity entity) { + if (entity != null) { + entity.streamSelfAndPassengers().forEach(this::updatePositionIfNotTracked); + } + return entity; + } + + private void updatePositionIfNotTracked(Entity entity) { + if (entity != null && entity.world instanceof ClientWorldAccessor world) { + if (!world.getEntityList().has(entity)) { + // Skip interpolation of position updates coming from server + // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) + int ticks = 0; + Vec3d prevPos; + do { + prevPos = entity.getPos(); + if (entity.hasVehicle()) { + entity.tickRiding(); + } else { + entity.tick(); + } + } while (prevPos.squaredDistanceTo(entity.getPos()) > 0.0001 && ticks++ < 100); + } + } + } +} diff --git a/versions/1.18.2/.gitkeep b/versions/1.18.2/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.19.1/.gitkeep b/versions/1.19.1/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.19.2/.gitkeep b/versions/1.19.2/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.19.3/.gitkeep b/versions/1.19.3/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.19/.gitkeep b/versions/1.19/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/versions/1.19/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java b/versions/1.19/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java new file mode 100644 index 000000000..e31c2de93 --- /dev/null +++ b/versions/1.19/src/main/java/com/replaymod/core/mixin/SimpleOptionAccessor.java @@ -0,0 +1,11 @@ +package com.replaymod.core.mixin; + +import net.minecraft.client.option.SimpleOption; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SimpleOption.class) +public interface SimpleOptionAccessor { + @Accessor("value") + void setRawValue(T value); +} diff --git a/versions/1.19/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java b/versions/1.19/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java new file mode 100644 index 000000000..2f2c711e5 --- /dev/null +++ b/versions/1.19/src/main/java/com/replaymod/replay/mixin/Mixin_AllowExpiredPlayerKeys.java @@ -0,0 +1,22 @@ +package com.replaymod.replay.mixin; + +import com.replaymod.replay.ReplayModReplay; +import net.minecraft.network.encryption.PlayerPublicKey; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(PlayerPublicKey.PublicKeyData.class) +public abstract class Mixin_AllowExpiredPlayerKeys { + //#if MC>=11902 + //$$ @Inject(method = { "isExpired()Z", "isExpired(Ljava/time/Duration;)Z" }, at = @At("HEAD"), cancellable = true) + //#else + @Inject(method = "isExpired", at = @At("HEAD"), cancellable = true) + //#endif + private void neverExpireWhenInReplay(CallbackInfoReturnable ci) { + if (ReplayModReplay.instance.getReplayHandler() != null) { + ci.setReturnValue(false); + } + } +} diff --git a/versions/1.8.9/mapping.txt b/versions/1.8.9/mapping.txt index 43123ad0c..37f9ead2e 100644 --- a/versions/1.8.9/mapping.txt +++ b/versions/1.8.9/mapping.txt @@ -1,3 +1,4 @@ +net.minecraft.client.gui.GuiScreen onResize() func_175273_b() net.minecraft.entity.player.EntityPlayer isWearing() func_175148_a() net.minecraft.client.renderer.WorldRenderer begin() startDrawing() net.minecraft.network.play.server.S38PacketPlayerListItem getAction() func_179768_b() @@ -7,6 +8,8 @@ net.minecraft.network.play.server.S48PacketResourcePackSend getHash() func_17978 net.minecraft.network.play.server.S08PacketPlayerPosLook getX() func_148932_c() net.minecraft.network.play.server.S08PacketPlayerPosLook getY() func_148928_d() net.minecraft.network.play.server.S08PacketPlayerPosLook getZ() func_148933_e() +net.minecraft.network.play.server.S08PacketPlayerPosLook getYaw() func_148931_f() +net.minecraft.network.play.server.S08PacketPlayerPosLook getPitch() func_148930_g() net.minecraft.network.play.server.S2BPacketChangeGameState getGameState() func_149138_c() net.minecraft.network.play.server.S0EPacketSpawnObject getType() func_148993_l() net.minecraft.network.play.server.S21PacketChunkData getChunkX() func_149273_e() diff --git a/versions/1.9.4/mapping.txt b/versions/1.9.4/mapping.txt index 9225b4c1b..0538db5ab 100644 --- a/versions/1.9.4/mapping.txt +++ b/versions/1.9.4/mapping.txt @@ -1,5 +1,6 @@ net.minecraft.stats.StatisticsManager net.minecraft.stats.StatFileWriter net.minecraft.init.MobEffects net.minecraft.potion.Potion +net.minecraft.util.math.Vec3d net.minecraft.util.Vec3 net.minecraft.util.text.TextComponentString net.minecraft.util.ChatComponentText net.minecraft.util.text.TextComponentTranslation net.minecraft.util.ChatComponentTranslation net.minecraft.util.text.Style net.minecraft.util.ChatStyle @@ -7,6 +8,7 @@ net.minecraft.util.text.TextFormatting net.minecraft.util.EnumChatFormatting net.minecraft.util.text.ITextComponent net.minecraft.util.IChatComponent net.minecraft.network.datasync.EntityDataManager net.minecraft.entity.DataWatcher net.minecraft.network.datasync.EntityDataManager.DataEntry net.minecraft.entity.DataWatcher.WatchableObject +net.minecraft.client.renderer.ItemRenderer rotateArm() rotateWithPlayerRotations() net.minecraft.client.renderer.VertexBuffer net.minecraft.client.renderer.WorldRenderer net.minecraft.client.particle.Particle net.minecraft.client.particle.EntityFX net.minecraft.util.math.MathHelper net.minecraft.util.MathHelper @@ -15,6 +17,7 @@ net.minecraft.util.math.BlockPos net.minecraft.util.BlockPos net.minecraft.client.gui.GuiWorldSelection net.minecraft.client.gui.GuiSelectWorld net.minecraft.util.math.AxisAlignedBB net.minecraft.util.AxisAlignedBB net.minecraft.util.math.RayTraceResult net.minecraft.util.MovingObjectPosition +net.minecraft.client.renderer.Matrix4f net.minecraft.util.Matrix4f net.minecraft.client.renderer.RenderItem net.minecraft.client.renderer.entity.RenderItem net.minecraft.network.play.server.SPacketSpawnMob dataManager field_149043_l net.minecraft.network.play.server.SPacketSpawnMob getDataManagerEntries() func_149027_c() diff --git a/versions/1.9.4/src/main/resources/pack.mcmeta b/versions/1.9.4/src/main/resources/pack.mcmeta new file mode 100644 index 000000000..9a0e34115 --- /dev/null +++ b/versions/1.9.4/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "ReplayMod resources", + "pack_format": 1 + } +} diff --git a/versions/mapping-fabric-1.16.1-1.15.2.txt b/versions/mapping-fabric-1.16.1-1.15.2.txt new file mode 100644 index 000000000..25f2e2db5 --- /dev/null +++ b/versions/mapping-fabric-1.16.1-1.15.2.txt @@ -0,0 +1,2 @@ +net.minecraft.client.network.ClientPlayerEntity getUnderwaterVisibility() method_3140() +net.minecraft.client.render.GameRenderer getBasicProjectionMatrix() method_22973() diff --git a/versions/mapping-fabric-1.17-1.16.4.txt b/versions/mapping-fabric-1.17-1.16.4.txt index 300a639a9..193eb19bf 100644 --- a/versions/mapping-fabric-1.17-1.16.4.txt +++ b/versions/mapping-fabric-1.17-1.16.4.txt @@ -1,4 +1,6 @@ com.mojang.blaze3d.systems.RenderSystem com.mojang.blaze3d.platform.GlStateManager +com.mojang.blaze3d.systems.RenderSystem setShaderFogStart() fogStart() +com.mojang.blaze3d.systems.RenderSystem setShaderFogEnd() fogEnd() net.minecraft.network.packet.s2c.play.PlayerRespawnS2CPacket getDimensionType() method_29445() net.minecraft.entity.Entity getId() getEntityId() net.minecraft.client.network.ClientPlayerEntity init() net.minecraft.entity.player.PlayerEntity afterSpawn() diff --git a/versions/mapping-fabric-1.18.1-1.17.1.txt b/versions/mapping-fabric-1.18.1-1.17.1.txt new file mode 100644 index 000000000..1384942ef --- /dev/null +++ b/versions/mapping-fabric-1.18.1-1.17.1.txt @@ -0,0 +1,16 @@ +net.minecraft.client.util.math.MatrixStack multiplyPositionMatrix() method_34425() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket playerEntityId() getEntityId() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket hardcore() isHardcore() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket gameMode() getGameMode() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket previousGameMode() getPreviousGameMode() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionIds() getDimensionIds() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket registryManager() getRegistryManager() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionType() getDimensionType() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket dimensionId() getDimensionId() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket sha256Seed() getSha256Seed() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket maxPlayers() getMaxPlayers() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket viewDistance() getViewDistance() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket reducedDebugInfo() hasReducedDebugInfo() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket showDeathScreen() showsDeathScreen() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket debugWorld() isDebugWorld() +net.minecraft.network.packet.s2c.play.GameJoinS2CPacket flatWorld() isFlatWorld() diff --git a/versions/mapping-fabric-1.19-1.18.2.txt b/versions/mapping-fabric-1.19-1.18.2.txt new file mode 100644 index 000000000..7ac37640e --- /dev/null +++ b/versions/mapping-fabric-1.19-1.18.2.txt @@ -0,0 +1,4 @@ +# FIXME remap should be able to map these without us explicitly declaring them +net.minecraft.client.render.WorldRenderer fullUpdateFuture field_34808 +net.minecraft.client.render.WorldRenderer updateFinished field_34809 +net.minecraft.client.render.WorldRenderer shouldUpdate field_34810 diff --git a/versions/mapping-fabric-1.19.3-1.19.2.txt b/versions/mapping-fabric-1.19.3-1.19.2.txt new file mode 100644 index 000000000..fa2e425dc --- /dev/null +++ b/versions/mapping-fabric-1.19.3-1.19.2.txt @@ -0,0 +1,4 @@ +org.joml.Matrix3 net.minecraft.util.math.Matrix3f +org.joml.Matrix4f net.minecraft.util.math.Matrix4f +org.joml.Quaternionf net.minecraft.util.math.Quaternion +org.joml.Vector3f net.minecraft.util.math.Vec3f