diff --git a/megamek/data/images/hexes/atmospheric_map/sky.png b/megamek/data/images/hexes/atmospheric_map/sky.png new file mode 100644 index 00000000000..f209be0f351 Binary files /dev/null and b/megamek/data/images/hexes/atmospheric_map/sky.png differ diff --git a/megamek/data/images/hexes/hq_saxarba.tileset b/megamek/data/images/hexes/hq_saxarba.tileset index dd4f3dec5ac..62cde949303 100644 --- a/megamek/data/images/hexes/hq_saxarba.tileset +++ b/megamek/data/images/hexes/hq_saxarba.tileset @@ -649,7 +649,7 @@ base 8 "" "lunar" "saxarba/theme_lunar/base_lunar_8.png;saxarba/theme_lunar/base base 9 "" "lunar" "saxarba/theme_lunar/base_lunar_9.png;saxarba/theme_lunar/base_lunar_9b.png;saxarba/theme_lunar/base_lunar_9c.png;saxarba/theme_lunar/base_lunar_9d.png;saxarba/theme_lunar/base_lunar_9e.png;saxarba/theme_lunar/base_lunar_9f.png" base 10 "" "lunar" "saxarba/theme_lunar/base_lunar_10.png;saxarba/theme_lunar/base_lunar_10b.png;saxarba/theme_lunar/base_lunar_10c.png;saxarba/theme_lunar/base_lunar_10d.png;saxarba/theme_lunar/base_lunar_10e.png;saxarba/theme_lunar/base_lunar_10f.png" - +base * "sky:1" "" "atmospheric_map/sky.png" # To avoid errors super * "fluff:*" "" "saxarba/misc/blank.png" diff --git a/megamek/data/images/hexes/saxarba.tileset b/megamek/data/images/hexes/saxarba.tileset index 79341757e04..3e8588ebf23 100644 --- a/megamek/data/images/hexes/saxarba.tileset +++ b/megamek/data/images/hexes/saxarba.tileset @@ -692,6 +692,7 @@ base 8 "" "lunar" "saxarba/theme_lunar/base_lunar_8.png;saxarba/theme_lunar/base base 9 "" "lunar" "saxarba/theme_lunar/base_lunar_9.png;saxarba/theme_lunar/base_lunar_9b.png;saxarba/theme_lunar/base_lunar_9c.png;saxarba/theme_lunar/base_lunar_9d.png;saxarba/theme_lunar/base_lunar_9e.png;saxarba/theme_lunar/base_lunar_9f.png" base 10 "" "lunar" "saxarba/theme_lunar/base_lunar_10.png;saxarba/theme_lunar/base_lunar_10b.png;saxarba/theme_lunar/base_lunar_10c.png;saxarba/theme_lunar/base_lunar_10d.png;saxarba/theme_lunar/base_lunar_10e.png;saxarba/theme_lunar/base_lunar_10f.png" +base * "sky:1" "" "atmospheric_map/sky.png" # To avoid errors super * "fluff:*" "" "saxarba/misc/blank.png" diff --git a/megamek/data/scenarios/Examples/ExampleV2.mms b/megamek/data/scenarios/Examples/ExampleV2.mms index feb10cc905c..40effc19443 100644 --- a/megamek/data/scenarios/Examples/ExampleV2.mms +++ b/megamek/data/scenarios/Examples/ExampleV2.mms @@ -3,7 +3,7 @@ # MMSVersion: 2 # Required to be recognized as a Scenario file of this format -name: Example Scenario V2 # Required title of the scenario; displayed in the scenario chooser +name: V2_test # Required title of the scenario; displayed in the scenario chooser gametype: SBF # default: TW; other values: AS, BF, SBF planet: Bellatrix # default: show no planet info @@ -25,15 +25,81 @@ singleplayer: yes # default: yes; the first player is # Game Map ------------------------------------------------------------------------------------------- map: - boardcolumns: 2 # a 2x1 map - boardrows: 1 + boardcolumns: 2 # a 2x1 map, default: 1 +# boardrows: 1 # default: 1 boards: - - board1.board # all files are first searched relative to the scenario file, and - - board2.board # if not found there, then relative to the appropriate data/... directory + - board1.board # all files are first searched relative to the scenario file, and + - board2.board # if not found there, then relative to the appropriate data/... directory # OR for a single board: -# board: theBoard.board +map: AGoAC Maps/16x17 Grassland 2.board +# OR "board node" +map: + file: AGoAC Maps/16x17 Grassland 2.board + +# surprise board from the given boards; require board nodes (file:) +# can modify individually and total +map: + surprise: + - file: AGoAC Maps/16x17 Grassland 2.board + modify: rotate + - file: AGoAC Maps/16x17 Grassland 3.board + - file: AGoAC Maps/16x17 Grassland 4.board + modify: rotate + +# add modifiers to a single board +map: + file: AGoAC Maps/16x17 Grassland 2.board + modify: rotate + +# atmospheric map without terrain +map: + type: sky + width: 65 + height: 35 + +# space map +map: + type: space + width: 65 + height: 35 + +# combined map with surprise maps +# when combining maps, full board nodes must be used (file:) +map: + cols: 1 + boards: + - file: unofficial/SimonLandmine/TheValley/30x15 TheValley-NorthEnd.board + - surprise: + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Open1.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Open2.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Open3.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Open4.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Open5.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Quarry.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Forest.board + - unofficial/SimonLandmine/TheValley/30x15 TheValley-Forest2.board + - file: unofficial/SimonLandmine/TheValley/30x15 TheValley-SouthEnd1.board + +# Multiple maps +maps: # map and maps are 100% synonymous + - file: AGoAC Maps/16x17 Grassland 2.board + - boardcolumns: 2 # a 2x1 map, default: 1 + boardrows: 1 # default: 1 + boards: + - board1.board + - board2.board + id: 1 + - type: sky # sky is atmospheric without terrain + width: 65 + height: 35 + - type: space + width: 20 + height: 20 + + +# old comments: ## Directories to choose random boards from ## RandomDirs=Map Set 2,Map Set 3,Map Set 4,Map Set 5,Map Set 6,Map Set 7 ## Maps can be specified by name. The order is left-to-right, top-to-bottom @@ -87,32 +153,61 @@ factions: camo: clans/wolf/Alpha Galaxy.jpg # image file, relative to the scenario file, or in data/camos otherwise # use slashes units: - - include: Annihilator ANH-13.mmu - # at: [7, 4] # alternative way to indicate position - x: 7 # position, indicates that the unit is deployed - y: 4 # must have both x and y or neither - elevation: 5 # default: 5 for airborne ground; can be used in place of altitude - altitude: 8 # default: 5 for aero - status: prone, hidden # default: none; other values: shutdown, hulldown - offboard: N # default: not offboard; values: N, E, S, W - crew: # default: unnamed 4/5 pilot - name: Cpt. Frederic Nguyen - piloting: 4 - gunnery: 3 - - type: ASElement # default: TW standard unit - fullname: Atlas AS7-D - x: 5 - y: 3 - reinforce: 2 # default: deploy at start; here: reinforce at the start of round 2 - # cannot be combined with a position - crew: - name: Cpt. Rhonda Snord - piloting: 4 - gunnery: 3 +# - include: Annihilator ANH-13.mmu + - fullname: Atlas AS7-D + # type: TW_UNIT # default: TW_UNIT other: ASElement + # pre-deployed: + at: [7, 4] # position 0704 (pre-deployed) + # x: 7 # alternative way to give position + # y: 4 # must have both x and y or neither + # NOT pre-deployed: + deploymentround: 2 # default: deploy at start; here: reinforce at the start of round 2 + # --- +# elevation: 5 # default: 5 for airborne ground; can be used in place of altitude +# altitude: 8 # default: 5 for aero + status: prone, hidden # default: none; values: shutdown, hulldown, prone, hidden + offboard: N # default: not offboard; values: N, E, S, W + crew: # default: unnamed 4/5 pilot + name: Cpt. Frederic Nguyen + piloting: 4 + gunnery: 3 + - type: ASElement # default: TW standard unit + fullname: Atlas AS7-D + x: 5 + y: 3 + # cannot be combined with a position + crew: + name: Cpt. Rhonda Snord + piloting: 4 + gunnery: 3 - name: "Player B" home: "E" - + units: + # - include: Annihilator ANH-13.mmu + - fullname: Schrek PPC Carrier + type: TW_UNIT + at: [7, 4] # alternative way to indicate position + # x: 7 # position, indicates that the unit is deployed + # y: 4 # must have both x and y or neither + elevation: 5 # default: 5 for airborne ground; can be used in place of altitude + altitude: 8 # default: 5 for aero + status: prone, hidden # default: none; other values: shutdown, hulldown + offboard: N # default: not offboard; values: N, E, S, W + crew: # default: unnamed 4/5 pilot + name: Cpt. Frederic Nguyen + piloting: 4 + gunnery: 3 + - type: ASElement # default: TW standard unit + fullname: Atlas AS7-D + x: 5 + y: 3 + reinforce: 2 # default: deploy at start; here: reinforce at the start of round 2 + # cannot be combined with a position + crew: + name: Cpt. Rhonda Snord + piloting: 4 + gunnery: 3 triggers: - message: diff --git a/megamek/src/megamek/client/ui/swing/ClientGUI.java b/megamek/src/megamek/client/ui/swing/ClientGUI.java index f5b49279501..834c1c7c08e 100644 --- a/megamek/src/megamek/client/ui/swing/ClientGUI.java +++ b/megamek/src/megamek/client/ui/swing/ClientGUI.java @@ -1211,7 +1211,7 @@ private JComponent initializePanel(GamePhase phase) { panMain.add(component, main); break; case STARTING_SCENARIO: - component = new JLabel(Messages.getString("ClientGUI.StartingScenario")); + component = new StartingScenarioPanel(); UIUtil.scaleComp(component, UIUtil.FONT_SCALE1); main = CG_STARTINGSCENARIO; component.setName(main); diff --git a/megamek/src/megamek/client/ui/swing/minimap/Minimap.java b/megamek/src/megamek/client/ui/swing/minimap/Minimap.java index 3dc137e42b8..592c180a4af 100644 --- a/megamek/src/megamek/client/ui/swing/minimap/Minimap.java +++ b/megamek/src/megamek/client/ui/swing/minimap/Minimap.java @@ -512,6 +512,8 @@ private void drawMap(boolean forceDraw) { gg.setColor(terrainColor(h)); if (h.containsTerrain(SPACE)) { paintSpaceCoord(gg, j, k); + } else if (h.containsTerrain(SKY)) { + paintLowAtmoSkyCoord(gg, j, k); } else { paintCoord(gg, j, k, zoom > 1); } @@ -823,7 +825,18 @@ private void paintCoord(Graphics g, int x, int y, boolean border) { } g.drawPolygon(xPoints, yPoints, 6); } - + + private void paintLowAtmoSkyCoord(Graphics g, int x, int y) { + int[] xPoints = xPoints(x); + int[] yPoints = yPoints(x, y); + int c = 160 + (int) (Math.random() * 80); + g.setColor(new Color(c / 2, c, c)); + g.fillPolygon(xPoints, yPoints, 6); + g.setColor(Color.LIGHT_GRAY); + g.drawPolygon(xPoints, yPoints, 6); + } + + private void paintSpaceCoord(Graphics g, int x, int y) { int baseX = (x * (HEX_SIDE[zoom] + HEX_SIDE_BY_SIN30[zoom])) + leftMargin; int baseY = (((2 * y) + 1 + (x % 2)) * HEX_SIDE_BY_COS30[zoom]) + topMargin; diff --git a/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java b/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java index 7db39c62052..cbc7f7271b3 100644 --- a/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java +++ b/megamek/src/megamek/client/ui/swing/scenario/ScenarioChooser.java @@ -132,7 +132,7 @@ private static List getScenarioInfos() { String userDir = PreferenceManager.getClientPreferences().getUserDir(); if (!userDir.isBlank()) { File subDir = new File(userDir, Configuration.scenariosDir().toString()); - parseScenariosInDirectory(subDir); + scenarios.addAll(parseScenariosInDirectory(subDir)); } return scenarios; } @@ -145,7 +145,7 @@ private static List getScenarioInfos() { * @return a List of scenarios */ private static List parseScenariosInDirectory(final File directory) { - LogManager.getLogger().info("Parsing scenarios from " + directory); + LogManager.getLogger().info("Parsing scenarios from {}", directory); List scenarios = new ArrayList<>(); for (String scenarioFile : CommonSettingsDialog.filteredFilesWithSubDirs(directory, MMConstants.SCENARIO_EXT)) { try { diff --git a/megamek/src/megamek/common/Board.java b/megamek/src/megamek/common/Board.java index 4d8f8a5f221..d9f50d668c4 100644 --- a/megamek/src/megamek/common/Board.java +++ b/megamek/src/megamek/common/Board.java @@ -192,6 +192,47 @@ public Board(int width, int height, Hex[] hexes, Vector bldgs, infernos = infMap; createBldgByCoords(); } + + /** + * Returns a new atmospheric (low altitude) board with no terrain (sky map) of the given + * size. + * + * @param width the width of the board + * @param height the height of the board + * @return the new board, ready to be used + */ + public static Board getSkyBoard(int width, int height) { + Hex[] data = new Hex[width * height]; + int index = 0; + for (int h = 0; h < height; h++) { + for (int w = 0; w < width; w++) { + data[index++] = new Hex(0, "sky:1", "", new Coords(w, h)); + } + } + Board result = new Board(width, height, data); + result.setType(Board.T_ATMOSPHERE); + return result; + } + + /** + * Returns a new space board of the given size. + * + * @param width the width of the board + * @param height the height of the board + * @return the new board, ready to be used + */ + public static Board getSpaceBoard(int width, int height) { + Hex[] data = new Hex[width * height]; + int index = 0; + for (int h = 0; h < height; h++) { + for (int w = 0; w < width; w++) { + data[index++] = new Hex(0, "space:1", "", new Coords(w, h)); + } + } + Board result = new Board(width, height, data); + result.setType(Board.T_SPACE); + return result; + } //endregion Constructors /** @@ -219,7 +260,7 @@ public Coords getCenter() { * @param width the width dimension. * @param height the height dimension. * @param data new hex data appropriate for the board. - * @param errBuff A buffer for storing error messages, if any. This is allowed to be null. + * @param errors A buffer for storing error messages, if any. This is allowed to be null. */ public void newData(final int width, final int height, final Hex[] data, final @Nullable List errors) { diff --git a/megamek/src/megamek/common/Compute.java b/megamek/src/megamek/common/Compute.java index d45bea0257d..ef54624c36d 100644 --- a/megamek/src/megamek/common/Compute.java +++ b/megamek/src/megamek/common/Compute.java @@ -259,6 +259,7 @@ public static float randomFloat() { * @param list The list of items to select from * @return An element in the list * @param The list type + * @throws IllegalArgumentException when the given list is empty */ public static T randomListElement(List list) { if (list.isEmpty()) { diff --git a/megamek/src/megamek/common/MechSummary.java b/megamek/src/megamek/common/MechSummary.java index 26178623131..fef76bf0ec8 100644 --- a/megamek/src/megamek/common/MechSummary.java +++ b/megamek/src/megamek/common/MechSummary.java @@ -1328,6 +1328,27 @@ public String formatSUA(BattleForceSUA sua, String delimiter, ASSpecialAbilityCo } } + /** + * Loads and returns the entity for the given full name. If the entity cannot be loaded, the error is logged + * and null is returned. This is a shortcut for first loading the MechSummary using + * {@link MechSummaryCache#getMech(String)} and then {@link #loadEntity()}. + * + * @return The loaded entity or null in case of an error + */ + public static @Nullable Entity loadEntity(String fullName) { + try { + MechSummary ms = MechSummaryCache.getInstance().getMech(fullName); + if (ms != null) { + return new MechFileParser(ms.sourceFile, ms.entryName).getEntity(); + } else { + LogManager.getLogger().error("MechSummary entry not found for {}", fullName); + } + } catch (Exception ex) { + LogManager.getLogger().error("", ex); + } + return null; + } + @Override public String toString() { return getName(); diff --git a/megamek/src/megamek/common/Terrains.java b/megamek/src/megamek/common/Terrains.java index 1d986468048..41c6976cda6 100644 --- a/megamek/src/megamek/common/Terrains.java +++ b/megamek/src/megamek/common/Terrains.java @@ -145,6 +145,9 @@ public class Terrains implements Serializable { public static final int BLACK_ICE = 55; + // This is for low atmosphere maps to indicate that an empty hex is to be drawn as sky, not grassland + public static final int SKY = 56; + /** * Keeps track of the different type of terrains that can have exits. */ @@ -156,11 +159,11 @@ public class Terrains implements Serializable { "bldg_armor", "bridge", "bridge_cf", "bridge_elev", "fuel_tank", "fuel_tank_cf", "fuel_tank_elev", "fuel_tank_magn", "impassable", "elevator", "fortified", "screen", "fluff", "arms", "legs", "metal_deposit", "bldg_base_collapsed", "bldg_fluff", "road_fluff", "ground_fluff", "water_fluff", "cliff_top", "cliff_bottom", - "incline_top", "incline_bottom", "incline_high_top", "incline_high_bottom", "foliage_elev", "black_ice" }; + "incline_top", "incline_bottom", "incline_high_top", "incline_high_bottom", "foliage_elev", "black_ice", "sky" }; /** Terrains in this set are hidden in the Editor, not saved to board files and handled internally. */ public static final HashSet AUTOMATIC = new HashSet<>(Arrays.asList( - INCLINE_TOP, INCLINE_BOTTOM, INCLINE_HIGH_TOP, INCLINE_HIGH_BOTTOM, CLIFF_BOTTOM)); + INCLINE_TOP, INCLINE_BOTTOM, INCLINE_HIGH_TOP, INCLINE_HIGH_BOTTOM, CLIFF_BOTTOM, SKY)); public static final int SIZE = names.length; diff --git a/megamek/src/megamek/common/icons/Camouflage.java b/megamek/src/megamek/common/icons/Camouflage.java index 6dd5e991dda..17fe66da7fc 100644 --- a/megamek/src/megamek/common/icons/Camouflage.java +++ b/megamek/src/megamek/common/icons/Camouflage.java @@ -218,7 +218,7 @@ public int hashCode() { return Objects.hash(getFilename(), getCategory(), rotationAngle, scale); } - private static String getDirectory(File file) { + public static String getDirectory(File file) { String result = file.getParent().replace("\\", "/"); return result + (!result.endsWith("/") ? "/" : ""); } diff --git a/megamek/src/megamek/common/icons/Portrait.java b/megamek/src/megamek/common/icons/Portrait.java index 8439c406f60..0d58ae3cdf5 100644 --- a/megamek/src/megamek/common/icons/Portrait.java +++ b/megamek/src/megamek/common/icons/Portrait.java @@ -24,6 +24,7 @@ import org.w3c.dom.Node; import java.awt.*; +import java.io.File; import java.io.PrintWriter; /** @@ -46,6 +47,19 @@ public Portrait() { public Portrait(final @Nullable String category, final @Nullable String filename) { super(category, filename); } + + /** + * Constructs a new portrait with the given file. Even though a file is accepted, this can only be used + * for portraits of the directories that are parsed automatically, i.e. the MM-internal portrait dir, the user dir + * and the story arcs directory! This method tries to parse the filename to find the portrait. This requires + * replacing Windows backslashes with normal slashes in order to find the file in the way portraits are + * stored (see {@link megamek.common.util.fileUtils.AbstractDirectory}) + * + * @param file The File, such as a file of "Female/Aerospace Pilot/ASF_F_3.png" + */ + public Portrait(File file) { + this(Camouflage.getDirectory(file), file.getName()); + } //endregion Constructors //region Getters/Setters diff --git a/megamek/src/megamek/common/jacksonadapters/BoardDeserializer.java b/megamek/src/megamek/common/jacksonadapters/BoardDeserializer.java new file mode 100644 index 00000000000..4a98be42ce1 --- /dev/null +++ b/megamek/src/megamek/common/jacksonadapters/BoardDeserializer.java @@ -0,0 +1,236 @@ +/* + * 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.common.jacksonadapters; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import megamek.common.Board; +import megamek.common.Compute; +import megamek.common.Configuration; +import megamek.common.util.BoardUtilities; +import org.apache.logging.log4j.LogManager; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class BoardDeserializer extends StdDeserializer { + + private static final String TYPE = "type"; + private static final String LOW_ALTITUDE = "lowaltitude"; + private static final String ATMOSPHERIC = "atmospheric"; + private static final String SKY = "sky"; + private static final String SPACE = "space"; + private static final String HIGH_ALTITUDE = "highaltitude"; + private static final String WIDTH = "width"; + private static final String HEIGHT = "height"; + private static final String COLS = "cols"; + private static final String BOARDS = "boards"; + private static final String FILE = "file"; + private static final String ROTATE = "rotate"; + private static final String FLIP_H = "fliph"; // scrambles the map + private static final String FLIP_V = "flipv"; // scrambles the map + private static final String MODIFY = "modify"; + private static final String SURPRISE = "surprise"; + private static final String RADAR = "radar"; + private static final String CAP_RADAR = "capitalradar"; + private static final String ID = "id"; + + protected BoardDeserializer(Class vc) { + super(vc); + } + + /** + * Parses the given map: or maps: node to return a list of one or more boards (the list should + * ideally never be empty, an exception being thrown instead). Board files are tried first + * in the given basePath; if not found there, MM's data/boards/ is tried instead. + + * @param mapNode a map: or maps: node from a YAML definition file + * @param basePath a path to search board files in (e.g. scenario path) + * @return a list of parsed boards + * @throws IllegalArgumentException for illegal node combinations and other errors + */ + public static List parse(JsonNode mapNode, File basePath) { + // the map node is + // - textual, giving the board file directly + // - one board node, or + // - an array of board nodes + + List result = new ArrayList<>(); + if (!mapNode.isContainerNode()) { + // "map: xyz.board" will directly load that board with no modifiers + result.add(loadBoard(mapNode.textValue(), basePath)); + + } else if (mapNode.isArray()) { + // as an array of multiple boards, it cannot be a simple string; each entry must be a board node + result.addAll(parseMultipleBoards(mapNode, basePath)); + + } else { + Board board = parseSingleBoard(mapNode, basePath); + if (board != null) { + result.add(board); + } + } + return result; + } + + private static List parseMultipleBoards(JsonNode node, File basePath) { + List result = new ArrayList<>(); + if (!node.isArray()) { + LogManager.getLogger().error("Called parseMultipleBoards with non-array node!"); + return result; + } + node.elements().forEachRemaining(n -> result.add(parseSingleBoard(n, basePath))); + return result; + } + + public static Board parseSingleBoard(JsonNode mapNode, File basePath) { + testBoardNodeFields(mapNode); + + if (mapNode.has(FILE)) { + // map: as node with file: and optional modify: + return parseBoardFileNode(mapNode, basePath, mapNode.get(FILE).textValue()); + + } else if (mapNode.has(SURPRISE)) { + // map: as node with surprise: filelist and optional modify: + if (!mapNode.get(SURPRISE).isArray()) { + throw new IllegalArgumentException("Surprise keyword without boards list!"); + } + List surpriseBoardsList = parseMultipleBoards(mapNode.get(SURPRISE), basePath); + Board board = Compute.randomListElement(surpriseBoardsList); + parseBoardModifiers(board, mapNode); + return board; + } + + // more complex map setup + int mapWidth = mapNode.has(WIDTH) ? mapNode.get(WIDTH).intValue() : 16; + int mapHeight = mapNode.has(HEIGHT) ? mapNode.get(HEIGHT).intValue() : 17; + int columns = mapNode.has(COLS) ? mapNode.get(COLS).intValue() : 1; + // TODO: board ID + + Board board; + if (mapNode.has(TYPE)) { + String type = mapNode.get(TYPE).asText(); + board = new Board(mapWidth, mapHeight); + switch (type) { + case SKY: + return Board.getSkyBoard(mapWidth, mapHeight); + case ATMOSPHERIC: + case LOW_ALTITUDE: + return Board.getSkyBoard(mapWidth, mapHeight); + case SPACE: + return Board.getSpaceBoard(mapWidth, mapHeight); + case HIGH_ALTITUDE: + //TODO: dont have that type yet + board.setType(Board.T_SPACE); + break; + } + return board; + } else { + // ground map + // this is the only map type that allows combining multiple board files + JsonNode boardsNode = mapNode.get(BOARDS); + if (!boardsNode.isArray()) { + throw new IllegalArgumentException("Must give multiple boards!"); + } + + List boardsList = parseMultipleBoards(boardsNode, basePath); + mapWidth = boardsList.get(0).getWidth(); + mapHeight = boardsList.get(0).getHeight(); + int rows = boardsList.size() / columns; + if (boardsList.size() != columns * rows) { + throw new IllegalArgumentException("The number of given boards must give full rows!"); + } + List isRotatedList = new ArrayList<>(); + Collections.fill(isRotatedList, Boolean.FALSE); + return BoardUtilities.combine(mapWidth, mapHeight, columns, rows, boardsList, isRotatedList, Board.T_GROUND); + } + } + + private static Board parseBoardFileNode(JsonNode boardNode, File basePath, String fileName) { + // map: as node with file: and optional rotate: + Board board = loadBoard(fileName, basePath); + parseBoardModifiers(board, boardNode); + return board; + } + + private static void parseBoardModifiers(Board board, JsonNode boardNode) { + if (boardNode.has(MODIFY)) { + JsonNode modifierNode = boardNode.get(MODIFY); + if (modifierNode.isArray()) { + modifierNode.iterator().forEachRemaining(n -> parseSingleBoardModifier(board, n.textValue())); + } else if (modifierNode.isTextual()) { + parseSingleBoardModifier(board, modifierNode.asText()); + } + } + } + + private static void parseSingleBoardModifier(Board board, String modifier) { + switch (modifier) { + case ROTATE: + BoardUtilities.flip(board, true, true); + break; + case FLIP_H: + BoardUtilities.flip(board, true, false); + break; + case FLIP_V: + BoardUtilities.flip(board, false, true); + break; + default: + throw new IllegalArgumentException("Unknown modifier " + modifier); + } + } + + private static Board loadBoard(String fileName, File basePath) { + File boardFile = new File(basePath, fileName); + if (!boardFile.exists()) { + boardFile = new File(Configuration.boardsDir(), fileName); + if (!boardFile.exists()) { + throw new IllegalArgumentException("Board file does not exist: " + boardFile + " in " + basePath); + } + } + Board result = new Board(); + result.load(boardFile); + return result; + } + + @Override + public Board deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { + return parse(p.getCodec().readTree(p), new File("")).get(0); + } + + private static void testBoardNodeFields(JsonNode mapNode) { + MMUReader.disallowCombinedFields("Board", mapNode, FILE, SURPRISE); + MMUReader.disallowCombinedFields("Board", mapNode, FILE, WIDTH); + MMUReader.disallowCombinedFields("Board", mapNode, FILE, HEIGHT); + MMUReader.disallowCombinedFields("Board", mapNode, FILE, BOARDS); + MMUReader.disallowCombinedFields("Board", mapNode, FILE, COLS); + + MMUReader.disallowCombinedFields("Board", mapNode, TYPE, COLS); + MMUReader.disallowCombinedFields("Board", mapNode, TYPE, FILE); + MMUReader.disallowCombinedFields("Board", mapNode, TYPE, SURPRISE); + MMUReader.disallowCombinedFields("Board", mapNode, TYPE, MODIFY); + MMUReader.disallowCombinedFields("Board", mapNode, TYPE, BOARDS); + } +} diff --git a/megamek/src/megamek/common/jacksonadapters/CrewDeserializer.java b/megamek/src/megamek/common/jacksonadapters/CrewDeserializer.java new file mode 100644 index 00000000000..837d88dfc18 --- /dev/null +++ b/megamek/src/megamek/common/jacksonadapters/CrewDeserializer.java @@ -0,0 +1,99 @@ +/* + * 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.common.jacksonadapters; + +import com.fasterxml.jackson.databind.JsonNode; +import megamek.common.Crew; +import megamek.common.Entity; +import megamek.common.icons.Portrait; + +import java.io.File; + +/** + * This class parses the Crew from a YAML (scenario) file. This requires the entity's base crew as + * input, therefore it's not possible (or better: I don't know how) to implement it as a subclass + * of Jackson's deserializer. + */ +public class CrewDeserializer { + + private static final String CREW = "crew"; + private static final String HITS = "hits"; + private static final String GUNNERY = "gunnery"; + private static final String PILOTING = "piloting"; + private static final String NAME = "name"; + private static final String PORTRAIT = "portrait"; + + public static void parseCrew(JsonNode entityNode, Entity entity) { + if (entityNode.has(CREW)) { + Crew crew = entity.getCrew(); + if (crew == null) { + throw new IllegalArgumentException("Entity " + entity + " has no crew; cannot parse crew keyword"); + } + JsonNode crewNode = entityNode.get(CREW); + assignHits(crew, crewNode); + assignGunnery(crew, crewNode); + assignPiloting(crew, crewNode); + assignName(crew, crewNode); + assignPortrait(crew, crewNode); + } + } + + private static void assignHits(Crew crew, JsonNode crewNode) { + if (crewNode.has(HITS)) { + int hits = crewNode.get(HITS).asInt(); + if (hits < 0 || hits > 6) { + throw new IllegalArgumentException("Invalid hits value " + hits); + } + crew.setHits(hits, 0); + } + } + + private static void assignGunnery(Crew crew, JsonNode crewNode) { + if (crewNode.has(GUNNERY)) { + int gunnery = crewNode.get(GUNNERY).asInt(); + if (gunnery < 0 || gunnery > 8) { + throw new IllegalArgumentException("Invalid gunnery value " + gunnery); + } + crew.setGunnery(gunnery, 0); + } + } + + private static void assignPiloting(Crew crew, JsonNode crewNode) { + if (crewNode.has(PILOTING)) { + int piloting = crewNode.get(PILOTING).asInt(); + if (piloting < 0 || piloting > 8) { + throw new IllegalArgumentException("Invalid piloting value " + piloting); + } + crew.setPiloting(piloting, 0); + } + } + + private static void assignName(Crew crew, JsonNode crewNode) { + if (crewNode.has(NAME)) { + crew.setName(crewNode.get(NAME).textValue(), 0); + } + } + + private static void assignPortrait(Crew crew, JsonNode crewNode) { + if (crewNode.has(PORTRAIT)) { + String portraitPath = crewNode.get(PORTRAIT).textValue(); + crew.setPortrait(new Portrait(new File(portraitPath)), 0); + } + } +} diff --git a/megamek/src/megamek/common/jacksonadapters/EntityDeserializer.java b/megamek/src/megamek/common/jacksonadapters/EntityDeserializer.java index c56b18e90b5..e55c7cc5f68 100644 --- a/megamek/src/megamek/common/jacksonadapters/EntityDeserializer.java +++ b/megamek/src/megamek/common/jacksonadapters/EntityDeserializer.java @@ -1,16 +1,34 @@ +/* + * 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.common.jacksonadapters; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import megamek.common.Entity; -import megamek.common.MechFileParser; -import megamek.common.MechSummary; -import megamek.common.MechSummaryCache; -import megamek.common.loaders.EntityLoadingException; +import megamek.common.*; +import megamek.common.icons.Camouflage; +import megamek.common.scenario.Scenario; +import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import static megamek.common.jacksonadapters.ASElementSerializer.FULL_NAME; @@ -18,8 +36,19 @@ public class EntityDeserializer extends StdDeserializer { - private static final List movementModes = List.of("qt", "qw", "t", "w", - "h", "v", "n", "s", "m", "j", "f", "g", "a", "p", "k"); + private static final String AT = "at"; + private static final String X = "x"; + private static final String Y = "y"; + private static final String STATUS = "status"; + private static final String PRONE = "prone"; + private static final String SHUTDOWN = "shutdown"; + private static final String HIDDEN = "hidden"; + private static final String HULLDOWN = "hulldown"; + private static final String FACING = "facing"; + private static final String DEPLOYMENTROUND = "deploymentround"; + private static final String ELEVATION = "elevation"; + private static final String ALTITUDE = "altitude"; + private static final String VELOCITY = "velocity"; public EntityDeserializer() { this(null); @@ -35,16 +64,140 @@ public Entity deserialize(JsonParser jp, DeserializationContext ctxt) throws IOE requireFields("TW Unit", node, FULL_NAME); // A TW unit (=Entity) must be loaded from the cache + Entity entity = loadEntity(node); + assignPosition(entity, node); + assignFacing(entity, node); + assignDeploymentRound(entity, node); + assignStatus(entity, node); + assignIndividualCamo(entity, node); + assignElevation(entity, node); + assignAltitude(entity, node); + assignVelocity(entity, node); + CrewDeserializer.parseCrew(node, entity); + return entity; + } + + private Entity loadEntity(JsonNode node) { String fullName = node.get(FULL_NAME).textValue(); - MechSummary unit = MechSummaryCache.getInstance().getMech(fullName); + Entity entity = MechSummary.loadEntity(fullName); + if (entity == null) { + throw new IllegalArgumentException("Could not retrieve unit " + fullName + " from cache!"); + } + return entity; + } + + private void assignPosition(Entity entity, JsonNode node) { try { - if (unit != null) { - return new MechFileParser(unit.getSourceFile(), unit.getEntryName()).getEntity(); - } else { - throw new IllegalArgumentException("Could not retrieve unit " + fullName + " from cache!"); + if (node.has(AT)) { + List xyList = new ArrayList<>(); + node.get(AT).elements().forEachRemaining(n -> xyList.add(n.asInt())); + setDeployedPosition(entity, new Coords(xyList.get(0), xyList.get(1))); + + } else if (node.has(X) || node.has(Y)) { + requireFields("Entity", node, X, Y); + setDeployedPosition(entity, new Coords(node.get(X).asInt(), node.get(Y).asInt())); + } + } catch (Exception e) { + throw new IllegalArgumentException("Illegal position information for entity " + entity, e); + } + } + + private void assignFacing(Entity entity, JsonNode node) { + if (node.has(FACING)) { + JsonNode facingNode = node.get(FACING); + if (facingNode.isInt()) { + int facing = facingNode.asInt(); + if (facing < 0 || facing > 5) { + throw new IllegalArgumentException("Illegal facing: " + facing + " for entity " + entity); + } + entity.setFacing(facing); + entity.setSecondaryFacing(facing, false); + } + } + } + + private void assignDeploymentRound(Entity entity, JsonNode node) { + if (node.has(DEPLOYMENTROUND)) { + entity.setDeployRound(node.get(DEPLOYMENTROUND).asInt()); + } + } + + private void setDeployedPosition(Entity entity, Coords coords) { + entity.setDeployed(true); + // translate the position so "at: 2, 3" will place a unit on 0203 (instead of 0102) + entity.setPosition(new Coords(coords.getX() - 1, coords.getY() - 1)); + } + + private void assignStatus(Entity entity, JsonNode node) { + if (node.has(STATUS)) { + JsonNode statusNode = node.get(STATUS); + if (statusNode.isContainerNode() && statusNode.isArray()) { + statusNode.iterator().forEachRemaining(n -> parseStatus(entity, n.textValue())); + } else if (statusNode.isTextual()) { + parseStatus(entity, statusNode.asText()); + } + } + } + + private void parseStatus(Entity entity, String statusString) { + switch (statusString) { + case PRONE: + entity.setProne(true); + break; + case SHUTDOWN: + entity.setShutDown(true); + break; + case HIDDEN: + entity.setHidden(true); + break; + case HULLDOWN: + entity.setHullDown(true); + break; + default: + throw new IllegalArgumentException("Unknown status " + statusString); + } + } + + private void assignIndividualCamo(Entity entity, JsonNode node) { + if (node.has(Scenario.PARAM_CAMO)) { + String camoPath = node.get(Scenario.PARAM_CAMO).textValue(); + entity.setCamouflage(new Camouflage(new File(camoPath))); + } + } + + private void assignElevation(Entity entity, JsonNode node) { + if (node.has(ELEVATION)) { + entity.setElevation(node.get(ELEVATION).asInt()); + } + } + + private void assignAltitude(Entity entity, JsonNode node) { + if (node.has(ALTITUDE)) { + if (!(entity instanceof IAero)) { + throw new IllegalArgumentException("Illegal keyword altitude for non-aerospace unit"); + } + int altitude = node.get(ALTITUDE).asInt(); + if (altitude < 0 || altitude > 10) { + throw new IllegalArgumentException("Illegal altitude " + altitude + " for entity " + entity); + } + entity.setAltitude(altitude); + if (altitude == 0) { + ((IAero) entity).land(); + } + } + } + + private void assignVelocity(Entity entity, JsonNode node) { + if (node.has(VELOCITY)) { + if (!(entity instanceof IAero)) { + throw new IllegalArgumentException("Illegal keyword velocity for non-aerospace unit"); + } + int velocity = node.get(VELOCITY).asInt(); + if (velocity < 0) { + throw new IllegalArgumentException("Illegal velocity " + velocity + " for entity " + entity); } - } catch (EntityLoadingException e) { - throw new IllegalArgumentException(e); + ((IAero) entity).setCurrentVelocity(velocity); + ((IAero) entity).setNextVelocity(velocity); } } } \ No newline at end of file diff --git a/megamek/src/megamek/common/jacksonadapters/MMUReader.java b/megamek/src/megamek/common/jacksonadapters/MMUReader.java index 249930f5cb0..d0c3d725f35 100644 --- a/megamek/src/megamek/common/jacksonadapters/MMUReader.java +++ b/megamek/src/megamek/common/jacksonadapters/MMUReader.java @@ -171,4 +171,27 @@ public static void requireFields(String objectType, JsonNode node, String... fie } } } + + /** + * Tests the given node if + * it has any combination of at least two of the given fields. If that is the case, an IllegalArgumentException + * is thrown. The given objectType can be any String, it is only used as part of the error + * message. If none of the fields or only a single one of the fields is found, this method does nothing. + * + * @param objectType The object type such as "ASElement". Only used as part of the exception message + * @param node the node containing an object to be deserialized + * @param fields The field names, e.g. "chassis" or "size" + */ + public static void disallowCombinedFields(String objectType, JsonNode node, String... fields) { + List foundFields = new ArrayList<>(); + for (String field : fields) { + if (node.has(field)) { + foundFields.add(field); + if (foundFields.size() > 1) { + throw new IllegalArgumentException("Fields " + foundFields.get(0) + " and " + foundFields.get(1) + + "found in " + objectType + " definition!"); + } + } + } + } } \ No newline at end of file diff --git a/megamek/src/megamek/common/scenario/ScenarioV2.java b/megamek/src/megamek/common/scenario/ScenarioV2.java index 4fd51d8ed4d..b0e8c431573 100644 --- a/megamek/src/megamek/common/scenario/ScenarioV2.java +++ b/megamek/src/megamek/common/scenario/ScenarioV2.java @@ -27,6 +27,7 @@ import megamek.common.enums.GamePhase; import megamek.common.icons.Camouflage; import megamek.common.icons.FileCamouflage; +import megamek.common.jacksonadapters.BoardDeserializer; import megamek.common.jacksonadapters.MMUReader; import megamek.common.planetaryconditions.PlanetaryConditions; import megamek.common.strategicBattleSystems.SBFGame; @@ -42,8 +43,7 @@ public class ScenarioV2 implements Scenario { private static final String DEPLOY = "deploy"; private static final String MAP = "map"; - private static final String COLUMNS = "columns"; - private static final String ROWS = "rows"; + private static final String MAPS = "maps"; private static final String UNITS = "units"; private final JsonNode node; @@ -99,7 +99,7 @@ public boolean hasFixedPlanetaryConditions() { @Override public IGame createGame() throws IOException, ScenarioLoaderException { - LogManager.getLogger().info("Loading scenario from " + scenariofile); + LogManager.getLogger().info("Loading scenario from {}", scenariofile); IGame game = selectGameType(); game.setPhase(GamePhase.STARTING_SCENARIO); parseOptions(game); @@ -119,6 +119,8 @@ public IGame createGame() throws IOException, ScenarioLoaderException { twGame.setVictoryContext(new HashMap<>()); twGame.createVictoryConditions(); } + + // TODO: check the game for inconsistencies such as units outside board coordinates return game; } @@ -153,8 +155,6 @@ private IGame selectGameType() { return new ASGame(); case SBF: return new SBFGame(); -// case GAMETYPE_BF: -// return new BFGame(); default: return new Game(); } @@ -230,52 +230,24 @@ private List readPlayers(IGame game) throws ScenarioLoaderException, IOE ((SBFGame) game).addUnit(unit); } } + // TODO: look at unit individual camo and see if it's a file in the scenario directory; the entity parsers + // cannot handle this as they don't know it's a scenario } return result; } private Board createBoard() throws ScenarioLoaderException { - if (!node.has(MAP)) { + if (!node.has(MAP) && !node.has(MAPS)) { throw new ScenarioLoaderException("ScenarioLoaderException.missingMap"); } JsonNode mapNode = node.get(MAP); - // "map: Xyz.board" will directly load that board with no modifiers - if (!mapNode.textValue().isBlank()) { - return loadBoard(mapNode.textValue()); + if (mapNode == null) { + mapNode = node.get(MAPS); } - //TODO: Board handling - this is incomplete, compare ScenarioV1 - - // more complex map setup - int mapWidth = 16; - int mapHeight = 17; - int columns = mapNode.has(COLUMNS) ? mapNode.get(COLUMNS).intValue() : 1; - int rows = mapNode.has(ROWS) ? mapNode.get(ROWS).intValue() : 1; - - // load available boards - // basically copied from Server.java. Should get moved somewhere neutral - List boards = new ArrayList<>(); - - // Find subdirectories given in the scenario file - List allDirs = new LinkedList<>(); - // "" entry stands for the boards base directory - allDirs.add(""); - - return null; - } - - private Board loadBoard(String fileName) throws ScenarioLoaderException { - File boardFile = new File(scenarioDirectory(), fileName); - if (!boardFile.exists()) { - boardFile = new File(Configuration.boardsDir(), fileName); - if (!boardFile.exists()) { - throw new ScenarioLoaderException("ScenarioLoaderException.nonexistentBoard", fileName); - } - } - Board result = new Board(); - result.load(boardFile); - return result; + //TODO: currently, the first parsed board is used + return BoardDeserializer.parse(mapNode, scenarioDirectory()).get(0); } private File scenarioDirectory() { diff --git a/megamek/src/megamek/common/util/BoardUtilities.java b/megamek/src/megamek/common/util/BoardUtilities.java index 3e476e499ca..315c25394bd 100644 --- a/megamek/src/megamek/common/util/BoardUtilities.java +++ b/megamek/src/megamek/common/util/BoardUtilities.java @@ -37,6 +37,22 @@ public static int getAmountElevationGenerators() { return 3 + elevationGenerators.size(); } + /** + * Combines one or more boards into one huge megaboard! + * + * @param width the width of each individual board, before the combine + * @param height the height of each individual board, before the combine + * @param sheetWidth how many sheets wide the combined map is + * @param sheetHeight how many sheets tall the combined map is + * @param boards a list of the boards to be combined + * @param isRotated Flag that determines if any of the maps are rotated + * @param medium Sets the medium the map is in (ie., ground, atmo, space) + */ + public static Board combine(int width, int height, int sheetWidth, int sheetHeight, + List boards, List isRotated, int medium) { + return combine(width, height, sheetWidth, sheetHeight, boards.toArray(new Board[0]), isRotated, medium); + } + /** * Combines one or more boards into one huge megaboard! *