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..1c02f201 --- /dev/null +++ b/src/main/java/com/replaymod/simplepathing/InterpolatorType.java @@ -0,0 +1,51 @@ +package com.replaymod.simplepathing; + +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), + CUBIC("cubic", CubicSplineInterpolator.class, CubicSplineInterpolator::new), + LINEAR("linear", LinearInterpolator.class, LinearInterpolator::new); + + 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 CUBIC; //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 d55ae73c..85dbd5d9 100644 --- a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java +++ b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java @@ -1,41 +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.ExplicitInterpolationProperty; -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.replaystudio.pathing.property.Property; +import com.replaymod.simplepathing.SPTimeline.SPPath; import com.replaymod.simplepathing.gui.GuiPathing; import com.replaymod.simplepathing.preview.PathPreview; +import lombok.Getter; import net.minecraftforge.fml.common.FMLCommonHandler; 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.ByteArrayOutputStream; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringReader; - @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; @@ -59,114 +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.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); - 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 { - 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); - - } - } - - /** - * Clones an interpolator by de- and reserializing it. - * @param interpolator The interpolator to clone - * @return The cloned interpolator - * @throws IOException - */ - public Interpolator cloneInterpolator(Interpolator interpolator) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - PrintWriter printWriter = new PrintWriter(baos); - - JsonWriter jsonWriter = new JsonWriter(printWriter); - jsonWriter.beginArray(); - serializeInterpolator(jsonWriter, interpolator); - jsonWriter.endArray(); - jsonWriter.flush(); - - String json = baos.toString(); - - JsonReader jsonReader = new JsonReader(new StringReader(json)); - jsonReader.beginArray(); - Interpolator cloned = deserializeInterpolator(jsonReader); - - for (Property p : interpolator.getKeyframeProperties()) { - cloned.registerProperty(p); - } - - return cloned; + 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..26442b66 --- /dev/null +++ b/src/main/java/com/replaymod/simplepathing/SPTimeline.java @@ -0,0 +1,623 @@ +package com.replaymod.simplepathing; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.replaymod.pathing.properties.CameraProperties; +import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty; +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.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 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.CUBIC; + + 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 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 { + 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); + + } + } + + /** + * 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.addCrashSectionCallable("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/gui/GuiEditKeyframe.java b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java index d1c57a8f..9cabba88 100644 --- a/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java +++ b/src/main/java/com/replaymod/simplepathing/gui/GuiEditKeyframe.java @@ -1,23 +1,29 @@ package com.replaymod.simplepathing.gui; import com.replaymod.pathing.properties.CameraProperties; -import com.replaymod.pathing.properties.ExplicitInterpolationProperty; +import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty; 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.SetInterpolator; -import com.replaymod.replaystudio.pathing.change.UpdateKeyframeProperties; 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.gui.GuiEditKeyframe.Position.InterpolationPanel.InterpolatorType; +import de.johni0702.minecraft.gui.container.AbstractGuiContainer; import de.johni0702.minecraft.gui.container.GuiPanel; -import de.johni0702.minecraft.gui.element.*; +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; @@ -26,19 +32,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 lombok.AllArgsConstructor; import lombok.Getter; import net.minecraft.client.resources.I18n; -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.ReadablePoint; import java.util.Map; +import java.util.Optional; -import static com.replaymod.simplepathing.gui.GuiEditKeyframe.Position.InterpolationPanel.InterpolatorSettingsPanel.CubicInterpolatorSettingsPanel; -import static com.replaymod.simplepathing.gui.GuiEditKeyframe.Position.InterpolationPanel.InterpolatorSettingsPanel.LinearInterpolatorSettingsPanel; import static de.johni0702.minecraft.gui.utils.Utils.link; public abstract class GuiEditKeyframe> extends AbstractGuiPopup implements Typeable { @@ -50,6 +53,7 @@ public abstract class GuiEditKeyframe> extends Abst protected final GuiPathing guiPathing; + protected final long time; protected final Keyframe keyframe; protected final Path path; @@ -85,13 +89,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.guiPathing = gui; - this.keyframe = keyframe; - this.path = path; + 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); @@ -101,10 +105,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(); }); } @@ -134,7 +139,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); @@ -167,10 +172,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); @@ -185,11 +190,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 @@ -207,7 +208,7 @@ 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(guiPathing); + public final InterpolationPanel interpolationPanel = new InterpolationPanel(); { GuiPanel positionInputs = new GuiPanel() @@ -224,15 +225,15 @@ public abstract class GuiEditKeyframe> extends Abst 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()); @@ -245,48 +246,17 @@ public abstract class GuiEditKeyframe> extends Abst @Override protected Change save() { - Change setInterpolatorChange = null; - - UpdateKeyframeProperties.Builder builder = 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())) - .removeProperty(ExplicitInterpolationProperty.PROPERTY); - - // if the interpolator is not the default, set the ExplicitInterpolationProperty flag - if (interpolationPanel.getInterpolatorType() != InterpolatorType.DEFAULT) { - PathSegment toModify = null; - for (PathSegment segment : path.getSegments()) { - if (segment.getStartKeyframe() == keyframe) { - toModify = segment; - break; - } - } - - if (toModify != null) { - builder.setValue(ExplicitInterpolationProperty.PROPERTY, new Object()); - - Interpolator interpolator = interpolationPanel.getInterpolatorSettingsPanel().createInterpolator(); - interpolator.registerProperty(CameraProperties.POSITION); - interpolator.registerProperty(CameraProperties.ROTATION); - - setInterpolatorChange = SetInterpolator.create(toModify, interpolator); - } else { - logger.warn("The Path segment to modify was not found. Setting interpolator to default."); - } - } - - Change keyframePropertiesChange = builder.done(); - keyframePropertiesChange.apply(path.getTimeline()); - - if (setInterpolatorChange == null) { - return keyframePropertiesChange; + SPTimeline timeline = guiPathing.getMod().getCurrentTimeline(); + Change positionChange = timeline.updatePositionKeyframe(time, + xField.getDouble(), yField.getDouble(), zField.getDouble(), + yawField.getFloat(), pitchField.getFloat(), rollField.getFloat() + ); + if (interpolationPanel.getInterpolatorType() == InterpolatorType.DEFAULT) { + return CombinedChange.createFromApplied(positionChange, timeline.setInterpolatorToDefault(time)); } else { - setInterpolatorChange.apply(path.getTimeline()); - guiPathing.updateInterpolators(); - path.updateAll(); + Interpolator interpolator = interpolationPanel.getSettingsPanel().createInterpolator(); + return CombinedChange.createFromApplied(positionChange, timeline.setInterpolator(time, interpolator)); } - - return CombinedChange.createFromApplied(keyframePropertiesChange, setInterpolatorChange); } @Override @@ -294,51 +264,20 @@ public abstract class GuiEditKeyframe> extends Abst return this; } - public static class InterpolationPanel extends de.johni0702.minecraft.gui.container.AbstractGuiContainer { - - private final GuiPathing guiPathing; + public class InterpolationPanel extends AbstractGuiContainer { @Getter - private InterpolatorSettingsPanel interpolatorSettingsPanel; + private SettingsPanel settingsPanel; private GuiDropdownMenu dropdown; - @AllArgsConstructor - public enum InterpolatorType { - DEFAULT("default", null), - CUBIC("cubic", CubicSplineInterpolator.class), - LINEAR("linear", LinearInterpolator.class); - - private String localizationKey; - - @Getter - private Class interpolatorClass; - - @Override - public String toString() { - return I18n.format(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.toString().equals(string)) return t; - } - return CUBIC; //the default - } - - } - - public InterpolationPanel(GuiPathing guiPathing) { - this.guiPathing = guiPathing; - + public InterpolationPanel() { setLayout(new VerticalLayout()); - dropdown = new GuiDropdownMenu().setValues(InterpolatorType.values()).setHeight(20); - dropdown.onSelection((index) -> setSettingsPanel(dropdown.getSelectedValue())); + 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()) { @@ -353,25 +292,35 @@ public abstract class GuiEditKeyframe> extends Abst addElements(new VerticalLayout.Data(0.5, false), dropdownPanel); - dropdown.onSelection(0); // trigger the callback once to display settings panel + Optional segment = path.getSegments().stream() + .filter(s -> s.getStartKeyframe() == keyframe).findFirst(); + if (segment.isPresent()) { + if (keyframe.getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) { + Interpolator interpolator = segment.get().getInterpolator(); + InterpolatorType type = InterpolatorType.fromClass(interpolator.getClass()); + dropdown.setSelected(type); // trigger the callback once to display settings panel + } else { + setSettingsPanel(InterpolatorType.DEFAULT); + } + } else { + // Disable dropdown if this is the last keyframe + dropdown.setDisabled(); + } } public void setSettingsPanel(InterpolatorType type) { - removeElement(this.interpolatorSettingsPanel); + removeElement(this.settingsPanel); - InterpolatorSettingsPanel settingsPanel = null; switch (getInterpolatorTypeNoDefault(type)) { case CUBIC: - settingsPanel = new CubicInterpolatorSettingsPanel(); + settingsPanel = new CubicSettingsPanel(); break; case LINEAR: - settingsPanel = new LinearInterpolatorSettingsPanel(); + settingsPanel = new LinearSettingsPanel(); break; } addElements(new GridLayout.Data(0.5, 0.5), settingsPanel); - - this.interpolatorSettingsPanel = settingsPanel; } protected InterpolatorType getInterpolatorTypeNoDefault(InterpolatorType interpolatorType) { @@ -392,44 +341,44 @@ public abstract class GuiEditKeyframe> extends Abst return this; } - public static abstract class InterpolatorSettingsPanel extends de.johni0702.minecraft.gui.container.GuiPanel { + public abstract class SettingsPanel> extends AbstractGuiContainer { public abstract void loadSettings(I interpolator); public abstract I createInterpolator(); + } - public static class CubicInterpolatorSettingsPanel extends InterpolatorSettingsPanel { + public class CubicSettingsPanel extends SettingsPanel { - @Override - public void loadSettings(CubicSplineInterpolator interpolator) { - } - - @Override - public CubicSplineInterpolator createInterpolator() { - return new CubicSplineInterpolator(); - } - - @Override - protected InterpolatorSettingsPanel getThis() { - return this; - } + @Override + public void loadSettings(CubicSplineInterpolator interpolator) { } - public static class LinearInterpolatorSettingsPanel extends InterpolatorSettingsPanel { + @Override + public CubicSplineInterpolator createInterpolator() { + return new CubicSplineInterpolator(); + } - @Override - public void loadSettings(LinearInterpolator interpolator) { - } + @Override + protected CubicSettingsPanel getThis() { + return this; + } + } - @Override - public LinearInterpolator createInterpolator() { - return new LinearInterpolator(); - } + public class LinearSettingsPanel extends SettingsPanel { - @Override - protected InterpolatorSettingsPanel getThis() { - return this; - } + @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,15 +290,14 @@ 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() { @@ -326,26 +319,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 +350,7 @@ public class GuiPathing { }).onClick(new Runnable() { @Override public void run() { - updateKeyframe(false); + updateKeyframe(SPPath.POSITION); } }); @@ -367,12 +362,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 +377,7 @@ public class GuiPathing { }).onClick(new Runnable() { @Override public void run() { - updateKeyframe(true); + updateKeyframe(SPPath.TIME); } }); @@ -404,12 +399,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 +430,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 +450,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 +466,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 +474,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 +506,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 +525,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 +538,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,267 +558,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() { - InterpolatorType type = InterpolatorType.fromString(mod.getCore().getSettingsRegistry().get(Setting.DEFAULT_INTERPOLATION)); - - Interpolator defaultInterpolator = null; - - // Iterate over all existing segments until the first interpolator - // of the same type without the fixed flag is found and clone it. - // This way, the default interpolator's settings can be saved on a per-camera-path basis. - // This is useful for interpolators that have customizable properties. - Collection pathSegments = mod.getCurrentTimeline().getPaths().get(POSITION_PATH).getSegments(); - - for (PathSegment segment : pathSegments) { - if (segment.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) continue; - if (segment.getInterpolator() == null) continue; - if (type == InterpolatorType.DEFAULT || type.getInterpolatorClass().equals(segment.getInterpolator().getClass())) { - try { - defaultInterpolator = mod.cloneInterpolator(segment.getInterpolator()); - break; - } catch (IOException e) { - Utils.error(LOGGER, replayHandler.getOverlay(), - CrashReport.makeCrashReport(e, "Cloning fixed interpolator"), null); - } - } - } - - // if no segment with the default interpolator was found, - // create a new one with default settings - if (defaultInterpolator == null) { - switch (type) { - case LINEAR: - defaultInterpolator = new LinearInterpolator(); - break; - case CUBIC: - default: - defaultInterpolator = new CubicSplineInterpolator(); - break; - } - } - - List changes = new ArrayList<>(); - Interpolator interpolator = null; - boolean isSpectatorInterpolator = false; - for (PathSegment segment : pathSegments) { - 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 - boolean explicit = segment.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent(); - if (isSpectatorInterpolator || interpolator == null || explicit) { - isSpectatorInterpolator = false; - - if (segment.getInterpolator() == null || !explicit) { - interpolator = defaultInterpolator; - interpolator.registerProperty(CameraProperties.POSITION); - interpolator.registerProperty(CameraProperties.ROTATION); - } else { - try { - interpolator = mod.cloneInterpolator(segment.getInterpolator()); - } catch (IOException e) { - Utils.error(LOGGER, replayHandler.getOverlay(), - CrashReport.makeCrashReport(e, "Cloning fixed interpolator"), null); - interpolator = defaultInterpolator; - } + 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)); - } + 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(); - // InterpolatorType might be required later (only if path is the time path) - Optional firstInterpolator = - path.getSegments().stream().findFirst().map(PathSegment::getInterpolator); - - // The interpolator before the segment - Optional interpolatorBefore = - path.getSegments().stream().filter(s -> s.getEndKeyframe() == keyframe).findFirst().map(PathSegment::getInterpolator); - - // The interpolator that follows the segment - Optional interpolatorAfter = - path.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe).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); - Keyframe newKeyframe = 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); - - // Set the interpolator of the segment before the keyframe to what it was - Change interpolatorBeforeChange; - - Optional segmentBefore = path.getSegments().stream().filter(s -> s.getEndKeyframe() == newKeyframe).findFirst(); - if (segmentBefore.isPresent() && interpolatorBefore.isPresent()) { - interpolatorBeforeChange = SetInterpolator.create(segmentBefore.get(), interpolatorBefore.get()); - } else { - interpolatorBeforeChange = CombinedChange.create(); - } - - interpolatorBeforeChange.apply(timeline); - - // Set the interpolator of the segment after the keyframe to what it was - Change interpolatorAfterChange; - - Optional segmentAfter = path.getSegments().stream().filter(s -> s.getStartKeyframe() == newKeyframe).findFirst(); - if (segmentAfter.isPresent() && interpolatorAfter.isPresent()) { - interpolatorAfterChange = SetInterpolator.create(segmentAfter.get(), interpolatorAfter.get()); - } else { - interpolatorAfterChange = CombinedChange.create(); - } - - interpolatorAfterChange.apply(timeline); - - // Finally update the interpolators - Change interpolatorUpdateChange; - if (path.getTimeline().getPaths().indexOf(path) == GuiPathing.POSITION_PATH) { - // 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) { - 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, - interpolatorBeforeChange, interpolatorAfterChange, 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)); } public ReplayModSimplePathing getMod() { @@ -835,13 +625,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 8c12a07b..101538ec 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; @@ -67,11 +67,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(); @@ -230,7 +230,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/pathing/properties/ExplicitInterpolationProperty.java b/src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java similarity index 85% rename from src/main/java/com/replaymod/pathing/properties/ExplicitInterpolationProperty.java rename to src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java index 6443e91b..5f181191 100644 --- a/src/main/java/com/replaymod/pathing/properties/ExplicitInterpolationProperty.java +++ b/src/main/java/com/replaymod/simplepathing/properties/ExplicitInterpolationProperty.java @@ -1,10 +1,11 @@ -package com.replaymod.pathing.properties; +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; @@ -36,8 +37,8 @@ public class ExplicitInterpolationProperty extends AbstractProperty { } @Override - public Void fromJson(JsonReader reader) throws IOException { + public Object fromJson(JsonReader reader) throws IOException { reader.nextNull(); - return null; + return ObjectUtils.NULL; } } 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..b94c7a53 --- /dev/null +++ b/src/test/java/com/replaymod/simplepathing/SPTimelineTest.java @@ -0,0 +1,594 @@ +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.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); + } + + 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(); + } +}