diff --git a/megamek/resources/megamek/client/messages.properties b/megamek/resources/megamek/client/messages.properties index b58858d7eca..de7199a0e37 100644 --- a/megamek/resources/megamek/client/messages.properties +++ b/megamek/resources/megamek/client/messages.properties @@ -102,6 +102,14 @@ TechLevelDisplayDialog.copyAsHTML=Copy as HTML TechLevelDisplayDialog.noUnit=Error: Could not access the unit! ## EntityReadoutDialog EntityReadoutDialog.title=Unit Readout:\u0020 +## EntityReadoutPanel +EntityReadoutPanel.previousImage.toolTipText=Show the previous fluff image +EntityReadoutPanel.nextImage.toolTipText=Show the next fluff image +EntityReadoutPanel.imageLoadError=Error loading fluff image +## FluffImageTooltip +FluffImageTooltip.unit=Unit: +FluffImageTooltip.artist=Artist: +FluffImageTooltip.insignia=Insignia: ## RandomSkillDialog Class SkillGenerationDialog.title=Skill Generation Dialog SkillGenerationDialog.btnRandomize.toolTipText=Randomize the skills for the units assigned to the current player and close the dialog. diff --git a/megamek/src/megamek/client/ui/FluffImageTooltip.java b/megamek/src/megamek/client/ui/FluffImageTooltip.java new file mode 100644 index 00000000000..ef2093d3eee --- /dev/null +++ b/megamek/src/megamek/client/ui/FluffImageTooltip.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2024-2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMek. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ +package megamek.client.ui; + +import java.awt.Color; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import megamek.client.ui.clientGUI.GUIPreferences; +import megamek.client.ui.util.FluffImageHelper; +import megamek.client.ui.util.UIUtil; +import megamek.common.annotations.Nullable; +import megamek.logging.MMLogger; + +/** + * This class is very specialized. It provides tooltip information for the fluff image tooltip in the + * {@link megamek.client.ui.dialogs.unitSelectorDialogs.EntityReadoutPanel}, taken from yaml files that are supplied + * with painted minis images. + */ +public class FluffImageTooltip { + + private static final MMLogger LOGGER = MMLogger.create(FluffImageTooltip.class); + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + /** The suffix that marks a yaml file as the info sidecar of a fluff image. */ + private static final String YAML_FILE_SUFFIX = "data.yaml"; + + /** The width of the tooltip text block, before GUI scaling. */ + private static final int TOOLTIP_WIDTH = 360; + + private static final String NODE_TITLE = "title"; + private static final String NODE_AUTHOR = "author"; + private static final String NODE_CONTENT = "content"; + private static final String NODE_TYPE = "type"; + private static final String VALUE_INSIGNIA = "insignia"; + + /** + * @return The CSS styles used by the fluff image tooltip. + */ + private static String styles() { + int labelSize = UIUtil.scaleForGUI(UIUtil.FONT_SCALE1); + Color color = GUIPreferences.getInstance().getToolTipLightFGColor(); + // Pad to six digits: a dark colour such as pure blue would otherwise give "ff", which is not a valid + // CSS colour and makes the renderer fall back to the default text colour + String styleColor = String.format("%06X", color.getRGB() & 0xFFFFFF); + return "span { font-family:Noto Sans; font-size:" + labelSize + "; }" + + ".label { color:" + styleColor + "; }"; + } + + /** + * Returns the tooltip text for the supplied FluffImageRecord, if any can be found, {@code null} otherwise. + * + * @param record The FluffImageRecord that is currently shown as an image + * + * @return A tooltip text, or {@code null} if no yaml info is available + */ + public static @Nullable String getTooltip(FluffImageHelper.FluffImageRecord record) { + return findYamlInfo(record).map(FluffImageTooltip::getTooltip).orElse(null); + } + + private static Optional findYamlInfo(FluffImageHelper.FluffImageRecord record) { + return (record.file() == null) ? Optional.empty() : getYamlFile(record.file()); + } + + private static @Nullable String getTooltip(File yamlFile) { + try { + JsonNode node = YAML_MAPPER.readTree(yamlFile); + + StringBuilder result = new StringBuilder(""); + int width = UIUtil.scaleForGUI(TOOLTIP_WIDTH); + result.append("
"); + + appendNodeValue(result, node, NODE_TITLE, Messages.getString("FluffImageTooltip.unit"), false); + appendNodeValue(result, node, NODE_AUTHOR, Messages.getString("FluffImageTooltip.artist"), true); + + if (node.has(NODE_CONTENT)) { + String description = findInsignia(node.get(NODE_CONTENT)); + if (!description.isBlank()) { + appendLabelledValue(result, Messages.getString("FluffImageTooltip.insignia"), description, true); + } + } + result.append("
"); + return result.toString(); + } catch (IOException exception) { + LOGGER.warn("Could not read fluff image info from {}", yamlFile, exception); + return null; + } + } + + /** + * Appends a "label value" pair to the tooltip, taking the value from the given yaml node. Does nothing if the + * node is absent or its value is blank. + * + * @param result The tooltip being assembled + * @param node The yaml root node + * @param nodeName The name of the yaml node holding the value + * @param label The already localized label to show in front of the value + * @param precedeWithLineBreak {@code true} to start the entry on a new line + */ + private static void appendNodeValue(StringBuilder result, JsonNode node, String nodeName, String label, + boolean precedeWithLineBreak) { + if (!node.has(nodeName)) { + return; + } + String value = node.get(nodeName).asText(); + if (!value.isBlank()) { + appendLabelledValue(result, label, value, precedeWithLineBreak); + } + } + + /** + * Appends a "label value" pair to the tooltip. The separating space is added here so that the localized labels + * do not have to carry a trailing space. + * + * @param result The tooltip being assembled + * @param label The already localized label to show in front of the value + * @param value The value to show + * @param precedeWithLineBreak {@code true} to start the entry on a new line + */ + private static void appendLabelledValue(StringBuilder result, String label, String value, + boolean precedeWithLineBreak) { + String lineBreak = precedeWithLineBreak ? "
" : ""; + result.append(UIUtil.spanCSS("label", lineBreak + label + " ")) + .append(UIUtil.spanCSS("value", value)); + } + + private static String findInsignia(JsonNode contentNode) { + List nodes = new ArrayList<>(); + contentNode.iterator().forEachRemaining(nodes::add); + for (JsonNode node : nodes) { + if (node.has(NODE_TYPE) && node.get(NODE_TYPE).asText().equals(VALUE_INSIGNIA)) { + return node.get(NODE_CONTENT).asText(); + } + } + return ""; + } + + private static Optional getYamlFile(File imageFile) { + File parent = imageFile.getParentFile(); + if (parent == null) { + LOGGER.warn("Image file {} has no parent directory; cannot search for YAML.", imageFile); + return Optional.empty(); + } + try (Stream entries = Files.walk(parent.toPath(), 1)) { + return entries.filter(entry -> isSuitableYamlFile(entry, imageFile)).map(Path::toFile).findFirst(); + } catch (Exception exception) { + // Deliberately broad: walking a user-supplied directory can also fail with UncheckedIOException + // or a SecurityException, and a missing tooltip must never break the readout panel. + LOGGER.warn("Error while reading files from {}", parent, exception); + return Optional.empty(); + } + } + + private static boolean isSuitableYamlFile(Path yamlFile, File imageFile) { + if (Files.isDirectory(yamlFile)) { + return false; + } + + Path yamlFileNamePath = yamlFile.getFileName(); + if (yamlFileNamePath == null) { + return false; + } + + String yamlFileName = yamlFileNamePath.toString(); + if (!yamlFileName.endsWith(YAML_FILE_SUFFIX)) { + return false; + } + + int baseLength = yamlFileName.length() - YAML_FILE_SUFFIX.length(); + if (baseLength <= 0) { + return false; + } + + String baseName = yamlFileName.substring(0, baseLength); + return imageFile.getName().contains(baseName); + } + + private FluffImageTooltip() {} +} diff --git a/megamek/src/megamek/client/ui/dialogs/unitSelectorDialogs/EntityReadoutPanel.java b/megamek/src/megamek/client/ui/dialogs/unitSelectorDialogs/EntityReadoutPanel.java index 6d0e4769257..09bb4dca41e 100644 --- a/megamek/src/megamek/client/ui/dialogs/unitSelectorDialogs/EntityReadoutPanel.java +++ b/megamek/src/megamek/client/ui/dialogs/unitSelectorDialogs/EntityReadoutPanel.java @@ -36,16 +36,22 @@ import java.awt.BorderLayout; import java.awt.ComponentOrientation; import java.awt.Dimension; +import java.awt.FlowLayout; import java.awt.GridLayout; import java.awt.Image; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; import java.awt.event.MouseWheelListener; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; +import javax.imageio.ImageIO; import javax.swing.Box; -import javax.swing.BoxLayout; import javax.swing.ImageIcon; +import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; @@ -58,31 +64,50 @@ import javax.swing.text.html.HTML; import javax.swing.text.html.HTMLDocument; +import megamek.client.ui.FluffImageTooltip; +import megamek.client.ui.Messages; import megamek.client.ui.entityreadout.EntityReadout; import megamek.client.ui.entityreadout.ReadoutSections; import megamek.client.ui.util.FluffImageHelper; import megamek.client.ui.util.UIUtil; -import megamek.client.ui.util.UIUtil.FixedXPanel; import megamek.client.ui.util.ViewFormatting; +import megamek.common.Configuration; import megamek.common.Report; +import megamek.common.annotations.Nullable; import megamek.common.preference.PreferenceManager; import megamek.common.templates.TROView; import megamek.common.units.Entity; import megamek.common.util.StringUtil; +import megamek.logging.MMLogger; /** * @author Jay Lawson * @since November 2, 2009 */ public class EntityReadoutPanel extends JPanel { + private static final MMLogger LOGGER = MMLogger.create(EntityReadoutPanel.class); + private final int TOOLTIP_MAX_SIZE = 85; private final JTextPane readoutTextComponent = new JTextPane(); - private final JLabel fluffImageComponent = new JLabel(); private final JScrollPane scrollPane = new JScrollPane(readoutTextComponent); + private final JLabel fluffImageLabel = new JLabel(); + private final List fluffImageList = new ArrayList<>(); + private int fluffImageIndex = 0; + private final JButton nextImageButton = new JButton(">"); + private final JButton prevImageButton = new JButton("<"); + private final JLabel imageInfoLabel = new JLabel("", JLabel.CENTER); + public static final int DEFAULT_WIDTH = 360; + /** The vertical gap between the fluff image and the info line below it, before GUI scaling. */ + private static final int IMAGE_INFO_GAP = 10; + + private static final String PLACEHOLDER_IMAGE_NAME = + new File(Configuration.fluffImagesDir(), "fluff_placeholder.png").getPath(); + private static final Image PLACEHOLDER_IMAGE = readPlaceHolderImage(); + public EntityReadoutPanel() { this(-1, -1); } @@ -147,21 +172,34 @@ public void mouseMoved(MouseEvent e) { } textPanel.add(scrollPane); - var fluffPanel = new FixedXPanel(); - if (width != -1) { - fluffPanel.setMinimumSize(new Dimension(width, height)); - fluffPanel.setPreferredSize(new Dimension(width, height)); - } - fluffPanel.add(fluffImageComponent); + prevImageButton.setToolTipText(Messages.getString("EntityReadoutPanel.previousImage.toolTipText")); + nextImageButton.setToolTipText(Messages.getString("EntityReadoutPanel.nextImage.toolTipText")); + + var imageControlsPanel = new UIUtil.FixedYPanel(new FlowLayout()); + imageControlsPanel.add(prevImageButton); + imageControlsPanel.add(nextImageButton); + + imageControlsPanel.setAlignmentX(CENTER_ALIGNMENT); + fluffImageLabel.setAlignmentX(CENTER_ALIGNMENT); + imageInfoLabel.setAlignmentX(CENTER_ALIGNMENT); - JPanel p = new JPanel(); - p.setLayout(new BoxLayout(p, BoxLayout.LINE_AXIS)); - p.add(textPanel); - p.add(fluffPanel); - p.add(Box.createHorizontalGlue()); + Box fluffPanel = Box.createVerticalBox(); + fluffPanel.setAlignmentY(TOP_ALIGNMENT); + fluffPanel.add(imageControlsPanel); + fluffPanel.add(fluffImageLabel); + fluffPanel.add(Box.createVerticalStrut(UIUtil.scaleForGUI(IMAGE_INFO_GAP))); + fluffPanel.add(imageInfoLabel); + + Box readoutAndFluffPanel = Box.createHorizontalBox(); + readoutAndFluffPanel.add(textPanel); + readoutAndFluffPanel.add(fluffPanel); + readoutAndFluffPanel.add(Box.createHorizontalGlue()); setLayout(new BorderLayout()); - add(p); + add(readoutAndFluffPanel); addMouseWheelListener(wheelForwarder); + + nextImageButton.addActionListener(event -> showNextFluffImage()); + prevImageButton.addActionListener(event -> showPrevFluffImage()); } public void showEntity(Entity entity, EntityReadout mekView) { @@ -215,31 +253,114 @@ public void showEntity(Entity entity, boolean showDetail, boolean useAlternateCo showEntity(entity, mekView, fontName, sections); } - private void setFluffImage(Entity entity) { + /** + * Shows the given image as the fluff image, scaled down to {@link #DEFAULT_WIDTH} if it is wider than that. + * + * @param image The image to show, or {@code null} to clear the fluff image + */ + private void displayFluffImage(@Nullable Image image) { + if (image == null) { + fluffImageLabel.setIcon(null); + fluffImageLabel.setToolTipText(null); + return; + } + Image displayedImage = image; + if (displayedImage.getWidth(this) > DEFAULT_WIDTH) { + displayedImage = displayedImage.getScaledInstance(DEFAULT_WIDTH, -1, Image.SCALE_SMOOTH); + } + fluffImageLabel.setIcon(new ImageIcon(displayedImage)); + } + + private void setFluffImage(@Nullable Entity entity) { + fluffImageList.clear(); + fluffImageIndex = 0; + boolean isSpritesOnly = PreferenceManager.getClientPreferences().getSpritesOnly(); - Image image = isSpritesOnly ? null : FluffImageHelper.getFluffImage(entity); - // Scale down to the default width if the image is wider than that - if (null != image) { - if (image.getWidth(this) > DEFAULT_WIDTH) { - image = image.getScaledInstance(DEFAULT_WIDTH, -1, Image.SCALE_SMOOTH); - } - fluffImageComponent.setIcon(new ImageIcon(image)); - } else { - fluffImageComponent.setIcon(null); + if (isSpritesOnly || (entity == null)) { + nextImageButton.setEnabled(false); + prevImageButton.setEnabled(false); + imageInfoLabel.setText(""); + displayFluffImage(null); + return; } + + fluffImageList.addAll(FluffImageHelper.getFluffRecords(entity)); + boolean hasMultipleImages = fluffImageList.size() > 1; + nextImageButton.setEnabled(hasMultipleImages); + prevImageButton.setEnabled(hasMultipleImages); + // Show the first image, not the next one - stepping by 1 here would open on the second image + changeFluffImageIndex(0); } public void reset() { readoutTextComponent.setText(""); - fluffImageComponent.setIcon(null); + fluffImageList.clear(); + fluffImageIndex = 0; + nextImageButton.setEnabled(false); + prevImageButton.setEnabled(false); + imageInfoLabel.setText(""); + displayFluffImage(null); } /** Forwards a mouse wheel scroll on the fluff image or free space to the TRO entry. */ - MouseWheelListener wheelForwarder = e -> { - MouseWheelEvent converted = (MouseWheelEvent) SwingUtilities.convertMouseEvent(EntityReadoutPanel.this, e, + MouseWheelListener wheelForwarder = event -> { + MouseWheelEvent converted = (MouseWheelEvent) SwingUtilities.convertMouseEvent(EntityReadoutPanel.this, event, scrollPane); for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) { listener.mouseWheelMoved(converted); } }; + + private void showNextFluffImage() { + changeFluffImageIndex(1); + } + + private void showPrevFluffImage() { + changeFluffImageIndex(-1); + } + + private void changeFluffImageIndex(int delta) { + fluffImageIndex += delta; + if (fluffImageIndex >= fluffImageList.size()) { + fluffImageIndex = 0; + } + if (fluffImageIndex < 0) { + fluffImageIndex = fluffImageList.size() - 1; + } + boolean hasImageAtIndex = (fluffImageIndex >= 0) && (fluffImageIndex < fluffImageList.size()); + if (!hasImageAtIndex) { + LOGGER.debug("[FluffImages] No fluff image available; showing the placeholder image."); + displayFluffImage(PLACEHOLDER_IMAGE); + imageInfoLabel.setText(""); + return; + } + + FluffImageHelper.FluffImageRecord record = fluffImageList.get(fluffImageIndex); + try { + displayFluffImage(record.getImage()); + } catch (IOException exception) { + LOGGER.warn("[FluffImages] Could not load fluff image {}", record.file(), exception); + displayFluffImage(null); + imageInfoLabel.setText(Messages.getString("EntityReadoutPanel.imageLoadError")); + return; + } + String imageInfo = FluffImageTooltip.getTooltip(record); + fluffImageLabel.setToolTipText(imageInfo); + imageInfoLabel.setText((imageInfo != null) ? imageInfo : ""); + } + + private static @Nullable Image readPlaceHolderImage() { + File placeholderFile = new File(PLACEHOLDER_IMAGE_NAME); + if (!placeholderFile.exists()) { + LOGGER.debug("[FluffImages] No placeholder image at {}; units without fluff will show no image.", + PLACEHOLDER_IMAGE_NAME); + return null; + } + try { + return ImageIO.read(placeholderFile); + } catch (IOException exception) { + LOGGER.warn("[FluffImages] Could not read the placeholder image {}", PLACEHOLDER_IMAGE_NAME, exception); + return null; + } + } } diff --git a/megamek/src/megamek/client/ui/util/FluffImageHelper.java b/megamek/src/megamek/client/ui/util/FluffImageHelper.java index 52b38ba49c1..9cb177c2272 100644 --- a/megamek/src/megamek/client/ui/util/FluffImageHelper.java +++ b/megamek/src/megamek/client/ui/util/FluffImageHelper.java @@ -37,18 +37,29 @@ import java.awt.Image; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.imageio.ImageIO; import javax.swing.ImageIcon; import megamek.common.Configuration; -import megamek.common.loaders.MekSummary; import megamek.common.annotations.Nullable; import megamek.common.battlefieldSupport.BFSAssetType; import megamek.common.battlefieldSupport.BattlefieldSupportAsset; +import megamek.common.loaders.MekSummary; import megamek.common.preference.PreferenceManager; import megamek.common.units.BTObject; import megamek.common.units.Mek; +import megamek.logging.MMLogger; /** * This class provides methods for retrieving fluff images, for use in MM, MML and MHQ; also for record sheets (where @@ -56,6 +67,8 @@ */ public final class FluffImageHelper { + private static final MMLogger LOGGER = MMLogger.create(FluffImageHelper.class); + public static final String DIR_NAME_BA = "BattleArmor"; public static final String DIR_NAME_ASSET = "Asset"; public static final String DIR_NAME_CONV_FIGHTER = "ConvFighter"; @@ -72,19 +85,27 @@ public final class FluffImageHelper { public static final String[] EXTENSIONS_FLUFF_IMAGE_FORMATS = { ".PNG", ".png", ".JPG", ".JPEG", ".jpg", ".jpeg", ".GIF", ".gif" }; + /** The extensions above as a set, for membership tests when scanning a chassis or model directory. */ + private static final Set EXTENSIONS_FLUFF_IMAGE_FORMAT_SET = Set.of(EXTENSIONS_FLUFF_IMAGE_FORMATS); + + /** The model subdirectory name that units with an empty model match, e.g. fluff/Mek/Chassis/Chassis ---empty---. */ + static final String EMPTY_MODEL_DIR_NAME = "---empty---"; + /** * Returns a fluff image for the given unit/object to be shown e.g. in the unit summary. * *

* If a fluff image is stored in the unit/object itself, e.g. if it was part of the unit's file or is created by the - * unit itself, this is returned. Note that this is not used for canon units, but may be used in custom ones by + * unit itself, this + * is returned. Note that this is not used for canon units, but may be used in custom ones by * adding a fluff image to the unit in MML. * *

* Otherwise, the fluff images directories are searched. First searches the user dir, then the internal dir. Tries - * to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and / - * characters before matching. For Meks with clan names, both names and the combinations are searched. The model - * alone is not used to search. + * to match the image by + * chassis + model or chassis alone. Chassis and model names are cleaned from " and / + * characters before matching. For Meks with clan + * names, both names and the combinations are searched. The model alone is not used to search. *

* Returns null if no fluff image can be found. * @@ -100,7 +121,7 @@ public final class FluffImageHelper { if (unit == null) { return null; } - File fluffImageFile = findFluffFile(unit, true); + File fluffImageFile = findFluffFiles(unit, true).stream().findFirst().orElse(null); if (fluffImageFile != null) { return fluffImageFile.toString(); } else { @@ -108,10 +129,50 @@ public final class FluffImageHelper { } } + /** + * Returns a list of all fluff images for the given unit/object to be shown e.g. in the unit summary. + * + *

If a fluff image is stored in the unit/object itself, e.g. if it was part of the + * unit's file or is created by the unit itself, only this is returned. Note that this is not used for canon units, but may be used in + * custom ones by adding a fluff image to the unit in MML.

+ * + *

Otherwise, the fluff image directories are searched. First searches the user dir, + * then the internal dir. Tries to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and / + * characters before matching. For Meks with clan names, both names and the combinations are searched. The model alone is not used to + * search.

+ * + * @param unit The unit + * @return a list of fluff images, or an empty list if none are found + */ + public static List getFluffImages(@Nullable BTObject unit) { + return getFluffImageList(unit, false); + } + + /** + * Returns a list of all fluff image records for the given unit/object to be shown e.g. in the unit summary. + * + *

If a fluff image is stored in the unit/object itself, e.g. if it was part of the + * unit's file or is created by the unit itself, only this is returned. Note that this is not used for canon units, but may be used in + * custom ones by adding a fluff image to the unit in MML.

+ * + *

Otherwise, the fluff image directories are searched. First searches the user dir, + * then the internal dir. Tries to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and / + * characters before matching. For Meks with clan names, both names and the combinations are searched. The model alone is not used to + * search.

+ * + * @param unit The unit + * @return a list of fluff image records, or an empty list if none are found + */ + public static List getFluffRecords(@Nullable BTObject unit) { + return getFluffImageRecords(unit, false); + } + /** * Returns a fluff image for the given unit for the record sheet, with a fallback file named "hud.png" if that is - * present in the right fluff directory, or null if nothing can be found. See {@link #getFluffImage(BTObject)} for - * further comments on how the fluff image is searched. + * present in the right + * fluff directory, or {@code null} if nothing can be found. See {@link #getFluffImage(BTObject)} for + * further comments on how the fluff image is + * searched. * * @param unit The unit * @@ -122,24 +183,65 @@ public final class FluffImageHelper { } private static @Nullable Image getFluffImage(@Nullable BTObject unit, boolean recordSheet) { - if (unit == null) { + List fluffImages = getFluffImageList(unit, recordSheet); + if (!fluffImages.isEmpty()) { + return fluffImages.get(0); + } else { return null; } + } + + /** + * Returns a list of available fluff images. If a fluff image is embedded in the unit file, + * only that image is returned, even if others are available from the fluff directories. The returned + * list may be empty, but not {@code null}. + * + * @param unit The unit + * @param recordSheet True if this image search is meant for a record sheet (used in MML) + * @return Available fluff images or the embedded fluff image + */ + private static List getFluffImageRecords(@Nullable BTObject unit, boolean recordSheet) { + if (unit == null) { + return new ArrayList<>(); + } Image embeddedFluffImage = unit.getFluffImage(); if (embeddedFluffImage != null) { - return embeddedFluffImage; + return List.of(new FluffImageRecord(embeddedFluffImage, null)); } else { - File fluffImageFile = findFluffFile(unit, recordSheet); - if (fluffImageFile != null) { - return new ImageIcon(fluffImageFile.toString()).getImage(); - } else { - return null; - } + return findFluffFiles(unit, recordSheet).stream().map(FluffImageRecord::toRecord).toList(); + } + } + + /** + * Returns a list of available fluff images. If a fluff image is embedded in the unit file, + * only that image is returned, even if others are available from the fluff directories. The returned + * list may be empty, but not {@code null}. + * + * @param unit The unit + * @param recordSheet True if this image search is meant for a record sheet + * @return Available fluff images or the embedded fluff image + */ + private static List getFluffImageList(@Nullable BTObject unit, boolean recordSheet) { + if (unit == null) { + return new ArrayList<>(); + } + Image embeddedFluffImage = unit.getFluffImage(); + if (embeddedFluffImage != null) { + return List.of(embeddedFluffImage); + } else { + return findFluffFiles(unit, recordSheet).stream() + .map(File::toString) + .map(ImageIcon::new) + .map(ImageIcon::getImage) + .collect(Collectors.toList()); } } - private static @Nullable File findFluffFile(BTObject unit, boolean recordSheet) { - List fileCandidates = new ArrayList<>(); + private static Set findFluffFiles(BTObject unit, boolean recordSheet) { + // A LinkedHashSet keeps the search order while removing duplicates. The order is significant: + // getFluffImage(BTObject) shows the first entry, and the directories below are searched from + // most to least specific so that the most specific art wins. + Set fileCandidates = new LinkedHashSet<>(); List nameCandidates = nameCandidates(unit); @@ -162,10 +264,12 @@ public final class FluffImageHelper { fileCandidates.addAll(findMatchingFiles(rsFluffUserDir, nameCandidates)); } fileCandidates.addAll(findMatchingFiles(fluffUserDir, nameCandidates)); + fileCandidates.addAll(getFluffInChassisDirs(unit, fluffUserDir)); } // Internal fluff path matches fileCandidates.addAll(findMatchingFiles(fluffDir, nameCandidates)); + fileCandidates.addAll(getFluffInChassisDirs(unit, fluffDir)); // Fallback for units other than HHWs. // The HHW fallback image is embedded into the RS template. @@ -177,12 +281,83 @@ public final class FluffImageHelper { } } - for (File possibleFile : fileCandidates) { - if (possibleFile.exists() && !possibleFile.isDirectory()) { - return possibleFile; + fileCandidates.removeIf(candidate -> !candidate.exists() || candidate.isDirectory()); + return fileCandidates; + } + + /** + * With the addition of multiple fluff images, file matching depends on the directory a file is in. + *
- In the main fluff/[unittype]/ directory the old rules apply, i.e. a file is valid if it + * matches the model exactly or if the filename is only the chassis and matches the unit's chassis. + * The filename may now contain additional information after an underscore (atlas_xyz.jpg matches for + * any Atlas mek). + *
- In a chassis subdirectory fluff/[unittype]/[chassis], all files match if [chassis] + * matches the unit's chassis (even if the filename has the wrong model) AND if there is no + * [model] subdirectory matching the unit's model. Empty models match the directory "---empty---". + * The filename doesn't matter for matching. + *
- In a model subdirectory fluff/[unittype]/[chassis]/[model], all files match if the + * unit's chassis and model match [chassis] and [model]. The filename doesn't matter for matching. + */ + static List getFluffInChassisDirs(BTObject unit, File unitTypeFluffDir) { + List result = new ArrayList<>(); + for (String nameCandidate : chassisNameCandidates(unit)) { + var chassisDir = new File(unitTypeFluffDir, nameCandidate); + if (chassisDir.isDirectory()) { + result.addAll(getFluffInChassisDir(unit, chassisDir)); } } - return null; + return result; + } + + /** + * @return For the unit, returns the possible chassis lookup strings, which is simply the chassis + * (the list has only one entry) for all units except Clan Meks with a double name, where the list + * includes the four variations on Timber Wolf (Mad Cat), Mad Cat (Timber Wolf), Mad Cat and + * Timber Wolf. Note that a few units have X (Y) chassis that are not clan double names. Those + * will return only the full chassis X (Y). + */ + private static List chassisNameCandidates(BTObject unit) { + List result = new ArrayList<>(); + String sanitizedChassis = sanitize(unit.generalName()); + result.add(sanitizedChassis); + if ((unit instanceof Mek) && !((Mek) unit).getClanChassisName().isBlank()) { + String sanitizedClanChassis = sanitize(((Mek) unit).getClanChassisName()); + result.add(sanitizedClanChassis + " (" + sanitizedChassis + ")"); + result.add(sanitizedChassis + " (" + sanitizedClanChassis + ")"); + result.add(sanitizedClanChassis); + } + return result; + } + + private static List getFluffInChassisDir(BTObject unit, File chassisDir) { + String sanitizedModel = sanitize(unit.specificName()); + if (sanitizedModel.isBlank()) { + sanitizedModel = EMPTY_MODEL_DIR_NAME; + } + List result = new ArrayList<>(); + for (String chassisNameCandidate : chassisNameCandidates(unit)) { + var modelDir = new File(chassisDir, chassisNameCandidate + " " + sanitizedModel); + if (modelDir.isDirectory()) { + result.addAll(getFluffInDir(modelDir)); + } + } + if (result.isEmpty()) { + result.addAll(getFluffInDir(chassisDir)); + } + return result; + } + + private static List getFluffInDir(File dir) { + List result = new ArrayList<>(); + // Files.list is a shallow listing and, unlike Files.walk(dir, 1), does not include the directory itself + try (Stream entries = Files.list(dir.toPath())) { + result.addAll(entries.map(Path::toFile).toList()); + result.removeIf(FluffImageHelper::isNoImageFile); + } catch (IOException exception) { + LOGGER.warn("Error while reading files from {}", dir, exception); + } + result.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + return result; } static File userFluffDir(String userDir, boolean recordSheet, String fluffPath) { @@ -240,7 +415,6 @@ private static String sanitize(String original) { private static List nameCandidates(BTObject unit) { List candidates = new ArrayList<>(); - String sanitizedChassis = sanitize(unit.generalName()); String sanitizedModel = sanitize(unit.specificName()); // Check for an empty model so the order more specific -> less specific name candidate is always kept @@ -254,7 +428,7 @@ private static List nameCandidates(BTObject unit) { addClanChassisVariants(mekSummary.getFullChassis(), candidates, sanitizedModel, mekSummary.getClanChassisName()); } - candidates.add(sanitizedChassis); + candidates.addAll(chassisNameCandidates(unit)); return candidates; } @@ -349,4 +523,58 @@ public static List getFluffPaths(BTObject unit) { case EMPLACEMENT -> null; }; } + + /** + * A fluff image that is either already loaded (when it was embedded in the unit file) or still on disk, in which + * case it is only read when it is actually shown. Exactly one of the two is set. + * + * @param image The already loaded image, or {@code null} when the image must be read from {@code file} + * @param file The file holding the image, or {@code null} when {@code image} is already loaded + */ + public record FluffImageRecord(@Nullable Image image, @Nullable File file) { + + /** + * @param file The image file to read when the image is shown + * + * @return A record for a fluff image that has not been loaded yet + */ + public static FluffImageRecord toRecord(File file) { + return new FluffImageRecord(null, file); + } + + /** + * Returns the fluff image, reading it from disk if it has not been loaded yet. + * + * @return The fluff image, or {@code null} if this record has neither an image nor a file + * + * @throws IOException When the image file is present but cannot be read + */ + public @Nullable Image getImage() throws IOException { + if (image != null) { + return image; + } else if (file != null) { + return ImageIO.read(file); + } else { + return null; + } + } + } + + private static boolean isNoImageFile(File file) { + Optional extension = getExtension(file.toString()); + return extension.isEmpty() || !EXTENSIONS_FLUFF_IMAGE_FORMAT_SET.contains(extension.get()); + } + + /** + * Returns the file extension of a given filename. + * source: baeldung.com/java-file-extension + * + * @param filename The filename, potentially with directories + * @return The extension, including the dot + */ + public static Optional getExtension(String filename) { + return Optional.ofNullable(filename) + .filter(name -> name.contains(".")) + .map(name -> name.substring(name.lastIndexOf("."))); + } } diff --git a/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java b/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java new file mode 100644 index 00000000000..cb295c5b17b --- /dev/null +++ b/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMek. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package megamek.client.ui.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import megamek.common.units.BipedMek; +import megamek.common.units.Mek; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests the chassis and model subdirectory matching rules for fluff images, i.e. the contents of + * fluff/[unittype]/[chassis] and fluff/[unittype]/[chassis]/[chassis model]. + */ +class FluffImageHelperChassisDirTest { + + private static Mek mek(String chassis, String model) { + Mek mek = new BipedMek(); + mek.setChassis(chassis); + mek.setModel(model); + return mek; + } + + private static void writeImage(Path directory, String fileName) throws IOException { + Files.createDirectories(directory); + Files.write(directory.resolve(fileName), new byte[] { 1, 2, 3 }); + } + + private static List fileNames(List files) { + return files.stream().map(File::getName).toList(); + } + + @Test + void findsEveryImageInTheChassisDirectoryRegardlessOfFileName(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "anything at all.png"); + writeImage(fluffDir.resolve("Atlas"), "second.jpg"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertEquals(List.of("anything at all.png", "second.jpg"), fileNames(found)); + } + + @Test + void modelDirectoryWinsOverChassisDirectory(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "generic atlas.png"); + writeImage(fluffDir.resolve("Atlas").resolve("Atlas AS7-D"), "specific.png"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertEquals(List.of("specific.png"), fileNames(found)); + } + + @Test + void chassisDirectoryIsUsedWhenNoModelDirectoryMatches(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "generic atlas.png"); + writeImage(fluffDir.resolve("Atlas").resolve("Atlas AS7-K"), "other model.png"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertEquals(List.of("generic atlas.png"), fileNames(found)); + } + + @Test + void emptyModelMatchesTheEmptyModelDirectory(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "generic atlas.png"); + writeImage(fluffDir.resolve("Atlas").resolve("Atlas " + FluffImageHelper.EMPTY_MODEL_DIR_NAME), + "no model.png"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", ""), fluffDir.toFile()); + + assertEquals(List.of("no model.png"), fileNames(found)); + } + + @Test + void multipleImagesAreReturnedInDeterministicOrder(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "charlie.png"); + writeImage(fluffDir.resolve("Atlas"), "alpha.png"); + writeImage(fluffDir.resolve("Atlas"), "bravo.png"); + + List firstCall = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + List secondCall = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertEquals(List.of("alpha.png", "bravo.png", "charlie.png"), fileNames(firstCall)); + assertEquals(fileNames(firstCall), fileNames(secondCall)); + } + + @Test + void nonImageFilesAreIgnored(@TempDir Path fluffDir) throws IOException { + writeImage(fluffDir.resolve("Atlas"), "picture.png"); + writeImage(fluffDir.resolve("Atlas"), "picture data.yaml"); + writeImage(fluffDir.resolve("Atlas"), "readme.txt"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertEquals(List.of("picture.png"), fileNames(found)); + } + + @Test + void clanChassisVariantDirectoriesAreFound(@TempDir Path fluffDir) throws IOException { + Mek timberWolf = mek("Mad Cat", "Prime"); + timberWolf.setClanChassisName("Timber Wolf"); + writeImage(fluffDir.resolve("Timber Wolf (Mad Cat)"), "timberwolf.png"); + + List found = FluffImageHelper.getFluffInChassisDirs(timberWolf, fluffDir.toFile()); + + assertEquals(List.of("timberwolf.png"), fileNames(found)); + } + + @Test + void aFileWhereAChassisDirectoryWouldBeIsIgnored(@TempDir Path fluffDir) throws IOException { + // A stray file named like a chassis must not be treated as a directory to scan + writeImage(fluffDir, "Atlas"); + + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertTrue(found.isEmpty(), "A regular file named like the chassis must not yield fluff images"); + } + + @Test + void missingChassisDirectoryYieldsNoImages(@TempDir Path fluffDir) { + List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile()); + + assertTrue(found.isEmpty()); + } +}