package com.replaymod.simplepathing.gui; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import com.replaymod.core.ReplayMod; import com.replaymod.pathing.gui.GuiKeyframeRepository; import com.replaymod.pathing.player.RealtimeTimelinePlayer; import com.replaymod.pathing.properties.CameraProperties; import com.replaymod.pathing.properties.SpectatorProperty; import com.replaymod.pathing.properties.TimestampProperty; import com.replaymod.render.gui.GuiRenderSettings; import com.replaymod.replay.ReplayHandler; import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replay.gui.overlay.GuiReplayOverlay; import com.replaymod.replaystudio.pathing.change.*; 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.ReplayModSimplePathing; import com.replaymod.simplepathing.Setting; import de.johni0702.minecraft.gui.GuiRenderer; import de.johni0702.minecraft.gui.RenderInfo; import de.johni0702.minecraft.gui.container.GuiContainer; import de.johni0702.minecraft.gui.container.GuiPanel; import de.johni0702.minecraft.gui.element.*; import de.johni0702.minecraft.gui.element.advanced.GuiProgressBar; import de.johni0702.minecraft.gui.element.advanced.GuiTimelineTime; import de.johni0702.minecraft.gui.layout.CustomLayout; import de.johni0702.minecraft.gui.layout.HorizontalLayout; import de.johni0702.minecraft.gui.layout.VerticalLayout; import de.johni0702.minecraft.gui.popup.AbstractGuiPopup; import de.johni0702.minecraft.gui.popup.GuiInfoPopup; import de.johni0702.minecraft.gui.popup.GuiYesNoPopup; import de.johni0702.minecraft.gui.utils.Colors; import net.minecraft.entity.Entity; import net.minecraftforge.fml.common.Loader; import org.apache.commons.lang3.tuple.Triple; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.lwjgl.input.Keyboard; import org.lwjgl.util.Dimension; import org.lwjgl.util.ReadableDimension; import org.lwjgl.util.ReadablePoint; import org.lwjgl.util.WritablePoint; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Consumer; /** * Gui plug-in to the GuiReplayOverlay for simple pathing. */ public class GuiPathing { public static final int TIME_PATH = 0; public static final int POSITION_PATH = 1; private static final Logger logger = LogManager.getLogger(); public final GuiTexturedButton playPauseButton = new GuiTexturedButton() { @Override public GuiElement getTooltip(RenderInfo renderInfo) { GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); if (tooltip != null) { if (player.isActive()) { tooltip.setI18nText("replaymod.gui.ingame.menu.pausepath"); } else if (Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)) { tooltip.setI18nText("replaymod.gui.ingame.menu.playpathfromstart"); } else { tooltip.setI18nText("replaymod.gui.ingame.menu.playpath"); } } return tooltip; } }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); public final GuiTexturedButton renderButton = new GuiTexturedButton().onClick(new Runnable() { @Override public void run() { if (!preparePathsForPlayback()) return; new GuiRenderSettings(replayHandler, mod.getCurrentTimeline()).display(); } }).setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTexturePosH(40, 0) .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.renderpath")); public final GuiTexturedButton positionKeyframeButton = new GuiTexturedButton() { @Override public GuiElement getTooltip(RenderInfo renderInfo) { GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); if (tooltip != null) { if (getTextureNormal().getY() == 40) { // Add keyframe if (getTextureNormal().getX() == 0) { // Position tooltip.setI18nText("replaymod.gui.ingame.menu.addposkeyframe"); } else { // Spectator tooltip.setI18nText("replaymod.gui.ingame.menu.addspeckeyframe"); } } else { // Remove keyframe if (getTextureNormal().getX() == 0) { // Position tooltip.setI18nText("replaymod.gui.ingame.menu.removeposkeyframe"); } else { // Spectator tooltip.setI18nText("replaymod.gui.ingame.menu.removespeckeyframe"); } } } return tooltip; } }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); public final GuiTexturedButton timeKeyframeButton = new GuiTexturedButton() { @Override public GuiElement getTooltip(RenderInfo renderInfo) { GuiTooltip tooltip = (GuiTooltip) super.getTooltip(renderInfo); if (tooltip != null) { if (getTextureNormal().getY() == 80) { // Add time keyframe tooltip.setI18nText("replaymod.gui.ingame.menu.addtimekeyframe"); } else { // Remove time keyframe tooltip.setI18nText("replaymod.gui.ingame.menu.removetimekeyframe"); } } return tooltip; } }.setSize(20, 20).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTooltip(new GuiTooltip()); public final GuiKeyframeTimeline timeline = new GuiKeyframeTimeline(this){ @Override public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { if (player.isActive()) { setCursorPosition((int) player.getTimePassed()); } super.draw(renderer, size, renderInfo); } }.setSize(Integer.MAX_VALUE, 20).setLength(30 * 60 * 1000).setMarkers(); public final GuiHorizontalScrollbar scrollbar = new GuiHorizontalScrollbar().setSize(Integer.MAX_VALUE, 9); {scrollbar.onValueChanged(new Runnable() { @Override public void run() { timeline.setOffset((int) (scrollbar.getPosition() * timeline.getLength())); timeline.setZoom(scrollbar.getZoom()); } }).setZoom(0.1);} public final GuiTimelineTime timelineTime = new GuiTimelineTime() .setTimeline(timeline); public final GuiTexturedButton zoomInButton = new GuiTexturedButton().setSize(9, 9).onClick(new Runnable() { @Override public void run() { zoomTimeline(2d / 3d); } }).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTexturePosH(40, 20) .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.zoomin")); public final GuiTexturedButton zoomOutButton = new GuiTexturedButton().setSize(9, 9).onClick(new Runnable() { @Override public void run() { zoomTimeline(3d / 2d); } }).setTexture(ReplayMod.TEXTURE, ReplayMod.TEXTURE_SIZE).setTexturePosH(40, 30) .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.ingame.menu.zoomout")); public final GuiPanel zoomButtonPanel = new GuiPanel() .setLayout(new VerticalLayout(VerticalLayout.Alignment.CENTER).setSpacing(2)) .addElements(null, zoomInButton, zoomOutButton); public final GuiPanel timelinePanel = new GuiPanel().setSize(Integer.MAX_VALUE, 40) .setLayout(new CustomLayout() { @Override protected void layout(GuiPanel container, int width, int height) { pos(zoomButtonPanel, width - width(zoomButtonPanel), 10); pos(timelineTime, 0, 2); size(timelineTime, x(zoomButtonPanel), 8); pos(timeline, 0, y(timelineTime) + height(timelineTime)); size(timeline, x(zoomButtonPanel) - 2, 20); pos(scrollbar, 0, y(timeline) + height(timeline) + 1); size(scrollbar, x(zoomButtonPanel) - 2, 9); } }).addElements(null, timelineTime, timeline, scrollbar, zoomButtonPanel); public final GuiPanel panel = new GuiPanel() .setLayout(new HorizontalLayout(HorizontalLayout.Alignment.CENTER).setSpacing(5)); { panel.addElements(new HorizontalLayout.Data(0.5), playPauseButton); if (Loader.isModLoaded("replaymod-render")) { panel.addElements(new HorizontalLayout.Data(0.5), renderButton); } panel.addElements(new HorizontalLayout.Data(0.5), positionKeyframeButton, timeKeyframeButton, timelinePanel); } /** * IGuiClickable dummy component that is inserted at a high level. * During path playback, this catches all click events and forwards them to the * abort path playback button. * Dragging does not have to be intercepted as every GUI element should only * respond to dragging events after it has received and handled a click event. */ private final IGuiClickable clickCatcher = new AbstractGuiClickable() { @Override public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { if (player.isActive()) { // Make sure the mouse is always visible during path playback // even if the game closes the overlay for some reason (e.g. world change) replayHandler.getOverlay().setMouseVisible(true); } } @Override protected AbstractGuiElement getThis() { return this; } @Override protected ReadableDimension calcMinSize() { return new Dimension(0, 0); } @Override public boolean mouseClick(ReadablePoint position, int button) { if (player.isActive()) { playPauseButton.mouseClick(position, button); return true; } return false; } @Override public int getLayer() { return player.isActive() ? 10 : 0; } }; private final ReplayModSimplePathing mod; private final ReplayHandler replayHandler; private final RealtimeTimelinePlayer player; private EntityPositionTracker entityTracker; private Consumer entityTrackerLoadingProgress; private SettableFuture entityTrackerFuture; public GuiPathing(final ReplayMod core, final ReplayModSimplePathing mod, final ReplayHandler replayHandler) { this.mod = mod; this.replayHandler = replayHandler; this.player = new RealtimeTimelinePlayer(replayHandler); final GuiReplayOverlay overlay = replayHandler.getOverlay(); playPauseButton.setTexturePosH(new ReadablePoint() { @Override public int getX() { return 0; } @Override public int getY() { return player.isActive() ? 20 : 0; } @Override public void getLocation(WritablePoint dest) { dest.setLocation(getX(), getY()); } }).onClick(new Runnable() { @Override public void run() { if (player.isActive()) { player.getFuture().cancel(false); } else { Timeline timeline = mod.getCurrentTimeline(); Path timePath = timeline.getPaths().get(TIME_PATH); 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); overlay.setCloseable(false); overlay.setMouseVisible(true); Futures.addCallback(future, new FutureCallback() { @Override public void onSuccess(@Nullable Void result) { overlay.setCloseable(true); } @Override public void onFailure(Throwable t) { t.printStackTrace(); overlay.setCloseable(true); } }); } } }); positionKeyframeButton.setTexturePosH(new ReadablePoint() { @Override public int getX() { Keyframe keyframe = mod.getSelectedKeyframe(); if (keyframe == null || !keyframe.getValue(CameraProperties.POSITION).isPresent()) { // No keyframe or wrong path keyframe = mod.getCurrentTimeline().getPaths().get(POSITION_PATH).getKeyframe(timeline.getCursorPosition()); } if (keyframe == null) { return replayHandler.isCameraView() ? 0 : 40; } else { return keyframe.getValue(SpectatorProperty.PROPERTY).isPresent() ? 40 : 0; } } @Override public int getY() { Keyframe keyframe = mod.getSelectedKeyframe(); if (keyframe == null || !keyframe.getValue(CameraProperties.POSITION).isPresent()) { // 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()); } return keyframe != null && keyframe.getValue(CameraProperties.POSITION).isPresent() ? 60 : 40; } @Override public void getLocation(WritablePoint dest) { dest.setLocation(getX(), getY()); } }).onClick(new Runnable() { @Override public void run() { updateKeyframe(false); } }); timeKeyframeButton.setTexturePosH(new ReadablePoint() { @Override public int getX() { return 0; } @Override public int getY() { Keyframe keyframe = mod.getSelectedKeyframe(); if (keyframe == null || !keyframe.getValue(TimestampProperty.PROPERTY).isPresent()) { // 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()); } return keyframe != null && keyframe.getValue(TimestampProperty.PROPERTY).isPresent() ? 100 : 80; } @Override public void getLocation(WritablePoint dest) { dest.setLocation(getX(), getY()); } }).onClick(new Runnable() { @Override public void run() { updateKeyframe(true); } }); overlay.addElements(null, panel, clickCatcher); overlay.setLayout(new CustomLayout(overlay.getLayout()) { @Override protected void layout(GuiReplayOverlay container, int width, int height) { pos(panel, 10, y(overlay.topPanel) + height(overlay.topPanel) + 3); size(panel, width - 20, 40); size(clickCatcher, 0, 0); } }); core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.keyframerepository", Keyboard.KEY_X, new Runnable() { @Override public void run() { if (!overlay.isVisible()) { return; } try { GuiKeyframeRepository gui = new GuiKeyframeRepository( mod, replayHandler.getReplayFile(), mod.getCurrentTimeline()); Futures.addCallback(gui.getFuture(), new FutureCallback() { @Override public void onSuccess(Timeline result) { if (result != null) { mod.setCurrentTimeline(result); } } @Override public void onFailure(Throwable t) { t.printStackTrace(); core.printWarningToChat("Error loading timeline: " + t.getMessage()); } }); gui.display(); } catch (IOException e) { e.printStackTrace(); core.printWarningToChat("Error loading timeline: " + e.getMessage()); } } }); core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.clearkeyframes", Keyboard.KEY_C, () -> { GuiYesNoPopup popup = GuiYesNoPopup.open(overlay, new GuiLabel().setI18nText("replaymod.gui.clearcallback.title").setColor(Colors.BLACK) ).setYesI18nLabel("gui.yes").setNoI18nLabel("gui.no"); Futures.addCallback(popup.getFuture(), new FutureCallback() { @Override public void onSuccess(Boolean delete) { if (delete) { Timeline timeline = mod.createTimeline(); timeline.createPath(); timeline.createPath(); mod.setCurrentTimeline(timeline); } } @Override public void onFailure(Throwable t) { t.printStackTrace(); } }); }); core.getKeyBindingRegistry().registerRepeatedKeyBinding("replaymod.input.synctimeline", Keyboard.KEY_V, () -> { // Current replay time int time = replayHandler.getReplaySender().currentTimeStamp(); // Position of the cursor int cursor = timeline.getCursorPosition(); // Get the last time keyframe before the cursor mod.getCurrentTimeline().getPaths().get(TIME_PATH).getKeyframes().stream() .filter(it -> it.getTime() <= cursor).reduce((__, last) -> last).ifPresent(keyframe -> { // Cursor position at the keyframe int keyframeCursor = (int) keyframe.getTime(); // Replay time at the keyframe // This is a keyframe from the time path, so it _should_ always have a time property int keyframeTime = keyframe.getValue(TimestampProperty.PROPERTY).get(); // Replay time passed int timePassed = time - keyframeTime; // Speed (set to 1 when shift is held) double speed = Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) ? 1 : overlay.getSpeedSliderValue(); // Cursor time passed int cursorPassed = (int) (timePassed / speed); // 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); }); }); core.getKeyBindingRegistry().registerRaw(Keyboard.KEY_DELETE, () -> { if (!overlay.isVisible()) { return; } if (mod.getSelectedKeyframe() != null) { updateKeyframe(mod.getSelectedKeyframe().getValue(TimestampProperty.PROPERTY).isPresent()); } }); // Start loading entity tracker entityTrackerFuture = SettableFuture.create(); new Thread(() -> { EntityPositionTracker tracker = new EntityPositionTracker(replayHandler.getReplayFile()); try { long start = System.currentTimeMillis(); tracker.load(p -> { if (entityTrackerLoadingProgress != null) { entityTrackerLoadingProgress.accept(p); } }); logger.info("Loaded entity tracker in " + (System.currentTimeMillis() - start) + "ms"); } catch (IOException e) { logger.error("Loading entity tracker:", e); mod.getCore().runLater(() -> { mod.getCore().printWarningToChat("Error loading entity tracker: %s", e.getLocalizedMessage()); entityTrackerFuture.setException(e); }); } entityTracker = tracker; mod.getCore().runLater(() -> { entityTrackerFuture.set(null); }); }).start(); } private boolean preparePathsForPlayback() { Timeline timeline = mod.getCurrentTimeline(); timeline.getPaths().get(TIME_PATH).updateAll(); timeline.getPaths().get(POSITION_PATH).updateAll(); // Make sure time keyframes's values are monotonically increasing int lastTime = 0; for (Keyframe keyframe : timeline.getPaths().get(TIME_PATH).getKeyframes()) { int time = keyframe.getValue(TimestampProperty.PROPERTY).orElseThrow(IllegalStateException::new); if (time < lastTime) { // We are going backwards in time GuiInfoPopup.open(replayHandler.getOverlay(), "replaymod.error.negativetime1", "replaymod.error.negativetime2", "replaymod.error.negativetime3"); return false; } lastTime = time; } return true; } public void zoomTimeline(double factor) { 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) { if (entityTracker == null) { 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); } @Override public void onFailure(@Nonnull Throwable t) { popup.close(); } }); return; } int time = timeline.getCursorPosition(); Timeline timeline = mod.getCurrentTimeline(); Path path = timeline.getPaths().get(isTime ? TIME_PATH : POSITION_PATH); 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()); } } 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); } 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)); } } 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() { return mod; } public EntityPositionTracker getEntityTracker() { return entityTracker; } public void openEditKeyframePopup(Path path, Keyframe keyframe) { if (keyframe.getProperties().contains(SpectatorProperty.PROPERTY)) { new GuiEditKeyframe.Spectator(this, path, keyframe).open(); } else if (keyframe.getProperties().contains(CameraProperties.POSITION)) { new GuiEditKeyframe.Position(this, path, keyframe).open(); } else { new GuiEditKeyframe.Time(this, path, keyframe).open(); } } private class LoadEntityTrackerPopup extends AbstractGuiPopup { private final GuiProgressBar progressBar = new GuiProgressBar(popup).setSize(300, 20) .setI18nLabel("replaymod.gui.loadentitytracker"); public LoadEntityTrackerPopup(GuiContainer container) { super(container); open(); } @Override public void close() { super.close(); } @Override protected LoadEntityTrackerPopup getThis() { return this; } } }