package org.erikaredmark.monkeyshines.editor; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.FlowLayout; import java.awt.Point; import java.awt.event.ActionEvent; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import javax.imageio.ImageIO; import javax.swing.AbstractAction; import javax.swing.JDesktopPane; import javax.swing.JDialog; import javax.swing.JFileChooser; import javax.swing.JFrame; import javax.swing.JInternalFrame; import javax.swing.JMenu; import javax.swing.JMenuBar; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import org.erikaredmark.monkeyshines.*; import org.erikaredmark.monkeyshines.editor.LevelDrawingCanvas.EditorState; import org.erikaredmark.monkeyshines.editor.dialog.CopyPasteDialog; import org.erikaredmark.monkeyshines.editor.dialog.CopyPasteDialog.CopyPasteConfiguration; import org.erikaredmark.monkeyshines.editor.dialog.GoToScreenDialog; import org.erikaredmark.monkeyshines.editor.dialog.ImportWorldDialog; import org.erikaredmark.monkeyshines.editor.dialog.NewWorldDialog; import org.erikaredmark.monkeyshines.editor.exception.BadEditorPersistantFormatException; import org.erikaredmark.monkeyshines.editor.model.Template; import org.erikaredmark.monkeyshines.editor.persist.TemplateXmlReader; import org.erikaredmark.monkeyshines.editor.persist.TemplateXmlReader.TemplateIssue; import org.erikaredmark.monkeyshines.editor.persist.TemplateXmlWriter; import org.erikaredmark.monkeyshines.encoder.EncodedWorld; import org.erikaredmark.monkeyshines.encoder.WorldIO; import org.erikaredmark.monkeyshines.encoder.exception.WorldRestoreException; import org.erikaredmark.monkeyshines.encoder.exception.WorldSaveException; import org.erikaredmark.monkeyshines.graphics.exception.ResourcePackException; import org.erikaredmark.monkeyshines.logging.MonkeyShinesLog; import org.erikaredmark.monkeyshines.resource.WorldResource; import org.erikaredmark.monkeyshines.resource.WorldResource.UseIntent; import org.erikaredmark.util.BinaryLocation; import com.google.common.base.Function; /* * The main GUI for the level editor. Contains in it a JPanel just like the game that contains the current screen */ @SuppressWarnings("serial") public class LevelEditor extends JFrame { private static final String CLASS_NAME = "org.erikaredmark.monkeyshines.editor.LevelEditor"; private static final Logger LOGGER = Logger.getLogger(CLASS_NAME); private final JDesktopPane editorDesktop; private final JInternalFrame brushPaletteFrame; private final JInternalFrame canvasFrame; private final JInternalFrame templatePaletteFrame; // There may be many open templates at once. Stored as associative because editors should not open // twice for the same template. Map<Template, JInternalFrame> openTemplateEditors = new HashMap<>(); // Initialised each time the current world changes. Null and not added to the pane // when there is no world. Callback called whenever a new world is loaded into the editor private BrushPalette brushPalette; private TemplatePalette templatePalette; private Function<World, Void> paletteUpdateCallback = new Function<World, Void>() { @Override public Void apply(final World world) { assert currentWorld != null : "Callback for palettes activated too early!"; // Remove original palettes if exists, create the new one, add it, and pack it. if (brushPalette != null) { brushPaletteFrame.remove(brushPalette); } if (templatePalette != null) { templatePaletteFrame.remove(templatePalette); } brushPalette = new BrushPalette(LevelEditor.this, world.getResource() ); brushPaletteFrame.add(brushPalette, BorderLayout.CENTER); brushPaletteFrame.setVisible(true); brushPaletteFrame.repaint(); // Load templates for the given world. TODO for now we ignore issues List<Template> worldTemplates = Collections.emptyList(); final Path editorPreferencesLocation = BinaryLocation.BINARY_LOCATION.getParent().resolve("editor_prefs.xml"); if (Files.exists(editorPreferencesLocation) ) { try (InputStream is = Files.newInputStream(editorPreferencesLocation) ) { worldTemplates = TemplateXmlReader.read( is, world, new Function<TemplateIssue, Void>() { @Override public Void apply(TemplateIssue t) { return null; } }); } catch (IOException | BadEditorPersistantFormatException e) { LOGGER.log(Level.WARNING, "Could not open editor preferences (editor will have default preferences and no templates loaded: ", e); } } templatePalette = new TemplatePalette( currentWorld, worldTemplates, world, new Function<List<Template>, Void>() { @Override public Void apply(List<Template> newTemplates) { try { TemplateXmlWriter.writeOutTemplatesForWorld(editorPreferencesLocation, world.getWorldName(), newTemplates); } catch (BadEditorPersistantFormatException e) { JOptionPane.showMessageDialog(LevelEditor.this, "Could not save template data to preferences: " + e.getMessage() ); LOGGER.log(Level.SEVERE, "Could not save template data to preferences: " + e.getMessage(), e); } return null; } }); templatePaletteFrame.add(templatePalette, BorderLayout.CENTER); templatePaletteFrame.setVisible(true); templatePaletteFrame.repaint(); return null; } }; // Package access intended; Makes it easier for palettes to perform actions defined final LevelDrawingCanvas currentWorld; /* Only set during loading a world, and only used during saving. */ private Path defaultSaveLocation; // Main menu Bar private JMenuBar mainMenuBar = new JMenuBar(); // Menu: File operations private JMenu fileMenu = new JMenu("File"); /* --------------------------- MENU ITEM NEW WORLD ---------------------------- */ private final JMenuItem newWorld = new JMenuItem("New World..."); // Can't be defined yet due to requiring enclosing instance of JFrame /* -------------------------- MENU ITEM LOAD WORLD ---------------------------- */ private JMenuItem loadWorld = new JMenuItem("Load World..."); /* -------------------------- MENU ITEM SAVE WORLD ---------------------------- */ private JMenuItem saveWorld = new JMenuItem("Save World..."); /* ------------------------- MENU ITEM IMPORT WORLD --------------------------- */ private JMenuItem importWorld = new JMenuItem("Import World..."); /* ----------------------------- MENU ITEM QUIT ------------------------------- */ private JMenuItem quit = new JMenuItem(new AbstractAction("Quit") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { // TODO better way of quitting: Also ask for save changes System.exit(0); } }); private JMenu spritesMenu = new JMenu("Sprites"); /* ------------------------- MENU ITEM PLACE SPRITES -------------------------- */ private JMenuItem placeSprites = new JMenuItem(new AbstractAction("Place Sprites") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.setTileBrushAndId(PaintbrushType.PLACE_SPRITES, 0); } }); /* ------------------------- MENU ITEM EDIT SPRITES --------------------------- */ private JMenuItem editSprites = new JMenuItem(new AbstractAction("Edit Sprites") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.setTileBrushAndId(PaintbrushType.EDIT_SPRITES, 0); } }); private JMenuItem editOffscreenSprites = new JMenuItem(new AbstractAction("Edit Offscreen Sprites...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.actionEditOffscreenSprites(); } }); /* ------------------------ MENU ITEM DELETE SPRITES --------------------------- */ private JMenuItem deleteSprites = new JMenuItem(new AbstractAction("Delete Sprites") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.setTileBrushAndId(PaintbrushType.ERASER_SPRITES, 0); } }); // Menu: Hazards private JMenu hazardMenu = new JMenu("Hazards"); /* ------------------------ MENU ITEM EDITING HAZARDS ------------------------- */ private JMenuItem editHazards = new JMenuItem(new AbstractAction("Edit Hazards...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { actionEditHazards(); } }); // Menu: Screens private JMenu screenMenu = new JMenu("Screens"); /* -------------------------- MENU ITEM GOTO SCREEN --------------------------- */ private JMenuItem gotoScreen = new JMenuItem(new AbstractAction("Go To Screen...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { actionGoToScreen(); } }); private JMenuItem copyPasteScreen = new JMenuItem(new AbstractAction("Copy Screen To...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { actionCopyPasteLevel(); } }); private JMenuItem changeBackground = new JMenuItem(new AbstractAction("Change Background...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.actionChangeBackground(); } }); private JMenuItem resetScreen = new JMenuItem(new AbstractAction("Reset Screen") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.actionResetScreen(); } }); // Special private JMenu specialMenu = new JMenu("Special"); /* -------------------------- MENU ITEM PLACE BONZO --------------------------- */ private JMenuItem placeBonzo = new JMenuItem(new AbstractAction("Place Bonzo") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.actionPlaceBonzo(); } }); private JMenuItem setAuthor = new JMenuItem(new AbstractAction("Author...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { currentWorld.actionSetAuthor(); } }); private JMenuItem setBonus = new JMenuItem(new AbstractAction("Set Bonus Screen...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { actionSelectBonusScreen(); } }); private JMenuItem exportAsPng = new JMenuItem(new AbstractAction("Export Level Map...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { actionExportAsPng(1000); } }); private JMenuItem exportAsPngBonus = new JMenuItem(new AbstractAction("Export Bonus Map...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { World world = currentWorld.getWorldEditor().getWorld(); if (world.screenIdExists(world.getBonusScreen() ) ) { actionExportAsPng(world.getBonusScreen() ); } else { JOptionPane.showMessageDialog(LevelEditor.this, "The bonus screen id " + world.getBonusScreen() + " does not exist.", "Cannot Export Bonus Screens", JOptionPane.ERROR_MESSAGE); } } }); // Returns false if a world is not loaded, after letting the user know // a world is not loaded. private boolean warnAndStopIfWorldNotLoaded() { if (currentWorld.getVisibleScreenEditor() == null) { JOptionPane.showMessageDialog(this, "You must load a world first before being able to load a specific screen", "World Not Loaded", JOptionPane.ERROR_MESSAGE); return false; } return true; } public void actionGoToScreen() { if (!(warnAndStopIfWorldNotLoaded() ) ) { return; } int oldScreenId = currentWorld.getVisibleScreenEditor().getId(); int screenId = GoToScreenDialog.displayAndGetId(oldScreenId, currentWorld.getWorldEditor().getWorld(), true); // If user changed screen id. if (screenId != oldScreenId) { if (currentWorld.screenExists(screenId ) ) { currentWorld.actionChangeScreen(screenId); } else { // Confirm the user wishes to add a new screen if one is not already present before we jump to the // new screen code int result = JOptionPane.showConfirmDialog(this, "This screen does not yet exist. Create screen " + screenId + "?"); if (result == JOptionPane.YES_OPTION) { currentWorld.actionChangeScreen(screenId); } } } } public void actionSelectBonusScreen() { if (!(warnAndStopIfWorldNotLoaded() ) ) { return; } final World world = currentWorld.getWorldEditor().getWorld(); int selectedScreenId = GoToScreenDialog.displayAndGetId(world.getBonusScreen(), world, false); world.setBonusScreen(selectedScreenId); } public void actionCopyPasteLevel() { CopyPasteConfiguration config = CopyPasteDialog.launch(currentWorld.getVisibleScreenEditor().getId(), currentWorld.getWorldEditor().getWorld() ); if (config != null) { currentWorld.getWorldEditor().copyAndPasteLevel(config.copyFromId, config.copyToId); } } /** * * Exports the level asking for a file location as a .png. This is normally either called with level * id 1000 for the main level, or the bonus level id for the bonus. There is no other physical way * for any other playable levels to exist if they are not connected to either the bonus room or 1000, * even if they are technically in the world data. * <p/> * If the given screen Id does not exist, this method will throw an exception. Check the id of the passed * screen. 1000 will always exist but the bonus may not. * * @param levelScreen * id of the screen to start from (all connected screens will be exported as a .png) * */ public void actionExportAsPng(int levelScreen) { JFileChooser exportChooser = new JFileChooser(); exportChooser.setDialogTitle("Save the generated map (as .png)"); int selected = exportChooser.showSaveDialog(this); if (selected == JFileChooser.APPROVE_OPTION) { Path location = exportChooser.getSelectedFile().toPath(); if (!(location.getFileName().toString().endsWith(".png") ) ) { location = location.getParent().resolve(location.getFileName().toString() + ".png"); } BufferedImage map = MapGenerator.generateMap(currentWorld.getWorldEditor().getWorld(), levelScreen); try { ImageIO.write(map, "PNG", location.toFile() ); } catch (IOException e) { JOptionPane.showMessageDialog(this, "Could not export world as .png: " + e.getMessage(), "Export Failed", JOptionPane.ERROR_MESSAGE); } } } /** * * Brings up the GUI editor for the list of hazards saved for that world. * */ public void actionEditHazards() { currentWorld.openEditHazards(); } public static void main(String[] args) { MonkeyShinesLog.initialise(); newInstance(); } /** * * Called by BrushPalette when a brush is changed. Changes are propogated to interested frames. * <p/> * This should be called for every brush * * @param brush * @param id */ public void setTileBrushAndId(PaintbrushType brush, int id) { templatePalette.trySetTileIdAndBrush(brush, id); currentWorld.setTileBrushAndId(brush, id); } /** * * Loads a world from the given path. Worlds must conform to the standard; {@code <worldName>.world} for the * level data and {@code <worldName>.zip} for the resource pack with proper resources. * * @param path * level to load. This should be the .world file, NOT the resource pack * * @throws WorldRestoreException * if the world cannot be loaded due to an issue with the .world file * * @throws ResourcePackException * if the world cannot be loaded due to an issue with the resource pack. This is generally * less serious than an issue with the .world file, as the resource pack can be easily * modified * * @throws IOException * if a low level I/O error prevents loading the world * */ public void loadWorld(final Path worldFile) throws WorldRestoreException, ResourcePackException, IOException { EncodedWorld world = WorldIO.restoreWorld(worldFile); // Try to load the resource pack String fileName = worldFile.getFileName().toString(); String worldName = fileName.substring(0, fileName.lastIndexOf('.') ); Path packFile = worldFile.getParent().resolve(worldName + ".zip"); WorldResource rsrc = WorldResource.fromPack(packFile, UseIntent.EDITOR); this.currentWorld.loadWorld(world, rsrc); this.defaultSaveLocation = worldFile; this.manipulationFunctions(true); } /** * * Delegates to {@code loadWorld}, catching any exceptions and printing them to the error * console in addition to showing an error message to the user. * * @param worldFile * level to load. This should be the .world file, NOT the resource pack * */ private void loadWorldNoisy(final Path worldFile) { try { loadWorld(worldFile); } catch (WorldRestoreException ex) { JOptionPane.showMessageDialog(this, "Cannot load world: Possibly corrupt or not a world file: " + ex.getMessage(), "Loading Error", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); } catch (ResourcePackException ex) { JOptionPane.showMessageDialog(this, "Resource pack issues: " + ex.getMessage(), "Loading Error", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); } catch (IOException ex) { JOptionPane.showMessageDialog(this, "Low level I/O issue: " + ex.getMessage(), "Loading Error", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); } } /** * * Constructs a new instance of the level editor and assigns all the required actions that could not be assigned in the constructor. * Actions that can't be assigned during construction include references to the level editor itself. * * @return * new instance of this object * */ public static LevelEditor newInstance() { final LevelEditor editor = new LevelEditor(); editor.newWorld.setAction(new AbstractAction("New World...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { // Setup dialog final JDialog dialog = new JDialog(editor); dialog.setLayout(new BorderLayout() ); dialog.setTitle("New World..."); dialog.setModal(true); // Center window Point pos = editor.getLocation(); dialog.setLocation(pos.x + 10, pos.y + 10 ); // Setup custom content for dialog NewWorldDialog theNewWorld = new NewWorldDialog(dialog); dialog.add(theNewWorld, BorderLayout.CENTER); dialog.pack(); // Launch (blocking operation) dialog.setVisible(true); // Did the user actually create a world? If so, load it up now! Path worldSave = theNewWorld.getModel().getSaveLocation(); if (worldSave != null) { editor.loadWorldNoisy(worldSave); } } }); editor.loadWorld.setAction(new AbstractAction("Load World...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { JFileChooser fileChooser = new JFileChooser(); fileChooser.setCurrentDirectory(BinaryLocation.BINARY_LOCATION.toFile() ); if (fileChooser.showOpenDialog(editor) == JFileChooser.APPROVE_OPTION) { File worldFile = fileChooser.getSelectedFile(); editor.loadWorldNoisy(worldFile.toPath() ); } } }); editor.saveWorld.setAction(new AbstractAction("Save World...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { if (editor.currentWorld.getState() == EditorState.NO_WORLD_LOADED) throw new IllegalStateException("Save should not be enabled when a world is not loaded"); // Perform world sanity checks. We stop here if anything wrong goes... wrong. Path saveTo; if (editor.defaultSaveLocation != null) saveTo = editor.defaultSaveLocation; else { // TODO save as System.out.println("Save as not implemented yet"); saveTo = null; } try { editor.currentWorld.saveWorld(saveTo); } catch (WorldSaveException ex) { JOptionPane.showMessageDialog(editor, "Cannot Save World: Possible world corruption: " + ex.getMessage(), "Saving Error", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); } catch (IOException ex) { JOptionPane.showMessageDialog(editor, "Cannot Save World: I/O error: " + ex.getMessage(), "Saving Error", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); } } }); editor.importWorld.setAction(new AbstractAction("Import World...") { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { Path p = ImportWorldDialog.launch(); // If the translation was succesful and no cancel, load world into editor. if (p != null) { editor.loadWorldNoisy(p); } } }); // Disable all editor manipulation functions since no world is loaded by default editor.manipulationFunctions(false); return editor; } /** * * Enables or disables menu items in the editor that require a world to be loaded. When no world is loaded, menu * functions manipulating the world should be disabled. Otherwise, they should be enabled. Any state changes the user * makes to load/unload a world should produce a state change here to enable/disable the proper menus. * * @param enable */ private void manipulationFunctions(boolean enable) { saveWorld.setEnabled(enable); screenMenu.setEnabled(enable); hazardMenu.setEnabled(enable); specialMenu.setEnabled(enable); spritesMenu.setEnabled(enable); } private LevelEditor() { int downToMainCanvasEnd = (GameConstants.LEVEL_ROWS * GameConstants.TILE_SIZE_Y) + 40; brushPaletteFrame = new JInternalFrame("Palette", true); brushPaletteFrame.setLayout(new BorderLayout() ); brushPaletteFrame.setSize(new Dimension(240, downToMainCanvasEnd) ); templatePaletteFrame = new JInternalFrame("Templates", true); templatePaletteFrame.setLayout(new BorderLayout() ); templatePaletteFrame.setSize(new Dimension(700, 260) ); // Default dock it to bottom of editor templatePaletteFrame.setLocation(0, downToMainCanvasEnd + 4); // Finally, install listeners to handle resizing the tabbed pane to fit the parent and re-packing it. canvasFrame = new JInternalFrame("Level"); canvasFrame.setLayout(new FlowLayout(FlowLayout.LEFT) ); currentWorld = new LevelDrawingCanvas(paletteUpdateCallback); canvasFrame.add(currentWorld); canvasFrame.pack(); setTitle("Monkey Shines Editor"); setDefaultCloseOperation(EXIT_ON_CLOSE); setUpMenuBar(); // Now, set up the desktop. The desktop will contain both frames editorDesktop = new JDesktopPane(); editorDesktop.add(canvasFrame); // Give space for toolbar in left corner canvasFrame.setLocation(280, 0); canvasFrame.setVisible(true); editorDesktop.add(brushPaletteFrame); editorDesktop.add(templatePaletteFrame); // Set visible later when palette initialised add(editorDesktop); setPreferredSize(new Dimension(960, 800) ); pack(); setLocationRelativeTo(null); setVisible(true); setResizable(true); } private void setUpMenuBar() { fileMenu.add(newWorld); fileMenu.add(loadWorld); fileMenu.add(saveWorld); fileMenu.add(importWorld); fileMenu.add(quit); mainMenuBar.add(fileMenu); spritesMenu.add(placeSprites); spritesMenu.add(editSprites); spritesMenu.add(editOffscreenSprites); spritesMenu.add(deleteSprites); mainMenuBar.add(spritesMenu); hazardMenu.add(editHazards); mainMenuBar.add(hazardMenu); screenMenu.add(gotoScreen); screenMenu.add(copyPasteScreen); screenMenu.add(changeBackground); screenMenu.add(resetScreen); mainMenuBar.add(screenMenu); specialMenu.add(placeBonzo); specialMenu.add(setAuthor); specialMenu.add(setBonus); specialMenu.addSeparator(); specialMenu.add(exportAsPng); specialMenu.add(exportAsPngBonus); mainMenuBar.add(specialMenu); // Set up menus setJMenuBar(mainMenuBar); } }