package org.erikaredmark.monkeyshines.editor;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionListener;
import java.awt.image.BufferedImage;
import javax.swing.JPanel;
import org.erikaredmark.monkeyshines.GameConstants;
import org.erikaredmark.monkeyshines.Point2D;
import org.erikaredmark.monkeyshines.TileMap;
import org.erikaredmark.monkeyshines.World;
import org.erikaredmark.monkeyshines.background.Background;
import org.erikaredmark.monkeyshines.resource.WorldResource;
import org.erikaredmark.monkeyshines.tiles.TileType;
import org.erikaredmark.monkeyshines.tiles.TileTypes;
import org.erikaredmark.monkeyshines.tiles.CommonTile.StatelessTileType;
/**
*
* Encapsulates the idea of editing a tile map. This ONLY handles the tile map and tiles; this does not handle sprites NOR
* goodies (goodies are a world-level object, not a tile-level object)
* <p/>
* The editor allows the tilemap to be edited using a basic brush based system where brushes can be
* set from the Palette object. This also handles drawing the tilemap along with an indicator (an image showing what will
* be drawn) to a graphics context.
* <p/>
* This panel may not handle mouse events itself. The component containing this editor must handle mouse events and forward them
* to the editor if it cannot handle them itself. That depends on construction parameters.
* <p/>
* Once created with a tilemap, the editor cannot be assigned a different tilemap. A new {@code MapEditor} instance would have
* to be created
*
* @author Erika Redmark
*
*/
@SuppressWarnings("serial")
public final class MapEditor extends JPanel {
/**
*
* Creates an editor for the given tilemap, background and world. The world is indirectly required for certain drawing jobs, such as
* hazards, where dependence on the world as a whole.
* <p/>
*
* @param map
* tile map to edit
*
* @param background
* initial background for tile map
*
* @param world
* reference to the entire world
*
* @param autonomousMouseControl
* {@code true} to let this component handle its own events. The primary level editor will set this to {@code false}
* since it also must handle things beyond the basic tilemap
*
*/
public MapEditor(final TileMap map, final Background background, final World world, final boolean autonomousMouseControl) {
super();
this.map = map;
this.background = background;
this.world = world;
int sizeX = map.getColumnCount() * GameConstants.TILE_SIZE_X;
int sizeY = map.getRowCount() * GameConstants.TILE_SIZE_Y;
// Basically, this editor is ALWAYS a constant size based on the tilemap. Expanding and shrinking makes no sense
// in this context.
setMinimumSize(new Dimension(sizeX, sizeY) );
setPreferredSize(new Dimension(sizeX, sizeY) );
// setSize(new Dimension(sizeX, sizeY) );
setMaximumSize(new Dimension(sizeX, sizeY) );
// Optimisations
setDoubleBuffered(true);
updateTileIndicator();
if (autonomousMouseControl) {
// Since this is being autonomous, mouse changes cause repaints (since
// the mouse clicks will change the tiles, and mouse moves will change
// the indicator position.
addMouseListener(new MouseAdapter() {
@Override public void mouseClicked(MouseEvent e) {
MapEditor.this.mouseClicked(e.getX(), e.getY() );
MapEditor.this.repaint();
}
});
addMouseMotionListener(new MouseMotionListener() {
@Override public void mouseMoved(MouseEvent e) {
MapEditor.this.mouseMoved(e.getX(), e.getY() );
MapEditor.this.repaint();
}
@Override public void mouseDragged(MouseEvent e) {
MapEditor.this.mouseClicked(e.getX(), e.getY() );
MapEditor.this.repaint();
}
});
}
}
/**
*
* Returns the backing tilemap to this editor. Changes to the returned tilemap WILL be reflected in the editor; this is
* not a copy.
*
* @return
* tilemap for this editor.
*
*/
public TileMap getTileMap() {
return map;
}
/**
*
* Returns the background used for the map editor. The returned object cannot be used to modify this object's background.
*
* @return
* the tilemap background.
*
*/
public Background getMapBackground() {
return background;
}
/**
*
* Returns the world that this editor was created with. Modifications to the returned world affect the same world
* object that backs this tilemap.
*
* @return
* the world
*
*/
public World getWorld() {
return world;
}
/**
*
* Sets the current brush, and the id of the graphics resource specific to that brush.
* <p/>
* If set to 'NONE', id is not relevant.
*
* @param brush
* @param id
*/
public void setBrushAndId(final TileBrush brush, final int id) {
this.currentBrush = brush;
this.currentId = id;
updateTileIndicator();
}
/**
*
* @return the id of the tile to be drawn by the current brush.
*
*/
public int getTileId() { return currentId; }
/**
*
* @return the brush used for current painting
*
*/
public TileBrush getCurrentBrush() { return currentBrush; }
private void updateTileIndicator() {
WorldResource rsrc = world.getResource();
BufferedImage sheet = null;
int srcX = 0;
int srcY = 0;
switch(currentBrush) {
case SOLIDS:
sheet = rsrc.getStatelessTileTypeSheet(StatelessTileType.SOLID);
srcX = computeSrcX(currentId, sheet);
srcY = computeSrcY(currentId, sheet);
break;
case THRUS:
sheet = rsrc.getStatelessTileTypeSheet(StatelessTileType.THRU);
srcX = computeSrcX(currentId, sheet);
srcY = computeSrcY(currentId, sheet);
break;
case SCENES:
sheet = rsrc.getStatelessTileTypeSheet(StatelessTileType.SCENE);
srcX = computeSrcX(currentId, sheet);
srcY = computeSrcY(currentId, sheet);
break;
case COLLAPSIBLE:
sheet = rsrc.getCollapsingSheet();
srcX = 0;
srcY = currentId * GameConstants.TILE_SIZE_Y;
break;
case CONVEYERS_CLOCKWISE:
sheet = rsrc.getConveyerSheet();
srcX = 0;
srcY = currentId * (GameConstants.TILE_SIZE_Y * 2);
break;
case CONVEYERS_ANTI_CLOCKWISE:
sheet = rsrc.getConveyerSheet();
srcX = 0;
srcY = (currentId * (GameConstants.TILE_SIZE_Y * 2) ) + 20;
break;
case HAZARDS:
sheet = rsrc.getHazardSheet();
srcX = currentId * (GameConstants.TILE_SIZE_X);
srcY = 0;
break;
default :
indicatorImage = null;
}
// If sheet was null, a green square will be drawn instead.
if (sheet != null) {
indicatorImage = new BufferedImage(GameConstants.TILE_SIZE_X, GameConstants.TILE_SIZE_Y, sheet.getType() );
Graphics2D g = indicatorImage.createGraphics();
try {
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f) );
g.drawImage(sheet,
0, 0,
GameConstants.TILE_SIZE_X, GameConstants.TILE_SIZE_Y,
srcX, srcY,
srcX + GameConstants.TILE_SIZE_X, srcY + GameConstants.TILE_SIZE_Y,
null);
} finally {
g.dispose();
}
}
}
private static int computeSrcX(int id, BufferedImage sheet) {
return (id * GameConstants.TILE_SIZE_X) % (sheet.getWidth() );
}
// Resolves the id based on the width/height of sheet to the x location of the top-left coordinate to draw the tile.
private static int computeSrcY(int id, BufferedImage sheet) {
return (id / (sheet.getWidth() / GameConstants.TILE_SIZE_X) ) * (GameConstants.TILE_SIZE_Y);
}
/**
*
* Changes the currently displayed background to the new one.
*
* @param b
* new background
*
*/
public void changeBackground(Background b) {
this.background = b;
}
/**
*
* Updates the state of all tiles on this editor. It is up to client to decide if this should update and at
* what speed.
*
*/
public void update() {
map.update();
}
/**
*
* Paints the tilemap along with editor specfic UI elements, such as the tile indicator.
*
* @param g
*
*/
@Override public void paint(Graphics g) {
super.paint(g);
Graphics2D g2d = (Graphics2D) g;
background.draw(g2d);
map.paint(g2d, world.getResource() );
int snapX = EditorMouseUtils.snapMouseX(mousePosition.x() );
int snapY = EditorMouseUtils.snapMouseY(mousePosition.y() );
drawTileIndicator(g2d, snapX, snapY);
// BELOW: Additional overlays that are not part of the actual world
}
/**
* Paint an indicator to the current tile location. This depends on the 'paintbrush' selected.
* No tile indicator will be drawn if null. In that case, indicator depends on the higher-level
* editor to draw.
*
*/
private void drawTileIndicator(Graphics2D g2d, int snapX, int snapY) {
if (indicatorImage != null) {
g2d.drawImage(indicatorImage,
snapX, snapY,
snapX + GameConstants.TILE_SIZE_X, snapY + GameConstants.TILE_SIZE_Y,
0, 0,
indicatorImage.getWidth(), indicatorImage.getHeight(),
null);
} else {
g2d.setColor(Color.green);
g2d.drawRect(snapX,
snapY,
GameConstants.TILE_SIZE_X, GameConstants.TILE_SIZE_Y);
}
}
public enum TileBrush {
SOLIDS {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.solidFromId(id);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
THRUS {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.thruFromId(id);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
SCENES {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.sceneFromId(id);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
HAZARDS {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.hazardFromId(id, world);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
CONVEYERS_CLOCKWISE {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.clockwiseConveyerFromId(id, world);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
CONVEYERS_ANTI_CLOCKWISE {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.anticlockwiseConveyerFromId(id, world);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
COLLAPSIBLE {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
TileType tile = TileTypes.collapsibleFromId(id);
map.setTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y, tile);
}
},
// ERASER is only for tiles. Goodies and Sprites are not included.
ERASER {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) {
map.eraseTileXY(pixelX / GameConstants.TILE_SIZE_X, pixelY / GameConstants.TILE_SIZE_Y);
}
},
// Means nothing happens on click. This is useful for if a higher level editor needs to do something on click that a basic
// map editor cannot, such as setting sprites or goodies.
NONE {
@Override public void onClick(int pixelX, int pixelY, int id, World world, TileMap map) { }
};
/**
*
* Performs the actual drawing of this tile entity to a specified pixel location with the given brush, id, and graphics,
* to the given tilemap. Tiles that contain state, such as Hazards, are always freshly created.
*
* @param pixelX
* pixel location x
*
* @param pixelY
* pixel location y
*
* @param id
* id of the graphics resource to use for the given brush. No bounds checking is done; it is up to client to ensure value never goes
* beyond the graphics resource indicated by the brush
*
* @param world
* the world the tilemap is part of. All tilemaps must be part of some world, especially for special tiles like Hazards to be used properly.
*
* @param map
* this parameter is being modified by the function. Drawing will take place on this object
*
*/
public abstract void onClick(int pixelX, int pixelY, int id, World world, TileMap map);
}
/**
*
* Parent must call this method to indicate a mouse click that the editor should handle (such as when the parent
* knows it has set the current brush properly and nothing non-map-editor related, like Sprites, are supposed to be
* handled by the click)
* <p/>
* This should also be called for mouse drags.
* <p/>
* If this editor was created with the ability to handle its own mouse events, this will automatically be called.
*
* @param x
* location of mouse click, x position
*
* @param y
* location of mouse click, y position
*
*/
public void mouseClicked(int x, int y) {
mousePosition.setX(x);
mousePosition.setY(y);
currentBrush.onClick(x, y, currentId, world, map);
}
/**
*
* Parent must call this method in their mouseListener implementation to keep the indicator image up-to-date.
* <p/>
* If this editor was created with the ability to handle its own mouse events, this will automatically be called.
*
* @param x
* location of mouse click, x position
*
* @param y
* location of mouse click, y position
*
*/
public void mouseMoved(int x, int y) {
mousePosition.setX(x);
mousePosition.setY(y);
}
/**
*
* Converts the brush type here to the brush type in the map editor. The map editor only uses a smaller
* subset of tile brushes, so this method is in error if called with a paintbrush that isn't a tile brush.
*
* @param t
* @return
*/
public static TileBrush paintbrushToTilebrush(PaintbrushType t) {
switch(t) {
case SOLIDS: return TileBrush.SOLIDS;
case THRUS: return TileBrush.THRUS;
case SCENES: return TileBrush.SCENES;
case CONVEYERS_CLOCKWISE: return TileBrush.CONVEYERS_CLOCKWISE;
case CONVEYERS_ANTI_CLOCKWISE: return TileBrush.CONVEYERS_ANTI_CLOCKWISE;
case COLLAPSIBLE: return TileBrush.COLLAPSIBLE;
case HAZARDS: return TileBrush.HAZARDS;
case ERASER_TILES: return TileBrush.ERASER;
default: throw new IllegalArgumentException("Paintbrush type " + t + " not a valid tile brush");
}
}
/**
*
* Determines if the given paintbrush can be directly converted to tile brush.
*
* @param t
*
* @return
* {@code true} if the paintbrush can be a tile brush, {@code false} if otherwise
*
*/
public static boolean isPaintbrushToTilebrush(PaintbrushType t) {
switch(t) {
case SOLIDS: // break omitted
case THRUS: // break omitted
case SCENES: // break omitted
case CONVEYERS_CLOCKWISE: // break omitted
case CONVEYERS_ANTI_CLOCKWISE: // break omitted
case COLLAPSIBLE: // break omitted
case HAZARDS: // break omitted
case ERASER_TILES: // break omitted
return true;
default:
return false;
}
}
// Only tile-type brushes are valid.
private TileBrush currentBrush = TileBrush.SOLIDS;
private int currentId = 0;
private final TileMap map;
private final World world;
private Background background;
// overlays on painting the tilemap where the brush mouse is positioned. Null is acceptable; means green square is drawn.
private BufferedImage indicatorImage = null;
// Constantly updated when mouse moves so tilemap can remember last position for overlay drawing purposes.
private Point2D mousePosition = Point2D.of(0, 0);
}