diff --git a/docs/content.md b/docs/content.md index 0b3f97fe..90f9a4f5 100644 --- a/docs/content.md +++ b/docs/content.md @@ -56,7 +56,7 @@ Please use [Optifine](https://optifine.net/) instead. ### Custom Main Menu [custom-main-menu] The [Custom Main Menu](https://mods.curse.com/mc-mods/minecraft/226406-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. -If you are familiar with it, the button ids for the Replay Mod are: **17890234** (text: `replaymod.gui.replayviewer`), **17890235** (text: `replaymod.gui.replayviewer`) and **17890236** (text: `replaymod.gui.replaycenter`) +If you are familiar with it, the button ids for the Replay Mod are: **17890234** (text: `replaymod.gui.replayviewer`), **17890237** (text: `replaymod.gui.replayeditor`) and **17890236** (text: `replaymod.gui.replaycenter`) Due to the nature of this Custom Main Menu mod, buttons added to the Main Menu by 3rd party mods like the **Replay Mod** will not show up by default. Thus, to access the Replay Viewer/Editor/Center, you need to manually configure the position for those buttons. diff --git a/jGui b/jGui index 33c65436..26af9162 160000 --- a/jGui +++ b/jGui @@ -1 +1 @@ -Subproject commit 33c65436f094417e9a5174db2ad13bbfa271be14 +Subproject commit 26af916257cbfe7da73a1f1b7af49219cc39885b diff --git a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java index c6a4a3d1..d922c1e4 100644 --- a/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java +++ b/src/main/java/com/replaymod/core/gui/RestoreReplayGui.java @@ -3,6 +3,7 @@ package com.replaymod.core.gui; import com.google.common.io.Files; import com.replaymod.replaystudio.PacketData; import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; import com.replaymod.replaystudio.replay.ReplayFile; import com.replaymod.replaystudio.replay.ReplayMetaData; import com.replaymod.replaystudio.replay.ZipReplayFile; @@ -45,15 +46,19 @@ public class RestoreReplayGui extends AbstractGuiScreen { ReplayMetaData metaData = replayFile.getMetaData(); if (metaData != null && metaData.getDuration() == 0) { // Try to restore replay duration - try (ReplayInputStream in = replayFile.getPacketData()) { + // We need to re-write the packet data in case there are any incomplete packets dangling at the end + try (ReplayInputStream in = replayFile.getPacketData(); + ReplayOutputStream out = replayFile.writePacketData()) { PacketData last = null; while ((last = in.readPacket()) != null) { metaData.setDuration((int) last.getTime()); + out.write(last); } - replayFile.writeMetaData(metaData); } catch (Throwable t) { t.printStackTrace(); } + // Write back the actual duration + replayFile.writeMetaData(metaData); } replayFile.save(); replayFile.close(); diff --git a/src/main/java/com/replaymod/core/utils/Utils.java b/src/main/java/com/replaymod/core/utils/Utils.java index 9a02c2dd..84f6f69b 100644 --- a/src/main/java/com/replaymod/core/utils/Utils.java +++ b/src/main/java/com/replaymod/core/utils/Utils.java @@ -27,8 +27,20 @@ import org.lwjgl.util.ReadableDimension; import javax.annotation.Nullable; import javax.imageio.ImageIO; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; import java.awt.image.BufferedImage; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; @@ -51,6 +63,42 @@ public class Utils { DEFAULT_THUMBNAIL = thumbnail; } + + /** + * Neither the root certificate of LetsEncrypt nor the root that cross-signed it is included in the default + * Java keystore prior to 8u101. + * Therefore whenever a connection to the replaymod.com site is made, this SSLContext has to be used instead. + * It has been constructed to include the necessary root certificates. + * @see #SSL_SOCKET_FACTORY + */ + public static final SSLContext SSL_CONTEXT; + + /** + * @see #SSL_CONTEXT + */ + public static final SSLSocketFactory SSL_SOCKET_FACTORY; + + static { + // Largely from https://community.letsencrypt.org/t/134/37 + try (InputStream in = Utils.class.getResourceAsStream("/dst_root_ca_x3.pem")){ + Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(in); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry("1", certificate); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, trustManagerFactory.getTrustManagers(), null); + SSL_CONTEXT = ctx; + SSL_SOCKET_FACTORY = ctx.getSocketFactory(); + } catch (IOException | CertificateException | KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException(e); + } + } + public static String convertSecondsToShortString(int seconds) { int hours = seconds/(60*60); int min = seconds/60 - hours*60; diff --git a/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java b/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java index 98ef0760..62b027ab 100644 --- a/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java +++ b/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java @@ -1,5 +1,6 @@ package com.replaymod.editor.gui; +import com.google.common.io.Files; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.gson.JsonObject; @@ -34,6 +35,7 @@ import de.johni0702.minecraft.gui.popup.GuiInfoPopup; import de.johni0702.minecraft.gui.popup.GuiYesNoPopup; import de.johni0702.minecraft.gui.utils.Colors; import net.minecraft.crash.CrashReport; +import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.lwjgl.util.ReadableDimension; @@ -119,10 +121,21 @@ public class GuiReplayEditor extends GuiScreen { public void save(File inputFile, PacketStream.FilterInfo...filters) { save(FilenameUtils.getBaseName(inputFile.getName()), (outputFile) -> { Studio studio = new ReplayStudio(); - try (ReplayFile outputReplay = new ZipReplayFile(studio, inputFile, outputFile); - ReplayOutputStream out = outputReplay.writePacketData()) { - // The input replay file MUST be closed before saving the output file - try (ReplayFile inputReplay = new ZipReplayFile(studio, inputFile); + File tmpDir = null; + try { + File actualOutputFile = outputFile; + if (outputFile.getCanonicalPath().equals(inputFile.getCanonicalPath())) { + // Input and output files are identical. Due to the way the ZipReplayFile stores its temporary + // data, the same replay file must not be opened twice for writing (tmp files will be deleted once + // either one is closed or, on Windows, will throw an exception when deleted). + tmpDir = Files.createTempDir(); + outputFile = new File(tmpDir, "replay.mcpr"); + LOGGER.debug("Output file is identical to input file, using temporary output file {} instead", + outputFile); + } + try (ReplayFile outputReplay = new ZipReplayFile(studio, inputFile, outputFile); + ReplayOutputStream out = outputReplay.writePacketData(); + ReplayFile inputReplay = new ZipReplayFile(studio, inputFile); ReplayInputStream in = inputReplay.getPacketData()) { ReplayMetaData metaData = inputReplay.getMetaData(); PacketStream stream = studio.createReplayStream(in, true); @@ -154,11 +167,21 @@ public class GuiReplayEditor extends GuiScreen { // Update duration of new replay metaData.setDuration((int) lastTimestamp); outputReplay.writeMetaData(metaData); + + out.close(); + outputReplay.save(); + } + if (outputFile != actualOutputFile) { + LOGGER.debug("Moving temporary output file {} to {}"); + FileUtils.forceDelete(actualOutputFile); + FileUtils.moveFile(outputFile, actualOutputFile); } - out.close(); - outputReplay.save(); } catch (IOException e) { throw new RuntimeException(e); + } finally { + if (tmpDir != null && !FileUtils.deleteQuietly(tmpDir)) { + LOGGER.warn("Failed to delete temporary directory {}", tmpDir); + } } }); } diff --git a/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java b/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java index ae5fd79e..fda001f1 100644 --- a/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java +++ b/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java @@ -39,6 +39,10 @@ import static com.replaymod.editor.ReplayModEditor.LOGGER; import static java.util.Optional.ofNullable; public class GuiTrimPanel extends GuiPanel { + private static GuiNumberField newGuiNumberField() { + return new GuiNumberField().setMaxLength(2).setSize(20, 20).setValidateOnFocusChange(true); + } + // Special value indicating no replay files were found private static final File NO_REPLAY = new File("."); // Special value for the initial "Select Marker Keyframe" entry @@ -50,16 +54,16 @@ public class GuiTrimPanel extends GuiPanel { .setMinSize(new Dimension(200, 20)).onSelection(i -> updateSelectedReplay()) .setToString(f -> f == NO_REPLAY ? "" : FilenameUtils.getBaseName(f.getName())); - public final GuiNumberField startHour = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField startMin = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField startSec = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField startMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4); + public final GuiNumberField startHour = newGuiNumberField(); + public final GuiNumberField startMin = newGuiNumberField(); + public final GuiNumberField startSec = newGuiNumberField(); + public final GuiNumberField startMilli = newGuiNumberField().setSize(40, 20).setMaxLength(4); public final GuiDropdownMenu startMarker = new GuiDropdownMenu<>(); - public final GuiNumberField endHour = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField endMin = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField endSec = new GuiNumberField().setSize(20, 20).setMaxLength(2); - public final GuiNumberField endMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4); + public final GuiNumberField endHour = newGuiNumberField(); + public final GuiNumberField endMin = newGuiNumberField(); + public final GuiNumberField endSec = newGuiNumberField(); + public final GuiNumberField endMilli = newGuiNumberField().setSize(40, 20).setMaxLength(4); public final GuiDropdownMenu endMarker = new GuiDropdownMenu<>(); public final GuiPanel timePanel = new GuiPanel(this) diff --git a/src/main/java/com/replaymod/extras/LocalizationExtra.java b/src/main/java/com/replaymod/extras/LocalizationExtra.java index 7b7001c5..56eef1a4 100644 --- a/src/main/java/com/replaymod/extras/LocalizationExtra.java +++ b/src/main/java/com/replaymod/extras/LocalizationExtra.java @@ -82,7 +82,7 @@ public class LocalizationExtra implements Extra { availableLanguages.put(langcode, prop); return true; } catch (ApiException e) { - if (e.getError().getId() != 16) { // This language has not been translated + if (e.getError() == null || e.getError().getId() != 16) { // This language has not been translated e.printStackTrace(); } } catch(ConnectException ce) { diff --git a/src/main/java/com/replaymod/extras/OpenEyeExtra.java b/src/main/java/com/replaymod/extras/OpenEyeExtra.java index e198a826..ad5c1004 100644 --- a/src/main/java/com/replaymod/extras/OpenEyeExtra.java +++ b/src/main/java/com/replaymod/extras/OpenEyeExtra.java @@ -17,6 +17,7 @@ import de.johni0702.minecraft.gui.utils.Colors; import net.minecraftforge.fml.common.Loader; import org.apache.commons.io.FileUtils; +import javax.net.ssl.HttpsURLConnection; import java.io.File; import java.io.FileOutputStream; import java.net.URL; @@ -24,8 +25,10 @@ import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.ReadableByteChannel; +import static com.replaymod.core.utils.Utils.SSL_SOCKET_FACTORY; + public class OpenEyeExtra implements Extra { - private static final String DOWNLOAD_URL = "http://www.replaymod.com/dl/openeye/" + Loader.MC_VERSION; + private static final String DOWNLOAD_URL = "https://www.replaymod.com/dl/openeye/" + Loader.MC_VERSION; private static final Setting ASK_FOR_OPEN_EYE = new Setting<>("advanced", "askForOpenEye", null, true); private ReplayMod mod; @@ -69,7 +72,9 @@ public class OpenEyeExtra implements Extra { File targetFile = new File("mods/" + Loader.MC_VERSION, "OpenEye.jar"); FileUtils.forceMkdir(targetFile.getParentFile()); - ReadableByteChannel in = Channels.newChannel(new URL(DOWNLOAD_URL).openStream()); + HttpsURLConnection connection = (HttpsURLConnection) new URL(DOWNLOAD_URL).openConnection(); + connection.setSSLSocketFactory(SSL_SOCKET_FACTORY); + ReadableByteChannel in = Channels.newChannel(connection.getInputStream()); FileChannel out = new FileOutputStream(targetFile).getChannel(); out.transferFrom(in, 0, Long.MAX_VALUE); } catch (Throwable e) { diff --git a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java index ff32dd91..b3988b7f 100644 --- a/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java +++ b/src/main/java/com/replaymod/extras/playeroverview/PlayerOverview.java @@ -11,7 +11,9 @@ import com.replaymod.replay.events.ReplayCloseEvent; import com.replaymod.replay.events.ReplayOpenEvent; import net.minecraft.client.renderer.entity.RenderManager; import net.minecraft.client.renderer.entity.RenderPlayer; +import net.minecraft.entity.Entity; import net.minecraft.entity.player.EntityPlayer; +import net.minecraftforge.client.event.RenderHandEvent; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import org.lwjgl.input.Keyboard; @@ -92,6 +94,14 @@ public class PlayerOverview implements Extra { hiddenPlayers.clear(); } + @SubscribeEvent + public void oRenderHand(RenderHandEvent event) { + Entity view = module.getCore().getMinecraft().getRenderViewEntity(); + if (view != null && isHidden(view.getUniqueID())) { + event.setCanceled(true); + } + } + public boolean isSavingEnabled() { return savingEnabled; } diff --git a/src/main/java/com/replaymod/online/api/ApiClient.java b/src/main/java/com/replaymod/online/api/ApiClient.java index f8e8a1b1..a12cd822 100755 --- a/src/main/java/com/replaymod/online/api/ApiClient.java +++ b/src/main/java/com/replaymod/online/api/ApiClient.java @@ -15,13 +15,19 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import javax.imageio.ImageIO; +import javax.net.ssl.HttpsURLConnection; import java.awt.image.BufferedImage; -import java.io.*; -import java.net.HttpURLConnection; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.net.URL; import java.util.List; import java.util.function.Consumer; +import static com.replaymod.core.utils.Utils.SSL_SOCKET_FACTORY; + public class ApiClient { private static final Minecraft mc = Minecraft.getMinecraft(); @@ -130,7 +136,11 @@ public class ApiClient { QueryBuilder builder = new QueryBuilder(ReplayModApiMethods.get_thumbnail); builder.put("id", file); URL url = new URL(builder.toString()); - return ImageIO.read(url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setSSLSocketFactory(SSL_SOCKET_FACTORY); + try (InputStream in = connection.getInputStream()) { + return ImageIO.read(in); + } } private boolean cancelDownload = false; @@ -143,7 +153,8 @@ public class ApiClient { builder.put("id", file); String url = builder.toString(); URL website = new URL(url); - HttpURLConnection con = (HttpURLConnection) website.openConnection(); + HttpsURLConnection con = (HttpsURLConnection) website.openConnection(); + con.setSSLSocketFactory(SSL_SOCKET_FACTORY); int fileSize = con.getContentLength(); diff --git a/src/main/java/com/replaymod/online/api/SimpleApiClient.java b/src/main/java/com/replaymod/online/api/SimpleApiClient.java index b2871c6c..c3add7a3 100755 --- a/src/main/java/com/replaymod/online/api/SimpleApiClient.java +++ b/src/main/java/com/replaymod/online/api/SimpleApiClient.java @@ -7,12 +7,14 @@ import com.google.gson.JsonParser; import com.replaymod.online.api.replay.holders.ApiError; import org.apache.commons.io.IOUtils; +import javax.net.ssl.HttpsURLConnection; import java.io.IOException; import java.io.InputStream; -import java.net.HttpURLConnection; import java.net.URL; import java.util.Map; +import static com.replaymod.core.utils.Utils.SSL_SOCKET_FACTORY; + public class SimpleApiClient { private static final JsonParser jsonParser = new JsonParser(); @@ -72,10 +74,11 @@ public class SimpleApiClient { // read response String responseContent = null; InputStream is = null; - HttpURLConnection httpUrlConnection = null; + HttpsURLConnection httpUrlConnection = null; try { URL url = new URL(urlString); - httpUrlConnection = (HttpURLConnection) url.openConnection(); + httpUrlConnection = (HttpsURLConnection) url.openConnection(); + httpUrlConnection.setSSLSocketFactory(SSL_SOCKET_FACTORY); httpUrlConnection.setRequestMethod("GET"); diff --git a/src/main/java/com/replaymod/online/api/replay/FileUploader.java b/src/main/java/com/replaymod/online/api/replay/FileUploader.java index f369b310..8dce3faa 100755 --- a/src/main/java/com/replaymod/online/api/replay/FileUploader.java +++ b/src/main/java/com/replaymod/online/api/replay/FileUploader.java @@ -9,16 +9,18 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import javax.net.ssl.HttpsURLConnection; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; -import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.util.Set; import java.util.function.Consumer; +import static com.replaymod.core.utils.Utils.SSL_SOCKET_FACTORY; + @RequiredArgsConstructor public class FileUploader { private static final Gson gson = new Gson(); @@ -48,7 +50,8 @@ public class FileUploader { postData += "&name=" + URLEncoder.encode(filename, "UTF-8"); String url = ReplayModApiMethods.upload_file + postData; - HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + HttpsURLConnection con = (HttpsURLConnection) new URL(url).openConnection(); + con.setSSLSocketFactory(SSL_SOCKET_FACTORY); con.setUseCaches(false); con.setDoOutput(true); con.setRequestMethod("POST"); diff --git a/src/main/java/com/replaymod/online/api/replay/ReplayModApiMethods.java b/src/main/java/com/replaymod/online/api/replay/ReplayModApiMethods.java index 6277c1cd..524fbba1 100755 --- a/src/main/java/com/replaymod/online/api/replay/ReplayModApiMethods.java +++ b/src/main/java/com/replaymod/online/api/replay/ReplayModApiMethods.java @@ -2,7 +2,7 @@ package com.replaymod.online.api.replay; public class ReplayModApiMethods { - public static final String REPLAYMOD_BASE_URL = "http://ReplayMod.com/api/"; + public static final String REPLAYMOD_BASE_URL = "https://ReplayMod.com/api/"; public static final String register = REPLAYMOD_BASE_URL+"register"; public static final String check_authkey = REPLAYMOD_BASE_URL+"check_authkey"; diff --git a/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java b/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java index 4bc59356..2e383fcb 100644 --- a/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java +++ b/src/main/java/com/replaymod/recording/packet/ResourcePackRecorder.java @@ -26,6 +26,7 @@ import javax.annotation.Nonnull; import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.util.HashMap; import java.util.Map; /** @@ -52,6 +53,9 @@ public class ResourcePackRecorder { boolean doWrite = false; // Whether we are the first and have to write it synchronized (replayFile) { // Need to read, modify and write the resource pack index atomically Map index = replayFile.getResourcePackIndex(); + if (index == null) { + index = new HashMap<>(); + } if (!index.containsValue(hash)) { // Hash is unknown, we have to write the resource pack ourselves doWrite = true; diff --git a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java index 14cd84fc..3c2faa88 100644 --- a/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java +++ b/src/main/java/com/replaymod/render/gui/GuiRenderSettings.java @@ -80,8 +80,8 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { } }).setMinSize(new Dimension(0, 20)).setValues(RenderSettings.EncodingPreset.values()); - public final GuiNumberField videoWidth = new GuiNumberField().setSize(50, 20).setMinValue(1); - public final GuiNumberField videoHeight = new GuiNumberField().setSize(50, 20).setMinValue(1); + public final GuiNumberField videoWidth = new GuiNumberField().setSize(50, 20).setMinValue(1).setValidateOnFocusChange(true); + public final GuiNumberField videoHeight = new GuiNumberField().setSize(50, 20).setMinValue(1).setValidateOnFocusChange(true); public final GuiSlider frameRateSlider = new GuiSlider().onValueChanged(new Runnable() { @Override public void run() { @@ -93,7 +93,7 @@ public class GuiRenderSettings extends GuiScreen implements Closeable { .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.RIGHT).setSpacing(2)) .addElements(new HorizontalLayout.Data(0.5), videoWidth, new GuiLabel().setText("*"), videoHeight); - public final GuiNumberField bitRateField = new GuiNumberField().setValue(10).setSize(50, 20); + public final GuiNumberField bitRateField = new GuiNumberField().setValue(10).setSize(50, 20).setValidateOnFocusChange(true); public final GuiDropdownMenu bitRateUnit = new GuiDropdownMenu() .setSize(50, 20).setValues("bps", "kbps", "mbps").setSelected("mbps"); diff --git a/src/main/java/com/replaymod/replay/camera/CameraEntity.java b/src/main/java/com/replaymod/replay/camera/CameraEntity.java index b834936d..719c5fa3 100755 --- a/src/main/java/com/replaymod/replay/camera/CameraEntity.java +++ b/src/main/java/com/replaymod/replay/camera/CameraEntity.java @@ -252,6 +252,15 @@ public class CameraEntity extends EntityPlayerSP { return false; } + @Override + public boolean isInvisible() { + Entity view = mc.getRenderViewEntity(); + if (view != this) { + return view.isInvisible(); + } + return super.isInvisible(); + } + @Override public ResourceLocation getLocationSkin() { Entity view = mc.getRenderViewEntity(); diff --git a/src/main/java/com/replaymod/replay/gui/overlay/GuiEditMarkerPopup.java b/src/main/java/com/replaymod/replay/gui/overlay/GuiEditMarkerPopup.java index 23e9e5b5..cc94f3cc 100644 --- a/src/main/java/com/replaymod/replay/gui/overlay/GuiEditMarkerPopup.java +++ b/src/main/java/com/replaymod/replay/gui/overlay/GuiEditMarkerPopup.java @@ -16,6 +16,10 @@ import org.lwjgl.input.Keyboard; import org.lwjgl.util.ReadablePoint; public class GuiEditMarkerPopup extends AbstractGuiPopup implements Typeable { + private static GuiNumberField newGuiNumberField() { + return new GuiNumberField().setSize(150, 20).setValidateOnFocusChange(true); + } + private final ReplayHandler replayHandler; private final Marker marker; @@ -23,15 +27,15 @@ public class GuiEditMarkerPopup extends AbstractGuiPopup imp public final GuiTextField nameField = new GuiTextField().setSize(150, 20); // TODO: Replace with a min/sec/msec field - public final GuiNumberField timeField = new GuiNumberField().setSize(150, 20).setPrecision(0); + public final GuiNumberField timeField = newGuiNumberField().setPrecision(0); - public final GuiNumberField xField = new GuiNumberField().setSize(150, 20).setPrecision(10); - public final GuiNumberField yField = new GuiNumberField().setSize(150, 20).setPrecision(10); - public final GuiNumberField zField = new GuiNumberField().setSize(150, 20).setPrecision(10); + public final GuiNumberField xField = newGuiNumberField().setPrecision(10); + public final GuiNumberField yField = newGuiNumberField().setPrecision(10); + public final GuiNumberField zField = newGuiNumberField().setPrecision(10); - public final GuiNumberField yawField = new GuiNumberField().setSize(150, 20).setPrecision(5); - public final GuiNumberField pitchField = new GuiNumberField().setSize(150, 20).setPrecision(5); - public final GuiNumberField rollField = new GuiNumberField().setSize(150, 20).setPrecision(5); + public final GuiNumberField yawField = newGuiNumberField().setPrecision(5); + public final GuiNumberField pitchField = newGuiNumberField().setPrecision(5); + public final GuiNumberField rollField = newGuiNumberField().setPrecision(5); public final GuiPanel inputs = GuiPanel.builder() .layout(new GridLayout().setColumns(2).setSpacingX(7).setSpacingY(3)) diff --git a/src/main/java/com/replaymod/simplepathing/InterpolatorType.java b/src/main/java/com/replaymod/simplepathing/InterpolatorType.java new file mode 100644 index 00000000..78993e0d --- /dev/null +++ b/src/main/java/com/replaymod/simplepathing/InterpolatorType.java @@ -0,0 +1,54 @@ +package com.replaymod.simplepathing; + +import com.replaymod.replaystudio.pathing.interpolation.CatmullRomSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.Interpolator; +import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.function.Supplier; + +@AllArgsConstructor +public enum InterpolatorType { + DEFAULT("default", null, null), + CATMULL_ROM("catmullrom", CatmullRomSplineInterpolator.class, () -> new CatmullRomSplineInterpolator(0.5)), + CUBIC("cubic", CubicSplineInterpolator.class, CubicSplineInterpolator::new), + LINEAR("linear", LinearInterpolator.class, LinearInterpolator::new); + + @Getter + private String localizationKey; + + @Getter + private Class interpolatorClass; + + private Supplier interpolatorConstructor; + + public String getI18nName() { + return String.format("replaymod.gui.editkeyframe.interpolator.%1$s.name", localizationKey); + } + + public String getI18nDescription() { + return String.format("replaymod.gui.editkeyframe.interpolator.%1$s.desc", localizationKey); + } + + public static InterpolatorType fromString(String string) { + for (InterpolatorType t : values()) { + if (t.getI18nName().equals(string)) return t; + } + return CATMULL_ROM; //the default + } + + public static InterpolatorType fromClass(Class cls) { + for (InterpolatorType type : values()) { + if (cls.equals(type.getInterpolatorClass())) { + return type; + } + } + return DEFAULT; + } + + public Interpolator newInstance() { + return interpolatorConstructor.get(); + } +} diff --git a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java index c1d11e22..95596baf 100644 --- a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java +++ b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java @@ -1,36 +1,25 @@ package com.replaymod.simplepathing; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; import com.replaymod.core.ReplayMod; import com.replaymod.core.events.SettingsChangedEvent; -import com.replaymod.pathing.properties.CameraProperties; -import com.replaymod.pathing.properties.SpectatorProperty; -import com.replaymod.pathing.properties.TimestampProperty; import com.replaymod.replay.events.ReplayCloseEvent; import com.replaymod.replay.events.ReplayOpenEvent; -import com.replaymod.replaystudio.pathing.PathingRegistry; -import com.replaymod.replaystudio.pathing.impl.TimelineImpl; -import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator; -import com.replaymod.replaystudio.pathing.interpolation.Interpolator; -import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; import com.replaymod.replaystudio.pathing.path.Keyframe; -import com.replaymod.replaystudio.pathing.path.Timeline; +import com.replaymod.simplepathing.SPTimeline.SPPath; import com.replaymod.simplepathing.gui.GuiPathing; import com.replaymod.simplepathing.preview.PathPreview; +import lombok.Getter; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import org.apache.logging.log4j.Logger; -import java.io.IOException; - @Mod(modid = ReplayModSimplePathing.MOD_ID, version = "@MOD_VERSION@", acceptedMinecraftVersions = "@MC_VERSION@", useMetadata = true) -public class ReplayModSimplePathing implements PathingRegistry { +public class ReplayModSimplePathing { public static final String MOD_ID = "replaymod-simplepathing"; private ReplayMod core; @@ -54,84 +43,60 @@ public class ReplayModSimplePathing implements PathingRegistry { @SubscribeEvent public void postReplayOpen(ReplayOpenEvent.Post event) { + currentTimeline = new SPTimeline(); guiPathing = new GuiPathing(core, this, event.getReplayHandler()); } @SubscribeEvent public void onReplayClose(ReplayCloseEvent.Post event) { + currentTimeline = null; guiPathing = null; - currentTimeline = createTimeline(); - currentTimeline.createPath(); - currentTimeline.createPath(); - selectedKeyframe = null; + selectedPath = null; } @SubscribeEvent public void onSettingsChanged(SettingsChangedEvent event) { - if (event.getKey() == Setting.LINEAR_INTERPOLATION) { + if (event.getKey() == Setting.DEFAULT_INTERPOLATION) { if (currentTimeline != null && guiPathing != null) { - currentTimeline.applyChange(guiPathing.updateInterpolators()); + updateDefaultInterpolatorType(); } } } - private Timeline currentTimeline = createTimeline(); { currentTimeline.createPath(); currentTimeline.createPath(); } - private Keyframe selectedKeyframe; + private SPTimeline currentTimeline; - public Keyframe getSelectedKeyframe() { - return selectedKeyframe; + @Getter + private SPPath selectedPath; + @Getter + private long selectedTime; + + public boolean isSelected(Keyframe keyframe) { + return selectedPath != null && currentTimeline.getKeyframe(selectedPath, selectedTime) == keyframe; } - public void setSelectedKeyframe(Keyframe selected) { - this.selectedKeyframe = selected; + public void setSelected(SPPath path, long time) { + selectedPath = path; + selectedTime = time; } - public void setCurrentTimeline(Timeline currentTimeline) { - if (this.currentTimeline != currentTimeline) { - selectedKeyframe = null; - } - this.currentTimeline = currentTimeline; + public void setCurrentTimeline(SPTimeline newTimeline) { + selectedPath = null; + currentTimeline = newTimeline; } - public Timeline getCurrentTimeline() { + public void clearCurrentTimeline() { + setCurrentTimeline(new SPTimeline()); + updateDefaultInterpolatorType(); + } + + public SPTimeline getCurrentTimeline() { return currentTimeline; } - @Override - public Timeline createTimeline() { - Timeline timeline = new TimelineImpl(); - - timeline.registerProperty(TimestampProperty.PROPERTY); - timeline.registerProperty(CameraProperties.POSITION); - timeline.registerProperty(CameraProperties.ROTATION); - timeline.registerProperty(SpectatorProperty.PROPERTY); - - return timeline; - } - - @Override - public void serializeInterpolator(JsonWriter writer, Interpolator interpolator) throws IOException { - if (interpolator instanceof LinearInterpolator) { - writer.value("linear"); - } else if (interpolator instanceof CubicSplineInterpolator) { - writer.value("cubic-spline"); - } else { - throw new IOException("Unknown interpolator type: " + interpolator); - } - } - - @Override - public Interpolator deserializeInterpolator(JsonReader reader) throws IOException { - String type = reader.nextString(); - switch (type) { - case "linear": - return new LinearInterpolator(); - case "cubic-spline": - return new CubicSplineInterpolator(); - default: - throw new IOException("Unknown interpolation type: " + type); - - } + private void updateDefaultInterpolatorType() { + InterpolatorType newDefaultType = + InterpolatorType.fromString(core.getSettingsRegistry().get(Setting.DEFAULT_INTERPOLATION)); + currentTimeline.setDefaultInterpolatorType(newDefaultType); } public ReplayMod getCore() { diff --git a/src/main/java/com/replaymod/simplepathing/SPTimeline.java b/src/main/java/com/replaymod/simplepathing/SPTimeline.java new file mode 100644 index 00000000..22a04fbe --- /dev/null +++ b/src/main/java/com/replaymod/simplepathing/SPTimeline.java @@ -0,0 +1,669 @@ +package com.replaymod.simplepathing; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.replaymod.pathing.properties.CameraProperties; +import com.replaymod.pathing.properties.SpectatorProperty; +import com.replaymod.pathing.properties.TimestampProperty; +import com.replaymod.replaystudio.pathing.PathingRegistry; +import com.replaymod.replaystudio.pathing.change.AddKeyframe; +import com.replaymod.replaystudio.pathing.change.Change; +import com.replaymod.replaystudio.pathing.change.CombinedChange; +import com.replaymod.replaystudio.pathing.change.SetInterpolator; +import com.replaymod.replaystudio.pathing.change.UpdateKeyframeProperties; +import com.replaymod.replaystudio.pathing.impl.TimelineImpl; +import com.replaymod.replaystudio.pathing.interpolation.CatmullRomSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.Interpolator; +import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; +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.path.Timeline; +import com.replaymod.replaystudio.pathing.property.Property; +import com.replaymod.replaystudio.util.EntityPositionTracker; +import com.replaymod.replaystudio.util.Location; +import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty; +import lombok.Getter; +import net.minecraft.crash.CrashReport; +import net.minecraft.crash.CrashReportCategory; +import net.minecraft.util.ReportedException; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.Validate; +import org.apache.commons.lang3.tuple.Triple; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static com.replaymod.replaystudio.pathing.change.RemoveKeyframe.create; +import static com.replaymod.simplepathing.ReplayModSimplePathing.LOGGER; + +/** + * Simplified timeline abstraction used in the SimplePathing module. + */ +public class SPTimeline implements PathingRegistry { + public enum SPPath { + TIME, + POSITION, + } + + @Getter + private final Timeline timeline; + @Getter + private final Path timePath; + @Getter + private final Path positionPath; + + @Getter + private EntityPositionTracker entityTracker; + private InterpolatorType defaultInterpolatorType = InterpolatorType.fromString("invalid string returns default"); + + public SPTimeline() { + this(createInitialTimeline()); + } + + public SPTimeline(Timeline timeline) { + this.timeline = timeline; + this.timePath = timeline.getPaths().get(SPPath.TIME.ordinal()); + this.positionPath = timeline.getPaths().get(SPPath.POSITION.ordinal()); + } + + public Path getPath(SPPath path) { + switch (path) { + case TIME: + return getTimePath(); + case POSITION: + return getPositionPath(); + } + throw new IllegalArgumentException("Unknown path " + path); + } + + public Keyframe getKeyframe(SPPath path, long keyframe) { + return getPath(path).getKeyframe(keyframe); + } + + public void setEntityTracker(EntityPositionTracker entityTracker) { + Preconditions.checkState(this.entityTracker == null, "Entity tracker already set"); + this.entityTracker = entityTracker; + } + + public void setDefaultInterpolatorType(InterpolatorType defaultInterpolatorType) { + Validate.isTrue(defaultInterpolatorType != InterpolatorType.DEFAULT, "Must not be DEFAULT"); + this.defaultInterpolatorType = Validate.notNull(defaultInterpolatorType); + + if (entityTracker != null) { + timeline.pushChange(updateInterpolators()); + } + } + + public Change setDefaultInterpolator(Interpolator interpolator) { + Preconditions.checkState(defaultInterpolatorType != null, "Default interpolator type not set."); + Validate.isInstanceOf(defaultInterpolatorType.getInterpolatorClass(), interpolator); + + registerPositionInterpolatorProperties(interpolator); + + Change change = CombinedChange.create( + positionPath.getSegments().stream() + // Ignore explicitly set segments + .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) + // Ignore spectator segments + .filter(s -> !isSpectatorSegment(s)) + // Update interpolator for every remaining segment + // This will create a fragmented interpolator which is split by the updateInterpolators call + .map(s -> SetInterpolator.create(s, interpolator)).toArray(Change[]::new) + ); + change.apply(timeline); + return CombinedChange.createFromApplied(change, updateInterpolators()); + } + + public boolean isTimeKeyframe(long time) { + return timePath.getKeyframe(time) != null; + } + + public boolean isPositionKeyframe(long time) { + return positionPath.getKeyframe(time) != null; + } + + public boolean isSpectatorKeyframe(long time) { + Keyframe keyframe = positionPath.getKeyframe(time); + return keyframe != null && keyframe.getValue(SpectatorProperty.PROPERTY).isPresent(); + } + + public void addPositionKeyframe(long time, double posX, double posY, double posZ, + float yaw, float pitch, float roll, int spectated) { + LOGGER.debug("Adding position keyframe at {} pos {}/{}/{} rot {}/{}/{} entId {}", + time, posX, posY, posZ, yaw, pitch, roll, spectated); + + Path path = positionPath; + + Preconditions.checkState(positionPath.getKeyframe(time) == null, "Keyframe already exists"); + + Change change = AddKeyframe.create(path, time); + change.apply(timeline); + Keyframe keyframe = path.getKeyframe(time); + + UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, keyframe); + builder.setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ)); + builder.setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll)); + if (spectated != -1) { + builder.setValue(SpectatorProperty.PROPERTY, spectated); + } + UpdateKeyframeProperties updateChange = builder.done(); + updateChange.apply(timeline); + change = CombinedChange.createFromApplied(change, updateChange); + + // If this new keyframe formed the first segment of the path + if (path.getSegments().size() == 1) { + // then create an initial interpolator of default type + PathSegment segment = path.getSegments().iterator().next(); + Interpolator interpolator = createDefaultInterpolator(); + SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator); + setInterpolator.apply(timeline); + change = CombinedChange.createFromApplied(change, setInterpolator); + } + + // Update interpolators for spectator keyframes + // while this is overkill, it is far simpler than updating differently for every possible case + change = CombinedChange.createFromApplied(change, updateInterpolators()); + + Change specPosUpdate = updateSpectatorPositions(); + specPosUpdate.apply(timeline); + change = CombinedChange.createFromApplied(change, specPosUpdate); + + timeline.pushChange(change); + } + + public Change updatePositionKeyframe(long time, double posX, double posY, double posZ, + float yaw, float pitch, float roll) { + LOGGER.debug("Updating position keyframe at {} to pos {}/{}/{} rot {}/{}/{}", + time, posX, posY, posZ, yaw, pitch, roll); + + Keyframe keyframe = positionPath.getKeyframe(time); + + Preconditions.checkState(keyframe != null, "Keyframe does not exists"); + Preconditions.checkState(!keyframe.getValue(SpectatorProperty.PROPERTY).isPresent(), "Cannot update spectator keyframe"); + + Change change = UpdateKeyframeProperties.create(positionPath, keyframe) + .setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ)) + .setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll)) + .done(); + change.apply(timeline); + return change; + } + + public void removePositionKeyframe(long time) { + LOGGER.debug("Removing position keyframe at {}", time); + + Path path = positionPath; + Keyframe keyframe = path.getKeyframe(time); + + Preconditions.checkState(keyframe != null, "No keyframe at that time"); + + Change change = create(path, keyframe); + change.apply(timeline); + + // Update interpolators for spectator keyframes + // while this is overkill, it is far simpler than updating differently for every possible case + change = CombinedChange.createFromApplied(change, updateInterpolators()); + + Change specPosUpdate = updateSpectatorPositions(); + specPosUpdate.apply(timeline); + change = CombinedChange.createFromApplied(change, specPosUpdate); + + timeline.pushChange(change); + } + + public void addTimeKeyframe(long time, int replayTime) { + LOGGER.debug("Adding time keyframe at {} time {}", time, replayTime); + + Path path = timePath; + + Preconditions.checkState(path.getKeyframe(time) == null, "Keyframe already exists"); + + Change change = AddKeyframe.create(path, time); + change.apply(timeline); + Keyframe keyframe = path.getKeyframe(time); + + UpdateKeyframeProperties updateChange = UpdateKeyframeProperties.create(path, keyframe) + .setValue(TimestampProperty.PROPERTY, replayTime) + .done(); + updateChange.apply(timeline); + change = CombinedChange.createFromApplied(change, updateChange); + + // If this new keyframe formed the first segment of the path + if (path.getSegments().size() == 1) { + // then create an initial interpolator + PathSegment segment = path.getSegments().iterator().next(); + Interpolator interpolator = new LinearInterpolator(); + interpolator.registerProperty(TimestampProperty.PROPERTY); + SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator); + setInterpolator.apply(timeline); + change = CombinedChange.createFromApplied(change, setInterpolator); + } + + Change specPosUpdate = updateSpectatorPositions(); + specPosUpdate.apply(timeline); + change = CombinedChange.createFromApplied(change, specPosUpdate); + + timeline.pushChange(change); + } + + public Change updateTimeKeyframe(long time, int replayTime) { + LOGGER.debug("Updating time keyframe at {} to time {}", time, replayTime); + + Keyframe keyframe = timePath.getKeyframe(time); + + Preconditions.checkState(keyframe != null, "Keyframe does not exists"); + + Change change = UpdateKeyframeProperties.create(timePath, keyframe) + .setValue(TimestampProperty.PROPERTY, replayTime) + .done(); + change.apply(timeline); + return change; + } + + public void removeTimeKeyframe(long time) { + LOGGER.debug("Removing time keyframe at {}", time); + + Path path = timePath; + 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); + change = CombinedChange.createFromApplied(change, specPosUpdate); + + timeline.pushChange(change); + } + + public Change setInterpolatorToDefault(long time) { + LOGGER.debug("Setting interpolator of position keyframe at {} to the default", time); + + Keyframe keyframe = positionPath.getKeyframe(time); + + Preconditions.checkState(keyframe != null, "Keyframe does not exists"); + + Change change = UpdateKeyframeProperties.create(positionPath, keyframe) + .removeProperty(ExplicitInterpolationProperty.PROPERTY) + .done(); + change.apply(timeline); + return CombinedChange.createFromApplied(change, updateInterpolators()); + } + + public Change setInterpolator(long time, Interpolator interpolator) { + LOGGER.debug("Setting interpolator of position keyframe at {} to {}", time, interpolator); + + Keyframe keyframe = positionPath.getKeyframe(time); + Preconditions.checkState(keyframe != null, "Keyframe does not exists"); + PathSegment segment = positionPath.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe) + .findFirst().orElseThrow(() -> new IllegalStateException("Keyframe has no following segment.")); + + registerPositionInterpolatorProperties(interpolator); + + Change change = CombinedChange.create( + UpdateKeyframeProperties.create(positionPath, keyframe) + .setValue(ExplicitInterpolationProperty.PROPERTY, ObjectUtils.NULL) + .done(), + SetInterpolator.create(segment, interpolator) + ); + change.apply(timeline); + return CombinedChange.createFromApplied(change, updateInterpolators()); + } + + public Change moveKeyframe(SPPath spPath, long oldTime, long newTime) { + LOGGER.debug("Moving keyframe on {} from {} to {}", spPath, oldTime, newTime); + Path path = getPath(spPath); + Keyframe keyframe = path.getKeyframe(oldTime); + + Preconditions.checkState(keyframe != null, "No keyframe at specified time"); + + // Interpolator of the first segment might be required later if it is the only one + Optional firstInterpolator = + path.getSegments().stream().findFirst().map(PathSegment::getInterpolator); + + // The interpolator of the previous segment + Optional interpolatorBefore = + path.getSegments().stream().filter(s -> s.getEndKeyframe() == keyframe).findFirst().map(PathSegment::getInterpolator); + + // The interpolator of the following segment + Optional interpolatorAfter = + path.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe).findFirst().map(PathSegment::getInterpolator); + + // First remove the old keyframe + Change removeChange = create(path, keyframe); + removeChange.apply(timeline); + + // and add a new one at the correct time + Change addChange = AddKeyframe.create(path, newTime); + addChange.apply(timeline); + + // Then copy over all properties + UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, path.getKeyframe(newTime)); + for (Property property : keyframe.getProperties()) { + copyProperty(property, keyframe, builder); + } + Change propertyChange = builder.done(); + propertyChange.apply(timeline); + + + Change restoreInterpolatorChange; + Keyframe newKf = path.getKeyframe(newTime); + if (Iterables.getLast(path.getKeyframes()) != newKf) { // Unless this is the last keyframe + // the interpolator of the following segment has been lost and needs to be restored + restoreInterpolatorChange = interpolatorAfter.flatMap(interpolator -> + path.getSegments().stream().filter(s -> s.getStartKeyframe() == newKf).findFirst().map(segment -> + SetInterpolator.create(segment, interpolator) + ) + ).orElseGet(CombinedChange::create); + } else { // If it is the last keyframe however, + // the interpolator of the previous segment has been lost and needs to be restored + restoreInterpolatorChange = interpolatorBefore.flatMap(interpolator -> + path.getSegments().stream().filter(s -> s.getEndKeyframe() == newKf).findFirst().map(segment -> + SetInterpolator.create(segment, interpolator) + ) + ).orElseGet(CombinedChange::create); + } + restoreInterpolatorChange.apply(timeline); + + // Finally update the interpolators + Change interpolatorUpdateChange; + if (spPath == SPPath.POSITION) { + // Position / Spectator keyframes need special handling + interpolatorUpdateChange = updateInterpolators(); + } else { + // Time keyframes only need updating when only one segment of them exists + if (path.getSegments().size() == 1) { + assert firstInterpolator.isPresent() : "One segment should have existed before as well"; + interpolatorUpdateChange = SetInterpolator.create(path.getSegments().iterator().next(), firstInterpolator.get()); + } else { + interpolatorUpdateChange = CombinedChange.create(); // Noop change + } + interpolatorUpdateChange.apply(timeline); + } + + // and update spectator positions + Change spectatorChange = updateSpectatorPositions(); + spectatorChange.apply(timeline); + + return CombinedChange.createFromApplied(removeChange, addChange, propertyChange, + restoreInterpolatorChange, interpolatorUpdateChange, spectatorChange); + } + + // Helper method because generics cannot be defined on blocks + private void copyProperty(Property property, Keyframe from, UpdateKeyframeProperties.Builder to) { + from.getValue(property).ifPresent(value -> to.setValue(property, value)); + } + + private Change updateInterpolators() { + Collection pathSegments = positionPath.getSegments(); + // Contains updated pathsegment-interpolator mappings, may be changed multiple times over the course of this method + Map updates = new HashMap<>(); + + // First, setup spectator interpolators + Interpolator interpolator = null; + // Iterate through all segments + for (PathSegment segment : pathSegments) { + if (isSpectatorSegment(segment)) { + // If the last segment was a spectator segment, then use its interpolator for this segment as well + if (interpolator == null) { + // otherwise create a new interpolator + interpolator = new LinearInterpolator(); + interpolator.registerProperty(SpectatorProperty.PROPERTY); + } + // Now that we have an interpolator, set it for the current segment + updates.put(segment, interpolator); + } else { + // Not a spectator segment, make sure we don't re-use the last interpolator (it would not be continuous) + interpolator = null; + } + } + + // Then, if required, replace old default interpolators with new ones + pathSegments.stream() + // Ignore explicitly set segments + .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) + // Ignore spectator segments + .filter(s -> !isSpectatorSegment(s)) + // Ignore already correct segments, should ignore all if default hasn't changed + .filter(s -> !s.getInterpolator().getClass().equals(defaultInterpolatorType.getInterpolatorClass())) + // Finally, set the interpolators + // This will create one interpolator per segment, they will be merged later + .forEach(segment -> updates.put(segment, createDefaultInterpolator())); + + // All interpolators should now be set appropriately but may still be fragmented. + // Cleaning that up is a two step process + + // Firstly, all interpolators that are not continuous need to be split up + Interpolator lastInterpolator = null; + Set used = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathSegment segment : pathSegments) { + if (isSpectatorSegment(segment)) { + lastInterpolator = null; + continue; // Chain broken by spectator segment + } + + Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator()); + if (lastInterpolator == currentInterpolator) { + continue; // All fine, interpolator is still continuous + } + + // New interpolator, make sure it hasn't been used before + if (!used.add(interpolator)) { + // It has been used before, we need to clone it and use the clone instead + // This will create a new interpolator for each segment which will be merged later + currentInterpolator = cloneInterpolator(currentInterpolator); + updates.put(segment, currentInterpolator); + } + lastInterpolator = currentInterpolator; + } + + // Secondly, all neighbouring interpolators that are equal need to be merged + lastInterpolator = null; + String lastInterpolatorSerialized = null; + for (PathSegment segment : pathSegments) { + if (isSpectatorSegment(segment)) { + lastInterpolator = null; + lastInterpolatorSerialized = null; + continue; // Spectator segments are continuous by construction + } + Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator()); + String serialized = serializeInterpolator(currentInterpolator); + if (lastInterpolator != currentInterpolator && serialized.equals(lastInterpolatorSerialized)) { + // This interpolator is equal to the last one but not the same, needs merging + updates.put(segment, lastInterpolator); + continue; + } + // New interpolator, not related to the previous one, merged by definition + lastInterpolator = currentInterpolator; + lastInterpolatorSerialized = serialized; + } + + Change change = CombinedChange.create(updates.entrySet().stream() + .map(e -> SetInterpolator.create(e.getKey(), e.getValue())).toArray(Change[]::new)); + change.apply(timeline); + return change; + } + + private boolean isSpectatorSegment(PathSegment segment) { + return segment.getStartKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent() + && segment.getEndKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent(); + } + + private Change updateSpectatorPositions() { + List changes = new ArrayList<>(); + timePath.updateAll(); + for (Keyframe keyframe : positionPath.getKeyframes()) { + Optional spectator = keyframe.getValue(SpectatorProperty.PROPERTY); + if (spectator.isPresent()) { + Optional time = timePath.getValue(TimestampProperty.PROPERTY, keyframe.getTime()); + if (!time.isPresent()) { + continue; // No time keyframes set at this video time, cannot determine replay time + } + Location expected = entityTracker.getEntityPositionAtTimestamp(spectator.get(), time.get()); + if (expected == null) { + continue; // We don't have any data on this entity for some reason + } + Triple pos = keyframe.getValue(CameraProperties.POSITION).orElse(Triple.of(0D, 0D, 0D)); + Triple rot = keyframe.getValue(CameraProperties.ROTATION).orElse(Triple.of(0F, 0F, 0F)); + Location actual = new Location(pos.getLeft(), pos.getMiddle(), pos.getRight(), rot.getLeft(), rot.getRight()); + if (!expected.equals(actual)) { + changes.add(UpdateKeyframeProperties.create(positionPath, keyframe) + .setValue(CameraProperties.POSITION, Triple.of(expected.getX(), expected.getY(), expected.getZ())) + .setValue(CameraProperties.ROTATION, Triple.of(expected.getYaw(), expected.getPitch(), 0f)).done() + ); + } + } + } + return CombinedChange.create(changes.toArray(new Change[changes.size()])); + } + + private Interpolator createDefaultInterpolator() { + return registerPositionInterpolatorProperties(defaultInterpolatorType.newInstance()); + } + + private Interpolator registerPositionInterpolatorProperties(Interpolator interpolator) { + interpolator.registerProperty(CameraProperties.POSITION); + interpolator.registerProperty(CameraProperties.ROTATION); + return interpolator; + } + + @Override + public Timeline createTimeline() { + return createTimelineStatic(); + } + + private static Timeline createInitialTimeline() { + Timeline timeline = createTimelineStatic(); + timeline.createPath(); + timeline.createPath(); + return timeline; + } + + private static Timeline createTimelineStatic() { + Timeline timeline = new TimelineImpl(); + + timeline.registerProperty(TimestampProperty.PROPERTY); + timeline.registerProperty(CameraProperties.POSITION); + timeline.registerProperty(CameraProperties.ROTATION); + timeline.registerProperty(SpectatorProperty.PROPERTY); + timeline.registerProperty(ExplicitInterpolationProperty.PROPERTY); + + return timeline; + } + + @Override + public void serializeInterpolator(JsonWriter writer, Interpolator interpolator) throws IOException { + if (interpolator instanceof LinearInterpolator) { + writer.value("linear"); + } else if (interpolator instanceof CubicSplineInterpolator) { + writer.value("cubic-spline"); + } else if (interpolator instanceof CatmullRomSplineInterpolator) { + writer.beginObject(); + writer.name("type").value("catmull-rom-spline"); + writer.name("alpha").value(((CatmullRomSplineInterpolator) interpolator).getAlpha()); + writer.endObject(); + } else { + throw new IOException("Unknown interpolator type: " + interpolator); + } + } + + @Override + public Interpolator deserializeInterpolator(JsonReader reader) throws IOException { + String type; + JsonObject args; + switch (reader.peek()) { + case STRING: + type = reader.nextString(); + args = null; + break; + case BEGIN_OBJECT: + args = new JsonParser().parse(reader).getAsJsonObject(); + type = args.get("type").getAsString(); + break; + default: + throw new IOException("Unexpected token: " + reader.peek()); + } + switch (type) { + case "linear": + return new LinearInterpolator(); + case "cubic-spline": + return new CubicSplineInterpolator(); + case "catmull-rom-spline": + if (args == null || !args.has("alpha")) { + throw new IOException("Missing alpha value for catmull-rom-spline."); + } + return new CatmullRomSplineInterpolator(args.get("alpha").getAsDouble()); + default: + throw new IOException("Unknown interpolation type: " + type); + + } + } + + /** + * Clones an interpolator by de- and re-serializing it. + * @param interpolator The interpolator to clone + * @return The cloned interpolator + */ + private Interpolator cloneInterpolator(Interpolator interpolator) { + Interpolator cloned = deserializeInterpolator(serializeInterpolator(interpolator)); + interpolator.getKeyframeProperties().forEach(cloned::registerProperty); + return cloned; + } + + /** + * Serializes the specific interpolator to String. + * Does not serialize the registered keyframe properties. + * @param interpolator The interpolator to serialize. + * @return The serialized interpolator + */ + private String serializeInterpolator(Interpolator interpolator) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + JsonWriter jsonWriter = new JsonWriter(new PrintWriter(baos)); + try { + jsonWriter.beginArray(); + serializeInterpolator(jsonWriter, interpolator); + jsonWriter.endArray(); + jsonWriter.flush(); + } catch (IOException e) { + CrashReport crash = CrashReport.makeCrashReport(e, "Serializing interpolator"); + CrashReportCategory category = crash.makeCategory("Serializing interpolator"); + category.setDetail("Interpolator", interpolator::toString); + throw new ReportedException(crash); + } + + return baos.toString(); + } + + private Interpolator deserializeInterpolator(String json) { + JsonReader jsonReader = new JsonReader(new StringReader(json)); + try { + jsonReader.beginArray(); + return deserializeInterpolator(jsonReader); + } catch (IOException e) { + CrashReport crash = CrashReport.makeCrashReport(e, "De-serializing interpolator"); + CrashReportCategory category = crash.makeCategory("De-serializing interpolator"); + category.addCrashSection("Interpolator", json); + throw new ReportedException(crash); + } + } +} diff --git a/src/main/java/com/replaymod/simplepathing/Setting.java b/src/main/java/com/replaymod/simplepathing/Setting.java index b1e99e17..79e432b2 100644 --- a/src/main/java/com/replaymod/simplepathing/Setting.java +++ b/src/main/java/com/replaymod/simplepathing/Setting.java @@ -2,9 +2,25 @@ package com.replaymod.simplepathing; import com.replaymod.core.SettingsRegistry; +import java.util.Arrays; +import java.util.stream.Collectors; + public final class Setting extends SettingsRegistry.SettingKeys { public static final Setting PATH_PREVIEW = make("pathpreview", "pathpreview", true); - public static final Setting LINEAR_INTERPOLATION = make("linear_interpolation", "linearinterpolation", false); + public static final SettingsRegistry.MultipleChoiceSettingKeys DEFAULT_INTERPOLATION; + + static { + String format = "replaymod.gui.editkeyframe.interpolator.%s.name"; + DEFAULT_INTERPOLATION = new SettingsRegistry.MultipleChoiceSettingKeys<>( + "simplepathing", "interpolator", "replaymod.gui.settings.interpolator", + String.format(format, InterpolatorType.fromString("invalid returns default").getLocalizationKey()) + ); + DEFAULT_INTERPOLATION.setChoices( + Arrays.stream(InterpolatorType.values()).filter(i -> i != InterpolatorType.DEFAULT) + .map(i -> String.format(format, i.getLocalizationKey())) + .collect(Collectors.toList()) + ); + } private static Setting make(String key, String displayName, T defaultValue) { return new Setting<>(key, displayName, defaultValue); diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java index bc61f618..7fa41072 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java @@ -5,14 +5,27 @@ import com.replaymod.pathing.properties.TimestampProperty; import com.replaymod.replay.ReplayModReplay; import com.replaymod.replaystudio.pathing.change.Change; import com.replaymod.replaystudio.pathing.change.CombinedChange; -import com.replaymod.replaystudio.pathing.change.UpdateKeyframeProperties; +import com.replaymod.replaystudio.pathing.interpolation.CatmullRomSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.Interpolator; +import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; import com.replaymod.replaystudio.pathing.path.Keyframe; import com.replaymod.replaystudio.pathing.path.Path; +import com.replaymod.replaystudio.pathing.path.PathSegment; +import com.replaymod.simplepathing.InterpolatorType; +import com.replaymod.simplepathing.SPTimeline; +import com.replaymod.simplepathing.SPTimeline.SPPath; +import com.replaymod.simplepathing.Setting; +import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty; +import de.johni0702.minecraft.gui.container.AbstractGuiContainer; import de.johni0702.minecraft.gui.container.GuiPanel; import de.johni0702.minecraft.gui.element.GuiButton; import de.johni0702.minecraft.gui.element.GuiLabel; import de.johni0702.minecraft.gui.element.GuiNumberField; +import de.johni0702.minecraft.gui.element.GuiTooltip; +import de.johni0702.minecraft.gui.element.IGuiClickable; import de.johni0702.minecraft.gui.element.IGuiLabel; +import de.johni0702.minecraft.gui.element.advanced.GuiDropdownMenu; import de.johni0702.minecraft.gui.function.Typeable; import de.johni0702.minecraft.gui.layout.GridLayout; import de.johni0702.minecraft.gui.layout.HorizontalLayout; @@ -20,10 +33,16 @@ import de.johni0702.minecraft.gui.layout.VerticalLayout; import de.johni0702.minecraft.gui.popup.AbstractGuiPopup; import de.johni0702.minecraft.gui.utils.Colors; import de.johni0702.minecraft.gui.utils.Consumer; -import org.apache.commons.lang3.tuple.Triple; +import lombok.Getter; +import net.minecraft.client.resources.I18n; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.lwjgl.input.Keyboard; import org.lwjgl.util.ReadablePoint; +import java.util.Map; +import java.util.Optional; + import static de.johni0702.minecraft.gui.utils.Utils.link; public abstract class GuiEditKeyframe> extends AbstractGuiPopup implements Typeable { @@ -31,6 +50,11 @@ public abstract class GuiEditKeyframe> extends Abst return new GuiNumberField().setPrecision(0).setValidateOnFocusChange(true); } + protected static final Logger logger = LogManager.getLogger(); + + protected final GuiPathing guiPathing; + + protected final long time; protected final Keyframe keyframe; protected final Path path; @@ -66,12 +90,13 @@ public abstract class GuiEditKeyframe> extends Abst .addElements(new VerticalLayout.Data(0.5, false), title, inputs, timePanel, buttons); } - public GuiEditKeyframe(GuiPathing gui, Path path, Keyframe keyframe, String type) { + public GuiEditKeyframe(GuiPathing gui, SPPath path, long time, String type) { super(ReplayModReplay.instance.getReplayHandler().getOverlay()); - this.keyframe = keyframe; - this.path = path; + this.guiPathing = gui; + this.time = time; + this.path = gui.getMod().getCurrentTimeline().getPath(path); + this.keyframe = this.path.getKeyframe(time); - long time = keyframe.getTime(); Consumer updateSaveButtonState = s -> saveButton.setEnabled(canSave()); timeMinField.setValue(time / 1000 / 60).onTextChanged(updateSaveButtonState); timeSecField.setValue(time / 1000 % 60).onTextChanged(updateSaveButtonState); @@ -81,10 +106,11 @@ public abstract class GuiEditKeyframe> extends Abst saveButton.onClick(() -> { Change change = save(); long newTime = (timeMinField.getInteger() * 60 + timeSecField.getInteger()) * 1000 + timeMSecField.getInteger(); - if (newTime != keyframe.getTime()) { - change = CombinedChange.createFromApplied(change, gui.moveKeyframe(path, keyframe, newTime)); + if (newTime != time) { + change = CombinedChange.createFromApplied(change, + gui.getMod().getCurrentTimeline().moveKeyframe(path, time, newTime)); } - path.getTimeline().pushChange(change); + gui.getMod().getCurrentTimeline().getTimeline().pushChange(change); close(); }); } @@ -114,7 +140,7 @@ public abstract class GuiEditKeyframe> extends Abst protected abstract Change save(); public static class Spectator extends GuiEditKeyframe { - public Spectator(GuiPathing gui, Path path, Keyframe keyframe) { + public Spectator(GuiPathing gui, SPPath path, long keyframe) { super(gui, path, keyframe, "spec"); link(timeMinField, timeSecField, timeMSecField); @@ -147,10 +173,10 @@ public abstract class GuiEditKeyframe> extends Abst timestampMSecField, new GuiLabel().setI18nText("replaymod.gui.milliseconds")); } - public Time(GuiPathing gui, Path path, Keyframe keyframe) { + public Time(GuiPathing gui, SPPath path, long keyframe) { super(gui, path, keyframe, "time"); - keyframe.getValue(TimestampProperty.PROPERTY).ifPresent(time -> { + this.keyframe.getValue(TimestampProperty.PROPERTY).ifPresent(time -> { timestampMinField.setValue(time / 1000 / 60); timestampSecField.setValue(time / 1000 % 60); timestampMSecField.setValue(time % 1000); @@ -165,11 +191,7 @@ public abstract class GuiEditKeyframe> extends Abst protected Change save() { int time = (timestampMinField.getInteger() * 60 + timestampSecField.getInteger()) * 1000 + timestampMSecField.getInteger(); - Change change = UpdateKeyframeProperties.create(path, keyframe) - .setValue(TimestampProperty.PROPERTY, time) - .done(); - change.apply(path.getTimeline()); - return change; + return guiPathing.getMod().getCurrentTimeline().updateTimeKeyframe(keyframe.getTime(), time); } @Override @@ -187,8 +209,11 @@ public abstract class GuiEditKeyframe> extends Abst public final GuiNumberField pitchField = newGuiNumberField().setSize(60, 20).setPrecision(5); public final GuiNumberField rollField = newGuiNumberField().setSize(60, 20).setPrecision(5); + public final InterpolationPanel interpolationPanel = new InterpolationPanel(); + { - inputs.setLayout(new GridLayout().setCellsEqualSize(false).setColumns(4).setSpacingX(3).setSpacingY(5)) + GuiPanel positionInputs = new GuiPanel() + .setLayout(new GridLayout().setCellsEqualSize(false).setColumns(4).setSpacingX(3).setSpacingY(5)) .addElements(new GridLayout.Data(1, 0.5), new GuiLabel().setI18nText("replaymod.gui.editkeyframe.xpos"), xField, new GuiLabel().setI18nText("replaymod.gui.editkeyframe.camyaw"), yawField, @@ -196,17 +221,20 @@ public abstract class GuiEditKeyframe> extends Abst new GuiLabel().setI18nText("replaymod.gui.editkeyframe.campitch"), pitchField, new GuiLabel().setI18nText("replaymod.gui.editkeyframe.zpos"), zField, new GuiLabel().setI18nText("replaymod.gui.editkeyframe.camroll"), rollField); + + inputs.setLayout(new VerticalLayout().setSpacing(10)).addElements(new VerticalLayout.Data(0.5, false), + positionInputs, interpolationPanel); } - public Position(GuiPathing gui, Path path, Keyframe keyframe) { + public Position(GuiPathing gui, SPPath path, long keyframe) { super(gui, path, keyframe, "pos"); - keyframe.getValue(CameraProperties.POSITION).ifPresent(pos -> { + this.keyframe.getValue(CameraProperties.POSITION).ifPresent(pos -> { xField.setValue(pos.getLeft()); yField.setValue(pos.getMiddle()); zField.setValue(pos.getRight()); }); - keyframe.getValue(CameraProperties.ROTATION).ifPresent(rot -> { + this.keyframe.getValue(CameraProperties.ROTATION).ifPresent(rot -> { yawField.setValue(rot.getLeft()); pitchField.setValue(rot.getMiddle()); rollField.setValue(rot.getRight()); @@ -219,17 +247,176 @@ public abstract class GuiEditKeyframe> extends Abst @Override protected Change save() { - Change change = UpdateKeyframeProperties.create(path, keyframe) - .setValue(CameraProperties.POSITION, Triple.of(xField.getDouble(), yField.getDouble(), zField.getDouble())) - .setValue(CameraProperties.ROTATION, Triple.of(yawField.getFloat(), pitchField.getFloat(), rollField.getFloat())) - .done(); - change.apply(path.getTimeline()); - return change; + SPTimeline timeline = guiPathing.getMod().getCurrentTimeline(); + Change positionChange = timeline.updatePositionKeyframe(time, + xField.getDouble(), yField.getDouble(), zField.getDouble(), + yawField.getFloat(), pitchField.getFloat(), rollField.getFloat() + ); + Interpolator interpolator = interpolationPanel.getSettingsPanel().createInterpolator(); + if (interpolationPanel.getInterpolatorType() == InterpolatorType.DEFAULT) { + return CombinedChange.createFromApplied(positionChange, timeline.setInterpolatorToDefault(time), + timeline.setDefaultInterpolator(interpolator)); + } else { + return CombinedChange.createFromApplied(positionChange, timeline.setInterpolator(time, interpolator)); + } } @Override protected Position getThis() { return this; } + + public class InterpolationPanel extends AbstractGuiContainer { + + @Getter + private SettingsPanel settingsPanel; + + private GuiDropdownMenu dropdown; + + public InterpolationPanel() { + setLayout(new VerticalLayout()); + + dropdown = new GuiDropdownMenu() + .setToString(s -> I18n.format(s.getI18nName())) + .setValues(InterpolatorType.values()).setHeight(20) + .onSelection(i -> setSettingsPanel(dropdown.getSelectedValue())); + + // set hover tooltips + for (Map.Entry e : dropdown.getDropdownEntries().entrySet()) { + e.getValue().setTooltip(new GuiTooltip().setI18nText(e.getKey().getI18nDescription())); + } + + GuiPanel dropdownPanel = new GuiPanel() + .setLayout(new GridLayout().setCellsEqualSize(false).setColumns(2).setSpacingX(3).setSpacingY(5)) + .addElements(new GridLayout.Data(1, 0.5), + new GuiLabel().setI18nText("replaymod.gui.editkeyframe.interpolator"), dropdown); + + + addElements(new VerticalLayout.Data(0.5, false), dropdownPanel); + + Optional segment = path.getSegments().stream() + .filter(s -> s.getStartKeyframe() == keyframe).findFirst(); + if (segment.isPresent()) { + Interpolator interpolator = segment.get().getInterpolator(); + InterpolatorType type = InterpolatorType.fromClass(interpolator.getClass()); + if (keyframe.getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) { + dropdown.setSelected(type); // trigger the callback once to display settings panel + } else { + setSettingsPanel(InterpolatorType.DEFAULT); + } + if (getInterpolatorTypeNoDefault(type).getInterpolatorClass().isInstance(interpolator)) { + //noinspection unchecked + settingsPanel.loadSettings(interpolator); + } + } else { + // Disable dropdown if this is the last keyframe + dropdown.setDisabled(); + } + } + + public void setSettingsPanel(InterpolatorType type) { + removeElement(this.settingsPanel); + + switch (getInterpolatorTypeNoDefault(type)) { + case CATMULL_ROM: + settingsPanel = new CatmullRomSettingsPanel(); + break; + case CUBIC: + settingsPanel = new CubicSettingsPanel(); + break; + case LINEAR: + settingsPanel = new LinearSettingsPanel(); + break; + } + + addElements(new GridLayout.Data(0.5, 0.5), settingsPanel); + } + + protected InterpolatorType getInterpolatorTypeNoDefault(InterpolatorType interpolatorType) { + if (interpolatorType == InterpolatorType.DEFAULT || interpolatorType == null) { + InterpolatorType defaultType = InterpolatorType.fromString( + guiPathing.getMod().getCore().getSettingsRegistry().get(Setting.DEFAULT_INTERPOLATION)); + return defaultType; + } + return interpolatorType; + } + + public InterpolatorType getInterpolatorType() { + return dropdown.getSelectedValue(); + } + + @Override + protected InterpolationPanel getThis() { + return this; + } + + public abstract class SettingsPanel> extends AbstractGuiContainer { + + public abstract void loadSettings(I interpolator); + + public abstract I createInterpolator(); + } + + public class CatmullRomSettingsPanel extends SettingsPanel { + public final GuiLabel alphaLabel = new GuiLabel().setColor(Colors.BLACK) + .setI18nText("replaymod.gui.editkeyframe.interpolator.catmullrom.alpha"); + public final GuiNumberField alphaField = new GuiNumberField().setSize(100, 20).setPrecision(5) + .setMinValue(0).setValidateOnFocusChange(true); + + { + setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER)); + addElements(new HorizontalLayout.Data(0.5), alphaLabel, alphaField); + } + + @Override + public void loadSettings(CatmullRomSplineInterpolator interpolator) { + alphaField.setValue(interpolator.getAlpha()); + } + + @Override + public CatmullRomSplineInterpolator createInterpolator() { + return new CatmullRomSplineInterpolator(alphaField.getDouble()); + } + + @Override + protected CatmullRomSettingsPanel getThis() { + return this; + } + } + + public class CubicSettingsPanel extends SettingsPanel { + + @Override + public void loadSettings(CubicSplineInterpolator interpolator) { + } + + @Override + public CubicSplineInterpolator createInterpolator() { + return new CubicSplineInterpolator(); + } + + @Override + protected CubicSettingsPanel getThis() { + return this; + } + } + + public class LinearSettingsPanel extends SettingsPanel { + + @Override + public void loadSettings(LinearInterpolator interpolator) { + } + + @Override + public LinearInterpolator createInterpolator() { + return new LinearInterpolator(); + } + + @Override + protected LinearSettingsPanel getThis() { + return this; + } + } + } } } diff --git a/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java b/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java index 72f1368c..d0285924 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiKeyframeTimeline.java @@ -1,7 +1,5 @@ package com.replaymod.simplepathing.gui; -import com.google.common.base.Function; -import com.google.common.collect.Iterables; import com.replaymod.core.ReplayMod; import com.replaymod.pathing.properties.CameraProperties; import com.replaymod.pathing.properties.SpectatorProperty; @@ -11,9 +9,10 @@ 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.path.Timeline; 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; @@ -23,7 +22,6 @@ import org.lwjgl.util.Point; import org.lwjgl.util.ReadableDimension; import org.lwjgl.util.ReadablePoint; -import javax.annotation.Nullable; import java.util.Comparator; import java.util.Optional; @@ -37,14 +35,14 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline>() { - @Nullable - @Override - public Iterable apply(@Nullable Path input) { - assert input != null; - return input.getKeyframes(); - } - }))) { + 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.getSelectedKeyframe() == keyframe ? KEYFRAME_SIZE : 0); + 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()) { @@ -113,10 +106,10 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline= startTimestamp) { @@ -162,29 +155,28 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline getKeyframe(ReadablePoint position) { + 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) { - Timeline timeline = gui.getMod().getCurrentTimeline(); - int path; + SPPath path; if (mouseY <= BORDER_TOP + KEYFRAME_SIZE) { // Position keyframe - path = GuiPathing.POSITION_PATH; + path = SPPath.POSITION; } else { // Time keyframe - path = GuiPathing.TIME_PATH; + path = SPPath.TIME; } int visibleTime = (int) (getZoom() * getLength()); - int tolerance = visibleTime * KEYFRAME_SIZE / (size.getWidth() - BORDER_LEFT - BORDER_RIGHT) / 2; - Optional keyframe = timeline.getPaths().get(path).getKeyframes().stream() + 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.orElse(null)); + return Pair.of(path, keyframe.map(Keyframe::getTime).orElse(null)); } } return Pair.of(null, null); @@ -193,30 +185,31 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline pathKeyframePair = getKeyframe(position); + Pair pathKeyframePair = getKeyframe(position); if (pathKeyframePair.getRight() != null) { + SPPath path = pathKeyframePair.getLeft(); // Clicked on keyframe - Keyframe keyframe = pathKeyframePair.getRight(); + long keyframeTime = pathKeyframePair.getRight(); if (button == 0) { // Left click long now = Minecraft.getSystemTime(); - if (lastClickedKeyframe == keyframe) { + 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 - Path path = gui.getMod().getCurrentTimeline().getPaths().get(pathKeyframePair.getLeft()); - gui.openEditKeyframePopup(path, keyframe); + gui.openEditKeyframePopup(path, keyframeTime); return true; } } // Not a double click, just update the click time and selection lastClickedTime = now; - lastClickedKeyframe = keyframe; - lastClickedPath = pathKeyframePair.getLeft(); - gui.getMod().setSelectedKeyframe(lastClickedKeyframe); + 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); } @@ -226,12 +219,11 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline k.getProperties().stream()).distinct().forEach( p -> applyPropertyToGame(p, path, time)); } @@ -280,12 +272,13 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline mouseDrag(position, button, timeSinceLastCall))) return true; // Threshold passed - Path path = gui.getMod().getCurrentTimeline().getPaths().get(lastClickedPath); + SPTimeline timeline = gui.getMod().getCurrentTimeline(); Point mouse = new Point(position); getContainer().convertFor(this, mouse); int mouseX = mouse.getX(); - int width = size.getWidth(); + int width = getLastSize().getWidth(); int bodyWidth = width - BORDER_LEFT - BORDER_RIGHT; double segmentLength = getLength() * getZoom(); double segmentTime = segmentLength * (mouseX - BORDER_LEFT) / bodyWidth; @@ -295,24 +288,21 @@ public class GuiKeyframeTimeline extends AbstractGuiTimeline {}); @@ -296,27 +290,28 @@ public class GuiPathing { if (player.isActive()) { player.getFuture().cancel(false); } else { - Timeline timeline = mod.getCurrentTimeline(); - Path timePath = timeline.getPaths().get(TIME_PATH); + Path timePath = mod.getCurrentTimeline().getTimePath(); if (!preparePathsForPlayback()) return; timePath.setActive(!Keyboard.isKeyDown(Keyboard.KEY_LSHIFT)); // 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); + ListenableFuture future = player.start(mod.getCurrentTimeline().getTimeline(), startTime); overlay.setCloseable(false); overlay.setMouseVisible(true); Futures.addCallback(future, new FutureCallback() { @Override public void onSuccess(@Nullable Void result) { overlay.setCloseable(true); + timePath.setActive(true); } @Override public void onFailure(Throwable t) { t.printStackTrace(); overlay.setCloseable(true); + timePath.setActive(true); } }); } @@ -326,26 +321,28 @@ public class GuiPathing { positionKeyframeButton.setTexturePosH(new ReadablePoint() { @Override public int getX() { - Keyframe keyframe = mod.getSelectedKeyframe(); - if (keyframe == null || !keyframe.getValue(CameraProperties.POSITION).isPresent()) { + SPPath keyframePath = mod.getSelectedPath(); + long keyframeTime = mod.getSelectedTime(); + if (keyframePath != SPPath.POSITION) { // No keyframe or wrong path - keyframe = mod.getCurrentTimeline().getPaths().get(POSITION_PATH).getKeyframe(timeline.getCursorPosition()); + keyframeTime = timeline.getCursorPosition(); + keyframePath = mod.getCurrentTimeline().isPositionKeyframe(keyframeTime) ? SPPath.POSITION : null; } - if (keyframe == null) { + if (keyframePath != SPPath.POSITION) { return replayHandler.isCameraView() ? 0 : 40; } else { - return keyframe.getValue(SpectatorProperty.PROPERTY).isPresent() ? 40 : 0; + return mod.getCurrentTimeline().isSpectatorKeyframe(keyframeTime) ? 40 : 0; } } @Override public int getY() { - Keyframe keyframe = mod.getSelectedKeyframe(); - if (keyframe == null || !keyframe.getValue(CameraProperties.POSITION).isPresent()) { + SPPath keyframePath = mod.getSelectedPath(); + if (keyframePath != SPPath.POSITION) { // No keyframe selected but there might be one at exactly the position of the cursor - keyframe = mod.getCurrentTimeline().getPaths().get(POSITION_PATH).getKeyframe(timeline.getCursorPosition()); + keyframePath = mod.getCurrentTimeline().isPositionKeyframe(timeline.getCursorPosition()) ? SPPath.POSITION : null; } - return keyframe != null && keyframe.getValue(CameraProperties.POSITION).isPresent() ? 60 : 40; + return keyframePath == SPPath.POSITION ? 60 : 40; } @Override @@ -355,7 +352,7 @@ public class GuiPathing { }).onClick(new Runnable() { @Override public void run() { - updateKeyframe(false); + updateKeyframe(SPPath.POSITION); } }); @@ -367,12 +364,12 @@ public class GuiPathing { @Override public int getY() { - Keyframe keyframe = mod.getSelectedKeyframe(); - if (keyframe == null || !keyframe.getValue(TimestampProperty.PROPERTY).isPresent()) { + SPPath keyframePath = mod.getSelectedPath(); + if (keyframePath != SPPath.TIME) { // No keyframe selected but there might be one at exactly the position of the cursor - keyframe = mod.getCurrentTimeline().getPaths().get(TIME_PATH).getKeyframe(timeline.getCursorPosition()); + keyframePath = mod.getCurrentTimeline().isTimeKeyframe(timeline.getCursorPosition()) ? SPPath.TIME : null; } - return keyframe != null && keyframe.getValue(TimestampProperty.PROPERTY).isPresent() ? 100 : 80; + return keyframePath == SPPath.TIME ? 100 : 80; } @Override @@ -382,7 +379,7 @@ public class GuiPathing { }).onClick(new Runnable() { @Override public void run() { - updateKeyframe(true); + updateKeyframe(SPPath.TIME); } }); @@ -404,12 +401,12 @@ public class GuiPathing { } try { GuiKeyframeRepository gui = new GuiKeyframeRepository( - mod, replayHandler.getReplayFile(), mod.getCurrentTimeline()); + mod.getCurrentTimeline(), replayHandler.getReplayFile(), mod.getCurrentTimeline().getTimeline()); Futures.addCallback(gui.getFuture(), new FutureCallback() { @Override public void onSuccess(Timeline result) { if (result != null) { - mod.setCurrentTimeline(result); + mod.setCurrentTimeline(new SPTimeline(result)); } } @@ -435,10 +432,10 @@ public class GuiPathing { @Override public void onSuccess(Boolean delete) { if (delete) { - Timeline timeline = mod.createTimeline(); - timeline.createPath(); - timeline.createPath(); - mod.setCurrentTimeline(timeline); + mod.clearCurrentTimeline(); + if (entityTracker != null) { + mod.getCurrentTimeline().setEntityTracker(entityTracker); + } } } @@ -455,7 +452,7 @@ public class GuiPathing { // Position of the cursor int cursor = timeline.getCursorPosition(); // Get the last time keyframe before the cursor - mod.getCurrentTimeline().getPaths().get(TIME_PATH).getKeyframes().stream() + 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(); @@ -471,7 +468,7 @@ public class GuiPathing { // Move cursor to new position timeline.setCursorPosition(keyframeCursor + cursorPassed); // Deselect keyframe to allow the user to add a new one right away - mod.setSelectedKeyframe(null); + mod.setSelected(null, 0); }); }); @@ -479,8 +476,8 @@ public class GuiPathing { if (!overlay.isVisible()) { return; } - if (mod.getSelectedKeyframe() != null) { - updateKeyframe(mod.getSelectedKeyframe().getValue(TimestampProperty.PROPERTY).isPresent()); + if (mod.getSelectedPath() != null) { + updateKeyframe(mod.getSelectedPath()); } }); @@ -511,13 +508,12 @@ public class GuiPathing { } private boolean preparePathsForPlayback() { - Timeline timeline = mod.getCurrentTimeline(); - timeline.getPaths().get(TIME_PATH).updateAll(); - timeline.getPaths().get(POSITION_PATH).updateAll(); + SPTimeline timeline = mod.getCurrentTimeline(); + timeline.getTimeline().getPaths().forEach(Path::updateAll); // Make sure time keyframes's values are monotonically increasing int lastTime = 0; - for (Keyframe keyframe : timeline.getPaths().get(TIME_PATH).getKeyframes()) { + for (Keyframe keyframe : timeline.getTimePath().getKeyframes()) { int time = keyframe.getValue(TimestampProperty.PROPERTY).orElseThrow(IllegalStateException::new); if (time < lastTime) { // We are going backwards in time @@ -531,8 +527,8 @@ public class GuiPathing { } // Make sure there are at least two position- and two time-keyframes - if (timeline.getPaths().get(POSITION_PATH).getSegments().isEmpty() - || timeline.getPaths().get(TIME_PATH).getSegments().isEmpty()) { + if (timeline.getPositionPath().getSegments().isEmpty() + || timeline.getTimePath().getSegments().isEmpty()) { GuiInfoPopup.open(replayHandler.getOverlay(), "replaymod.chat.morekeyframes"); return false; } @@ -544,19 +540,19 @@ public class GuiPathing { scrollbar.setZoom(scrollbar.getZoom() * factor); } - /** - * Called when either one of the property buttons is pressed. - * @param isTime {@code true} for the time property button, {@code false} for the place property button - */ - private void updateKeyframe(final boolean isTime) { + public boolean ensureEntityTracker(Runnable withDelayedTracker) { if (entityTracker == null) { + LOGGER.debug("Entity tracker not yet loaded, delaying..."); LoadEntityTrackerPopup popup = new LoadEntityTrackerPopup(replayHandler.getOverlay()); entityTrackerLoadingProgress = p -> popup.progressBar.setProgress(p.floatValue()); Futures.addCallback(entityTrackerFuture, new FutureCallback() { @Override public void onSuccess(@Nullable Void result) { popup.close(); - updateKeyframe(isTime); + if (mod.getCurrentTimeline().getEntityTracker() == null) { + mod.getCurrentTimeline().setEntityTracker(entityTracker); + } + withDelayedTracker.run(); } @Override @@ -564,184 +560,63 @@ public class GuiPathing { popup.close(); } }); - return; + return false; } + if (mod.getCurrentTimeline().getEntityTracker() == null) { + mod.getCurrentTimeline().setEntityTracker(entityTracker); + } + 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 + */ + private void updateKeyframe(SPPath path) { + LOGGER.debug("Updating keyframe on path {}" + path); + if (!ensureEntityTracker(() -> updateKeyframe(path))) return; int time = timeline.getCursorPosition(); - Timeline timeline = mod.getCurrentTimeline(); - Path path = timeline.getPaths().get(isTime ? TIME_PATH : POSITION_PATH); + SPTimeline timeline = mod.getCurrentTimeline(); - Keyframe keyframe = mod.getSelectedKeyframe(); - if (keyframe != null && keyframe.getValue(TimestampProperty.PROPERTY).isPresent() ^ isTime) { - // Keyframe is on the wrong timeline - keyframe = null; - } - if (keyframe == null) { - // No keyframe selected but there may still be one at this exact time - keyframe = path.getKeyframe(time); - } - Change change; - if (keyframe == null) { - change = AddKeyframe.create(path, time); - change.apply(timeline); - keyframe = path.getKeyframe(time); - } else { - change = RemoveKeyframe.create(path, keyframe); - change.apply(timeline); - keyframe = null; - } - - if (keyframe != null) { - UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, keyframe); - if (isTime) { - builder.setValue(TimestampProperty.PROPERTY, replayHandler.getReplaySender().currentTimeStamp()); - } else { - CameraEntity camera = replayHandler.getCameraEntity(); - builder.setValue(CameraProperties.POSITION, Triple.of(camera.posX, camera.posY, camera.posZ)); - builder.setValue(CameraProperties.ROTATION, Triple.of(camera.rotationYaw, camera.rotationPitch, camera.roll)); - if (!replayHandler.isCameraView()) { - Entity spectated = replayHandler.getOverlay().getMinecraft().getRenderViewEntity(); - builder.setValue(SpectatorProperty.PROPERTY, spectated.getEntityId()); + 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); } - } - UpdateKeyframeProperties updateChange = builder.done(); - updateChange.apply(timeline); - change = CombinedChange.createFromApplied(change, updateChange); - - // If this new keyframe formed the first segment of the time path - if (isTime && path.getSegments().size() == 1) { - PathSegment segment = path.getSegments().iterator().next(); - Interpolator interpolator = new LinearInterpolator(); - interpolator.registerProperty(TimestampProperty.PROPERTY); - SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator); - setInterpolator.apply(timeline); - change = CombinedChange.createFromApplied(change, setInterpolator); - } - } - - // Update interpolators for spectator keyframes - // while this is overkill, it is far simpler than updating differently for every possible case - if (!isTime) { - Change interpolators = updateInterpolators(); - interpolators.apply(timeline); - change = CombinedChange.createFromApplied(change, interpolators); - } - - Change specPosUpdate = updateSpectatorPositions(); - specPosUpdate.apply(timeline); - change = CombinedChange.createFromApplied(change, specPosUpdate); - - timeline.pushChange(change); - - mod.setSelectedKeyframe(keyframe); - } - - public Change updateInterpolators() { - boolean linearInterpolation = mod.getCore().getSettingsRegistry().get(Setting.LINEAR_INTERPOLATION); - List changes = new ArrayList<>(); - Interpolator interpolator = null; - boolean isSpectatorInterpolator = false; - for (PathSegment segment : mod.getCurrentTimeline().getPaths().get(POSITION_PATH).getSegments()) { - if (segment.getStartKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent() - && segment.getEndKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent()) { - // Spectator segment - if (!isSpectatorInterpolator) { - isSpectatorInterpolator = true; - interpolator = new LinearInterpolator(); - interpolator.registerProperty(SpectatorProperty.PROPERTY); + 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()) { + spectatedId = replayHandler.getOverlay().getMinecraft().getRenderViewEntity().getEntityId(); + } + timeline.addPositionKeyframe(time, camera.posX, camera.posY, camera.posZ, + camera.rotationYaw, camera.rotationPitch, camera.roll, spectatedId); + mod.setSelected(path, time); } - changes.add(SetInterpolator.create(segment, interpolator)); - } else { - // Normal segment - if (isSpectatorInterpolator || interpolator == null) { - isSpectatorInterpolator = false; - interpolator = linearInterpolation ? new LinearInterpolator() : new CubicSplineInterpolator(); - interpolator.registerProperty(CameraProperties.POSITION); - interpolator.registerProperty(CameraProperties.ROTATION); - } - changes.add(SetInterpolator.create(segment, interpolator)); - } + break; } - return CombinedChange.create(changes.toArray(new Change[changes.size()])); - } - - public Change updateSpectatorPositions() { - List changes = new ArrayList<>(); - Path positionPath = mod.getCurrentTimeline().getPaths().get(POSITION_PATH); - Path timePath = mod.getCurrentTimeline().getPaths().get(TIME_PATH); - timePath.updateAll(); - for (Keyframe keyframe : positionPath.getKeyframes()) { - Optional spectator = keyframe.getValue(SpectatorProperty.PROPERTY); - if (spectator.isPresent()) { - Optional time = timePath.getValue(TimestampProperty.PROPERTY, keyframe.getTime()); - if (!time.isPresent()) { - continue; // No time keyframes set at this video time, cannot determine replay time - } - Location expected = entityTracker.getEntityPositionAtTimestamp(spectator.get(), time.get()); - if (expected == null) { - continue; // We don't have any data on this entity for some reason - } - Triple pos = keyframe.getValue(CameraProperties.POSITION).orElse(Triple.of(0D, 0D, 0D)); - Triple rot = keyframe.getValue(CameraProperties.ROTATION).orElse(Triple.of(0F, 0F, 0F)); - Location actual = new Location(pos.getLeft(), pos.getMiddle(), pos.getRight(), rot.getLeft(), rot.getRight()); - if (!expected.equals(actual)) { - changes.add(UpdateKeyframeProperties.create(positionPath, keyframe) - .setValue(CameraProperties.POSITION, Triple.of(expected.getX(), expected.getY(), expected.getZ())) - .setValue(CameraProperties.ROTATION, Triple.of(expected.getYaw(), expected.getPitch(), 0f)).done() - ); - } - } - } - return CombinedChange.create(changes.toArray(new Change[changes.size()])); - } - - public Change moveKeyframe(Path path, Keyframe keyframe, long newTime) { - Timeline timeline = mod.getCurrentTimeline(); - // Interpolator might be required later (only if path is the time path) - Optional interpolator = - path.getSegments().stream().findFirst().map(PathSegment::getInterpolator); - - // First remove the old keyframe - Change removeChange = RemoveKeyframe.create(path, keyframe); - removeChange.apply(timeline); - - // and add a new one at the correct time - Change addChange = AddKeyframe.create(path, newTime); - addChange.apply(timeline); - path.getKeyframe(newTime); - - // Then copy over all properties - UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, path.getKeyframe(newTime)); - for (Property property : keyframe.getProperties()) { - copyProperty(property, keyframe, builder); - } - Change propertyChange = builder.done(); - propertyChange.apply(timeline); - - // Finally set the interpolators - Change interpolatorChange; - if (path.getTimeline().getPaths().indexOf(path) == GuiPathing.POSITION_PATH) { - // Position / Spectator keyframes need special handling - interpolatorChange = updateInterpolators(); - } else { - // Time keyframes only need updating when only one segment of them exists - if (path.getSegments().size() == 1) { - interpolatorChange = SetInterpolator.create(path.getSegments().iterator().next(), interpolator.get()); - } else { - interpolatorChange = CombinedChange.create(); // Noop change - } - } - interpolatorChange.apply(timeline); - // and update spectator positions - Change spectatorChange = updateSpectatorPositions(); - spectatorChange.apply(timeline); - - return CombinedChange.createFromApplied(removeChange, addChange, propertyChange, interpolatorChange, spectatorChange); - } - - // Helper method because generics cannot be defined on blocks - private void copyProperty(Property property, Keyframe from, UpdateKeyframeProperties.Builder to) { - from.getValue(property).ifPresent(value -> to.setValue(property, value)); } public ReplayModSimplePathing getMod() { @@ -752,13 +627,15 @@ public class GuiPathing { return entityTracker; } - public void openEditKeyframePopup(Path path, Keyframe keyframe) { + public void openEditKeyframePopup(SPPath path, long time) { + if (!ensureEntityTracker(() -> openEditKeyframePopup(path, time))) return; + Keyframe keyframe = mod.getCurrentTimeline().getKeyframe(path, time); if (keyframe.getProperties().contains(SpectatorProperty.PROPERTY)) { - new GuiEditKeyframe.Spectator(this, path, keyframe).open(); + new GuiEditKeyframe.Spectator(this, path, keyframe.getTime()).open(); } else if (keyframe.getProperties().contains(CameraProperties.POSITION)) { - new GuiEditKeyframe.Position(this, path, keyframe).open(); + new GuiEditKeyframe.Position(this, path, keyframe.getTime()).open(); } else { - new GuiEditKeyframe.Time(this, path, keyframe).open(); + new GuiEditKeyframe.Time(this, path, keyframe.getTime()).open(); } } diff --git a/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java b/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java index ecb61234..092c861a 100644 --- a/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java +++ b/src/main/java/com/replaymod/simplepathing/preview/PathPreviewRenderer.java @@ -9,10 +9,10 @@ import com.replaymod.replaystudio.pathing.interpolation.Interpolator; 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.path.Timeline; import com.replaymod.replaystudio.util.EntityPositionTracker; import com.replaymod.replaystudio.util.Location; import com.replaymod.simplepathing.ReplayModSimplePathing; +import com.replaymod.simplepathing.SPTimeline; import com.replaymod.simplepathing.gui.GuiPathing; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GlStateManager; @@ -68,11 +68,11 @@ public class PathPreviewRenderer { if (guiPathing == null) return; EntityPositionTracker entityTracker = guiPathing.getEntityTracker(); - Timeline timeline = mod.getCurrentTimeline(); + SPTimeline timeline = mod.getCurrentTimeline(); if (timeline == null) return; - Path path = timeline.getPaths().get(GuiPathing.POSITION_PATH); + Path path = timeline.getPositionPath(); if (path.getKeyframes().isEmpty()) return; - Path timePath = timeline.getPaths().get(GuiPathing.TIME_PATH); + Path timePath = timeline.getTimePath(); path.update(); @@ -97,8 +97,8 @@ public class PathPreviewRenderer { if (spectator && entityTracker == null) { continue; // Cannot render spectator positions when entity tracker is not yet loaded } - // Spectator segments have 20 lines per second whereas normal segments have a fixed 100 - long steps = spectator ? diff / 50 : 100; + // Spectator segments have 20 lines per second (at least 10) whereas normal segments have a fixed 100 + long steps = spectator ? Math.max(diff / 50, 10) : 100; Triple prevPos = null; for (int i = 0; i <= steps; i++) { long time = start.getTime() + diff * i / steps; @@ -240,7 +240,7 @@ public class PathPreviewRenderer { float posY = 0f; float size = 10f / ReplayMod.TEXTURE_SIZE; - if (keyframe == mod.getSelectedKeyframe()) { + if (mod.isSelected(keyframe)) { posY += size; } diff --git a/src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java b/src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java new file mode 100644 index 00000000..5f181191 --- /dev/null +++ b/src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java @@ -0,0 +1,44 @@ +package com.replaymod.simplepathing.properties; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.replaymod.replaystudio.pathing.property.AbstractProperty; +import com.replaymod.replaystudio.pathing.property.PropertyPart; +import lombok.NonNull; +import org.apache.commons.lang3.ObjectUtils; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; + +/** + * Property indicating that the next path segment's interpolator is fixed + */ +public class ExplicitInterpolationProperty extends AbstractProperty { + public static final ExplicitInterpolationProperty PROPERTY = new ExplicitInterpolationProperty(); + + private ExplicitInterpolationProperty() { + super("interpolationFixed", "", null, new Object()); + } + + @Override + public Collection> getParts() { + return Collections.emptyList(); + } + + @Override + public void applyToGame(Object value, @NonNull Object replayHandler) { + // dummy property, do nothing + } + + @Override + public void toJson(JsonWriter writer, Object value) throws IOException { + writer.nullValue(); + } + + @Override + public Object fromJson(JsonReader reader) throws IOException { + reader.nextNull(); + return ObjectUtils.NULL; + } +} diff --git a/src/main/resources/assets/replaymod/lang/en_US.lang b/src/main/resources/assets/replaymod/lang/en_US.lang index 3c85d8d8..c901032b 100644 --- a/src/main/resources/assets/replaymod/lang/en_US.lang +++ b/src/main/resources/assets/replaymod/lang/en_US.lang @@ -292,6 +292,7 @@ replaymod.gui.settings.title=Replay Mod Settings replaymod.gui.settings.interpolation.linear=Linear replaymod.gui.settings.interpolation.cubic=Cubic +replaymod.gui.settings.interpolation.catmullrom=Catmull Rom replaymod.gui.settings.bitrate=Video Bitrate replaymod.gui.settings.framerate=Video Framerate @@ -309,6 +310,7 @@ replaymod.gui.settings.keyframecleancallback=Clear Confirmation replaymod.gui.settings.renderinvisible=Render invisible Entities replaymod.gui.settings.camera=Camera replaymod.gui.settings.showchat=Show Chat +replaymod.gui.settings.interpolator=Default Interpolator replaymod.gui.settings.warning.linea=WARNING: Recording settings will be replaymod.gui.settings.warning.lineb=applied the next time you join a world. @@ -372,6 +374,17 @@ replaymod.gui.editkeyframe.spec.method.shoulder.pitch=Pitch Offset replaymod.gui.editkeyframe.spec.method.shoulder.yaw=Rotation Angle replaymod.gui.editkeyframe.spec.method.shoulder.smoothness=Path Smoothness +replaymod.gui.editkeyframe.interpolator=Interpolator +replaymod.gui.editkeyframe.interpolator.default.name=Default Interpolator +replaymod.gui.editkeyframe.interpolator.default.desc=Uses the default interpolator defined in the Replay Mod Settings. +replaymod.gui.editkeyframe.interpolator.catmullrom.name=Catmull Rom Spline Interpolator +replaymod.gui.editkeyframe.interpolator.catmullrom.desc=Calculates a catmull rom spline with customizable alpha value for precise tightness and smoothness of the camera path. +replaymod.gui.editkeyframe.interpolator.catmullrom.alpha=Alpha: +replaymod.gui.editkeyframe.interpolator.cubic.name=Cubic Spline Interpolator +replaymod.gui.editkeyframe.interpolator.cubic.desc=Calculates a cubic equation matrix for all points for a smooth camera path. +replaymod.gui.editkeyframe.interpolator.linear.name=Linear Interpolator +replaymod.gui.editkeyframe.interpolator.linear.desc=Draws straight lines between the keyframes. + #Render Settings GUI replaymod.gui.rendersettings.title=Rendering Options replaymod.gui.rendersettings.renderer=Rendering Method diff --git a/src/main/resources/dst_root_ca_x3.pem b/src/main/resources/dst_root_ca_x3.pem new file mode 100644 index 00000000..b2e43c93 --- /dev/null +++ b/src/main/resources/dst_root_ca_x3.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow +PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD +Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O +rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq +OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b +xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw +7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD +aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG +SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69 +ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr +AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz +R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5 +JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo +Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ +-----END CERTIFICATE----- diff --git a/src/test/java/com/replaymod/simplepathing/SPTimelineTest.java b/src/test/java/com/replaymod/simplepathing/SPTimelineTest.java new file mode 100644 index 00000000..de249b0e --- /dev/null +++ b/src/test/java/com/replaymod/simplepathing/SPTimelineTest.java @@ -0,0 +1,626 @@ +package com.replaymod.simplepathing; + +import com.google.common.collect.Iterables; +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.interpolation.CatmullRomSplineInterpolator; +import com.replaymod.replaystudio.pathing.interpolation.Interpolator; +import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; +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.path.Timeline; +import com.replaymod.replaystudio.util.EntityPositionTracker; +import com.replaymod.replaystudio.util.Location; +import com.replaymod.simplepathing.SPTimeline.SPPath; +import org.apache.commons.lang3.tuple.Triple; +import org.apache.logging.log4j.LogManager; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class SPTimelineTest { + private SPTimeline impl; + private Timeline timeline; + + @BeforeClass + public static void setupLogger() { + ReplayModSimplePathing.LOGGER = LogManager.getLogger(); + } + + @Before + public void setup() { + impl = new SPTimeline(); + impl.setDefaultInterpolatorType(InterpolatorType.CUBIC); + impl.setEntityTracker(new EntityPositionTrackerMock()); + timeline = impl.getTimeline(); + } + + @Test + public void testGetPath() { + assertSame(impl.getTimeline().getPaths().get(0), impl.getTimePath()); + assertSame(impl.getTimeline().getPaths().get(0), impl.getPath(SPPath.TIME)); + assertSame(impl.getTimeline().getPaths().get(1), impl.getPositionPath()); + assertSame(impl.getTimeline().getPaths().get(1), impl.getPath(SPPath.POSITION)); + } + + @Test + public void testGetKeyframe() { + assertSame(timeline.getPaths().get(0).insert(123), impl.getKeyframe(SPPath.TIME, 123)); + assertSame(timeline.getPaths().get(1).insert(456), impl.getKeyframe(SPPath.POSITION, 456)); + } + + @Test + public void testIsTimeKeyframe() { + impl.getTimePath().insert(123); + impl.getPositionPath().insert(456); + impl.getPositionPath().insert(42).setValue(SpectatorProperty.PROPERTY, 42); + assertTrue(impl.isTimeKeyframe(123)); + assertFalse(impl.isTimeKeyframe(456)); + assertFalse(impl.isTimeKeyframe(789)); + assertFalse(impl.isTimeKeyframe(42)); + } + + @Test + public void testIsPositionKeyframe() { + impl.getTimePath().insert(123); + impl.getPositionPath().insert(456); + impl.getPositionPath().insert(42).setValue(SpectatorProperty.PROPERTY, 42); + assertFalse(impl.isPositionKeyframe(123)); + assertTrue(impl.isPositionKeyframe(456)); + assertFalse(impl.isPositionKeyframe(789)); + assertTrue(impl.isSpectatorKeyframe(42)); + } + + @Test + public void testIsSpectatorKeyframe() { + impl.getTimePath().insert(123); + impl.getPositionPath().insert(456); + impl.getPositionPath().insert(42).setValue(SpectatorProperty.PROPERTY, 42); + assertFalse(impl.isSpectatorKeyframe(123)); + assertFalse(impl.isSpectatorKeyframe(456)); + assertFalse(impl.isSpectatorKeyframe(789)); + assertTrue(impl.isSpectatorKeyframe(42)); + } + + @Test + public void testAddPositionKeyframe() { + assertNull(timeline.peekUndoStack()); + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, -1); + assertNotNull(timeline.peekUndoStack()); + Keyframe keyframe = impl.getKeyframe(SPPath.POSITION, 0); + assertNotNull(keyframe); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(1d, 2d, 3d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(4f, 5f, 6f))); + assertFalse(keyframe.getValue(SpectatorProperty.PROPERTY).isPresent()); + timeline.undoLastChange(); + assertNull(timeline.peekUndoStack()); + assertNull(impl.getKeyframe(SPPath.POSITION, 0)); + } + + @Test + public void testAddPositionKeyframeSpectator() { + assertNull(timeline.peekUndoStack()); + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, 7); + assertNotNull(timeline.peekUndoStack()); + Keyframe keyframe = impl.getKeyframe(SPPath.POSITION, 0); + assertNotNull(keyframe); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(1d, 2d, 3d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(4f, 5f, 6f))); + assertEquals(keyframe.getValue(SpectatorProperty.PROPERTY), Optional.of(7)); + timeline.undoLastChange(); + assertNull(timeline.peekUndoStack()); + assertNull(impl.getKeyframe(SPPath.POSITION, 0)); + } + + @Test + public void testPositionKeyframeSpectatorPosition() { + Keyframe keyframe; + impl.addTimeKeyframe(0, 0); + impl.addTimeKeyframe(9, 9); + + impl.addPositionKeyframe(4, 1, 2, 3, 4, 5, 6, 1); + keyframe = impl.getKeyframe(SPPath.POSITION, 4); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(1d, 2d, 3d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(4f, 5f, 6f))); + + impl.addPositionKeyframe(6, 1, 2, 3, 4, 5, 6, 42); + keyframe = impl.getKeyframe(SPPath.POSITION, 6); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(12d, 0d, 0d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(0f, 0f, 0f))); + + impl.addPositionKeyframe(8, 1, 2, 3, 4, 5, 6, 42); + keyframe = impl.getKeyframe(SPPath.POSITION, 8); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(16d, 0d, 0d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(0f, 0f, 0f))); + + impl.addPositionKeyframe(10, 1, 2, 3, 4, 5, 6, 42); + keyframe = impl.getKeyframe(SPPath.POSITION, 10); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(1d, 2d, 3d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(4f, 5f, 6f))); + } + + @Test(expected = IllegalStateException.class) + public void testAddPositionKeyframeDuplicate() { + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, -1); + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, -1); + } + + @Test + public void testUpdatePositionKeyframe() { + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, -1); + Change change = impl.updatePositionKeyframe(0, 7, 8, 9, 10, 11, 12); + assertNotNull(change); + Keyframe keyframe = impl.getKeyframe(SPPath.POSITION, 0); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(7d, 8d, 9d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(10f, 11f, 12f))); + assertFalse(keyframe.getValue(SpectatorProperty.PROPERTY).isPresent()); + change.undo(timeline); + keyframe = impl.getKeyframe(SPPath.POSITION, 0); + assertEquals(keyframe.getValue(CameraProperties.POSITION), Optional.of(Triple.of(1d, 2d, 3d))); + assertEquals(keyframe.getValue(CameraProperties.ROTATION), Optional.of(Triple.of(4f, 5f, 6f))); + assertFalse(keyframe.getValue(SpectatorProperty.PROPERTY).isPresent()); + } + + @Test(expected = IllegalStateException.class) + public void testUpdatePositionKeyframeNoKeyframe() { + impl.updatePositionKeyframe(0, 1, 2, 3, 4, 5, 6); + } + + @Test(expected = IllegalStateException.class) + public void testUpdatePositionKeyframeSpectatorKeyframe() { + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, 7); + impl.updatePositionKeyframe(0, 1, 2, 3, 4, 5, 6); + } + + @Test + public void testRemovePositionKeyframe() { + impl.addPositionKeyframe(0, 1, 2, 3, 4, 5, 6, -1); + impl.removePositionKeyframe(0); + assertNull(impl.getKeyframe(SPPath.POSITION, 0)); + } + + @Test(expected = IllegalStateException.class) + public void testRemovePositionKeyframeNoKeyframe() { + impl.removePositionKeyframe(0); + } + + @Test + public void testAddTimeKeyframe() { + assertNull(timeline.peekUndoStack()); + impl.addTimeKeyframe(0, 1); + assertNotNull(timeline.peekUndoStack()); + Keyframe keyframe = impl.getKeyframe(SPPath.TIME, 0); + assertNotNull(keyframe); + assertEquals(keyframe.getValue(TimestampProperty.PROPERTY), Optional.of(1)); + timeline.undoLastChange(); + assertNull(timeline.peekUndoStack()); + assertNull(impl.getKeyframe(SPPath.TIME, 0)); + } + + @Test(expected = IllegalStateException.class) + public void testAddTimeKeyframeDuplicate() { + impl.addTimeKeyframe(0, 1); + impl.addTimeKeyframe(0, 1); + } + + @Test + public void testUpdateTimeKeyframe() { + impl.addTimeKeyframe(0, 1); + Change change = impl.updateTimeKeyframe(0, 2); + assertNotNull(change); + assertEquals(impl.getKeyframe(SPPath.TIME, 0).getValue(TimestampProperty.PROPERTY), Optional.of(2)); + change.undo(timeline); + assertEquals(impl.getKeyframe(SPPath.TIME, 0).getValue(TimestampProperty.PROPERTY), Optional.of(1)); + } + + @Test(expected = IllegalStateException.class) + public void testUpdateTimeKeyframeNoKeyframe() { + impl.updateTimeKeyframe(0, 1); + } + + @Test + public void testRemoveTimeKeyframe() { + impl.addTimeKeyframe(0, 1); + impl.removeTimeKeyframe(0); + assertNull(impl.getKeyframe(SPPath.TIME, 0)); + } + + @Test(expected = IllegalStateException.class) + public void testRemoveTimeKeyframeNoKeyframe() { + impl.removeTimeKeyframe(0); + } + + /** + * Checks all interpolators to make sure that they are continuous and not fragmented. + */ + private void assertValidInterpolators(SPPath path, int expectedNumberOfInterpolators) { + String str = prettyPrintInterpolators(impl, path); + List interpolators = impl.getPath(path).getSegments().stream().map(PathSegment::getInterpolator) + .distinct().collect(Collectors.toList()); + assertFalse("Not all interpolators set: " + str, interpolators.contains(null)); + assertEquals("Mismatched interpolator number: " + str, expectedNumberOfInterpolators, interpolators.size()); + int i = 0; + for (Interpolator interpolator : interpolators) { + Keyframe lastKeyframe = null; + for (PathSegment segment : interpolator.getSegments().stream() + .sorted(Comparator.comparing(s -> s.getStartKeyframe().getTime())).collect(Collectors.toList())) { + String message = "Fragmentation in interpolator " + i + ": " + str; + assertTrue(message, lastKeyframe == null || segment.getStartKeyframe() == lastKeyframe); + lastKeyframe = segment.getEndKeyframe(); + } + i++; + } + } + + @Test + public void testAddPositionKeyframeAppend() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + addPosition(3, 1); + addPosition(4, 1); + } + + @Test + public void testAddPositionKeyframePrepend() { + addPosition(4, 0); + addPosition(3, 1); + addPosition(2, 1); + addPosition(1, 1); + addPosition(0, 1); + } + + @Test + public void testAddPositionKeyframeMixed() { + addPosition(2, 0); + addPosition(4, 1); + addPosition(3, 1); + addPosition(0, 1); + addPosition(1, 1); + } + + @Test + public void testAddPositionSpectatorKeyframe() { + addPosition(0, 0); + addSpectator(1, 1); + addSpectator(2, 2); + addPosition(4, 3); + addPosition(7, 3); + addPosition(8, 3); + addSpectator(3, 3); + addSpectator(5, 3); + addSpectator(6, 5); + } + + @Test + public void testRemovePositionKeyframeFromStart() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + addPosition(3, 1); + removePosition(0, 1); + removePosition(1, 1); + removePosition(2, 0); + removePosition(3, 0); + } + + @Test + public void testRemovePositionKeyframeFromEnd() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + addPosition(3, 1); + removePosition(3, 1); + removePosition(2, 1); + removePosition(1, 0); + removePosition(0, 0); + } + + @Test + public void testRemovePositionKeyframeFromMiddle() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + addPosition(3, 1); + removePosition(1, 1); + removePosition(2, 1); + removePosition(0, 0); + removePosition(3, 0); + } + + @Test + public void testRemovePositionSpectatorKeyframe() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + addSpectator(3, 1); + addSpectator(4, 2); + addSpectator(5, 2); + addPosition(6, 3); + addPosition(7, 3); + addSpectator(8, 3); + addSpectator(9, 4); + removePosition(4, 4); + removePosition(9, 3); + removePosition(6, 3); + removePosition(7, 2); + removePosition(0, 2); + removePosition(1, 2); + removePosition(3, 2); + removePosition(2, 1); + removePosition(5, 0); + removePosition(8, 0); + } + + @Test + public void testAddTimeKeyframeAppend() { + addTime(0, 0); + addTime(1, 1); + addTime(2, 1); + addTime(3, 1); + addTime(4, 1); + } + + @Test + public void testAddTimeKeyframePrepend() { + addTime(4, 0); + addTime(3, 1); + addTime(2, 1); + addTime(1, 1); + addTime(0, 1); + } + + @Test + public void testAddTimeKeyframeMixed() { + addTime(2, 0); + addTime(4, 1); + addTime(3, 1); + addTime(0, 1); + addTime(1, 1); + } + + @Test + public void testRemoveTimeKeyframeFromStart() { + addTime(0, 0); + addTime(1, 1); + addTime(2, 1); + addTime(3, 1); + removeTime(0, 1); + removeTime(1, 1); + removeTime(2, 0); + removeTime(3, 0); + } + + @Test + public void testRemoveTimeKeyframeFromEnd() { + addTime(0, 0); + addTime(1, 1); + addTime(2, 1); + addTime(3, 1); + removeTime(3, 1); + removeTime(2, 1); + removeTime(1, 0); + removeTime(0, 0); + } + + @Test + public void testRemoveTimeKeyframeFromMiddle() { + addTime(0, 0); + addTime(1, 1); + addTime(2, 1); + addTime(3, 1); + removeTime(1, 1); + removeTime(2, 1); + removeTime(0, 0); + removeTime(3, 0); + } + + @Test + public void testSetInterpolator() { + addPosition(0, 0); + addPosition(1, 1); + addPosition(2, 1); + setInterpolator(1, new LinearInterpolator(), 2); + assertIsLinear(1); + Interpolator interpolator = Iterables.get(impl.getPositionPath().getSegments(), 1).getInterpolator(); + assertTrue(interpolator.getKeyframeProperties().contains(CameraProperties.POSITION)); + assertTrue(interpolator.getKeyframeProperties().contains(CameraProperties.ROTATION)); + addPosition(3, 3); + addPosition(4, 3); + addPosition(5, 3); + setInterpolator(3, new LinearInterpolator(), 5); + assertIsLinear(3); + removePosition(2, 3); + removePosition(3, 3); + removePosition(0, 2); + removePosition(1, 1); + removePosition(4, 0); + removePosition(5, 0); + } + + private void assertIsLinear(int index) { + assertTrue("Expected segment " + index + " to have linear interpolator: " + prettyPrintInterpolators(impl, SPPath.POSITION), + Iterables.get(impl.getPositionPath().getSegments(), index).getInterpolator() instanceof LinearInterpolator); + } + + @Test(expected = IllegalStateException.class) + public void testSetInterpolatorNoKeyframe() { + addTime(0, 0); + impl.setInterpolator(0, new LinearInterpolator()); + } + + @Test(expected = IllegalStateException.class) + public void testSetInterpolatorToDefaultNoKeyframe() { + addTime(0, 0); + impl.setInterpolatorToDefault(0); + } + + @Test + public void testMoveKeyframe() { + addPosition(1, 0); + addPosition(3, 1); + addPosition(5, 1); + addPosition(7, 1); + addSpectator(9, 1); + addSpectator(11, 2); + addSpectator(13, 2); + addPosition(15, 3); + addPosition(17, 3); + addPosition(19, 3); + setInterpolator(3, new LinearInterpolator(), 5); + assertIsLinear(1); + setInterpolator(5, new LinearInterpolator(), 5); + assertIsLinear(2); + // [P 1] C0 [P 3] L1 [P 5] L1 [P 7] C2 [S 9] L3 [S 11] L3 [S 13] C4 [P 15] C4 [P 17] C4 [P 19] + + impl.moveKeyframe(SPPath.POSITION, 5, 0); + assertValidInterpolators(SPPath.POSITION, 6); + assertIsLinear(0); + impl.moveKeyframe(SPPath.POSITION, 0, 5); + assertValidInterpolators(SPPath.POSITION, 5); + assertIsLinear(2); + + impl.moveKeyframe(SPPath.POSITION, 3, 12); + assertValidInterpolators(SPPath.POSITION, 7); + assertIsLinear(5); + impl.moveKeyframe(SPPath.POSITION, 12, 3); + assertValidInterpolators(SPPath.POSITION, 5); + assertIsLinear(1); + + impl.moveKeyframe(SPPath.POSITION, 7, 14); + assertValidInterpolators(SPPath.POSITION, 4); + impl.moveKeyframe(SPPath.POSITION, 14, 20); + assertValidInterpolators(SPPath.POSITION, 4); + } + + @Test(expected = IllegalArgumentException.class) + public void testSetDefaultInterpolatorDifferentType() { + addPosition(0, 0); + addPosition(1, 1); + impl.setDefaultInterpolator(new CatmullRomSplineInterpolator(42)); + } + + @Test + public void testSetDefaultInterpolatorSameType() { + impl.setDefaultInterpolatorType(InterpolatorType.CATMULL_ROM); + addPosition(0, 0); + addSpectator(1, 1); + addSpectator(2, 2); + addPosition(3, 3); + addPosition(4, 3); + impl.setDefaultInterpolator(new CatmullRomSplineInterpolator(42)); + assertValidInterpolators(SPPath.POSITION, 3); + assertIsCatmullRom(0, 42); + assertIsCatmullRom(2, 42); + assertIsCatmullRom(3, 42); + } + + private void assertIsCatmullRom(int index, double alpha) { + String str = prettyPrintInterpolators(impl, SPPath.POSITION); + Interpolator interpolator = Iterables.get(impl.getPositionPath().getSegments(), index).getInterpolator(); + assertTrue("Expected segment " + index + " to be catmull rom interpolator: " + str, + interpolator instanceof CatmullRomSplineInterpolator); + assertTrue("Expected interpolator of segment segment " + index + " to have alpha " + alpha + ": " + str, + ((CatmullRomSplineInterpolator) interpolator).getAlpha() == 42); + } + + private void addPosition(int time, int expectedNumberOfInterpolators) { + impl.addPositionKeyframe(time, 1, 2, 3, 4, 5, 6, -1); + assertNotNull(impl.getKeyframe(SPPath.POSITION, time)); + assertValidInterpolators(SPPath.POSITION, expectedNumberOfInterpolators); + } + + private void addSpectator(int time, int expectedNumberOfInterpolators) { + impl.addPositionKeyframe(time, 1, 2, 3, 4, 5, 6, 42); + assertNotNull(impl.getKeyframe(SPPath.POSITION, time)); + assertTrue(impl.isSpectatorKeyframe(time)); + assertValidInterpolators(SPPath.POSITION, expectedNumberOfInterpolators); + } + + private void addTime(int time, int expectedNumberOfInterpolators) { + impl.addTimeKeyframe(time, 1); + assertNotNull(impl.getKeyframe(SPPath.TIME, time)); + assertValidInterpolators(SPPath.TIME, expectedNumberOfInterpolators); + } + + private void removePosition(int time, int expectedNumberOfInterpolators) { + impl.removePositionKeyframe(time); + assertNull(impl.getKeyframe(SPPath.POSITION, time)); + assertValidInterpolators(SPPath.POSITION, expectedNumberOfInterpolators); + } + + private void removeTime(int time, int expectedNumberOfInterpolators) { + impl.removeTimeKeyframe(time); + assertNull(impl.getKeyframe(SPPath.TIME, time)); + assertValidInterpolators(SPPath.TIME, expectedNumberOfInterpolators); + } + + private void setInterpolator(int time, Interpolator interpolator, int expectedNumberOfInterpolators) { + impl.setInterpolator(time, interpolator); + assertValidInterpolators(SPPath.POSITION, expectedNumberOfInterpolators); + } + + private void assertPositionState(String expected) { + assertEquals(expected, prettyPrintInterpolators(impl, SPPath.POSITION)); + } + + // Tracks only entity 42 which always is at x=time*2, y,z=0 + private class EntityPositionTrackerMock extends EntityPositionTracker { + public EntityPositionTrackerMock() { + super(null); + } + + @Override + public Location getEntityPositionAtTimestamp(int entityID, long timestamp) { + if (entityID == 42) { + return new Location(timestamp * 2, 0 ,0, 0, 0); + } else { + return null; + } + } + } + + public static String prettyPrintInterpolators(SPTimeline timeline, SPPath spPath) { + return prettyPrintInterpolators(timeline, spPath, Collections.emptyMap()); + } + + public static String prettyPrintInterpolators(SPTimeline timeline, SPPath spPath, Map updates) { + Map interpolatorIdMap = new IdentityHashMap<>(); + Path path = timeline.getPath(spPath); + StringBuilder sb = new StringBuilder(); + for (Keyframe keyframe : path.getKeyframes()) { + if (spPath == SPPath.TIME) { + sb.append("[T ").append(keyframe.getTime()).append("]"); + } else if (keyframe.getValue(SpectatorProperty.PROPERTY).isPresent()) { + sb.append("[S ").append(keyframe.getTime()).append("]"); + } else { + sb.append("[P ").append(keyframe.getTime()).append("]"); + } + path.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe).findFirst().ifPresent(segment -> { + Interpolator interpolator = updates.getOrDefault(segment, segment.getInterpolator()); + String id = interpolatorIdMap.computeIfAbsent(interpolator, i -> + (i instanceof LinearInterpolator ? "L" : "C") + interpolatorIdMap.size()); + sb.append(' ').append(id).append(' '); + }); + } + return sb.toString(); + } +}