From eb5ccb2fd5c26bc51d83d35a6462054a83c86f9c Mon Sep 17 00:00:00 2001 From: johni0702 Date: Mon, 26 Dec 2016 20:35:50 +0100 Subject: [PATCH] Add Replay Editor (for now, trimming only) --- .../java/com/replaymod/core/utils/Utils.java | 104 ++++- .../com/replaymod/editor/ReplayModEditor.java | 38 ++ .../replaymod/editor/gui/GuiReplayEditor.java | 360 ++++++++++++++++++ .../replaymod/editor/gui/GuiTrimPanel.java | 198 ++++++++++ .../replaymod/editor/handler/GuiHandler.java | 54 +++ .../assets/replaymod/lang/en_US.lang | 2 + src/main/resources/mcmod.info | 17 + 7 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/replaymod/editor/ReplayModEditor.java create mode 100644 src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java create mode 100644 src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java create mode 100644 src/main/java/com/replaymod/editor/handler/GuiHandler.java diff --git a/src/main/java/com/replaymod/core/utils/Utils.java b/src/main/java/com/replaymod/core/utils/Utils.java index 3a088a83..2457ae15 100644 --- a/src/main/java/com/replaymod/core/utils/Utils.java +++ b/src/main/java/com/replaymod/core/utils/Utils.java @@ -1,17 +1,41 @@ package com.replaymod.core.utils; -import net.minecraft.client.Minecraft; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import de.johni0702.minecraft.gui.GuiRenderer; +import de.johni0702.minecraft.gui.RenderInfo; +import de.johni0702.minecraft.gui.container.AbstractGuiScrollable; +import de.johni0702.minecraft.gui.container.GuiContainer; +import de.johni0702.minecraft.gui.container.GuiPanel; +import de.johni0702.minecraft.gui.container.GuiScrollable; +import de.johni0702.minecraft.gui.element.GuiButton; +import de.johni0702.minecraft.gui.element.GuiElement; +import de.johni0702.minecraft.gui.element.GuiLabel; +import de.johni0702.minecraft.gui.layout.HorizontalLayout; +import de.johni0702.minecraft.gui.layout.VerticalLayout; +import de.johni0702.minecraft.gui.popup.GuiInfoPopup; +import de.johni0702.minecraft.gui.utils.Colors; +import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.network.NetworkPlayerInfo; import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.crash.CrashReport; import net.minecraft.util.ResourceLocation; +import org.apache.logging.log4j.Logger; import org.lwjgl.input.Keyboard; import org.lwjgl.util.Dimension; import org.lwjgl.util.ReadableDimension; +import javax.annotation.Nullable; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; import java.util.UUID; +import static net.minecraft.client.Minecraft.getMinecraft; + public class Utils { public static final BufferedImage DEFAULT_THUMBNAIL; @@ -63,7 +87,7 @@ public class Utils { } public static ResourceLocation getResourceLocationForPlayerUUID(UUID uuid) { - NetworkPlayerInfo info = Minecraft.getMinecraft().getNetHandler().getPlayerInfo(uuid); + NetworkPlayerInfo info = getMinecraft().getNetHandler().getPlayerInfo(uuid); ResourceLocation skinLocation; if (info != null && info.hasLocationSkin()) { skinLocation = info.getLocationSkin(); @@ -76,4 +100,80 @@ public class Utils { public static boolean isCtrlDown() { return Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) || Keyboard.isKeyDown(Keyboard.KEY_RCONTROL); } + + public static GuiInfoPopup error(Logger logger, GuiContainer container, CrashReport crashReport, Runnable onClose) { + // Convert crash report to string + String crashReportStr = crashReport.getCompleteReport(); + + // Log via logger + logger.error(crashReportStr); + + // Try to save the crash report + if (crashReport.getFile() == null) { + try { + File folder = new File(getMinecraft().mcDataDir, "crash-reports"); + File file = new File(folder, "crash-" + (new SimpleDateFormat("yyyy-MM-dd_HH.mm.ss")).format(new Date()) + "-client.txt"); + logger.debug("Saving crash report to file: {}", file); + crashReport.saveToFile(file); + } catch (Throwable t) { + logger.error("Saving crash report file:", t); + } + } else { + logger.debug("Not saving crash report as file already exists: {}", crashReport.getFile()); + } + + logger.trace("Opening crash report popup GUI"); + GuiCrashReportPopup popup = new GuiCrashReportPopup(container, crashReportStr); + Futures.addCallback(popup.getFuture(), new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + logger.trace("Crash report popup closed"); + if (onClose != null) { + onClose.run(); + } + } + + @Override + public void onFailure(Throwable t) { + logger.error("During error popup:", t); + } + }); + return popup; + } + + private static class GuiCrashReportPopup extends GuiInfoPopup { + private final GuiScrollable scrollable; + + public GuiCrashReportPopup(GuiContainer container, String crashReport) { + super(container); + setBackgroundColor(Colors.DARK_TRANSPARENT); + + // Add crash report to scrollable info + getInfo().addElements(new VerticalLayout.Data(0.5), + new GuiLabel().setColor(Colors.BLACK).setI18nText("replaymod.gui.unknownerror"), + scrollable = new GuiScrollable().setScrollDirection(AbstractGuiScrollable.Direction.VERTICAL) + .setLayout(new VerticalLayout().setSpacing(2)) + .addElements(null,Arrays.stream(crashReport.replace("\t", " ").split("\n")).map( + l -> new GuiLabel().setText(l).setColor(Colors.BLACK)).toArray(GuiElement[]::new))); + + // Replace close button with panel containing close and copy buttons + GuiButton copyToClipboardButton = new GuiButton().setI18nLabel("chat.copy").onClick(() -> + GuiScreen.setClipboardString(crashReport)).setSize(150, 20); + GuiButton closeButton = getCloseButton(); + popup.removeElement(closeButton); + popup.addElements(new VerticalLayout.Data(1), + new GuiPanel().setLayout(new HorizontalLayout().setSpacing(5)).setSize(305, 20) + .addElements(null, copyToClipboardButton, closeButton)); + + // And open it + open(); + } + + @Override + public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { + // Re-size the scrollable containing the crash report to 3/4 of the screen + scrollable.setSize(size.getWidth() * 3 / 4, size.getHeight() * 3 / 4); + super.draw(renderer, size, renderInfo); + } + } } diff --git a/src/main/java/com/replaymod/editor/ReplayModEditor.java b/src/main/java/com/replaymod/editor/ReplayModEditor.java new file mode 100644 index 00000000..a0d65471 --- /dev/null +++ b/src/main/java/com/replaymod/editor/ReplayModEditor.java @@ -0,0 +1,38 @@ +package com.replaymod.editor; + +import com.replaymod.core.ReplayMod; +import com.replaymod.editor.handler.GuiHandler; +import com.replaymod.online.Setting; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; +import org.apache.logging.log4j.Logger; + +@Mod(modid = ReplayModEditor.MOD_ID, useMetadata = true) +public class ReplayModEditor { + public static final String MOD_ID = "replaymod-editor"; + + @Mod.Instance(MOD_ID) + public static ReplayModEditor instance; + + @Mod.Instance(ReplayMod.MOD_ID) + private static ReplayMod core; + + public static Logger LOGGER; + + @Mod.EventHandler + public void preInit(FMLPreInitializationEvent event) { + ReplayModEditor.LOGGER = event.getModLog(); + + core.getSettingsRegistry().register(Setting.class); + } + + @Mod.EventHandler + public void init(FMLInitializationEvent event) { + new GuiHandler(this).register(); + } + + public ReplayMod getCore() { + return core; + } +} diff --git a/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java b/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java new file mode 100644 index 00000000..98ef0760 --- /dev/null +++ b/src/main/java/com/replaymod/editor/gui/GuiReplayEditor.java @@ -0,0 +1,360 @@ +package com.replaymod.editor.gui; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.gson.JsonObject; +import com.replaymod.core.ReplayMod; +import com.replaymod.core.utils.Utils; +import com.replaymod.replaystudio.PacketData; +import com.replaymod.replaystudio.Studio; +import com.replaymod.replaystudio.filter.StreamFilter; +import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ReplayMetaData; +import com.replaymod.replaystudio.replay.ZipReplayFile; +import com.replaymod.replaystudio.stream.PacketStream; +import com.replaymod.replaystudio.studio.ReplayStudio; +import de.johni0702.minecraft.gui.GuiRenderer; +import de.johni0702.minecraft.gui.RenderInfo; +import de.johni0702.minecraft.gui.container.GuiContainer; +import de.johni0702.minecraft.gui.container.GuiPanel; +import de.johni0702.minecraft.gui.container.GuiScreen; +import de.johni0702.minecraft.gui.element.GuiButton; +import de.johni0702.minecraft.gui.element.GuiLabel; +import de.johni0702.minecraft.gui.element.GuiTextField; +import de.johni0702.minecraft.gui.element.GuiTooltip; +import de.johni0702.minecraft.gui.element.advanced.GuiProgressBar; +import de.johni0702.minecraft.gui.layout.CustomLayout; +import de.johni0702.minecraft.gui.layout.GridLayout; +import de.johni0702.minecraft.gui.layout.HorizontalLayout; +import de.johni0702.minecraft.gui.layout.VerticalLayout; +import de.johni0702.minecraft.gui.popup.AbstractGuiPopup; +import de.johni0702.minecraft.gui.popup.GuiInfoPopup; +import de.johni0702.minecraft.gui.popup.GuiYesNoPopup; +import de.johni0702.minecraft.gui.utils.Colors; +import net.minecraft.crash.CrashReport; +import org.apache.commons.io.FilenameUtils; +import org.lwjgl.util.ReadableDimension; + +import javax.annotation.Nullable; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static com.replaymod.editor.ReplayModEditor.LOGGER; + +public class GuiReplayEditor extends GuiScreen { + private final ReplayMod mod; + + private volatile ProcessingStage processingStage; + private volatile double progress; + private GuiProcessingProgressPopup popup; + + public GuiButton currentTabButton; + public GuiPanel currentTabPanel; + + public final GuiPanel tabButtons = new GuiPanel(this).setLayout(new GridLayout().setSpacingX(5)); + public final List tabPanels = new ArrayList<>(); + + public final GuiButton saveButton = new GuiButton().setI18nLabel("replaymod.gui.save").setSize(100, 20); + public final GuiButton backButton = new GuiButton().setI18nLabel("replaymod.gui.back").setSize(100, 20); + + public final GuiPanel buttonPanel = new GuiPanel(this).setLayout(new HorizontalLayout().setSpacing(5)) + .addElements(null, saveButton, backButton); + + public GuiReplayEditor(GuiScreen parent, ReplayMod mod) { + this.mod = mod; + backButton.onClick(parent::display); + + setTitle(new GuiLabel().setI18nText("replaymod.gui.replayeditor")); + + makeTab("trim", () -> new GuiTrimPanel(this)); + makeTab("connect", GuiPanel::new).setDisabled(); // Not yet implemented + makeTab("modify", GuiPanel::new).setDisabled(); // Not yet implemented + + setLayout(new CustomLayout() { + @Override + protected void layout(GuiScreen container, int width, int height) { + // Move all inactive panels aside + tabPanels.forEach(e -> pos(e, Integer.MIN_VALUE, Integer.MIN_VALUE)); + + pos(buttonPanel, width - 10 - width(buttonPanel), height - 10 - height(buttonPanel)); + + if (currentTabPanel != null) { + pos(currentTabPanel, 10, 50); + size(currentTabPanel, width - 20, y(buttonPanel) - 10 - y(currentTabPanel)); + } + + pos(tabButtons, 10, 20); + size(tabButtons, width - 20, 20); + } + }); + } + + public GuiButton makeTab(String name, Supplier panel) { + GuiButton button = new GuiButton().setI18nLabel("replaymod.gui.editor." + name + ".title") + .setTooltip(new GuiTooltip().setI18nText("replaymod.gui.editor." + name + ".description")) + .setSize(Integer.MAX_VALUE, Integer.MAX_VALUE); + button.onClick(() -> { + // Remove previous tab + if (currentTabButton != null) { + currentTabButton.setEnabled(); + removeElement(currentTabPanel); + } + + // Activate new tab + currentTabButton = button.setDisabled(); + addElements(null, currentTabPanel = panel.get()); + }); + // Add new tab button to tabs panel + tabButtons.addElements(null, button); + ((GridLayout) tabButtons.getLayout()).setColumns(tabButtons.getChildren().size()); + return button; + } + + public void save(File inputFile, PacketStream.FilterInfo...filters) { + save(FilenameUtils.getBaseName(inputFile.getName()), (outputFile) -> { + Studio studio = new ReplayStudio(); + try (ReplayFile outputReplay = new ZipReplayFile(studio, inputFile, outputFile); + ReplayOutputStream out = outputReplay.writePacketData()) { + // The input replay file MUST be closed before saving the output file + try (ReplayFile inputReplay = new ZipReplayFile(studio, inputFile); + ReplayInputStream in = inputReplay.getPacketData()) { + ReplayMetaData metaData = inputReplay.getMetaData(); + PacketStream stream = studio.createReplayStream(in, true); + + stream.addFilter(new ProgressFilter(metaData.getDuration())); + for (PacketStream.FilterInfo info : filters) { + stream.addFilter(info.getFilter(), info.getFrom(), info.getTo()); + LOGGER.debug("Added filter {}", info); + } + + LOGGER.info("Built pipeline: {}", stream); + stream.start(); + + long lastTimestamp = 0; + + PacketData data; + while ((data = stream.next()) != null) { + out.write(data); + lastTimestamp = data.getTime(); + } + + for (PacketData d : stream.end()) { + out.write(d); + lastTimestamp = d.getTime(); + } + + updateProgress(ProcessingStage.FINALIZING, 0); + + // Update duration of new replay + metaData.setDuration((int) lastTimestamp); + outputReplay.writeMetaData(metaData); + } + out.close(); + outputReplay.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + public void save(String sourceName, Consumer runnable) { + GuiTextField nameField = new GuiTextField().setSize(200, 20).setFocused(true).setText(sourceName); + GuiYesNoPopup popup = GuiYesNoPopup.open(this, + nameField + ).setYesI18nLabel("replaymod.gui.editor.savemode.override").setNoI18nLabel("replaymod.gui.cancel"); + nameField.onEnter(() -> { + if (popup.getYesButton().isEnabled()) { + popup.getYesButton().onClick(); + } + }).onTextChanged(obj -> { + popup.getYesButton().setEnabled(!nameField.getText().isEmpty()); + popup.setYesI18nLabel("replaymod.gui.editor.savemode." + + (nameField.getText().equals(sourceName) ? "override" : "newfile")); + }); + Futures.addCallback(popup.getFuture(), new FutureCallback() { + @Override + public void onSuccess(@Nullable Boolean result) { + if (result == Boolean.TRUE) { + // Sanitize their input + String name = nameField.getText().trim().replace("[^a-zA-Z0-9\\.\\- ]", "_"); + // This file is what they want + File replayFolder; + try { + replayFolder = mod.getReplayFolder(); + } catch (IOException e) { + Utils.error(LOGGER, GuiReplayEditor.this, CrashReport.makeCrashReport(e, "Getting replay folder"), null); + return; + } + File targetFile = new File(replayFolder, name + ".mcpr"); + if (targetFile.exists()) { + LOGGER.trace("Selected file already exists, asking for confirmation"); + Futures.addCallback(GuiYesNoPopup.open(GuiReplayEditor.this, + new GuiLabel().setColor(Colors.BLACK) + .setI18nText("replaymod.gui.replaymodified.warning1", name), + new GuiLabel().setColor(Colors.BLACK) + .setI18nText("replaymod.gui.replaymodified.warning2")) + .setYesI18nLabel("gui.yes").setNoI18nLabel("gui.no") + .getFuture(), new FutureCallback() { + @Override + public void onSuccess(@Nullable Boolean result) { + if (result == Boolean.TRUE) { + LOGGER.trace("Saving output in {}", targetFile); + runEdit(() -> runnable.accept(targetFile)); + } else { + LOGGER.trace("Not overwriting file {}, reopening naming dialog", targetFile); + save(sourceName, runnable); + } + } + + @Override + public void onFailure(Throwable t) { + LOGGER.error("During save file confirmation dialog:", t); + } + }); + } else { + LOGGER.trace("Saving output in {}", targetFile); + runEdit(() -> runnable.accept(targetFile)); + } + } else { + LOGGER.trace("Saving cancelled"); + } + } + + @Override + public void onFailure(Throwable t) { + LOGGER.error("During save file dialog:", t); + } + }); + } + + public void runEdit(Runnable runnable) { + popup = new GuiProcessingProgressPopup(this); + processingStage = ProcessingStage.INITIALIZING; + progress = 0; + new Thread(() -> { + LOGGER.info("Starting editing of replay"); + try { + runnable.run(); + } catch (Throwable t) { + CrashReport crashReport = CrashReport.makeCrashReport(t, "Editing replay file"); + mod.runLater(() -> Utils.error(LOGGER, this, crashReport, popup::close)); + return; + } + LOGGER.info("Successfully finished editing"); + mod.runLater(() -> { + popup.close(); + GuiInfoPopup.open(this, "replaymod.gui.editor.progress.status.finished"); + }); + }).start(); + } + + public void updateProgress(ProcessingStage stage, double progress) { + if (this.processingStage != stage) { + LOGGER.trace("Changing state from {} to {}", this.processingStage, stage); + } + this.processingStage = stage; + this.progress = progress; + } + + public ReplayMod getMod() { + return mod; + } + + public enum ProcessingStage { + INITIALIZING("initializing", 0.01), + WORKING("writing.raw", 0.89), + FINALIZING("writing.final", 0.1), + DONE("finished", 0); + + private final String i18nKey; + private final double progressFraction; + + ProcessingStage(String i18nKey, double progressFraction) { + this.i18nKey = "replaymod.gui.editor.progress.status." + i18nKey; + this.progressFraction = progressFraction; + } + + public double getOverallProgress(double progress) { + progress *= progressFraction; + for (ProcessingStage processingStage : values()) { + if (processingStage == this) break; + progress += processingStage.progressFraction; + } + return progress; + } + } + + private class ProgressFilter implements StreamFilter { + private final long total; + + public ProgressFilter(long total) { + this.total = total; + } + + @Override + public String getName() { + return "progress"; + } + + @Override + public void init(Studio studio, JsonObject config) { + + } + + @Override + public void onStart(PacketStream stream) { + updateProgress(ProcessingStage.WORKING, 0); + } + + @Override + public boolean onPacket(PacketStream stream, PacketData data) { + updateProgress(ProcessingStage.WORKING, (double) data.getTime() / total); + return true; + } + + @Override + public void onEnd(PacketStream stream, long timestamp) {} + } + + private class GuiProcessingProgressPopup extends AbstractGuiPopup { + private final GuiProgressBar progressBar = new GuiProgressBar().setSize(300, 20); + + { + popup.setLayout(new VerticalLayout().setSpacing(10)); + popup.addElements(new VerticalLayout.Data(0.5), + new GuiLabel().setI18nText("replaymod.gui.editor.progress.title").setColor(Colors.BLACK), + new GuiLabel().setI18nText("replaymod.gui.editor.progress.pleasewait").setColor(Colors.BLACK), + progressBar); + } + + public GuiProcessingProgressPopup(GuiContainer container) { + super(container); + open(); + } + + @Override + public void draw(GuiRenderer renderer, ReadableDimension size, RenderInfo renderInfo) { + // Update progress bar + progressBar.setI18nLabel(processingStage.i18nKey); + progressBar.setProgress((float) processingStage.getOverallProgress(progress)); + + super.draw(renderer, size, renderInfo); + } + + @Override + public void close() { + super.close(); + } + + @Override + protected GuiProcessingProgressPopup getThis() { + return this; + } + } +} diff --git a/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java b/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java new file mode 100644 index 00000000..ae5fd79e --- /dev/null +++ b/src/main/java/com/replaymod/editor/gui/GuiTrimPanel.java @@ -0,0 +1,198 @@ +package com.replaymod.editor.gui; + +import com.google.gson.JsonObject; +import com.replaymod.core.utils.Utils; +import com.replaymod.replaystudio.data.Marker; +import com.replaymod.replaystudio.filter.ChangeTimestampFilter; +import com.replaymod.replaystudio.filter.RemoveFilter; +import com.replaymod.replaystudio.filter.SquashFilter; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.replay.ReplayMetaData; +import com.replaymod.replaystudio.replay.ZipReplayFile; +import com.replaymod.replaystudio.stream.PacketStream; +import com.replaymod.replaystudio.studio.ReplayStudio; +import de.johni0702.minecraft.gui.container.GuiPanel; +import de.johni0702.minecraft.gui.element.GuiLabel; +import de.johni0702.minecraft.gui.element.GuiNumberField; +import de.johni0702.minecraft.gui.element.advanced.GuiDropdownMenu; +import de.johni0702.minecraft.gui.layout.GridLayout; +import de.johni0702.minecraft.gui.layout.HorizontalLayout; +import de.johni0702.minecraft.gui.layout.VerticalLayout; +import net.minecraft.client.resources.I18n; +import net.minecraft.crash.CrashReport; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOCase; +import org.apache.commons.io.filefilter.SuffixFileFilter; +import org.lwjgl.util.Dimension; + +import java.io.File; +import java.io.FileFilter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import static com.replaymod.editor.ReplayModEditor.LOGGER; +import static java.util.Optional.ofNullable; + +public class GuiTrimPanel extends GuiPanel { + // Special value indicating no replay files were found + private static final File NO_REPLAY = new File("."); + // Special value for the initial "Select Marker Keyframe" entry + private static final Marker NO_MARKER = new Marker(); static {NO_MARKER.setTime(-1);} + + private final GuiReplayEditor gui; + + public final GuiDropdownMenu inputReplays = new GuiDropdownMenu(this) + .setMinSize(new Dimension(200, 20)).onSelection(i -> updateSelectedReplay()) + .setToString(f -> f == NO_REPLAY ? "" : FilenameUtils.getBaseName(f.getName())); + + public final GuiNumberField startHour = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField startMin = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField startSec = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField startMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4); + public final GuiDropdownMenu startMarker = new GuiDropdownMenu<>(); + + public final GuiNumberField endHour = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField endMin = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField endSec = new GuiNumberField().setSize(20, 20).setMaxLength(2); + public final GuiNumberField endMilli = new GuiNumberField().setSize(40, 20).setMaxLength(4); + public final GuiDropdownMenu endMarker = new GuiDropdownMenu<>(); + + public final GuiPanel timePanel = new GuiPanel(this) + .setLayout(new GridLayout().setCellsEqualSize(false).setSpacingX(20).setSpacingY(5).setColumns(3)) + .addElements(new GridLayout.Data(0, 0.5), + new GuiLabel().setI18nText("replaymod.gui.start"), + new GuiPanel().setLayout(new HorizontalLayout().setSpacing(2)).addElements( + new HorizontalLayout.Data(0.5), + startHour, new GuiLabel().setI18nText("replaymod.gui.hours"), + startMin, new GuiLabel().setI18nText("replaymod.gui.minutes"), + startSec, new GuiLabel().setI18nText("replaymod.gui.seconds"), + startMilli, new GuiLabel().setI18nText("replaymod.gui.milliseconds") + ), startMarker, + new GuiLabel().setI18nText("replaymod.gui.end"), + new GuiPanel().setLayout(new HorizontalLayout().setSpacing(2)).addElements( + new HorizontalLayout.Data(0.5), + endHour, new GuiLabel().setI18nText("replaymod.gui.hours"), + endMin, new GuiLabel().setI18nText("replaymod.gui.minutes"), + endSec, new GuiLabel().setI18nText("replaymod.gui.seconds"), + endMilli, new GuiLabel().setI18nText("replaymod.gui.milliseconds") + ), endMarker); + + public GuiTrimPanel(GuiReplayEditor gui) { + this.gui = gui; + + setLayout(new VerticalLayout().setSpacing(10)); + + Function toString = m -> { + if (m == NO_MARKER) { + return I18n.format("replaymod.gui.editor.trim.marker"); + } else { + return ofNullable(m.getName()).orElse(I18n.format("replaymod.gui.ingame.unnamedmarker")) + + " (" + Utils.convertSecondsToShortString(m.getTime() / 1000) + ")"; + } + }; + startMarker.setToString(toString).setMinSize(new Dimension(100, 20)).onSelection(i -> + onSelectedMarkerChanged(startMarker, startHour, startMin, startSec, startMilli)); + endMarker.setToString(toString).setMinSize(new Dimension(100, 20)).onSelection(i -> + onSelectedMarkerChanged(endMarker, endHour, endMin, endSec, endMilli)); + + File[] files = null; + try { + File folder = gui.getMod().getReplayFolder(); + files = folder.listFiles((FileFilter) new SuffixFileFilter(".mcpr", IOCase.INSENSITIVE)); + } catch (IOException e) { + Utils.error(LOGGER, gui, CrashReport.makeCrashReport(e, "Listing available replays"), gui.backButton::onClick); + } + if (files == null) { + LOGGER.warn("Replay file list is null, has the replay folder been deleted?"); + files = new File[0]; + } else { + LOGGER.debug("Found {} replays in the replay folder", files.length); + } + if (files.length == 0) { + inputReplays.setValues(NO_REPLAY); + } else { + inputReplays.setValues(files); + } + + updateSelectedReplay(); + updateReadyState(); + + gui.saveButton.onClick(() -> { + // Read start and end times + int start = ((startHour.getInteger() * 60 + startMin.getInteger()) * 60 + startSec.getInteger()) * 1000 + startMilli.getInteger(); + int end = ((endHour.getInteger() * 60 + endMin.getInteger()) * 60 + endSec.getInteger()) * 1000 + endMilli.getInteger(); + // Configure offset of change timestamp filter + ChangeTimestampFilter ctf = new ChangeTimestampFilter(); + JsonObject config = new JsonObject(); + config.addProperty("offset", -start); + ctf.init(null, config); + // Pass filters to save dialog + gui.save(inputReplays.getSelectedValue(), + new PacketStream.FilterInfo(new SquashFilter(), -1, start), + new PacketStream.FilterInfo(ctf, start, end), + new PacketStream.FilterInfo(new RemoveFilter(), end, -1) + ); + }); + } + + private void onSelectedMarkerChanged(GuiDropdownMenu dropdown, GuiNumberField hour, GuiNumberField min, + GuiNumberField sec, GuiNumberField milli) { + Marker marker = dropdown.getSelectedValue(); + if (marker != NO_MARKER) { + int time = marker.getTime(); + setTime(time, hour, min, sec, milli); + } + } + + private void setTime(int time, GuiNumberField hour, GuiNumberField min, + GuiNumberField sec, GuiNumberField milli) { + milli.setValue(time % 1000); time /= 1000; + sec.setValue(time % 60); time /= 60; + min.setValue(time % 60); time /= 60; + hour.setValue(time); + } + + private void updateSelectedReplay() { + File file = inputReplays.getSelectedValue(); + // Load markers and meta data from replay file + ReplayMetaData metaData; + Set markers; + if (file == NO_REPLAY) { + metaData = new ReplayMetaData(); + markers = Collections.emptySet(); + } else { + try (ReplayFile replayFile = new ZipReplayFile(new ReplayStudio(), file)) { + metaData = replayFile.getMetaData(); + markers = replayFile.getMarkers().or(Collections.emptySet()); + LOGGER.debug("Loaded {} markers from replay {}", markers.size(), file); + } catch (Exception e) { + Utils.error(LOGGER, gui, CrashReport.makeCrashReport(e, "Reading markers from replay"), null); + metaData = new ReplayMetaData(); + markers = Collections.emptySet(); + } + } + + // Sort and add initial "Select Event Marker" element + List markerList = new ArrayList<>(markers); + markerList.sort(Comparator.comparing(Marker::getTime)); + markerList.add(0, NO_MARKER); + + // Set marker dropdown values + Marker[] markerArray = markerList.stream().toArray(Marker[]::new); + startMarker.setValues(markerArray).setSelected(0); + endMarker.setValues(markerArray).setSelected(0); + + // Set start and end time values + setTime(0, startHour, startMin, startSec, startMilli); + setTime(metaData.getDuration(), endHour, endMin, endSec, endMilli); + } + + private void updateReadyState() { + gui.saveButton.setEnabled(inputReplays.getSelectedValue() != NO_REPLAY); + } +} diff --git a/src/main/java/com/replaymod/editor/handler/GuiHandler.java b/src/main/java/com/replaymod/editor/handler/GuiHandler.java new file mode 100644 index 00000000..7fdb59e1 --- /dev/null +++ b/src/main/java/com/replaymod/editor/handler/GuiHandler.java @@ -0,0 +1,54 @@ +package com.replaymod.editor.handler; + +import com.replaymod.editor.ReplayModEditor; +import com.replaymod.editor.gui.GuiReplayEditor; +import de.johni0702.minecraft.gui.container.GuiScreen; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +import java.util.List; + +public class GuiHandler { + private static final int BUTTON_REPLAY_EDITOR = 17890237; + + private final ReplayModEditor mod; + + public GuiHandler(ReplayModEditor mod) { + this.mod = mod; + } + + public void register() { + FMLCommonHandler.instance().bus().register(this); + MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void injectIntoMainMenu(GuiScreenEvent.InitGuiEvent event) { + if (!(event.gui instanceof GuiMainMenu)) { + return; + } + + @SuppressWarnings("unchecked") + List buttonList = event.buttonList; + GuiButton button = new GuiButton(BUTTON_REPLAY_EDITOR, event.gui.width / 2 + 2, + event.gui.height / 4 + 10 + 3 * 24, I18n.format("replaymod.gui.replayeditor")); + button.width = button.width / 2 - 2; + buttonList.add(button); + } + + @SubscribeEvent + public void onButton(GuiScreenEvent.ActionPerformedEvent.Pre event) { + if(!event.button.enabled) return; + + if (event.gui instanceof GuiMainMenu) { + if (event.button.id == BUTTON_REPLAY_EDITOR) { + new GuiReplayEditor(GuiScreen.wrap(event.gui), mod.getCore()).display(); + } + } + } +} diff --git a/src/main/resources/assets/replaymod/lang/en_US.lang b/src/main/resources/assets/replaymod/lang/en_US.lang index 772db299..8f1b8c44 100644 --- a/src/main/resources/assets/replaymod/lang/en_US.lang +++ b/src/main/resources/assets/replaymod/lang/en_US.lang @@ -255,6 +255,8 @@ replaymod.gui.editor.trim.description=Removes the beginning and end of a Replay replaymod.gui.editor.connect.description=Connects multiple Replays in the specified order replaymod.gui.editor.modify.description=Provides several filters to modify Replay Files +replaymod.gui.editor.trim.marker=Select Event Marker + replaymod.gui.editor.progress.title=Editing Replay File... replaymod.gui.editor.progress.pleasewait=Please wait while the Replay is being edited. diff --git a/src/main/resources/mcmod.info b/src/main/resources/mcmod.info index f6ac199a..065f181d 100755 --- a/src/main/resources/mcmod.info +++ b/src/main/resources/mcmod.info @@ -100,6 +100,23 @@ "screenshots": [], "dependencies": ["required-after:replaymod-replay"] }, + { + "modid": "replaymod-editor", + "name": "Replay Mod - Replay Editor", + "description": "Replay Editor Module of the ReplayMod", + "version": "${version}", + "mcversion": "${mcversion}", + "url": "https://replaymod.com", + "updateUrl": "https://replaymod.com/download", + "authorList": [ + "CrushedPixel", + "johni0702" + ], + "logoFile": "replaymod_logo.png", + "parent": "replaymod", + "screenshots": [], + "dependencies": [] + }, { "modid": "replaymod-extras", "name": "Replay Mod - Extras",