package com.replaymod.simplepathing; import com.google.common.base.Preconditions; import com.google.common.collect.Iterables; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.replaymod.pathing.properties.CameraProperties; import com.replaymod.pathing.properties.SpectatorProperty; import com.replaymod.pathing.properties.TimestampProperty; import com.replaymod.replaystudio.pathing.PathingRegistry; import com.replaymod.replaystudio.pathing.change.AddKeyframe; import com.replaymod.replaystudio.pathing.change.Change; import com.replaymod.replaystudio.pathing.change.CombinedChange; import com.replaymod.replaystudio.pathing.change.SetInterpolator; import com.replaymod.replaystudio.pathing.change.UpdateKeyframeProperties; import com.replaymod.replaystudio.pathing.impl.TimelineImpl; import com.replaymod.replaystudio.pathing.interpolation.CatmullRomSplineInterpolator; import com.replaymod.replaystudio.pathing.interpolation.CubicSplineInterpolator; import com.replaymod.replaystudio.pathing.interpolation.Interpolator; import com.replaymod.replaystudio.pathing.interpolation.LinearInterpolator; import com.replaymod.replaystudio.pathing.path.Keyframe; import com.replaymod.replaystudio.pathing.path.Path; import com.replaymod.replaystudio.pathing.path.PathSegment; import com.replaymod.replaystudio.pathing.path.Timeline; import com.replaymod.replaystudio.pathing.property.Property; import com.replaymod.replaystudio.util.EntityPositionTracker; import com.replaymod.replaystudio.util.Location; import com.replaymod.simplepathing.properties.ExplicitInterpolationProperty; import lombok.Getter; import net.minecraft.crash.CrashReport; import net.minecraft.crash.CrashReportCategory; import net.minecraft.util.ReportedException; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.tuple.Triple; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.StringReader; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import static com.replaymod.replaystudio.pathing.change.RemoveKeyframe.create; import static com.replaymod.simplepathing.ReplayModSimplePathing.LOGGER; /** * Simplified timeline abstraction used in the SimplePathing module. */ public class SPTimeline implements PathingRegistry { public enum SPPath { TIME, POSITION, } @Getter private final Timeline timeline; @Getter private final Path timePath; @Getter private final Path positionPath; @Getter private EntityPositionTracker entityTracker; private InterpolatorType defaultInterpolatorType; public SPTimeline() { this(createInitialTimeline()); } public SPTimeline(Timeline timeline) { this.timeline = timeline; this.timePath = timeline.getPaths().get(SPPath.TIME.ordinal()); this.positionPath = timeline.getPaths().get(SPPath.POSITION.ordinal()); } public Path getPath(SPPath path) { switch (path) { case TIME: return getTimePath(); case POSITION: return getPositionPath(); } throw new IllegalArgumentException("Unknown path " + path); } public Keyframe getKeyframe(SPPath path, long keyframe) { return getPath(path).getKeyframe(keyframe); } public void setEntityTracker(EntityPositionTracker entityTracker) { Preconditions.checkState(this.entityTracker == null, "Entity tracker already set"); this.entityTracker = entityTracker; } public void setDefaultInterpolatorType(InterpolatorType defaultInterpolatorType) { Validate.isTrue(defaultInterpolatorType != InterpolatorType.DEFAULT, "Must not be DEFAULT"); this.defaultInterpolatorType = Validate.notNull(defaultInterpolatorType); if (entityTracker != null) { timeline.pushChange(updateInterpolators()); } } public Change setDefaultInterpolator(Interpolator interpolator) { Preconditions.checkState(defaultInterpolatorType != null, "Default interpolator type not set."); Validate.isInstanceOf(defaultInterpolatorType.getInterpolatorClass(), interpolator); registerPositionInterpolatorProperties(interpolator); Change change = CombinedChange.create( positionPath.getSegments().stream() // Ignore explicitly set segments .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) // Ignore spectator segments .filter(s -> !isSpectatorSegment(s)) // Update interpolator for every remaining segment // This will create a fragmented interpolator which is split by the updateInterpolators call .map(s -> SetInterpolator.create(s, interpolator)).toArray(Change[]::new) ); change.apply(timeline); return CombinedChange.createFromApplied(change, updateInterpolators()); } public boolean isTimeKeyframe(long time) { return timePath.getKeyframe(time) != null; } public boolean isPositionKeyframe(long time) { return positionPath.getKeyframe(time) != null; } public boolean isSpectatorKeyframe(long time) { Keyframe keyframe = positionPath.getKeyframe(time); return keyframe != null && keyframe.getValue(SpectatorProperty.PROPERTY).isPresent(); } public void addPositionKeyframe(long time, double posX, double posY, double posZ, float yaw, float pitch, float roll, int spectated) { LOGGER.debug("Adding position keyframe at {} pos {}/{}/{} rot {}/{}/{} entId {}", time, posX, posY, posZ, yaw, pitch, roll, spectated); Path path = positionPath; Preconditions.checkState(positionPath.getKeyframe(time) == null, "Keyframe already exists"); Change change = AddKeyframe.create(path, time); change.apply(timeline); Keyframe keyframe = path.getKeyframe(time); UpdateKeyframeProperties.Builder builder = UpdateKeyframeProperties.create(path, keyframe); builder.setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ)); builder.setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll)); if (spectated != -1) { builder.setValue(SpectatorProperty.PROPERTY, spectated); } UpdateKeyframeProperties updateChange = builder.done(); updateChange.apply(timeline); change = CombinedChange.createFromApplied(change, updateChange); // If this new keyframe formed the first segment of the path if (path.getSegments().size() == 1) { // then create an initial interpolator of default type PathSegment segment = path.getSegments().iterator().next(); Interpolator interpolator = createDefaultInterpolator(); SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator); setInterpolator.apply(timeline); change = CombinedChange.createFromApplied(change, setInterpolator); } // Update interpolators for spectator keyframes // while this is overkill, it is far simpler than updating differently for every possible case change = CombinedChange.createFromApplied(change, updateInterpolators()); Change specPosUpdate = updateSpectatorPositions(); specPosUpdate.apply(timeline); change = CombinedChange.createFromApplied(change, specPosUpdate); timeline.pushChange(change); } public Change updatePositionKeyframe(long time, double posX, double posY, double posZ, float yaw, float pitch, float roll) { LOGGER.debug("Updating position keyframe at {} to pos {}/{}/{} rot {}/{}/{}", time, posX, posY, posZ, yaw, pitch, roll); Keyframe keyframe = positionPath.getKeyframe(time); Preconditions.checkState(keyframe != null, "Keyframe does not exists"); Preconditions.checkState(!keyframe.getValue(SpectatorProperty.PROPERTY).isPresent(), "Cannot update spectator keyframe"); Change change = UpdateKeyframeProperties.create(positionPath, keyframe) .setValue(CameraProperties.POSITION, Triple.of(posX, posY, posZ)) .setValue(CameraProperties.ROTATION, Triple.of(yaw, pitch, roll)) .done(); change.apply(timeline); return change; } public void removePositionKeyframe(long time) { LOGGER.debug("Removing position keyframe at {}", time); Path path = positionPath; Keyframe keyframe = path.getKeyframe(time); Preconditions.checkState(keyframe != null, "No keyframe at that time"); Change change = create(path, keyframe); change.apply(timeline); // Update interpolators for spectator keyframes // while this is overkill, it is far simpler than updating differently for every possible case change = CombinedChange.createFromApplied(change, updateInterpolators()); Change specPosUpdate = updateSpectatorPositions(); specPosUpdate.apply(timeline); change = CombinedChange.createFromApplied(change, specPosUpdate); timeline.pushChange(change); } public void addTimeKeyframe(long time, int replayTime) { LOGGER.debug("Adding time keyframe at {} time {}", time, replayTime); Path path = timePath; Preconditions.checkState(path.getKeyframe(time) == null, "Keyframe already exists"); Change change = AddKeyframe.create(path, time); change.apply(timeline); Keyframe keyframe = path.getKeyframe(time); UpdateKeyframeProperties updateChange = UpdateKeyframeProperties.create(path, keyframe) .setValue(TimestampProperty.PROPERTY, replayTime) .done(); updateChange.apply(timeline); change = CombinedChange.createFromApplied(change, updateChange); // If this new keyframe formed the first segment of the path if (path.getSegments().size() == 1) { // then create an initial interpolator PathSegment segment = path.getSegments().iterator().next(); Interpolator interpolator = new LinearInterpolator(); interpolator.registerProperty(TimestampProperty.PROPERTY); SetInterpolator setInterpolator = SetInterpolator.create(segment, interpolator); setInterpolator.apply(timeline); change = CombinedChange.createFromApplied(change, setInterpolator); } Change specPosUpdate = updateSpectatorPositions(); specPosUpdate.apply(timeline); change = CombinedChange.createFromApplied(change, specPosUpdate); timeline.pushChange(change); } public Change updateTimeKeyframe(long time, int replayTime) { LOGGER.debug("Updating time keyframe at {} to time {}", time, replayTime); Keyframe keyframe = timePath.getKeyframe(time); Preconditions.checkState(keyframe != null, "Keyframe does not exists"); Change change = UpdateKeyframeProperties.create(timePath, keyframe) .setValue(TimestampProperty.PROPERTY, replayTime) .done(); change.apply(timeline); return change; } public void removeTimeKeyframe(long time) { LOGGER.debug("Removing time keyframe at {}", time); Path path = timePath; Keyframe keyframe = path.getKeyframe(time); Preconditions.checkState(keyframe != null, "No keyframe at that time"); Change change = create(path, keyframe); change.apply(timeline); Change specPosUpdate = updateSpectatorPositions(); specPosUpdate.apply(timeline); change = CombinedChange.createFromApplied(change, specPosUpdate); timeline.pushChange(change); } public Change setInterpolatorToDefault(long time) { LOGGER.debug("Setting interpolator of position keyframe at {} to the default", time); Keyframe keyframe = positionPath.getKeyframe(time); Preconditions.checkState(keyframe != null, "Keyframe does not exists"); Change change = UpdateKeyframeProperties.create(positionPath, keyframe) .removeProperty(ExplicitInterpolationProperty.PROPERTY) .done(); change.apply(timeline); return CombinedChange.createFromApplied(change, updateInterpolators()); } public Change setInterpolator(long time, Interpolator interpolator) { LOGGER.debug("Setting interpolator of position keyframe at {} to {}", time, interpolator); Keyframe keyframe = positionPath.getKeyframe(time); Preconditions.checkState(keyframe != null, "Keyframe does not exists"); PathSegment segment = positionPath.getSegments().stream().filter(s -> s.getStartKeyframe() == keyframe) .findFirst().orElseThrow(() -> new IllegalStateException("Keyframe has no following segment.")); registerPositionInterpolatorProperties(interpolator); Change change = CombinedChange.create( UpdateKeyframeProperties.create(positionPath, keyframe) .setValue(ExplicitInterpolationProperty.PROPERTY, ObjectUtils.NULL) .done(), SetInterpolator.create(segment, interpolator) ); change.apply(timeline); return CombinedChange.createFromApplied(change, updateInterpolators()); } public Change moveKeyframe(SPPath spPath, long oldTime, long newTime) { LOGGER.debug("Moving keyframe on {} from {} to {}", spPath, oldTime, newTime); Path path = getPath(spPath); Keyframe keyframe = path.getKeyframe(oldTime); Preconditions.checkState(keyframe != null, "No keyframe at specified time"); // Interpolator of the first segment might be required later if it is the only one Optional firstInterpolator = path.getSegments().stream().findFirst().map(PathSegment::getInterpolator); // The interpolator that will be lost once we remove the old keyframe and has to be restored afterwards Optional lostInterpolator = path.getSegments().stream().filter(s -> { // If this is the last keyframe, if (Iterables.getLast(path.getKeyframes()) == keyframe) { return s.getEndKeyframe() == keyframe; // the previous interpolator will be lost } else { // otherwise return s.getStartKeyframe() == keyframe; // the following interpolator will be lost } }).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 = lostInterpolator.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 = path.getSegments().stream().filter(s -> s.getEndKeyframe() == newKf) .findFirst().flatMap(segment -> lostInterpolator.map(interpolator -> { // additionally, if the interpolation of this keyframe was set to explicit, that property // has to be transferred to the start keyframe of the new segment if (newKf.getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) { return CombinedChange.create( SetInterpolator.create(segment, interpolator), UpdateKeyframeProperties.create(path, segment.getStartKeyframe()) .setValue(ExplicitInterpolationProperty.PROPERTY, ObjectUtils.NULL).done() ); } else { return SetInterpolator.create(segment, interpolator); } })).orElseGet(CombinedChange::create); } restoreInterpolatorChange.apply(timeline); // Finally update the interpolators Change interpolatorUpdateChange; if (spPath == SPPath.POSITION) { // Position / Spectator keyframes need special handling interpolatorUpdateChange = updateInterpolators(); } else { // Time keyframes only need updating when only one segment of them exists if (path.getSegments().size() == 1) { assert firstInterpolator.isPresent() : "One segment should have existed before as well"; interpolatorUpdateChange = SetInterpolator.create(path.getSegments().iterator().next(), firstInterpolator.get()); } else { interpolatorUpdateChange = CombinedChange.create(); // Noop change } interpolatorUpdateChange.apply(timeline); } // and update spectator positions Change spectatorChange = updateSpectatorPositions(); spectatorChange.apply(timeline); return CombinedChange.createFromApplied(removeChange, addChange, propertyChange, restoreInterpolatorChange, interpolatorUpdateChange, spectatorChange); } // Helper method because generics cannot be defined on blocks private void copyProperty(Property property, Keyframe from, UpdateKeyframeProperties.Builder to) { from.getValue(property).ifPresent(value -> to.setValue(property, value)); } private Change updateInterpolators() { Collection pathSegments = positionPath.getSegments(); // Contains updated pathsegment-interpolator mappings, may be changed multiple times over the course of this method Map updates = new HashMap<>(); // First, setup spectator interpolators Interpolator interpolator = null; // Iterate through all segments for (PathSegment segment : pathSegments) { if (isSpectatorSegment(segment)) { // If the last segment was a spectator segment, then use its interpolator for this segment as well if (interpolator == null) { // otherwise create a new interpolator interpolator = new LinearInterpolator(); interpolator.registerProperty(SpectatorProperty.PROPERTY); } // Now that we have an interpolator, set it for the current segment updates.put(segment, interpolator); } else { // Not a spectator segment, make sure we don't re-use the last interpolator (it would not be continuous) interpolator = null; } } // Then, if required, replace old default interpolators with new ones pathSegments.stream() // Ignore explicitly set segments .filter(s -> !s.getStartKeyframe().getValue(ExplicitInterpolationProperty.PROPERTY).isPresent()) // Ignore spectator segments .filter(s -> !isSpectatorSegment(s)) // Ignore already correct segments, should ignore all if default hasn't changed .filter(s -> !s.getInterpolator().getClass().equals(defaultInterpolatorType.getInterpolatorClass())) // Finally, set the interpolators // This will create one interpolator per segment, they will be merged later .forEach(segment -> updates.put(segment, createDefaultInterpolator())); // All interpolators should now be set appropriately but may still be fragmented. // Cleaning that up is a two step process // Firstly, all interpolators that are not continuous need to be split up Interpolator lastInterpolator = null; Set used = Collections.newSetFromMap(new IdentityHashMap<>()); for (PathSegment segment : pathSegments) { if (isSpectatorSegment(segment)) { lastInterpolator = null; continue; // Chain broken by spectator segment } Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator()); if (lastInterpolator == currentInterpolator) { continue; // All fine, interpolator is still continuous } // New interpolator, make sure it hasn't been used before if (!used.add(interpolator)) { // It has been used before, we need to clone it and use the clone instead // This will create a new interpolator for each segment which will be merged later currentInterpolator = cloneInterpolator(currentInterpolator); updates.put(segment, currentInterpolator); } lastInterpolator = currentInterpolator; } // Secondly, all neighbouring interpolators that are equal need to be merged lastInterpolator = null; String lastInterpolatorSerialized = null; for (PathSegment segment : pathSegments) { if (isSpectatorSegment(segment)) { lastInterpolator = null; lastInterpolatorSerialized = null; continue; // Spectator segments are continuous by construction } Interpolator currentInterpolator = updates.getOrDefault(segment, segment.getInterpolator()); String serialized = serializeInterpolator(currentInterpolator); if (lastInterpolator != currentInterpolator && serialized.equals(lastInterpolatorSerialized)) { // This interpolator is equal to the last one but not the same, needs merging updates.put(segment, lastInterpolator); continue; } // New interpolator, not related to the previous one, merged by definition lastInterpolator = currentInterpolator; lastInterpolatorSerialized = serialized; } Change change = CombinedChange.create(updates.entrySet().stream() .map(e -> SetInterpolator.create(e.getKey(), e.getValue())).toArray(Change[]::new)); change.apply(timeline); return change; } private boolean isSpectatorSegment(PathSegment segment) { return segment.getStartKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent() && segment.getEndKeyframe().getValue(SpectatorProperty.PROPERTY).isPresent(); } private Change updateSpectatorPositions() { List changes = new ArrayList<>(); timePath.updateAll(); for (Keyframe keyframe : positionPath.getKeyframes()) { Optional spectator = keyframe.getValue(SpectatorProperty.PROPERTY); if (spectator.isPresent()) { Optional time = timePath.getValue(TimestampProperty.PROPERTY, keyframe.getTime()); if (!time.isPresent()) { continue; // No time keyframes set at this video time, cannot determine replay time } Location expected = entityTracker.getEntityPositionAtTimestamp(spectator.get(), time.get()); if (expected == null) { continue; // We don't have any data on this entity for some reason } Triple pos = keyframe.getValue(CameraProperties.POSITION).orElse(Triple.of(0D, 0D, 0D)); Triple rot = keyframe.getValue(CameraProperties.ROTATION).orElse(Triple.of(0F, 0F, 0F)); Location actual = new Location(pos.getLeft(), pos.getMiddle(), pos.getRight(), rot.getLeft(), rot.getRight()); if (!expected.equals(actual)) { changes.add(UpdateKeyframeProperties.create(positionPath, keyframe) .setValue(CameraProperties.POSITION, Triple.of(expected.getX(), expected.getY(), expected.getZ())) .setValue(CameraProperties.ROTATION, Triple.of(expected.getYaw(), expected.getPitch(), 0f)).done() ); } } } return CombinedChange.create(changes.toArray(new Change[changes.size()])); } private Interpolator createDefaultInterpolator() { return registerPositionInterpolatorProperties(defaultInterpolatorType.newInstance()); } private Interpolator registerPositionInterpolatorProperties(Interpolator interpolator) { interpolator.registerProperty(CameraProperties.POSITION); interpolator.registerProperty(CameraProperties.ROTATION); return interpolator; } @Override public Timeline createTimeline() { return createTimelineStatic(); } private static Timeline createInitialTimeline() { Timeline timeline = createTimelineStatic(); timeline.createPath(); timeline.createPath(); return timeline; } private static Timeline createTimelineStatic() { Timeline timeline = new TimelineImpl(); timeline.registerProperty(TimestampProperty.PROPERTY); timeline.registerProperty(CameraProperties.POSITION); timeline.registerProperty(CameraProperties.ROTATION); timeline.registerProperty(SpectatorProperty.PROPERTY); timeline.registerProperty(ExplicitInterpolationProperty.PROPERTY); return timeline; } @Override public void serializeInterpolator(JsonWriter writer, Interpolator interpolator) throws IOException { if (interpolator instanceof LinearInterpolator) { writer.value("linear"); } else if (interpolator instanceof CubicSplineInterpolator) { writer.value("cubic-spline"); } else if (interpolator instanceof CatmullRomSplineInterpolator) { writer.beginObject(); writer.name("type").value("catmull-rom-spline"); writer.name("alpha").value(((CatmullRomSplineInterpolator) interpolator).getAlpha()); writer.endObject(); } else { throw new IOException("Unknown interpolator type: " + interpolator); } } @Override public Interpolator deserializeInterpolator(JsonReader reader) throws IOException { String type; JsonObject args; switch (reader.peek()) { case STRING: type = reader.nextString(); args = null; break; case BEGIN_OBJECT: args = new JsonParser().parse(reader).getAsJsonObject(); type = args.get("type").getAsString(); break; default: throw new IOException("Unexpected token: " + reader.peek()); } switch (type) { case "linear": return new LinearInterpolator(); case "cubic-spline": return new CubicSplineInterpolator(); case "catmull-rom-spline": if (args == null || !args.has("alpha")) { throw new IOException("Missing alpha value for catmull-rom-spline."); } return new CatmullRomSplineInterpolator(args.get("alpha").getAsDouble()); default: throw new IOException("Unknown interpolation type: " + type); } } /** * Clones an interpolator by de- and re-serializing it. * @param interpolator The interpolator to clone * @return The cloned interpolator */ private Interpolator cloneInterpolator(Interpolator interpolator) { Interpolator cloned = deserializeInterpolator(serializeInterpolator(interpolator)); interpolator.getKeyframeProperties().forEach(cloned::registerProperty); return cloned; } /** * Serializes the specific interpolator to String. * Does not serialize the registered keyframe properties. * @param interpolator The interpolator to serialize. * @return The serialized interpolator */ private String serializeInterpolator(Interpolator interpolator) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); JsonWriter jsonWriter = new JsonWriter(new PrintWriter(baos)); try { jsonWriter.beginArray(); serializeInterpolator(jsonWriter, interpolator); jsonWriter.endArray(); jsonWriter.flush(); } catch (IOException e) { CrashReport crash = CrashReport.makeCrashReport(e, "Serializing interpolator"); CrashReportCategory category = crash.makeCategory("Serializing interpolator"); category.setDetail("Interpolator", interpolator::toString); throw new ReportedException(crash); } return baos.toString(); } private Interpolator deserializeInterpolator(String json) { JsonReader jsonReader = new JsonReader(new StringReader(json)); try { jsonReader.beginArray(); return deserializeInterpolator(jsonReader); } catch (IOException e) { CrashReport crash = CrashReport.makeCrashReport(e, "De-serializing interpolator"); CrashReportCategory category = crash.makeCategory("De-serializing interpolator"); category.addCrashSection("Interpolator", json); throw new ReportedException(crash); } } }