package org.erikaredmark.monkeyshines.editor.dialog;
import java.awt.Color;
import java.awt.Component;
import java.awt.FlowLayout;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
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.DefaultComboBoxModel;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ListSelectionModel;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import org.erikaredmark.monkeyshines.DeathAnimation;
import org.erikaredmark.monkeyshines.Hazard;
import org.erikaredmark.monkeyshines.editor.HazardMutable;
import org.erikaredmark.monkeyshines.resource.WorldResource;
/**
*
* For a given graphics resource which supplies a given number of hazard icons, allows the user to define each hazard
* icon graphically in terms of what properties each hazard has when placed on a world. This is not WHERE the hazards are
* placed, but rather what they actually are.
* <p/>
* Initially all hazards start out at some default value. Since hazard sprite sheets enumerate tile sized hazards and each
* column is a new hazard, the number of hazard available to the user is equal to the length-wise size of the hazard sheet
* divided by the pixels per tile (length-wise). Since these dialogs can only be launched with valid graphics resources users
* cannot assign hazards to non-existent sprites.
*
* @author Erika Redmark
*
*/
public class EditHazardsDialog extends JDialog {
private static final long serialVersionUID = 1L;
private static final String CLASS_NAME = "org.erikaredmark.monkeyshines.editor.dialog.EditHazardsDialog";
private static final Logger LOGGER = Logger.getLogger(CLASS_NAME);
private final EditHazardsModel model;
private final DefaultListModel<Hazard> hazardListModel;
private final EditSingleHazardPanel editPanel;
private final JList<Hazard> hazardList;
/**
*
* Constructs the dialog with the diven model. The dialog is ready but not immediately visible after this construction.
*
* @param model
*
* @param rsrc
*
*/
private EditHazardsDialog(final EditHazardsModel model, final WorldResource rsrc) {
this.model = model;
getContentPane().setLayout(new GridBagLayout() );
/* ------------------ Hazard list ------------------- */
// This list will sync changes to the underlying EditHazardsModel
hazardListModel = new DefaultListModel<>();
// Assumption: All elements in the passed model are sorted, since they can only be actually generated from here,
// and all elements generated here will be sorted
for (Hazard h : this.model.getHazards() ) {
hazardListModel.addElement(h);
}
hazardList = new JList<Hazard>(hazardListModel);
hazardList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
hazardList.setCellRenderer(new HazardCellRenderer(rsrc) );
// Attach to upper left of window and extend to bottom
final GridBagConstraints hazardListGbc = new GridBagConstraints();
hazardListGbc.gridx = 0;
hazardListGbc.gridy = 0;
hazardListGbc.weightx = 1;
hazardListGbc.weighty = 1;
hazardListGbc.gridheight = GridBagConstraints.REMAINDER;
hazardListGbc.gridwidth = 1;
hazardListGbc.fill = GridBagConstraints.BOTH;
// Embed list in Scrollable pane to allow scrollbars, but apply constraints to scrollable pane
JScrollPane hazardListWrapped = new JScrollPane(hazardList);
getContentPane().add(hazardListWrapped, hazardListGbc);
// Define edit panel here so listener for delete hazard can access it now
editPanel = new EditSingleHazardPanel();
/* -------------------- Edit Hazard Panel --------------------- */
/* ----------------- Selection Listener ----------------- */
/* Selected hazards are under edit in the edit panel. */
/* This listener binds to the original hazard list. */
hazardList.addListSelectionListener(new ListSelectionListener() {
@Override public void valueChanged(ListSelectionEvent event) {
// Does not save the current hazard. Saving should be done on each property change.
Hazard newHazard = hazardList.getSelectedValue();
if (newHazard == null) return;
editPanel.setHazardEditing(newHazard);
}
});
final GridBagConstraints editPanelGbc = new GridBagConstraints();
editPanelGbc.gridx = 2;
editPanelGbc.gridy = 1;
editPanelGbc.gridwidth = GridBagConstraints.REMAINDER;
editPanelGbc.gridheight = GridBagConstraints.REMAINDER;
getContentPane().add(editPanel, editPanelGbc);
hazardList.setVisible(true);
setSize(600, 300);
}
private void save() {
if (this.editPanel.isEditingHazard() ) {
if (!(this.editPanel.isHazardEdited() ) ) return;
// Save the current selection so we can re-select when we redo the list model.
int currentSelection = hazardList.getSelectedIndex();
Hazard editedCopy = this.editPanel.getHazardEdited();
List<Hazard> newModel = model.getMutableHazards();
Hazard.replaceHazard(newModel, editedCopy);
// update the list model used by the view
this.hazardListModel.clear();
for (Hazard h : newModel) {
this.hazardListModel.addElement(h);
}
hazardList.setSelectedIndex(currentSelection);
}
}
/**
*
* Panel used by dialog that, given a specific Hazard property, alonws one to make modifications to it. Changes to
* a hazard can then replace a hazard in the view, and therefore the main model, or can be discarded.
*
* @author Erika Redmark
*
*/
private final class EditSingleHazardPanel extends JPanel {
private static final long serialVersionUID = 1L;
// This mutable copy is never published outside of this object.
private HazardMutable currentlyEditingHazard;
// The immutable id of even the mutable hazard can tell us what to replace, but for easeness we
// store a reference to the immutable hazard this one is derived to do equals calculations within
// this object (for unsaved changes and such)
private Hazard originalFromCurrentEdit;
final JCheckBox explodesBtn;
final JCheckBox harmlessBtn;
final JComboBox<DeathAnimation> deathAnimation;
private EditSingleHazardPanel() {
/* ------------- Function ------------- */
final JLabel deathAnimationLbl = new JLabel("Death Animation");
deathAnimation = new JComboBox<>(new DefaultComboBoxModel<>(DeathAnimation.values() ) );
deathAnimation.addActionListener(new ActionListener() {
@Override public void actionPerformed(ActionEvent evt) {
currentlyEditingHazard.setDeathAnimation((DeathAnimation)deathAnimation.getSelectedItem() );
EditHazardsDialog.this.save();
}
});
explodesBtn = new JCheckBox(new AbstractAction("Explodes") {
private static final long serialVersionUID = 1L;
@Override public void actionPerformed(ActionEvent evt) {
// set mutable hazard to same exploded state as checkbox
currentlyEditingHazard.setExplodes(((JCheckBox)evt.getSource()).isSelected() );
EditHazardsDialog.this.save();
}
});
harmlessBtn = new JCheckBox(new AbstractAction("Harmless") {
private static final long serialVersionUID = 1L;
@Override public void actionPerformed(ActionEvent evt) {
boolean selected = ((JCheckBox)evt.getSource()).isSelected();
currentlyEditingHazard.setHarmless(selected);
deathAnimation.setEnabled(!(selected) );
EditHazardsDialog.this.save();
}
});
/* --------------- Form ---------------- */
setLayout(new FlowLayout(FlowLayout.LEFT) );
// Grid inside a flow; ensures grid doesn't expand too big and have too much space between
// components and works nicely as a simple set of controls down the left-hand side.
JPanel internalPanel = new JPanel();
internalPanel.setLayout(new GridLayout(3, 2) );
add(internalPanel);
internalPanel.add(deathAnimationLbl);
internalPanel.add(deathAnimation);
internalPanel.add(new JLabel() ); // Placeholder for grid
internalPanel.add(explodesBtn);
internalPanel.add(new JLabel() ); // Placeholder for grid
internalPanel.add(harmlessBtn);
// Initially, there are no hazards being edited on construction (we have no initial selection)
noHazardEditing();
}
/**
*
* Indicates to this panel no hazard is currently selected for editing.
*
*/
public void noHazardEditing() {
explodesBtn.setEnabled(false);
deathAnimation.setEnabled(false);
harmlessBtn.setEnabled(false);
}
/**
*
* Makes a mutable copy of the given hazard and places it under editing. If the panel was previously not
* under editing, enables all controls.
* <p/>
* This also enables the view if it is disabled and updates it to the properties of the hazard
*
*/
public void setHazardEditing(final Hazard hazard) {
this.currentlyEditingHazard = hazard.mutableCopy();
this.originalFromCurrentEdit = hazard;
// enable first. Selections may disable certain conflicting controls
explodesBtn.setEnabled(true);
deathAnimation.setEnabled(true);
harmlessBtn.setEnabled(true);
explodesBtn.setSelected(originalFromCurrentEdit.getExplodes() );
deathAnimation.setSelectedItem(originalFromCurrentEdit.getDeathAnimation() );
harmlessBtn.setSelected(originalFromCurrentEdit.isHarmless() );
}
/**
*
* Returns an immutable copy of the hazard currently under edit. This call MAY fail with an exception if there is
* no hazard under edit. Use {@code isEditingHazard() } first to confirm.
*
* @throws IllegalStateException
* if called when there is no hazard being edited
*
*/
public Hazard getHazardEdited() {
if (currentlyEditingHazard == null) throw new IllegalStateException("No hazard being editing");
return this.currentlyEditingHazard.immutableCopy();
}
/**
*
* Determines if the given hazard being edited by this panel is actually different in some way from the hazard that
* it started with (i.e have any changes been made). This method may also throw an exception if there is no hazard
* under edit. Use {@code isEditingHazard() } first to confirm.
*
* @return
* {@code true} if the hazard is changed, {@code false} if the hazard is the same
*
* @throws IllegalStateException
* if called when there is no hazard being edited
*/
public boolean isHazardEdited() {
if (currentlyEditingHazard == null) throw new IllegalStateException("No hazard being editing");
// Don't compare Id or resources. Those never change
return currentlyEditingHazard.getDeathAnimation() != originalFromCurrentEdit.getDeathAnimation()
|| currentlyEditingHazard.getExplodes() != originalFromCurrentEdit.getExplodes()
|| currentlyEditingHazard.isHarmless() != originalFromCurrentEdit.isHarmless();
}
/**
*
* Determines if this panel is currently editing a hazard or if it is not.
*
* @return
* {@code true} if editing a hazard, {@code false} if otherwise
*
*/
public boolean isEditingHazard() {
return currentlyEditingHazard != null;
}
}
/**
*
* Allows rendering of the Image of the hazard, whether or not it explodes, and the type of
* death animation (in that order) in 20x20 chunks with 2px padding in-between each.
* <p/>
* The graphic used for the hazard is taken from the index in the list (index in list = rsrc
* index). The graphics used for the other properties are internal to the .jar and are taken if
* the data model requires them.
*
* @author Erika Redmark
*
*/
private static final class HazardCellRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 1L;
private final Map<DeathAnimation, ImageIcon> deathTypeIcons = new HashMap<>();
private ImageIcon explodesIcon;
private ImageIcon harmlessIcon;
private final List<ImageIcon> indexToHazard = new ArrayList<>();
private HazardCellRenderer(final WorldResource rsrc) {
try {
// All or none. If any one load fails, all image icons will not be initialised.
BufferedImage explodes = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/explodesIcon.png") );
BufferedImage burn = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/burnIcon.png") );
BufferedImage bee = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/beeSting.png") );
BufferedImage standardDeath = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/standardDeath.png") );
BufferedImage electricDeath = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/electricDeath.png") );
BufferedImage harmlessImage = ImageIO.read(EditHazardsDialog.class.getResourceAsStream("/resources/graphics/editor/hazard/harmless.png") );
deathTypeIcons.put(DeathAnimation.BURN, new ImageIcon(burn) );
deathTypeIcons.put(DeathAnimation.BEE, new ImageIcon(bee) );
deathTypeIcons.put(DeathAnimation.NORMAL, new ImageIcon(standardDeath) );
deathTypeIcons.put(DeathAnimation.ELECTRIC, new ImageIcon(electricDeath) );
explodesIcon = new ImageIcon(explodes);
harmlessIcon = new ImageIcon(harmlessImage);
} catch (IOException e) {
// No big deal, we just can't render the images like we wanted.
LOGGER.log(Level.SEVERE,
"Missing graphics resources in .jar; cannot render information images in hazards display: " + e.getMessage(),
e);
}
// indexToHazard ALWAYS works.
BufferedImage source = rsrc.getHazardSheet();
for (int srcX = 0; srcX < source.getWidth(); srcX += 20) {
BufferedImage destination = new BufferedImage(20, 20, source.getType() );
Graphics2D g2d = destination.createGraphics();
try {
g2d.drawImage(source, 0, 0, 20, 20, srcX, 0, srcX + 20, 20, null);
indexToHazard.add(new ImageIcon(destination) );
} finally {
g2d.dispose();
}
}
}
@Override public Component getListCellRendererComponent(JList<?> list,
Object v,
int index,
boolean isSelected,
boolean cellHasFocus) {
assert v instanceof Hazard;
Hazard value = (Hazard) v;
// Return a composite of three labels, each showing one image based on either the id,
// explosion type, or death type of the hazard
JPanel display = new JPanel();
display.setLayout(new FlowLayout(FlowLayout.LEFT) );
JLabel hazardItself = new JLabel();
hazardItself.setIcon(indexToHazard.get(index) );
display.add(hazardItself);
// Null is acceptable for the next two
// Special Case: Death type is not drawn (instead the Harmless icon is drawn) if the hazard is harmless, since
// a harmless hazard has no death type (technically it does in the code, it just isn't used)
if (!(value.isHarmless() ) ) {
JLabel deathType = new JLabel();
deathType.setIcon(deathTypeIcons.get(value.getDeathAnimation() ) );
display.add(deathType);
} else {
JLabel deathType = new JLabel();
deathType.setIcon(harmlessIcon);
display.add(deathType);
}
if (value.explodes() && explodesIcon != null ) {
JLabel explod = new JLabel();
explod.setIcon(explodesIcon);
display.add(explod);
}
if (isSelected) {
display.setBackground(Color.GRAY);
}
return display;
}
}
/**
*
* Launches the given dialog with the associated parent and world resource. This call will block until the user is
* finished, upon which the model will be returned representing the logical state that the user made changes to.
* This model should then be synced with the world resource currently being edited for changes to take effect.
*
* @param parent
* the parent component
*
* @param rsrc
* the world resource to draw the graphics from. <strong> This affects how many hazards are available</strong>
* based on the size of the sprite sheet.
*
*/
public static EditHazardsModel launch(JComponent parent, WorldResource rsrc, List<Hazard> worldHazards) {
final EditHazardsModel model = new EditHazardsModel(worldHazards);
final EditHazardsDialog dialog = new EditHazardsDialog(model, rsrc);
// Blocks due to dialog modality
dialog.setLocationRelativeTo(null);
dialog.setModal(true);
dialog.setVisible(true);
return dialog.model;
}
}