diff --git a/megamek/data/scenarios/Northwind Highlanders/NWH2_InstantFame.mms b/megamek/data/scenarios/Northwind Highlanders/NWH2_InstantFame.mms index 285fce54bbd..029040a971e 100644 --- a/megamek/data/scenarios/Northwind Highlanders/NWH2_InstantFame.mms +++ b/megamek/data/scenarios/Northwind Highlanders/NWH2_InstantFame.mms @@ -4,9 +4,10 @@ MMSVersion=1 -Name=Instant Fame:Northwind Highlanders +Name=Instant Fame: Northwind Highlanders +Description=This scenario visits a fight of McCormack's Fusiliers in the campaign on Bellatrix against Ajax's Avengers. The retreating Avengers tried deperately to shake the Highlanders as they fled east across the southern continent of Bellatrix, but to no avail. Exhausted and bogged down by bad weather, the Avengers had no choice but to stand and fight. -Description=The campaign on Bellatrix against Ajax's Avengers brought McCormack's Fusiliers instant fame. Arriving to find themselves outnumbered three-to-one, the Fusiliers quickly used their superior grasp of tactics and their ferocity to smash the defending unit. The retreating Avengers tried deperately to shake the Highlanders as they fled east across the southern continent of Bellatrix, but to no avail. Exhausted and bogged down by bad weather, the Avengers had no choice but to stand and fight. +#FullDescription=The campaign on Bellatrix against Ajax's Avengers brought McCormack's Fusiliers instant fame. Arriving to find themselves outnumbered three-to-one, the Fusiliers quickly used their superior grasp of tactics and their ferocity to smash the defending unit. The retreating Avengers tried deperately to shake the Highlanders as they fled east across the southern continent of Bellatrix, but to no avail. Exhausted and bogged down by bad weather, the Avengers had no choice but to stand and fight. BoardWidth=2 BoardHeight=1 diff --git a/megamek/i18n/megamek/client/messages.properties b/megamek/i18n/megamek/client/messages.properties index c1e64a58f6b..048cb5f0c12 100644 --- a/megamek/i18n/megamek/client/messages.properties +++ b/megamek/i18n/megamek/client/messages.properties @@ -4539,3 +4539,6 @@ CMVPanel.font=Font: Gamemaster.Gamemaster=Gamemaster Gamemaster.EditDamage=Edit Damage Gamemaster.Configure=Configure + +# Scenario Chooser +ScenarioChooser.title=Choose Scenario \ No newline at end of file diff --git a/megamek/i18n/megamek/server/messages.properties b/megamek/i18n/megamek/client/scenario/messages.properties similarity index 100% rename from megamek/i18n/megamek/server/messages.properties rename to megamek/i18n/megamek/client/scenario/messages.properties diff --git a/megamek/i18n/megamek/server/messages_en.properties b/megamek/i18n/megamek/client/scenario/messages_en.properties similarity index 100% rename from megamek/i18n/megamek/server/messages_en.properties rename to megamek/i18n/megamek/client/scenario/messages_en.properties diff --git a/megamek/i18n/megamek/server/messages_es.properties b/megamek/i18n/megamek/client/scenario/messages_es.properties similarity index 100% rename from megamek/i18n/megamek/server/messages_es.properties rename to megamek/i18n/megamek/client/scenario/messages_es.properties diff --git a/megamek/src/megamek/SuiteConstants.java b/megamek/src/megamek/SuiteConstants.java index f04c01b693f..cafceb57769 100644 --- a/megamek/src/megamek/SuiteConstants.java +++ b/megamek/src/megamek/SuiteConstants.java @@ -51,6 +51,7 @@ public abstract class SuiteConstants { //region File Formats public static final String TRUETYPE_FONT = ".ttf"; + public static final String SCENARIO_EXT = ".mms"; //endregion File Formats //region File Paths diff --git a/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java b/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java index 131da058a40..43993cd291f 100644 --- a/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java +++ b/megamek/src/megamek/client/ui/swing/CommonSettingsDialog.java @@ -3109,6 +3109,10 @@ public static List filteredFiles(File path, String fileEnding) { } public static List filteredFilesWithSubDirs(File path, String fileEnding) { + if (!path.exists()) { + LogManager.getLogger().warn("Path " + path + " does not exist."); + return new ArrayList<>(); + } try (Stream entries = Files.walk(path.toPath())) { return entries.map(Objects::toString).filter(name -> name.endsWith(fileEnding)).collect(toList()); } catch (IOException e) { diff --git a/megamek/src/megamek/client/ui/swing/MegaMekGUI.java b/megamek/src/megamek/client/ui/swing/MegaMekGUI.java index 64e5383db87..9d9113b5fe1 100644 --- a/megamek/src/megamek/client/ui/swing/MegaMekGUI.java +++ b/megamek/src/megamek/client/ui/swing/MegaMekGUI.java @@ -30,6 +30,7 @@ import megamek.client.ui.swing.dialog.MainMenuUnitBrowserDialog; import megamek.client.ui.swing.gameConnectionDialogs.ConnectDialog; import megamek.client.ui.swing.gameConnectionDialogs.HostDialog; +import megamek.client.ui.swing.scenario.ScenarioChooser; import megamek.client.ui.swing.skinEditor.SkinEditorMainGUI; import megamek.client.ui.swing.tooltip.PilotToolTip; import megamek.client.ui.swing.util.MegaMekController; @@ -52,7 +53,7 @@ import megamek.common.util.ImageUtil; import megamek.common.util.fileUtils.MegaMekFile; import megamek.server.GameManager; -import megamek.server.ScenarioLoader; +import megamek.common.scenario.ScenarioLoader; import megamek.server.Server; import megamek.utilities.xml.MMXMLUtility; import org.apache.logging.log4j.LogManager; @@ -687,48 +688,13 @@ public void quickLoadGame() { * Host a game constructed from a scenario file */ void scenario() { - JFileChooser fc = new JFileChooser("data" + File.separatorChar + "scenarios"); - fc.setLocation(frame.getLocation().x + 150, frame.getLocation().y + 100); - fc.setDialogTitle(Messages.getString("MegaMek.SelectScenarioDialog.title")); - - FileFilter filter = new FileFilter() { - - @Override - public boolean accept(File f) { - if (f.isDirectory()) { - return true; - } - - String ext = null; - String s = f.getName(); - int i = s.lastIndexOf('.'); - - if ((i > 0) && (i < (s.length() - 1))) { - ext = s.substring(i + 1).toLowerCase(); - } - - if (ext != null) { - return ext.equalsIgnoreCase("mms"); - } - - return false; - } - - @Override - public String getDescription() { - return "MegaMek Scenario Files"; - } - - }; - fc.setFileFilter(filter); - - int returnVal = fc.showOpenDialog(frame); - if ((returnVal != JFileChooser.APPROVE_OPTION) || (fc.getSelectedFile() == null)) { - // I want a file, y'know! + ScenarioChooser scenarioChooser = new ScenarioChooser(frame); + scenarioChooser.setVisible(true); + if (scenarioChooser.getSelectedScenarioFilename() == null) { return; } - ScenarioLoader sl = new ScenarioLoader(fc.getSelectedFile()); + ScenarioLoader sl = new ScenarioLoader(new File(scenarioChooser.getSelectedScenarioFilename())); Game g; try { g = sl.createGame(); diff --git a/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java b/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java new file mode 100644 index 00000000000..b22d7260327 --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.scenario; + +import megamek.MMConstants; +import megamek.client.ui.Messages; +import megamek.client.ui.baseComponents.AbstractButtonDialog; +import megamek.client.ui.swing.ButtonEsc; +import megamek.client.ui.swing.CloseAction; +import megamek.client.ui.swing.CommonSettingsDialog; +import megamek.client.ui.swing.dialog.DialogButton; +import megamek.client.ui.swing.util.ClickableLabel; +import megamek.client.ui.swing.util.UIUtil; +import megamek.common.Configuration; +import megamek.common.annotations.Nullable; +import megamek.common.preference.PreferenceManager; +import megamek.common.scenario.ScenarioInfo; +import megamek.common.scenario.ScenarioLoader; +import org.apache.logging.log4j.LogManager; + +import javax.swing.*; +import javax.swing.border.MatteBorder; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.awt.event.MouseEvent; +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * This dialog lists all scenarios found in MM's scenario directory as well as the corresponding user directory. + * The scenarios are grouped by subdirectory (only the one directly below scenarios, further subdirectories + * are scanned for scenarios but not used for grouping). After closing, a chosen scenario can be retrieved + * by calling {@link #getSelectedScenarioFilename()}. As a fallback, the dialog also allows choosing a + * scenario by file. + */ +public class ScenarioChooser extends AbstractButtonDialog { + + private final JTabbedPane tabbedPane = new JTabbedPane(); + private final Map> sortedScenarios = sortScenarios(getScenarioInfos()); + private String scenarioFileName; + + public ScenarioChooser(final JFrame parentFrame) { + super(parentFrame, "ScenarioChooser", "ScenarioChooser.title"); + initialize(); + setMinimumSize(UIUtil.scaleForGUI(ScenarioInfoPanel.BASE_MINIMUM_WIDTH, ScenarioInfoPanel.BASE_MINIMUM_HEIGHT * 3)); + } + + /** + * @return the selected preset, or null if the dialog was cancelled or no preset was selected + */ + public @Nullable String getSelectedScenarioFilename() { + if (scenarioFileName != null) { + return scenarioFileName; + } + Component selectedTab = tabbedPane.getSelectedComponent(); + if (!(selectedTab instanceof ScenarioInfoPane) || !getResult().isConfirmed()) { + return null; + } else { + return ((ScenarioInfoPane) selectedTab).getSelectedPreset().getFileName(); + } + } + + @Override + protected Container createCenterPane() { + for (String directory : sortedScenarios.keySet()) { + if (!sortedScenarios.get(directory).isEmpty()) { + ScenarioInfoPane pane = new ScenarioInfoPane(sortedScenarios.get(directory)); + tabbedPane.addTab(directory.isBlank() ? "Basic" : directory, pane); + } + } + return tabbedPane; + } + + @Override + protected JPanel createButtonPanel() { + JButton cancelButton = new ButtonEsc(new CloseAction(this)); + JButton okButton = new DialogButton(Messages.getString("Ok.text")); + okButton.addActionListener(this::okButtonActionPerformed); + + ClickableLabel chooseFileLabel = new ClickableLabel(this::selectFromFile); + chooseFileLabel.setText("Select from File..."); + + JPanel buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.LINE_AXIS)); + buttonPanel.setBorder(BorderFactory.createCompoundBorder( + new MatteBorder(1, 0, 0, 0, UIManager.getColor("Separator.foreground")), + new UIUtil.ScaledEmptyBorder(10, 0, 10, 0))); + + Box verticalBox = Box.createVerticalBox(); + verticalBox.add(Box.createVerticalGlue()); + JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + panel.add(chooseFileLabel); + panel.add(Box.createHorizontalGlue()); + verticalBox.add(panel); + verticalBox.add(Box.createVerticalGlue()); + + JPanel rightButtonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 20, 0)); + rightButtonPanel.add(okButton); + rightButtonPanel.add(cancelButton); + getRootPane().setDefaultButton(okButton); + + buttonPanel.add(Box.createHorizontalStrut(20)); + buttonPanel.add(verticalBox); + buttonPanel.add(rightButtonPanel); + return buttonPanel; + } + + private static List getScenarioInfos() { + List scenarios = new ArrayList<>(parseScenariosInDirectory(Configuration.scenariosDir())); + + String userDir = PreferenceManager.getClientPreferences().getUserDir(); + if (!userDir.isBlank()) { + File subDir = new File(userDir, Configuration.scenariosDir().toString()); + parseScenariosInDirectory(subDir); + } + return scenarios; + } + + /** + * Searches the provided directory and all subdirectories for scenario files and returns a list of + * them. + * + * @param directory the directory to parse + * @return a List of scenarios + */ + private static List parseScenariosInDirectory(final File directory) { + LogManager.getLogger().info("Parsing scenarios from " + directory); + List scenarios = new ArrayList<>(); + for (String scenarioFile : CommonSettingsDialog.filteredFilesWithSubDirs(directory, MMConstants.SCENARIO_EXT)) { + try { + ScenarioInfo scenario = new ScenarioLoader(new File(scenarioFile)).load(); + scenarios.add(scenario); + } catch (Exception ex) { + LogManager.getLogger().error("Failed to parse scenario " + scenarioFile, ex); + } + } + return scenarios; + } + + /** Groups the given scenarios by the first subdirectory under scenarios they're in (disregards any deeper dirs) */ + private Map> sortScenarios(List scenarioInfos) { + return scenarioInfos.stream().collect(Collectors.groupingBy(this::getSubDirectory, Collectors.toList())); + } + + @Override + public void setVisible(boolean b) { + if (b) { + UIUtil.adjustDialog(this, UIUtil.FONT_SCALE1); + } + super.setVisible(b); + } + + private void selectFromFile(MouseEvent event) { + JFileChooser fc = new JFileChooser(Configuration.scenariosDir()); + fc.setFileFilter(new FileNameExtensionFilter("Scenario files", "mms")); + fc.setDialogTitle(Messages.getString("MegaMek.SelectScenarioDialog.title")); + int returnVal = fc.showOpenDialog(this); + if ((returnVal == JFileChooser.APPROVE_OPTION) && (fc.getSelectedFile() != null)) { + scenarioFileName = fc.getSelectedFile().toString(); + okButtonActionPerformed(null); + } + } + + /** + * @return The first subdirectory under the scenarios directory that the scenario is in; a scenario + * in scenarios/tukkayid/secondencounter/ would return "tukkayid". This is used for grouping + */ + private String getSubDirectory(ScenarioInfo scenarioInfo) { + String scenariosDir = Configuration.scenariosDir().toString(); + if (!scenarioInfo.getFileName().contains(scenariosDir)) { + return ""; + } else { + return subDirUnderScenarios(directoriesAsList(scenarioInfo.getFileName())); + } + } + + /** @return The directories (and filename) present in the given full fileName as a list. */ + private static List directoriesAsList(String fileName) { + Path path = Paths.get(fileName); + List result = new ArrayList<>(); + for (int i = 0; i < path.getNameCount(); i++) { + result.add(path.getName(i).toString()); + } + return result; + } + + private String subDirUnderScenarios(List directoryList) { + if (!directoryList.contains("scenarios")) { + return ""; + } else { + int index = directoryList.indexOf("scenarios"); + // The next entry must not be the last, as the last entry is the scenario filename + return (index + 2 < directoryList.size()) ? directoryList.get(index + 1) : ""; + } + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPane.java b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPane.java new file mode 100644 index 00000000000..b32447c1afd --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPane.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.scenario; + +import megamek.common.annotations.Nullable; +import megamek.common.scenario.ScenarioInfo; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.List; + +/** + * This panel displays a scrollable vertical list of ScenarioInfo panels. + */ +class ScenarioInfoPane extends JScrollPane { + + private JList presets; + private final List scenarioInfoList = new ArrayList<>(); + + public ScenarioInfoPane(List scenarioInfoList) { + this.scenarioInfoList.addAll(scenarioInfoList); + setBorder(null); + getVerticalScrollBar().setUnitIncrement(16); + initialize(); + } + + public @Nullable ScenarioInfo getSelectedPreset() { + return presets.getSelectedValue(); + } + + protected void initialize() { + final DefaultListModel listModel = new DefaultListModel<>(); + listModel.addAll(scenarioInfoList); + presets = new JList<>(listModel); + presets.setName("ScenarioInfoList"); + presets.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + presets.setLayoutOrientation(JList.VERTICAL); + presets.setCellRenderer(new ScenarioInfoRenderer()); + + var panel = new JPanel(); + panel.add(presets); + setViewportView(panel); + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPanel.java b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPanel.java new file mode 100644 index 00000000000..2c94dd049bd --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoPanel.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.scenario; + +import megamek.client.ui.swing.util.DashedSeparator; +import megamek.client.ui.swing.util.LocationBorder; +import megamek.client.ui.swing.util.UIUtil; +import megamek.common.scenario.ScenarioInfo; + +import javax.swing.*; +import java.awt.*; + +/** + * This panel displays a single {@link ScenarioInfo} object in a well-formatted manner for display in the + * {@link ScenarioChooser}. + */ +public class ScenarioInfoPanel extends JPanel { + + static final int BASE_MINIMUM_WIDTH = 600; + static final int BASE_MINIMUM_HEIGHT = 100; + + private final JLabel lblTitle = new HeaderLabel(); + private final DescriptionLabel textDescription2 = new DescriptionLabel(); + + public ScenarioInfoPanel() { + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(10, 5, 10, 5), + new LocationBorder(UIManager.getColor("Separator.foreground"), 3))); + setName("ScenarioInfoPanel"); + setLayout(new BoxLayout(this, BoxLayout.PAGE_AXIS)); + + lblTitle.setName("lblTitle"); + lblTitle.setAlignmentX(Component.CENTER_ALIGNMENT); + lblTitle.setForeground(UIUtil.uiLightGreen()); + lblTitle.setFont(new Font("Exo 2", Font.BOLD, UIUtil.FONT_SCALE1)); + add(lblTitle); + add(UIUtil.scaledVerticalSpacer(10)); + add(new DashedSeparator(UIUtil.uiLightGreen(), 0.9f, 2f)); + add(UIUtil.scaledVerticalSpacer(10)); + + textDescription2.setFont(new Font("Noto Sans", Font.PLAIN, UIUtil.FONT_SCALE1)); + textDescription2.setAlignmentX(0.5f); + textDescription2.setVerticalAlignment(SwingConstants.TOP); + textDescription2.setBorder(new UIUtil.ScaledEmptyBorder(0, 10, 0, 10)); + add(textDescription2); + } + + protected void updateFromPreset(final ScenarioInfo preset) { + lblTitle.setText(preset.getName()); + textDescription2.setText("" + preset.getDescription()); + } + + private static class DescriptionLabel extends JLabel { + @Override + public Dimension getMaximumSize() { + return UIUtil.scaleForGUI(BASE_MINIMUM_WIDTH, BASE_MINIMUM_HEIGHT); + } + + @Override + public Dimension getPreferredSize() { + return UIUtil.scaleForGUI(BASE_MINIMUM_WIDTH, BASE_MINIMUM_HEIGHT); + } + } + + private static class HeaderLabel extends JLabel { + @Override + public void setFont(Font font) { + // Keep a higher font size; UIUtil.adjustDialog sets everything to the same absolute font size + font = font.deriveFont(1.4f * font.getSize()); + super.setFont(font); + } + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoRenderer.java b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoRenderer.java new file mode 100644 index 00000000000..8e285d4d79d --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/scenario/ScenarioInfoRenderer.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.scenario; + +import megamek.common.scenario.ScenarioInfo; + +import javax.swing.*; +import java.awt.*; + +/** + * This is a JList renderer for {@link ScenarioInfoPanel}. + */ +public class ScenarioInfoRenderer extends ScenarioInfoPanel implements ListCellRenderer { + + @Override + public Component getListCellRendererComponent(final JList list, + final ScenarioInfo value, final int index, + final boolean isSelected, + final boolean cellHasFocus) { + final Color foreground = new Color((isSelected + ? list.getSelectionForeground() : list.getForeground()).getRGB()); + final Color background = new Color((isSelected + ? list.getSelectionBackground() : list.getBackground()).getRGB()); + setForeground(foreground); + setBackground(background); + updateFromPreset(value); + return this; + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/util/ClickableLabel.java b/megamek/src/megamek/client/ui/swing/util/ClickableLabel.java new file mode 100644 index 00000000000..445b979c8bf --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/util/ClickableLabel.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.util; + +import javax.swing.*; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * This is a specialized label that acts as a replacement for a button. It underlines its text when hovered + * like a hyperlink and so indicates that it is clickable. Note that the label always uses HTML to underline + * its text internally and so does not need an additional HTML tag to show HTML content. + * This can be used in circumstances where the button's function is less important or a fallback and the + * label is used to call less attention to it than a button would. + * Another use is when a text info label should have the button functionality only as a secondary function. + * The label calls a callback method when it is clicked. + */ +public class ClickableLabel extends JLabel implements MouseListener { + + private static final String HOVERED_PREFIX = ""; + private static final String NON_HOVERED_PREFIX = ""; + private boolean isHovered = false; + private String baseText = ""; + private final Consumer clickCallback; + + /** + * Creates a new clickable label. The given callback method is called when the + * label is mouse-clicked. + * + * @param clickCallback The method to call when the label is clicked + */ + public ClickableLabel(Consumer clickCallback) { + this.clickCallback = Objects.requireNonNull(clickCallback); + addMouseListener(this); + } + + @Override + public void setText(String text) { + baseText = text; + updateText(); + } + + private void updateText() { + super.setText((isHovered ? HOVERED_PREFIX : NON_HOVERED_PREFIX) + baseText); + } + + @Override + public void mouseClicked(MouseEvent e) { + clickCallback.accept(e); + } + + @Override + public void mousePressed(MouseEvent e) { } + + @Override + public void mouseReleased(MouseEvent e) { } + + @Override + public void mouseEntered(MouseEvent e) { + isHovered = true; + updateText(); + } + + @Override + public void mouseExited(MouseEvent e) { + isHovered = false; + updateText(); + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/util/DashedSeparator.java b/megamek/src/megamek/client/ui/swing/util/DashedSeparator.java new file mode 100644 index 00000000000..2c1f86fa090 --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/util/DashedSeparator.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.client.ui.swing.util; + +import javax.swing.*; +import java.awt.*; + +/** + * This component draws a dashed horizontal line, similarly to a JSeparator. Its color and line strength (stroke + * width) can be set. Also, its width (the amount of available horizontal space it covers) can be set. + */ +public class DashedSeparator extends JComponent { + + private final Color color; + private final float relativeWidth; + private final float strokeWidth; + + /** + * Creates a dashed separator that uses the UI LaF's separator color and a line width of 1. It uses the + * available horizontal space. + */ + public DashedSeparator() { + this(UIManager.getColor("Separator.foreground")); + } + + /** + * Creates a dashed separator of the given color and a line width of 1. It uses the + * available horizontal space. + */ + public DashedSeparator(Color color) { + this(color, 1, 1); + } + + /** + * Creates a dashed separator that uses the UI LaF's separator color and the given values for the + * stroke width (line thickness) and the relative horizontal length. The values for relativeWidth + * should be between 0 and 1; 1 meaning the line extends for all the horizontal width; 0.5 meaning + * only half the horizontal space is used (the line is centered). + */ + public DashedSeparator(float relativeWidth, float strokeWidth) { + this(UIManager.getColor("Separator.foreground"), relativeWidth, strokeWidth); + } + + /** + * Creates a dashed separator of the given color and the given values for the + * stroke width (line thickness) and the relative horizontal length. The values for relativeWidth + * should be between 0 and 1; 1 meaning the line extends for all the horizontal width; 0.5 meaning + * only half the horizontal space is used (the line is centered). + */ + public DashedSeparator(Color color, float relativeWidth, float strokeWidth) { + this.color = color; + this.relativeWidth = relativeWidth; + this.strokeWidth = strokeWidth; + } + + @Override + public Dimension getMaximumSize() { + return new Dimension(super.getMaximumSize().width, (int) (strokeWidth + 2)); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(super.getPreferredSize().width, (int) (strokeWidth + 2)); + } + + @Override + public Dimension getMinimumSize() { + return new Dimension(super.getMinimumSize().width, (int) (strokeWidth + 2)); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + try { + Stroke dashed = new BasicStroke(strokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, + 0, new float[]{9}, 0); + g2.setStroke(dashed); + g2.setColor(color); + g2.drawLine((int) (getWidth() * (1 - relativeWidth) / 2), 0, + (int) (getWidth() * (1 + relativeWidth) / 2), 0); + } finally { + g2.dispose(); + } + } +} \ No newline at end of file diff --git a/megamek/src/megamek/client/ui/swing/util/LocationBorder.java b/megamek/src/megamek/client/ui/swing/util/LocationBorder.java new file mode 100644 index 00000000000..51e599dd27c --- /dev/null +++ b/megamek/src/megamek/client/ui/swing/util/LocationBorder.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ + +package megamek.client.ui.swing.util; + +import javax.swing.border.AbstractBorder; +import java.awt.*; +import java.awt.geom.Path2D; + +public class LocationBorder extends AbstractBorder { + + private final static float CL = 40; + private final static float HCL = 10; + private final static float HCH = 5; + + /** + * Thickness of the border. + */ + protected float thickness; + + /** + * Color of the border. + */ + protected Color lineColor; + + public LocationBorder(Color color, float thickness) { + this.thickness = thickness; + lineColor = color; + } + + @Override + public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) { + if ((thickness > 0) && (lineColor != null) && (width > 0) && (height > 0) && (g instanceof Graphics2D)) { + Graphics2D g2d = (Graphics2D) g; + + Color oldColor = g2d.getColor(); + Stroke oldStroke = g2d.getStroke(); + + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setColor(this.lineColor); + g2d.setStroke(new BasicStroke(thickness)); + + Path2D.Float line = new Path2D.Float(); + float xc = x + thickness; + float xw = x + width - thickness; + float yc = y + thickness; + float yh = y + height - thickness; + + if (width < 2 * CL) { + float hcl = 0.5f * HCL / CL * width; + line.moveTo(xc, yc + HCH); + line.curveTo(xc + hcl, yc, xc + hcl, yc, width / 2.0d, yc); + line.curveTo(xw - hcl, yc, xw - hcl, yc, xw, yc + HCH); + line.lineTo(xw, yh - HCH); + line.curveTo(xw - hcl, yh, xw - hcl, yh, width / 2.0d, yh); + line.curveTo(xc + hcl, yh, xc + hcl, yh, xc, yh - HCH); + } else { + line.moveTo(xc, yc + HCH); + line.curveTo(xc + HCL, yc, xc + HCL, yc, xc + CL, yc); + line.lineTo(xw - CL, yc); + line.curveTo(xw - HCL, yc, xw - HCL, yc, xw, yc + HCH); + line.lineTo(xw, yh - HCH); + line.curveTo(xw - HCL, yh, xw - HCL, yh, xw - CL, yh); + line.lineTo(xc + CL, yh); + line.curveTo(xc + HCL, yh, xc + HCL, yh, xc, yh - HCH); + } + line.closePath(); + g2d.draw(line); + + g2d.setStroke(oldStroke); + g2d.setColor(oldColor); + } + } + + @Override + public Insets getBorderInsets(Component c, Insets insets) { + insets.set((int) (HCH + 1.5 * thickness), (int) (3 + 1.5 * thickness), + (int) (HCH + 1.5 * thickness), (int) (3 + 1.5 * thickness)); + return insets; + } + +} diff --git a/megamek/src/megamek/client/ui/swing/util/UIUtil.java b/megamek/src/megamek/client/ui/swing/util/UIUtil.java index ff184e06366..f7cbc6a90f8 100644 --- a/megamek/src/megamek/client/ui/swing/util/UIUtil.java +++ b/megamek/src/megamek/client/ui/swing/util/UIUtil.java @@ -350,8 +350,12 @@ public static float scaleForGUI(float value) { } public static Dimension scaleForGUI(Dimension dim) { + return scaleForGUI(dim.width, dim.height); + } + + public static Dimension scaleForGUI(int width, int height) { float scale = GUIPreferences.getInstance().getGUIScale(); - return new Dimension((int) (scale * dim.width), (int) (scale * dim.height)); + return new Dimension((int) (scale * width), (int) (scale * height)); } /** diff --git a/megamek/src/megamek/common/scenario/ScenarioInfo.java b/megamek/src/megamek/common/scenario/ScenarioInfo.java new file mode 100644 index 00000000000..df99f4ebe4f --- /dev/null +++ b/megamek/src/megamek/common/scenario/ScenarioInfo.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022, 2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ +package megamek.common.scenario; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; + +/** + * This class holds all scenario info loaded from a scenario (.mms) file. It is a map of constants given in + * {@link ScenarioLoader} to a list of data for that constant. + */ +public class ScenarioInfo extends HashMap> { + + /** + * @return The name of the scenario; keyword {@link ScenarioLoader#NAME} + */ + public String getName() { + return getString(ScenarioLoader.NAME); + } + + /** + * @return The description of the scenario; keyword {@link ScenarioLoader#DESCRIPTION} + */ + public String getDescription() { + return getString(ScenarioLoader.DESCRIPTION); + } + + /** + * @return The filename including directories of the scenario + */ + public String getFileName() { + return getString(ScenarioLoader.FILENAME); + } + + public void put(String key, String value) { + Collection values = get(key); + if (values == null) { + values = new ArrayList<>(); + put(key, values); + } + values.add(value); + } + + public String getString(String key) { + return getString(key, ScenarioLoader.SEPARATOR_COMMA); + } + + public String getString(String key, String separator) { + Collection values = get(key); + if ((values == null) || values.isEmpty()) { + return null; + } + + boolean firstElement = true; + StringBuilder sb = new StringBuilder(); + for (String val : values) { + if (firstElement) { + firstElement = false; + } else { + sb.append(separator); + } + sb.append(val); + } + return sb.toString(); + } + + /** + * @return the number of values for this key in the file + */ + public int getNumValues(String key) { + Collection values = get(key); + return (values == null) ? 0 : values.size(); + } +} \ No newline at end of file diff --git a/megamek/src/megamek/server/ScenarioLoader.java b/megamek/src/megamek/common/scenario/ScenarioLoader.java similarity index 94% rename from megamek/src/megamek/server/ScenarioLoader.java rename to megamek/src/megamek/common/scenario/ScenarioLoader.java index f6da2e63ed0..9fde10877f5 100644 --- a/megamek/src/megamek/server/ScenarioLoader.java +++ b/megamek/src/megamek/common/scenario/ScenarioLoader.java @@ -1,19 +1,22 @@ /* - * MegaMek - Copyright (C) 2003, 2004, 2005 Ben Mazur (bmazur@sev.org) - * ScenarioLoader - Copyright (C) 2002 Josh Yockey - * Copyright © 2013 Edward Cullen (eddy@obsessedcomputers.co.uk) + * Copyright (c) 2022 - The MegaMek Team. All Rights Reserved. * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. + * This file is part of MegaMek. * - * This program 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. + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . */ -package megamek.server; +package megamek.common.scenario; import megamek.client.generator.RandomGenderGenerator; import megamek.common.*; @@ -26,6 +29,8 @@ import megamek.common.options.OptionsConstants; import megamek.common.util.BoardUtilities; import megamek.common.util.fileUtils.MegaMekFile; +import megamek.server.GameManager; +import megamek.server.Messages; import org.apache.logging.log4j.LogManager; import java.io.*; @@ -37,8 +42,12 @@ public class ScenarioLoader { private static final String COMMENT_MARK = "#"; + protected static final String NAME = "Name"; + protected static final String DESCRIPTION = "Description"; + protected static final String FILENAME = "FileName"; + private static final String SEPARATOR_PROPERTY = "="; - private static final String SEPARATOR_COMMA = ","; + static final String SEPARATOR_COMMA = ","; private static final String SEPARATOR_SPACE = " "; private static final String SEPARATOR_COLON = ":"; private static final String SEPARATOR_UNDERSCORE = "_"; @@ -345,7 +354,7 @@ else if (chp.entity instanceof Tank) { public Game createGame() throws Exception { LogManager.getLogger().info("Loading scenario from " + scenarioFile); - StringMultiMap p = load(); + ScenarioInfo p = load(); String sCheck = p.getString(PARAM_MMSVERSION); if (sCheck == null) { @@ -412,7 +421,7 @@ public Game createGame() throws Exception { return g; } - private void parsePlanetaryConditions(Game g, StringMultiMap p) { + private void parsePlanetaryConditions(Game g, ScenarioInfo p) { if (p.containsKey(PARAM_PLANETCOND_TEMP)) { g.getPlanetaryConditions().setTemperature(Integer.parseInt(p.getString(PARAM_PLANETCOND_TEMP))); } @@ -474,7 +483,7 @@ private void parsePlanetaryConditions(Game g, StringMultiMap p) { } } - private Collection buildFactionEntities(StringMultiMap p, Player player) throws ScenarioLoaderException { + private Collection buildFactionEntities(ScenarioInfo p, Player player) throws ScenarioLoaderException { String faction = player.getName(); Pattern unitPattern = Pattern.compile(String.format("^Unit_\\Q%s\\E_[^_]+$", faction)); Pattern unitDataPattern = Pattern.compile(String.format("^(Unit_\\Q%s\\E_[^_]+)_([A-Z][^_]+)$", faction)); @@ -713,7 +722,7 @@ private String getFactionParam(String faction, String param) { return param + SEPARATOR_UNDERSCORE + faction; } - private Collection createPlayers(StringMultiMap p) throws ScenarioLoaderException { + private Collection createPlayers(ScenarioInfo p) throws ScenarioLoaderException { String sFactions = p.getString(PARAM_FACTIONS); if ((sFactions == null) || sFactions.isEmpty()) { throw new ScenarioLoaderException("missingFactions"); @@ -780,7 +789,7 @@ private Collection createPlayers(StringMultiMap p) throws ScenarioLoader /** * Load board files and create the megaboard. */ - private Board createBoard(StringMultiMap p) throws ScenarioLoaderException { + private Board createBoard(ScenarioInfo p) throws ScenarioLoaderException { int mapWidth = 16, mapHeight = 17; if (p.getString(PARAM_MAP_WIDTH) == null) { LogManager.getLogger().info("No map width specified; using " + mapWidth); @@ -888,8 +897,9 @@ private Board createBoard(StringMultiMap p) throws ScenarioLoaderException { return BoardUtilities.combine(mapWidth, mapHeight, nWidth, nHeight, ba, rotateBoard, MapSettings.MEDIUM_GROUND); } - private StringMultiMap load() throws ScenarioLoaderException { - StringMultiMap props = new StringMultiMap(); + public ScenarioInfo load() throws ScenarioLoaderException { + ScenarioInfo props = new ScenarioInfo(); + props.put(FILENAME, List.of(scenarioFile.toString())); try (FileInputStream fis = new FileInputStream(scenarioFile); InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8); BufferedReader br = new BufferedReader(isr)) { @@ -923,7 +933,7 @@ private StringMultiMap load() throws ScenarioLoaderException { /** * Parses out the external game id from the scenario file */ - private int parseExternalGameId(StringMultiMap p) { + private int parseExternalGameId(ScenarioInfo p) { String sExternalId = p.getString(PARAM_GAME_EXTERNAL_ID); int ExternalGameId = 0; if (sExternalId != null) { @@ -949,7 +959,7 @@ public boolean isSinglePlayer() { * defaultValue. When the key is present, interprets "true" and "on" and "1" * as true and everything else as false. */ - private boolean parseBoolean(StringMultiMap p, String key, boolean defaultValue) { + private boolean parseBoolean(ScenarioInfo p, String key, boolean defaultValue) { boolean result = defaultValue; if (p.containsKey(key)) { if (p.getString(key).equalsIgnoreCase("true") @@ -1127,7 +1137,7 @@ public void addSpecificDamage(String s) { } } - private static class ScenarioLoaderException extends Exception { + public static class ScenarioLoaderException extends Exception { private static final long serialVersionUID = 8622648319531348199L; private final Object[] params; @@ -1156,46 +1166,4 @@ public String getMessage() { } } - private static class StringMultiMap extends HashMap> { - private static final long serialVersionUID = 2171662843329151622L; - - public void put(String key, String value) { - Collection values = get(key); - if (values == null) { - values = new ArrayList<>(); - put(key, values); - } - values.add(value); - } - - public String getString(String key) { - return getString(key, SEPARATOR_COMMA); - } - - public String getString(String key, String separator) { - Collection values = get(key); - if ((values == null) || values.isEmpty()) { - return null; - } - - boolean firstElement = true; - StringBuilder sb = new StringBuilder(); - for (String val : values) { - if (firstElement) { - firstElement = false; - } else { - sb.append(separator); - } - sb.append(val); - } - return sb.toString(); - } - - /** @return the number of values for this key in the file */ - public int getNumValues(String key) { - Collection values = get(key); - return (values == null) ? 0 : values.size(); - } - } - } diff --git a/megamek/src/megamek/test/ScenarioLoaderTest.java b/megamek/src/megamek/test/ScenarioLoaderTest.java index d8d7dd7f7c3..48ba3f6e1bf 100644 --- a/megamek/src/megamek/test/ScenarioLoaderTest.java +++ b/megamek/src/megamek/test/ScenarioLoaderTest.java @@ -11,7 +11,7 @@ import megamek.common.Game; import megamek.common.MechSummaryCache; import megamek.server.GameManager; -import megamek.server.ScenarioLoader; +import megamek.common.scenario.ScenarioLoader; import megamek.server.Server; public class ScenarioLoaderTest {