diff --git a/megamek/i18n/megamek/client/messages.properties b/megamek/i18n/megamek/client/messages.properties index a7f2e896348..4b4019409b3 100644 --- a/megamek/i18n/megamek/client/messages.properties +++ b/megamek/i18n/megamek/client/messages.properties @@ -1278,6 +1278,8 @@ CommonSettingsDialog.entityOwnerColor=Add a border to the unit label CommonSettingsDialog.floatingIso=Floating isometric (no hex sides in isometric mode) CommonSettingsDialog.gameSummaryBV.name=Save game summary image of board CommonSettingsDialog.gameSummaryBV.tooltip=At the end of certain phases, save an image of the board to {0}, creating a visual summary of the progression of the game. +CommonSettingsDialog.showUnitDisplayNamesOnMinimap.name=Show unit display name on minimap +CommonSettingsDialog.showUnitDisplayNamesOnMinimap.tooltip=Useful when you want to persist the game summary with the unit names. Helps to navigate the targets at a glance on a large map. CommonSettingsDialog.gameSummaryMM.name=Save game summary image of minimap CommonSettingsDialog.gameSummaryMM.tooltip=At the end of certain phases, save an image of the minimap to {0}, creating a visual summary of the progression of the game. CommonSettingsDialog.generateNames=Generate a random name for the pilots of new units. diff --git a/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java b/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java index 201634f7a71..22c7bc3440c 100644 --- a/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java +++ b/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java @@ -348,7 +348,8 @@ private void moveElement(DefaultListModel srcModel, int srcIndex, int trg Messages.getString("CommonSettingsDialog.gameSummaryBV.name")); private final JCheckBox gameSummaryMM = new JCheckBox( Messages.getString("CommonSettingsDialog.gameSummaryMM.name")); - + private final JCheckBox showUnitDisplayNamesOnMinimap = new JCheckBox( + Messages.getString("CommonSettingsDialog.showUnitDisplayNamesOnMinimap.name")); private JComboBox skinFiles; private JComboBox uiThemes; @@ -1674,6 +1675,8 @@ private JPanel getMiniMapPanel() { comps.add(checkboxEntry(drawFacingArrowsOnMiniMap, null)); comps.add(checkboxEntry(drawSensorRangeOnMiniMap, null)); comps.add(checkboxEntry(paintBordersOnMiniMap, null)); + comps.add(checkboxEntry(showUnitDisplayNamesOnMinimap, + Messages.getString("CommonSettingsDialog.showUnitDisplayNamesOnMinimap.tooltip"))); SpinnerNumberModel movePathPersistenceModel = new SpinnerNumberModel(GUIP.getMovePathPersistenceOnMiniMap(), 0, 100, 1); movePathPersistenceOnMiniMap = new JSpinner(movePathPersistenceModel); @@ -2082,6 +2085,7 @@ public void setVisible(boolean visible) { drawFacingArrowsOnMiniMap.setSelected(GUIP.getDrawFacingArrowsOnMiniMap()); drawSensorRangeOnMiniMap.setSelected(GUIP.getDrawSensorRangeOnMiniMap()); paintBordersOnMiniMap.setSelected(GUIP.paintBorders()); + showUnitDisplayNamesOnMinimap.setSelected(GUIP.showUnitDisplayNamesOnMinimap()); levelhighlight.setSelected(GUIP.getLevelHighlight()); shadowMap.setSelected(GUIP.getShadowMap()); hexInclines.setSelected(GUIP.getHexInclines()); @@ -2473,6 +2477,7 @@ protected void okAction() { GUIP.setDrawFacingArrowsOnMiniMap(drawFacingArrowsOnMiniMap.isSelected()); GUIP.setDrawSensorRangeOnMiniMap(drawSensorRangeOnMiniMap.isSelected()); GUIP.setPaintBorders(paintBordersOnMiniMap.isSelected()); + GUIP.setShowUnitDisplayNamesOnMinimap(showUnitDisplayNamesOnMinimap.isSelected()); try { GUIP.setButtonsPerRow(Integer.parseInt(buttonsPerRow.getText())); } catch (Exception ex) { @@ -2576,7 +2581,7 @@ protected void okAction() { GUIP.setAutoSelectNextUnit(useAutoSelectNext.isSelected()); GUIP.setGameSummaryBoardView(gameSummaryBV.isSelected()); GUIP.setGameSummaryMinimap(gameSummaryMM.isSelected()); - + GUIP.setShowUnitDisplayNamesOnMinimap(showUnitDisplayNamesOnMinimap.isSelected()); UITheme newUITheme = (UITheme) uiThemes.getSelectedItem(); String oldUITheme = GUIP.getUITheme(); if (!oldUITheme.equals(newUITheme.getClassName())) { @@ -2961,6 +2966,8 @@ public void itemStateChanged(ItemEvent event) { GUIP.setPaintBorders(paintBordersOnMiniMap.isSelected()); } else if (source.equals(movePathPersistenceOnMiniMap)) { GUIP.setMovePathPersistenceOnMiniMap((int) movePathPersistenceOnMiniMap.getValue()); + } else if (source.equals(showUnitDisplayNamesOnMinimap)) { + GUIP.setShowUnitDisplayNamesOnMinimap(showUnitDisplayNamesOnMinimap.isSelected()); } } diff --git a/megamek/src/megamek/client/ui/swing/GUIPreferences.java b/megamek/src/megamek/client/ui/swing/GUIPreferences.java index fc1c34c2ca8..834223c00da 100644 --- a/megamek/src/megamek/client/ui/swing/GUIPreferences.java +++ b/megamek/src/megamek/client/ui/swing/GUIPreferences.java @@ -214,6 +214,7 @@ public class GUIPreferences extends PreferenceStoreProxy { public static final String SPLIT_PANE_A_DIVIDER_LOCATION = "SplitPaneADividerLocation"; public static final String GAME_SUMMARY_BOARD_VIEW = "GameSummaryBoardView"; public static final String GAME_SUMMARY_MINIMAP = "GameSummaryMinimap"; + public static final String SHOW_UNIT_DISPLAY_NAMES_ON_MINIMAP = "ShowUnitDisplayNamesOnMinimap"; public static final String ENTITY_OWNER_LABEL_COLOR = "EntityOwnerLabelColor"; public static final String UNIT_LABEL_BORDER = "EntityOwnerLabelColor"; public static final String TEAM_COLORING = "EntityTeamLabelColor"; @@ -688,7 +689,7 @@ protected GUIPreferences() { store.setDefault(MINI_MAP_SHOW_FACING_ARROW, true); store.setDefault(MINI_MAP_PAINT_BORDERS, true); store.setDefault(MINI_MAP_MOVE_PATH_PERSISTENCE, 2); - + store.setDefault(SHOW_UNIT_DISPLAY_NAMES_ON_MINIMAP, false); store.setDefault(MOVE_DISPLAY_TAB_DURING_PHASES, true); store.setDefault(FIRE_DISPLAY_TAB_DURING_PHASES, true); @@ -1077,6 +1078,10 @@ public boolean getGameSummaryMinimap() { return store.getBoolean(GAME_SUMMARY_MINIMAP); } + public boolean showUnitDisplayNamesOnMinimap() { + return store.getBoolean(SHOW_UNIT_DISPLAY_NAMES_ON_MINIMAP); + } + public boolean getEntityOwnerLabelColor() { return store.getBoolean(ENTITY_OWNER_LABEL_COLOR); } @@ -1922,6 +1927,10 @@ public void setGameSummaryMinimap(boolean state) { store.setValue(GAME_SUMMARY_MINIMAP, state); } + public void setShowUnitDisplayNamesOnMinimap(boolean state) { + store.setValue(SHOW_UNIT_DISPLAY_NAMES_ON_MINIMAP, state); + } + public void setEntityOwnerLabelColor(boolean i) { store.setValue(ENTITY_OWNER_LABEL_COLOR, i); } diff --git a/megamek/src/megamek/client/ui/swing/minimap/Minimap.java b/megamek/src/megamek/client/ui/swing/minimap/Minimap.java index f8be0e9e5e5..d5ef6f89ae6 100644 --- a/megamek/src/megamek/client/ui/swing/minimap/Minimap.java +++ b/megamek/src/megamek/client/ui/swing/minimap/Minimap.java @@ -48,6 +48,8 @@ import megamek.MMConstants; import megamek.client.Client; +import megamek.client.CloseClientListener; +import megamek.client.IClient; import megamek.client.event.BoardViewEvent; import megamek.client.event.BoardViewListener; import megamek.client.event.BoardViewListenerAdapter; @@ -71,6 +73,8 @@ import megamek.common.preference.PreferenceManager; import megamek.common.util.ImageUtil; import megamek.logging.MMLogger; +import megamek.utilities.GifWriter; +import megamek.utilities.GifWriterThread; /** * Obviously, displays the map in scaled-down size. @@ -82,6 +86,7 @@ public final class Minimap extends JPanel implements IPreferenceChangeListener { private static final MMLogger logger = MMLogger.create(Minimap.class); private static final Color[] terrainColors = new Color[Terrains.SIZE]; + public static final int DESTROYED_UNIT_ALPHA = 64; private static Color HEAVY_WOODS; private static Color ULTRA_HEAVY_WOODS; private static Color BACKGROUND; @@ -141,7 +146,7 @@ public final class Minimap extends JPanel implements IPreferenceChangeListener { private final JDialog dialog; private Client client; private final IClientGUI clientGui; - + private GifWriterThread gifWriterThread; private int margin = MARGIN; private int topMargin; private int leftMargin; @@ -166,7 +171,8 @@ public final class Minimap extends JPanel implements IPreferenceChangeListener { private boolean paintBorders = GUIP.paintBorders(); private Coords firstLOS; private Coords secondLOS; - + private static final Set removalReasons = Set.of(IEntityRemovalConditions.REMOVE_CAPTURED, IEntityRemovalConditions.REMOVE_SALVAGEABLE, + IEntityRemovalConditions.REMOVE_DEVASTATED, IEntityRemovalConditions.REMOVE_EJECTED); /** Signifies that the whole minimap must be repainted. */ private boolean dirtyMap = true; /** Keeps track of portions of the minimap that must be repainted. */ @@ -294,6 +300,7 @@ private Minimap(@Nullable JDialog dlg, Game g, @Nullable BoardView bview, @Nulla */ private void initializeListeners() { game.addGameListener(new GameListenerAdapter() { + @Override public void gamePhaseChange(GamePhaseChangeEvent e) { if (GUIP.getGameSummaryMinimap() @@ -308,10 +315,25 @@ public void gamePhaseChange(GamePhaseChangeEvent e) { } File imgFile = new File(dir, "round_" + game.getRoundCount() + "_" + e.getOldPhase().ordinal() + "_" + e.getOldPhase() + ".png"); + if (gifWriterThread == null) { + gifWriterThread = new GifWriterThread(new GifWriter(game.getUUIDString()), "GifWriterThread"); + gifWriterThread.start(); + } try { - ImageIO.write(getMinimapImage(game, bv, GAME_SUMMARY_ZOOM, clientGui, null, movePathLines), "png", imgFile); + + BufferedImage image = getMinimapImage(game, bv, GAME_SUMMARY_ZOOM, clientGui, null, movePathLines); + ImageIO.write(image, "png", imgFile); + long frameDurationInMillis = e.getOldPhase().isFiring()? 400 : 200; + gifWriterThread.addFrame(image, frameDurationInMillis); } catch (Exception ex) { - logger.error(ex, ""); + logger.error(ex, "Error saving game summary image."); + } + if (e.getNewPhase().isVictory() && gifWriterThread.isAlive()) { + try { + gifWriterThread.stopThread(); + } catch (Exception ex) { + logger.error(ex, "Error closing gif writer."); + } } } // We clear the move path lines locally, since the game does not currently hold this information until the end of the turn @@ -362,10 +384,16 @@ public void gameNewAction(GameNewActionEvent e) { refreshMap(); } }); + board.addBoardListener(boardListener); if (bv != null) { bv.addBoardViewListener(boardViewListener); } + client.addCloseClientListener(() -> { + if (gifWriterThread != null && gifWriterThread.isAlive()) { + gifWriterThread.stopThread(); + } + }); GUIP.addPreferenceChangeListener(this); } @@ -585,7 +613,7 @@ public void run() { drawMap(); } else { try { - Thread.sleep(16); + Thread.sleep(50); } catch (InterruptedException ie) { // should never happen } @@ -602,8 +630,7 @@ public record Line(int x1, int y1, int x2, int y2, Color color, int round) {}; private final Color MOVE_PATH_COLOR = new Color(0, 0, 0, 128); private void addMovePath(List unitLocations, Entity entity) { - // values equal or lower than 0 mean no persistence - if (GUIP.getMovePathPersistenceOnMiniMap() <= 0) { + if ((GUIP.getMovePathPersistenceOnMiniMap() <= 0) || !EntityVisibilityUtils.detectedOrHasVisual(getLocalPlayer(), game, entity)) { return; } Coords previousCoords = entity.getPosition(); @@ -703,6 +730,14 @@ private void drawMap(boolean forceDraw) { // In case the flag SHOW SYMBOLS is set, it will draw the units and other stuff if (symbolsDisplayMode == SHOW_SYMBOLS) { if (null != game) { + // draw dead units + multiUnits.clear(); + for (Entity e : game.getOutOfGameEntitiesVector()) { + if (e.getPosition() != null && removalReasons.contains(e.getRemovalCondition())) { + paintUnit(g, e); + } + } + if (!movePathLines.isEmpty()) { paintMoveTracks(g); } @@ -714,7 +749,7 @@ private void drawMap(boolean forceDraw) { } } - multiUnits.clear(); + // draw living units for (Entity e : game.getEntitiesVector()) { if (e.getPosition() != null) { paintUnit(g, e); @@ -1139,6 +1174,11 @@ private void paintAttack(Graphics g, AttackAction attack) { } } + public static Color changeColorForDestroyedUnit(Color color, int alpha) { + color = color.brighter(); + return new Color(color.getRed(), color.getGreen(), color.getBlue(), alpha); + } + /** Draws the symbol for a single entity. Checks visibility in double blind. */ private void paintUnit(Graphics g, Entity entity) { int x = entity.getPosition().getX(); @@ -1146,7 +1186,7 @@ private void paintUnit(Graphics g, Entity entity) { int baseX = coordsXToPixel(x); int baseY = coordsYtoPixel(y, x); - if (EntityVisibilityUtils.onlyDetectedBySensors(getLocalPlayer(), entity)) { + if (EntityVisibilityUtils.onlyDetectedBySensors(getLocalPlayer(), entity) && !entity.isDestroyed()) { // This unit is visible only as a sensor Return String sensorReturn = "?"; Font font = new Font(MMConstants.FONT_SANS_SERIF, Font.BOLD, FONT_SIZE[zoom]); @@ -1180,6 +1220,10 @@ private void paintUnit(Graphics g, Entity entity) { } } + if (entity.isDestroyed()) { + iconColor = changeColorForDestroyedUnit(iconColor.brighter(), DESTROYED_UNIT_ALPHA); + } + // Transform for placement and scaling var placement = AffineTransform.getTranslateInstance(baseX, baseY); placement.scale(UNIT_SCALE[zoom] / 100.0d, UNIT_SCALE[zoom] / 100.0d); @@ -1193,9 +1237,14 @@ private void paintUnit(Graphics g, Entity entity) { Path2D form = MinimapUnitSymbols.getForm(entity); - Color borderColor = entity.moved != EntityMovementType.MOVE_NONE ? Color.BLACK : Color.WHITE; + Color borderColor = entity.moved != EntityMovementType.MOVE_NONE && !entity.isDestroyed() ? Color.BLACK : Color.WHITE; + if (entity.isDestroyed()) { + borderColor = changeColorForDestroyedUnit(borderColor.brighter(), DESTROYED_UNIT_ALPHA); + } Color fontColor = Color.BLACK; - + if (entity.isDestroyed()) { + fontColor = changeColorForDestroyedUnit(fontColor.brighter(), DESTROYED_UNIT_ALPHA); + } float outerBorderWidth = 30f; float innerBorderWidth = 10f; float formStrokeWidth = 20f; @@ -1203,13 +1252,13 @@ private void paintUnit(Graphics g, Entity entity) { if (stratOpsSymbols) { // White border to set off the icon from the background g2.setStroke(new BasicStroke(outerBorderWidth, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_BEVEL)); - g2.setColor(Color.BLACK); - g2.draw(STRAT_BASERECT); - - // Black background to fill forms like the DropShip g2.setColor(fontColor); + g2.draw(STRAT_BASERECT); g2.fill(STRAT_BASERECT); + g.setColor(fontColor); + + // Set a thin brush for filled areas (leave a thick brush for line symbols if ((entity instanceof Mek) || (entity instanceof ProtoMek) || (entity instanceof VTOL) || (entity.isAero())) { @@ -1217,13 +1266,11 @@ private void paintUnit(Graphics g, Entity entity) { } else { g2.setStroke(new BasicStroke(formStrokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); } - // Fill the form in player color / team color g.setColor(iconColor); g2.fill(form); - // Add the weight class or other lettering for certain units - g.setColor(fontColor); + g2.setColor(fontColor); if ((entity instanceof ProtoMek) || (entity instanceof Mek) || (entity instanceof Aero)) { String s = ""; if (entity instanceof ProtoMek) { @@ -1234,18 +1281,24 @@ private void paintUnit(Graphics g, Entity entity) { s = STRAT_WEIGHTS[entity.getWeightClass()]; } if (!s.isBlank()) { + int fontType = Font.BOLD; + if (entity.isDestroyed()) { + fontType = Font.PLAIN; + } var fontContext = new FontRenderContext(null, true, true); - var font = new Font(MMConstants.FONT_SANS_SERIF, Font.BOLD, 100); + var font = new Font(MMConstants.FONT_SANS_SERIF, fontType, 100); FontMetrics currentMetrics = getFontMetrics(font); int stringWidth = currentMetrics.stringWidth(s); GlyphVector gv = font.createGlyphVector(fontContext, s); g2.fill(gv.getOutline((int) STRAT_CX - (float) stringWidth / 2, - (float) STRAT_SYMBOLSIZE.getHeight() / 3.0f)); + (float) STRAT_SYMBOLSIZE.getHeight() / 3.0f)); } } else if (entity instanceof MekWarrior) { g2.setColor(fontColor); g2.fillOval(-25, -25, 50, 50); } + + // Draw the unit icon in black g2.draw(form); @@ -1253,30 +1306,61 @@ private void paintUnit(Graphics g, Entity entity) { g2.setColor(borderColor); g2.setStroke(new BasicStroke(innerBorderWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); g2.draw(STRAT_BASERECT); - } else { // Standard symbols // White border to set off the icon from the background g2.setStroke(new BasicStroke(outerBorderWidth, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_ROUND)); - g2.setColor(Color.BLACK); + g2.setColor(fontColor); g2.draw(form); // Fill the form in player color / team color - g.setColor(iconColor); + g2.setColor(iconColor); g2.fill(form); // Black border g2.setColor(borderColor); g2.setStroke(new BasicStroke(innerBorderWidth / 2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER)); g2.draw(form); + if (entity.isDestroyed()) { + g2.draw(STRAT_DESTROYED); + g2.fill(STRAT_DESTROYED); + } } + + if (GUIP.showUnitDisplayNamesOnMinimap()) { + // write unit ID and name to the minimap: + g2.setColor(fontColor); + int fontType = Font.BOLD; + if (entity.isDestroyed()) { + fontType = Font.PLAIN; + } + g2.setStroke(new BasicStroke(innerBorderWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); + String s = entity.getShortName(); + var fontContext = new FontRenderContext(null, true, true); + var font = new Font(MMConstants.FONT_SANS_SERIF, fontType, 75); + GlyphVector gv = font.createGlyphVector(fontContext, s); + g2.fill(gv.getOutline((float) -STRAT_SYMBOLSIZE.getWidth() / 3f, + (float) -STRAT_SYMBOLSIZE.getHeight() / 5 * 4)); + + } + // If the unit is destroyed, it gets a strike on it. + if (entity.isDestroyed()) { + g2.setColor(fontColor); + g2.setStroke(new BasicStroke(formStrokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); + if (stratOpsSymbols) { + g2.draw(STRAT_DESTROYED); + } else { + g2.draw(STD_DESTROYED); + } + } + g2.setStroke(new BasicStroke(innerBorderWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); - if (drawFacingArrowsOnMiniMap) { + if (GUIP.getDrawFacingArrowsOnMiniMap() && !entity.isDestroyed() && !entity.isInfantry()) { // draw facing arrow var facing = entity.getFacing(); if (facing > -1) { - g2.setColor(Color.BLACK); + g2.setColor(fontColor); g2.rotate(Math.toRadians(facing * 60)); g2.draw(FACING_ARROW); g.setColor(iconColor); @@ -1313,6 +1397,8 @@ private void paintSensor(Graphics g, Entity entity) { int maxSensorRange = 0; int minSensorRange = 0; + int ecmRange = entity.getECMRange(); + boolean ecmActive = entity.hasActiveECM(); if (game.getOptions().booleanOption(OptionsConstants.ADVANCED_TACOPS_SENSORS)) { int bracket = Compute.getSensorRangeBracket(entity, null, null); @@ -1347,30 +1433,40 @@ private void paintSensor(Graphics g, Entity entity) { g2.setColor(iconColorSemiTransparent); var origin = entity.getPosition(); - for (var sensorRange : List.of(minSensorRange, maxSensorRange)) { - if (sensorRange <= 0) { - continue; - } + if (maxSensorRange > 0) { + paintSensorRange(maxSensorRange, true, origin, g2); + } + if (minSensorRange > 0) { + paintSensorRange(minSensorRange, false, origin, g2); + } + if (ecmActive && ecmRange > 0) { + Stroke dashed = new BasicStroke(2, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, + 0, new float[]{6, 3}, 3); + g2.setStroke(dashed); + paintSensorRange(ecmRange, true, origin, g2); + } - int xo; - int yo; - var sensor = new Path2D.Double(); - - var internalOrExternal = (sensorRange == minSensorRange) && (maxSensorRange != 0) ? -1 : 1; - for (int i = 0; i < 6; i++) { - var movingCoord = origin.translated(i, sensorRange + internalOrExternal); - xo = coordsXToPixel(movingCoord.getX()); - yo = coordsYtoPixel(movingCoord.getY(), movingCoord.getX()); - if (i == 0) { - sensor.moveTo(xo, yo); - } else { - sensor.lineTo(xo, yo); - } + g2.setStroke(saveStroke); + } + + private void paintSensorRange(int sensorRange, boolean offsetOut, Coords origin, Graphics2D g2) { + int xo; + int yo; + var sensor = new Path2D.Double(); + + var internalOrExternal = offsetOut ? 1 : -1; + for (int i = 0; i < 6; i++) { + var movingCoord = origin.translated(i, sensorRange + internalOrExternal); + xo = coordsXToPixel(movingCoord.getX()); + yo = coordsYtoPixel(movingCoord.getY(), movingCoord.getX()); + if (i == 0) { + sensor.moveTo(xo, yo); + } else { + sensor.lineTo(xo, yo); } - sensor.closePath(); - g2.draw(sensor); } - g2.setStroke(saveStroke); + sensor.closePath(); + g2.draw(sensor); } private int coordsYtoPixel(int y, int x) { diff --git a/megamek/src/megamek/client/ui/swing/minimap/MinimapUnitSymbols.java b/megamek/src/megamek/client/ui/swing/minimap/MinimapUnitSymbols.java index 92a1a280d91..7606b1451bc 100644 --- a/megamek/src/megamek/client/ui/swing/minimap/MinimapUnitSymbols.java +++ b/megamek/src/megamek/client/ui/swing/minimap/MinimapUnitSymbols.java @@ -49,12 +49,14 @@ public class MinimapUnitSymbols { public static final Path2D STRAT_HOVER; public static final Path2D STRAT_WHEELED; public static final Path2D STRAT_NAVAL; + public static final Path2D STRAT_DESTROYED; public static final Path2D FACING_ARROW; public static final Path2D STD_MEK; public static final Path2D STD_TANK; public static final Path2D STD_VTOL; public static final Path2D STD_AERO; public static final Path2D STD_INFANTRY; + public static final Path2D STD_DESTROYED; public static final Path2D STD_MEKWARRIOR; public static final Path2D STD_NAVAL; public static final Path2D STD_SPHEROID; @@ -127,6 +129,10 @@ public class MinimapUnitSymbols { STD_INFANTRY.curveTo(0, -20, 0, -20, -50, 0); STD_INFANTRY.closePath(); + STD_DESTROYED = new Path2D.Double(); + STD_DESTROYED.moveTo(-70, 0); + STD_DESTROYED.lineTo(70, 0); + STD_MEKWARRIOR = new Path2D.Double(); STD_MEKWARRIOR.moveTo(-30, 0); STD_MEKWARRIOR.curveTo(0, 15, 0, 15, 30, 0); @@ -149,6 +155,12 @@ public class MinimapUnitSymbols { STRAT_INFANTRY.moveTo(-STRAT_SYMBOLSIZE.getWidth() / 2, STRAT_SYMBOLSIZE.getHeight() / 2); STRAT_INFANTRY.lineTo(STRAT_SYMBOLSIZE.getWidth() / 2, -STRAT_SYMBOLSIZE.getHeight() / 2); + STRAT_DESTROYED = new Path2D.Double(); + STRAT_DESTROYED.append(STRAT_BASERECT, false); + STRAT_DESTROYED.moveTo(-STRAT_SYMBOLSIZE.getWidth() / 2, 0); + STRAT_DESTROYED.lineTo(STRAT_SYMBOLSIZE.getWidth() / 2, 0); + + STRAT_VTOL = new Path2D.Double(); STRAT_VTOL.append(STRAT_BASERECT, false); STRAT_VTOL.moveTo(-STRAT_SYMBOLSIZE.getWidth() / 4, -STRAT_SYMBOLSIZE.getHeight() / 4); @@ -264,7 +276,6 @@ public class MinimapUnitSymbols { /** Returns the Path2D minimap symbol shape for the given entity. */ public static Path2D getForm(Entity entity) { boolean stratOps = GUIP.getMmSymbol(); - if ((entity instanceof Mek) || (entity instanceof ProtoMek)) { return stratOps ? STRAT_MEK : STD_MEK; } else if (entity instanceof VTOL) { diff --git a/megamek/src/megamek/utilities/GifWriter.java b/megamek/src/megamek/utilities/GifWriter.java index ece8b4c64c0..58f7be0b34d 100644 --- a/megamek/src/megamek/utilities/GifWriter.java +++ b/megamek/src/megamek/utilities/GifWriter.java @@ -15,6 +15,7 @@ import com.squareup.gifencoder.GifEncoder; import com.squareup.gifencoder.ImageOptions; +import megamek.logging.MMLogger; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; @@ -36,20 +37,74 @@ */ public class GifWriter { + private static final MMLogger logger = MMLogger.create(GifWriter.class); + + private final File folder; + private final File outputFile; + private int height = -1; + private int width = -1; + private GifEncoder encoder = null; + private boolean isEncoding = false; + private boolean isLive = true; /** * Creates a gif from a series of images of a game summary. * @param gameSummary the game summary to create the gif from, its commonly a UUID inside the /logs/gameSummary/minimap folder * @throws IOException if an I/O error occurs */ public static void createGifFromGameSummary(String gameSummary) throws IOException { - new GifWriter().run(gameSummary); + new GifWriter(gameSummary).run(); + } + + public GifWriter(String gameSummary) { + folder = new File(gameSummaryImagesMMDir(), gameSummary); + outputFile = new File(gameSummaryImagesMMDir(), gameSummary + ".gif"); } - private GifWriter() {} + private OutputStream outputStream = null; + + public void appendFrame(BufferedImage image, long duration) throws IOException { + if (outputStream == null) { + outputStream = new FileOutputStream(outputFile); + } + if (width == -1 || height == -1) { + width = image.getWidth(); + height = image.getHeight(); + } else if (width != image.getWidth() || height != image.getHeight()) { + throw new IllegalArgumentException("Image dimensions do not match previous images"); + } + if (encoder == null) { + encoder = new GifEncoder(outputStream, width, height, 0); + isEncoding = true; + } + ImageOptions options = new ImageOptions(); + options.setDelay(duration, TimeUnit.MILLISECONDS); + int[] rgbData; + + rgbData = image.getRGB(0, 0, width, height, null, 0, width); + encoder.addImage(rgbData, width, options); + } - private void run(String gameSummary) throws IOException { - File folder = new File(gameSummaryImagesMMDir(), gameSummary); + public void close() { + if (encoder != null && isEncoding) { + try { + encoder.finishEncoding(); + } catch (IOException e) { + logger.error(e, "Error finishing encoding"); + } + encoder = null; + } + if (outputStream != null) { + try { + outputStream.close(); + } catch (IOException e) { + logger.error(e, "Error closing output stream"); + } + } + outputStream = null; + isLive = false; + } + private void run() throws IOException { List files = new ArrayList<>( List.of( Objects.requireNonNull( @@ -71,8 +126,6 @@ private void run(String gameSummary) throws IOException { return Integer.compare(splitO11, splitO21); }); - File outputFile = new File(gameSummaryImagesMMDir(), gameSummary + ".gif"); - // grab the output image type from the first image in the sequence BufferedImage firstImage = ImageIO.read(files.get(0)); @@ -103,6 +156,10 @@ private void run(String gameSummary) throws IOException { outputStream.close(); } + public boolean isLive() { + return isLive; + } + public static void main(String[] args) throws Exception { GifWriter.createGifFromGameSummary(args[0]); } diff --git a/megamek/src/megamek/utilities/GifWriterThread.java b/megamek/src/megamek/utilities/GifWriterThread.java new file mode 100644 index 00000000000..e5d0a3f7729 --- /dev/null +++ b/megamek/src/megamek/utilities/GifWriterThread.java @@ -0,0 +1,60 @@ +package megamek.utilities; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; + +public class GifWriterThread extends Thread { + + private record Frame(BufferedImage image, long duration) {} + + private final GifWriter gifWriter; + private final Deque imageDeque = new ConcurrentLinkedDeque<>(); + private boolean isLive = true; + + public GifWriterThread(GifWriter gifWriter, String name) { + super(name); + this.gifWriter = gifWriter; + } + + public void addFrame(BufferedImage image, long durationMillis) { + synchronized (this) { + imageDeque.add(new Frame(image, durationMillis)); + notifyAll(); + } + } + + @Override + public void run() { + try { + while (isLive) { + try { + synchronized (this) { + while (imageDeque.isEmpty() && gifWriter.isLive() && isLive) { + wait(); + } + if (!gifWriter.isLive()) { + break; + } + Frame frame = imageDeque.pollFirst(); + if (frame == null) { + continue; + } + gifWriter.appendFrame(frame.image(), frame.duration()); + } + } catch (InterruptedException | IOException e) { + break; + } + } + } finally { + gifWriter.close(); + imageDeque.clear(); + } + } + + public void stopThread() { + isLive = false; + interrupt(); + } +}