diff --git a/build.gradle.kts b/build.gradle.kts index a9d35c2c1..fa16018c2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,11 @@ dependencies { api("com.formdev", "flatlaf-extras", "3.0") api("org.jgrapht", "jgrapht-core", "1.3.0") + //contains JNI libraries + //api("org.lz4:lz4-java:1.8.0") + api("org.lz4:lz4-pure-java:1.8.0") + api("org.jcodec:jcodec:0.2.5") + compileOnly("org.jetbrains", "annotations", "23.0.0") testImplementation("org.junit.jupiter:junit-jupiter:5.9.0") diff --git a/src/main/java/com/github/manolo8/darkbot/config/Config.java b/src/main/java/com/github/manolo8/darkbot/config/Config.java index f0ba5ba10..a77145ba6 100644 --- a/src/main/java/com/github/manolo8/darkbot/config/Config.java +++ b/src/main/java/com/github/manolo8/darkbot/config/Config.java @@ -287,6 +287,7 @@ public static class Other { public @Option boolean DISABLE_MASTER_PASSWORD = false; public @Option @Number(min = 10, max = 300) int ZONE_RESOLUTION = 30; public @Option @Visibility(Level.DEVELOPER) @Number(min = 10, max = 250) int MIN_TICK = 15; + public @Option @Visibility(Level.ADVANCED) boolean RECORD_DEATH = false; public @Option @Visibility(Level.ADVANCED) boolean DEV_STUFF = false; } } diff --git a/src/main/java/com/github/manolo8/darkbot/core/manager/RepairManager.java b/src/main/java/com/github/manolo8/darkbot/core/manager/RepairManager.java index 0a9eb1ba0..ab0493e7d 100644 --- a/src/main/java/com/github/manolo8/darkbot/core/manager/RepairManager.java +++ b/src/main/java/com/github/manolo8/darkbot/core/manager/RepairManager.java @@ -122,6 +122,7 @@ public void tick() { } if (!destroyed) { + main.getGui().onDeath(); shouldInstantRepair = true; destroyed = true; deaths++; diff --git a/src/main/java/com/github/manolo8/darkbot/gui/MainGui.java b/src/main/java/com/github/manolo8/darkbot/gui/MainGui.java index 706c9c642..5fcffbeed 100644 --- a/src/main/java/com/github/manolo8/darkbot/gui/MainGui.java +++ b/src/main/java/com/github/manolo8/darkbot/gui/MainGui.java @@ -6,13 +6,17 @@ import com.github.manolo8.darkbot.core.api.GameAPI; import com.github.manolo8.darkbot.gui.components.ExitConfirmation; import com.github.manolo8.darkbot.gui.titlebar.MainTitleBar; +import com.github.manolo8.darkbot.gui.utils.DeathRecorder; import com.github.manolo8.darkbot.gui.utils.UIUtils; import com.github.manolo8.darkbot.gui.utils.window.WindowUtils; import eu.darkbot.api.config.ConfigSetting; import org.jetbrains.annotations.Nullable; -import javax.swing.*; -import java.awt.*; +import javax.swing.JFrame; +import javax.swing.ToolTipManager; +import java.awt.BorderLayout; +import java.awt.HeadlessException; +import java.awt.Image; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.WindowEvent; @@ -33,6 +37,9 @@ public class MainGui extends JFrame { public static final int DEFAULT_WIDTH = 640, DEFAULT_HEIGHT = 480; private int lastTick; + private final Consumer deathRecorderListener; + private DeathRecorder deathRecorder; + public MainGui(Main main) throws HeadlessException { super("DarkBot"); getRootPane().putClientProperty(FlatClientProperties.TITLE_BAR_SHOW_ICON, false); @@ -79,6 +86,16 @@ public void componentMoved(ComponentEvent e) { } main.configManager.saveConfig(); })); + + ConfigSetting recordDeath = main.configHandler.requireConfig("bot_settings.other.record_death"); + recordDeath.addListener(deathRecorderListener = (value -> { + if (value && deathRecorder == null) deathRecorder = new DeathRecorder(this); + else if (!value && deathRecorder != null) deathRecorder = null; + })); + + if (recordDeath.getValue()) { + this.deathRecorder = new DeathRecorder(this); + } } private void setComponentPosition() { @@ -130,6 +147,20 @@ public void tick() { if ((lastTick++ % main.config.BOT_SETTINGS.MAP_DISPLAY.REFRESH_DELAY) == 0) { mapDrawer.repaint(); } + + // prevent race condition + DeathRecorder recorder = deathRecorder; + if (recorder != null) { + recorder.onTick(); + } + } + + public void onDeath() { + // prevent race condition + DeathRecorder recorder = deathRecorder; + if (recorder != null) { + recorder.onDeath(); + } } @Override diff --git a/src/main/java/com/github/manolo8/darkbot/gui/MapDrawer.java b/src/main/java/com/github/manolo8/darkbot/gui/MapDrawer.java index ddb9f0534..212235b37 100644 --- a/src/main/java/com/github/manolo8/darkbot/gui/MapDrawer.java +++ b/src/main/java/com/github/manolo8/darkbot/gui/MapDrawer.java @@ -103,7 +103,9 @@ public void setup(Main main) { } protected void onPaint() { - drawBackgroundImage(); + if (!isPaintingForPrint()) { + drawBackgroundImage(); + } for (Drawable drawable : drawableHandler.getDrawables()) { drawable.onDraw(mapGraphics); diff --git a/src/main/java/com/github/manolo8/darkbot/gui/utils/DeathRecorder.java b/src/main/java/com/github/manolo8/darkbot/gui/utils/DeathRecorder.java new file mode 100644 index 000000000..c7da6b28a --- /dev/null +++ b/src/main/java/com/github/manolo8/darkbot/gui/utils/DeathRecorder.java @@ -0,0 +1,163 @@ +package com.github.manolo8.darkbot.gui.utils; + +import com.github.manolo8.darkbot.utils.LogUtils; +import eu.darkbot.util.Timer; +import net.jpountz.lz4.LZ4Compressor; +import net.jpountz.lz4.LZ4Exception; +import net.jpountz.lz4.LZ4Factory; +import net.jpountz.lz4.LZ4FastDecompressor; +import org.jcodec.api.SequenceEncoder; +import org.jcodec.common.model.ColorSpace; +import org.jcodec.common.model.Picture; + +import javax.swing.JFrame; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class DeathRecorder { + public static final int FPS = 4; + + private static final int WIDTH = 640; + private static final int HEIGHT = 480; + private static final int PICTURE_LENGTH = WIDTH * HEIGHT * 3; + + private static final int MAX_FRAMES = 100; + private static final int MAX_COMPRESSION_LENGTH = PICTURE_LENGTH / 8; //115_200 + + private final byte[] bitmapBuffer = new byte[PICTURE_LENGTH]; + private final byte[] compressionBuffer = new byte[MAX_COMPRESSION_LENGTH]; + + private final List compressedFrames = new ArrayList<>(MAX_FRAMES); + private final BufferedImage imageCache = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + + private final Timer frameTimer = Timer.get(1000 / FPS); + + private final JFrame mainGui; + private final LZ4Compressor compressor; + private final LZ4FastDecompressor decompressor; + + private int currentFrame, validFrames; + private boolean saving; + + public DeathRecorder(JFrame mainGui) { + this.mainGui = mainGui; + + LZ4Factory factory = LZ4Factory.fastestInstance(); + this.compressor = factory.fastCompressor(); + this.decompressor = factory.fastDecompressor(); + } + + public void onTick() { + if (frameTimer.tryActivate()) { + saveFrame(); + } + } + + public void onDeath() { + synchronized (this) { + if (saving) return; + saving = true; + } + + new Thread(() -> { + try { + saveVideo(); + } catch (IOException e) { + e.printStackTrace(); + } + + validFrames = currentFrame = 0; + saving = false; + }).start(); + } + + private synchronized void saveFrame() { + if (saving) return; + Graphics2D g2 = (Graphics2D) imageCache.getGraphics(); + + // cut native border from FlatLaf - only on Windows? + double frameWidth = mainGui.getWidth() - 16; + double frameHeight = mainGui.getHeight() - 8; + + g2.scale(WIDTH / frameWidth, HEIGHT / frameHeight); + g2.translate(-8, 0); + mainGui.print(g2); + //g2.dispose(); + + for (int offset = 0, h = 0; h < HEIGHT; h++) { + for (int w = 0; w < WIDTH; w++) { + int v = imageCache.getRGB(w, h); + bitmapBuffer[offset++] = (byte) (((v >>> 16) & 0xff) - 128); + bitmapBuffer[offset++] = (byte) (((v >>> 8) & 0xff) - 128); + bitmapBuffer[offset++] = (byte) (((v) & 0xff) - 128); + } + } + + CompressedFrame compressedImage; + if (compressedFrames.size() <= currentFrame) { + compressedFrames.add(compressedImage = new CompressedFrame()); + } else { + compressedImage = compressedFrames.get(currentFrame); + } + compressedImage.compress(); + + if (++currentFrame >= MAX_FRAMES) + currentFrame = 0; + + if (validFrames < MAX_FRAMES) + validFrames++; + } + + private void saveVideo() throws IOException { + if (validFrames > 0) { + long time = System.currentTimeMillis(); + + File outputFile = new File("logs/" + LocalDateTime.now().format(LogUtils.FILENAME_DATE) + ".mov"); + SequenceEncoder sequenceEncoder = SequenceEncoder.createSequenceEncoder(outputFile, FPS); + + Picture picture = Picture.create(WIDTH, HEIGHT, ColorSpace.RGB); + for (int i = 0; i < validFrames; i++) { + int frame = (currentFrame + i) % validFrames; + + CompressedFrame compressedFrame = compressedFrames.get(frame); + compressedFrame.decompressToPicture(picture); + + sequenceEncoder.encodeNativeFrame(picture); + } + sequenceEncoder.finish(); + System.out.println("Saved video in: " + (System.currentTimeMillis() - time) + "ms | " + validFrames); + } + } + + private class CompressedFrame { + private byte[] compressed; + private int size; + + private void compress() { + try { + size = compressor.compress(bitmapBuffer, compressionBuffer); + } catch (LZ4Exception e) { + size = 0; + return; + } + + if (compressed == null || compressed.length < size) { + compressed = new byte[(int) Math.min(MAX_COMPRESSION_LENGTH, size * 1.1)]; + } + + System.arraycopy(compressionBuffer, 0, compressed, 0, size); + } + + private void decompressToPicture(Picture picture) { + if (size == 0) return; // keep old data? + + byte[] data = picture.getPlaneData(0); + decompressor.decompress(compressed, data, PICTURE_LENGTH); + } + } +} diff --git a/src/main/resources/lang/strings_en.properties b/src/main/resources/lang/strings_en.properties index 6fa38c799..63466c1e1 100644 --- a/src/main/resources/lang/strings_en.properties +++ b/src/main/resources/lang/strings_en.properties @@ -45,7 +45,7 @@ config.general.safety.revive_location.list.spot=Spot config.general.safety.wait_before_revive=Wait before revive (sec) config.general.safety.wait_before_revive.desc=Seconds to wait before reviving config.general.safety.wait_after_revive=Wait after revive (sec) -config.general.safety.wait_after_revive.des=Seconds to wait after reviving, lets ship repair +config.general.safety.wait_after_revive.desc=Seconds to wait after reviving, lets ship repair config.general.safety.instant_repair=Use instant repairs when above config.general.safety.instant_repair.desc=Use instant repairs to fully heal the ship after revive if there's at least these amount of instant repairs left config.general.running=Running @@ -306,6 +306,8 @@ config.bot_settings.other.zone_resolution.desc=Amount of map subdivisions when s config.bot_settings.other.min_tick=Minimum tick time config.bot_settings.other.dev_stuff=Developer stuff shown config.bot_settings.other.dev_stuff.desc=Enabling this WILL make your bot use more cpu. +config.bot_settings.other.record_death=Record bot gui before death +config.bot_settings.other.record_death.desc=Record last ~25 seconds of bot gui before death, saved in logs folder # Misc misc.editor.checkbox_list.selected={0} selected