/*
* $Id$
*
* Copyright (c) 2005 by Rodney Kinney, Brent Easton
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License (LGPL) as published by the Free Software Foundation.
*
* This library 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
* Library General Public License for more details.
*
* You should have received a copy of the GNU Library General Public
* License along with this library; if not, copies are available
* at http://www.opensource.org.
*/
package VASSAL.build.module.map;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Paint;
import java.awt.Rectangle;
import java.awt.Stroke;
import java.awt.TexturePaint;
import java.awt.event.ActionListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import org.apache.commons.lang.builder.HashCodeBuilder;
import VASSAL.build.AbstractConfigurable;
import VASSAL.build.AutoConfigurable;
import VASSAL.build.Buildable;
import VASSAL.build.GameModule;
import VASSAL.build.module.GameComponent;
import VASSAL.build.module.Map;
import VASSAL.build.module.documentation.HelpFile;
import VASSAL.build.module.map.boardPicker.Board;
import VASSAL.command.Command;
import VASSAL.configure.ColorConfigurer;
import VASSAL.configure.Configurer;
import VASSAL.configure.ConfigurerFactory;
import VASSAL.configure.IconConfigurer;
import VASSAL.configure.StringArrayConfigurer;
import VASSAL.configure.StringEnum;
import VASSAL.configure.VisibilityCondition;
import VASSAL.counters.Decorator;
import VASSAL.counters.GamePiece;
import VASSAL.counters.Stack;
import VASSAL.i18n.Resources;
import VASSAL.tools.LaunchButton;
import VASSAL.tools.NamedKeyStroke;
import VASSAL.tools.UniqueIdManager;
import VASSAL.tools.image.ImageUtils;
import VASSAL.tools.imageop.AbstractTileOpImpl;
import VASSAL.tools.imageop.ImageOp;
import VASSAL.tools.imageop.Op;
/**
* Draw shaded regions on a map.
*
* @author Brent Easton
*/
public class MapShader extends AbstractConfigurable implements GameComponent, Drawable, UniqueIdManager.Identifyable {
public static final String NAME = "name";
public static final String ALWAYS_ON = "alwaysOn";
public static final String STARTS_ON = "startsOn";
public static final String HOT_KEY = "hotkey";
public static final String ICON = "icon";
public static final String BUTTON_TEXT = "buttonText";
public static final String TOOLTIP = "tooltip";
public static final String BOARDS = "boards";
public static final String BOARD_LIST = "boardList";
public static final String ALL_BOARDS = "Yes";
public static final String EXC_BOARDS = "No, exclude Boards in list";
public static final String INC_BOARDS = "No, only shade Boards in List";
protected static UniqueIdManager idMgr = new UniqueIdManager("MapShader");
protected LaunchButton launch;
protected boolean alwaysOn = false;
protected boolean startsOn = false;
protected String boardSelection = ALL_BOARDS;
protected String[] boardList = new String[0];
protected boolean shadingVisible;
protected boolean scaleImage;
protected Map map;
protected String id;
protected Area boardClip = null;
public static final String TYPE = "type";
public static final String DRAW_OVER = "drawOver";
public static final String PATTERN = "pattern";
public static final String COLOR = "color";
public static final String IMAGE = "image";
public static final String SCALE_IMAGE = "scaleImage";
public static final String OPACITY = "opacity";
public static final String BORDER = "border";
public static final String BORDER_COLOR = "borderColor";
public static final String BORDER_WIDTH = "borderWidth";
public static final String BORDER_OPACITY = "borderOpacity";
public static final String BG_TYPE = "Background";
public static final String FG_TYPE = "Foreground";
public static final String TYPE_25_PERCENT = "25%";
public static final String TYPE_50_PERCENT = "50%";
public static final String TYPE_75_PERCENT = "75%";
public static final String TYPE_SOLID = "100% (Solid)";
public static final String TYPE_IMAGE = "Custom Image";
protected String imageName;
protected Color color = Color.BLACK;
protected String type = FG_TYPE;
protected boolean drawOver = false;
protected String pattern = TYPE_25_PERCENT;
protected int opacity = 100;
protected boolean border = false;
protected Color borderColor = Color.BLACK;
protected int borderWidth = 1;
protected int borderOpacity = 100;
protected Area shape;
@Deprecated protected BufferedImage shadePattern = null;
protected Rectangle patternRect = new Rectangle();
protected ImageOp srcOp;
protected TexturePaint texture = null;
protected java.util.Map<Double,TexturePaint> textures =
new HashMap<Double,TexturePaint>();
protected AlphaComposite composite = null;
protected AlphaComposite borderComposite = null;
protected BasicStroke stroke = null;
public void draw(Graphics g, Map map) {
if (shadingVisible) {
double zoom = map.getZoom();
buildStroke(zoom);
final Graphics2D g2 = (Graphics2D) g;
final Composite oldComposite = g2.getComposite();
final Color oldColor = g2.getColor();
final Paint oldPaint = g2.getPaint();
final Stroke oldStroke = g2.getStroke();
g2.setComposite(getComposite());
g2.setColor(getColor());
g2.setPaint(
scaleImage && pattern.equals(TYPE_IMAGE) && imageName != null ?
getTexture(zoom) : getTexture());
Area area = getShadeShape(map);
if (zoom != 1.0) {
area = new Area(AffineTransform.getScaleInstance(zoom,zoom)
.createTransformedShape(area));
}
g2.fill(area);
if (border) {
g2.setComposite(getBorderComposite());
g2.setStroke(getStroke(map.getZoom()));
g2.setColor(getBorderColor());
g2.draw(area);
}
g2.setComposite(oldComposite);
g2.setColor(oldColor);
g2.setPaint(oldPaint);
g2.setStroke(oldStroke);
}
}
/**
* Get/Build the AlphaComposite used to draw the semi-transparent shade/
*/
protected AlphaComposite getComposite() {
if (composite == null) {
buildComposite();
}
return composite;
}
protected void buildComposite() {
composite =
AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity / 100.0f);
}
protected AlphaComposite getBorderComposite() {
if (borderComposite == null) {
borderComposite = buildBorderComposite();
}
return borderComposite;
}
protected AlphaComposite buildBorderComposite() {
return AlphaComposite.getInstance(
AlphaComposite.SRC_OVER, borderOpacity / 100.0f);
}
/**
* Get/Build the shape of the shade.
*/
protected Area getShadeShape(Map map) {
final Area myShape = type.equals(FG_TYPE) ?
new Area() : new Area(getBoardClip());
for (GamePiece p : map.getPieces()) {
checkPiece(myShape, p);
}
return myShape;
}
protected void checkPiece(Area area, GamePiece piece) {
if (piece instanceof Stack) {
Stack s = (Stack) piece;
for (int i = 0; i < s.getPieceCount(); i++) {
checkPiece(area, s.getPieceAt(i));
}
}
else {
ShadedPiece shaded = (ShadedPiece) Decorator.getDecorator(piece,ShadedPiece.class);
if (shaded != null) {
Area shape = shaded.getArea(this);
if (shape != null) {
if (type.equals(FG_TYPE)) {
area.add(shape);
}
else {
area.subtract(shape);
}
}
}
}
}
/**
* Get/Build the repeating rectangle used to generate the shade texture
* pattern.
*/
protected BufferedImage getShadePattern() {
if (srcOp == null) buildShadePattern();
return srcOp.getImage();
}
protected BufferedImage getShadePattern(double zoom) {
if (srcOp == null) buildShadePattern();
return Op.scale(srcOp,zoom).getImage();
}
protected Rectangle getPatternRect() {
return patternRect;
}
protected Rectangle getPatternRect(double zoom) {
return new Rectangle((int)Math.round(zoom*patternRect.width),(int)Math.round(zoom*patternRect.height));
}
protected void buildShadePattern() {
srcOp = pattern.equals(TYPE_IMAGE) && imageName != null
? Op.load(imageName) : new PatternOp(color, pattern);
patternRect = new Rectangle(srcOp.getSize());
}
private static class PatternOp extends AbstractTileOpImpl {
private final Color color;
private final String pattern;
private final int hash;
public PatternOp(Color color, String pattern) {
if (color == null || pattern == null)
throw new IllegalArgumentException();
this.color = color;
this.pattern = pattern;
hash = new HashCodeBuilder().append(color).append(pattern).toHashCode();
}
public BufferedImage eval() throws Exception {
final BufferedImage im =
ImageUtils.createCompatibleTranslucentImage(2, 2);
final Graphics2D g = im.createGraphics();
g.setColor(color);
if (TYPE_25_PERCENT.equals(pattern)) {
g.drawLine(0, 0, 0, 0);
}
else if (TYPE_50_PERCENT.equals(pattern)) {
g.drawLine(0, 0, 0, 0);
g.drawLine(1, 1, 1, 1);
}
else if (TYPE_75_PERCENT.equals(pattern)) {
g.drawLine(0, 0, 1, 0);
g.drawLine(1, 1, 1, 1);
}
else {
g.drawLine(0, 0, 1, 0);
g.drawLine(0, 1, 1, 1);
}
g.dispose();
return im;
}
protected void fixSize() { }
@Override
public Dimension getSize() {
return new Dimension(2,2);
}
@Override
public int getWidth() {
return 2;
}
@Override
public int getHeight() {
return 2;
}
public List<VASSAL.tools.opcache.Op<?>> getSources() {
return Collections.emptyList();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PatternOp)) return false;
return color.equals(((PatternOp) o).color) &&
pattern.equals(((PatternOp) o).pattern);
}
@Override
public int hashCode() {
return hash;
}
}
protected BasicStroke getStroke(double zoom) {
if (stroke == null) {
buildStroke(zoom);
}
return stroke;
}
protected void buildStroke(double zoom) {
stroke = new BasicStroke((float) Math.min(borderWidth * zoom, 1.0),
BasicStroke.CAP_ROUND,
BasicStroke.JOIN_ROUND);
}
public Color getBorderColor() {
return borderColor;
}
/**
* Get/Build the textured paint used to fill in the Shade
*/
protected TexturePaint getTexture() {
if (texture == null) {
buildTexture();
}
return texture;
}
protected TexturePaint getTexture(double zoom) {
if (zoom == 1.0) {
return getTexture();
}
TexturePaint texture = textures.get(zoom);
if (texture == null) {
texture = new TexturePaint(getShadePattern(zoom),getPatternRect(zoom));
textures.put(zoom,texture);
}
return texture;
}
protected void buildTexture() {
if (getShadePattern() != null) {
texture = new TexturePaint(getShadePattern(), getPatternRect());
}
}
public Color getColor() {
return color;
}
/**
* Is this Shade drawn over or under counters?
*/
public boolean drawAboveCounters() {
return drawOver;
}
public String[] getAttributeNames() {
return new String[]{
NAME,
ALWAYS_ON,
STARTS_ON,
BUTTON_TEXT,
TOOLTIP,
ICON,
HOT_KEY,
BOARDS,
BOARD_LIST,
TYPE,
DRAW_OVER,
PATTERN,
COLOR,
IMAGE,
SCALE_IMAGE,
OPACITY,
BORDER,
BORDER_COLOR,
BORDER_WIDTH,
BORDER_OPACITY
};
}
public Class<?>[] getAttributeTypes() {
return new Class<?>[]{
String.class,
Boolean.class,
Boolean.class,
String.class,
String.class,
IconConfig.class,
NamedKeyStroke.class,
BoardPrompt.class,
String[].class,
TypePrompt.class,
Boolean.class,
PatternPrompt.class,
Color.class,
Image.class,
Boolean.class,
Integer.class,
Boolean.class,
Color.class,
Integer.class,
Integer.class
};
}
public String[] getAttributeDescriptions() {
return new String[]{
Resources.getString(Resources.NAME_LABEL),
Resources.getString("Editor.MapShader.shading_on"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.shading_start"), //$NON-NLS-1$
Resources.getString(Resources.BUTTON_TEXT),
Resources.getString(Resources.TOOLTIP_TEXT),
Resources.getString(Resources.BUTTON_ICON),
Resources.getString(Resources.HOTKEY_LABEL),
Resources.getString("Editor.MapShader.shade_boards"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.board_list"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.type"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.shade_top"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.pattern"), //$NON-NLS-1$
Resources.getString(Resources.COLOR_LABEL),
Resources.getString("Editor.MapShader.image"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.scale"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.opacity"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.border"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.border_color"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.border_width"), //$NON-NLS-1$
Resources.getString("Editor.MapShader.border_opacity"), //$NON-NLS-1$
};
}
public static class TypePrompt extends StringEnum {
public String[] getValidValues(AutoConfigurable target) {
return new String[]{FG_TYPE, BG_TYPE};
}
}
public static class PatternPrompt extends StringEnum {
public String[] getValidValues(AutoConfigurable target) {
return new String[]{TYPE_25_PERCENT, TYPE_50_PERCENT, TYPE_75_PERCENT, TYPE_SOLID, TYPE_IMAGE};
}
}
public Class<?>[] getAllowableConfigureComponents() {
return new Class<?>[0];
}
public static class BoardPrompt extends StringEnum {
public String[] getValidValues(AutoConfigurable target) {
return new String[]{ALL_BOARDS, EXC_BOARDS, INC_BOARDS};
}
}
public MapShader() {
ActionListener al = new ActionListener() {
public void actionPerformed(java.awt.event.ActionEvent e) {
toggleShading();
}
};
launch = new LaunchButton("Shade", TOOLTIP, BUTTON_TEXT, HOT_KEY, ICON, al);
launch.setEnabled(false);
setLaunchButtonVisibility();
setConfigureName("Shading");
reset();
}
public void reset() {
shadingVisible = isAlwaysOn() || isStartsOn();
}
protected void toggleShading() {
setShadingVisibility(!shadingVisible);
}
public void setShadingVisibility(boolean b) {
shadingVisible = b;
map.repaint();
}
protected boolean isAlwaysOn() {
return alwaysOn;
}
protected boolean isStartsOn() {
return startsOn;
}
protected Map getMap() {
return map;
}
public Area getBoardClip() {
buildBoardClip();
return boardClip;
}
/**
* Build a clipping region excluding boards that do not needed to be Shaded.
*/
protected void buildBoardClip() {
if (boardClip == null) {
boardClip = new Area();
for (Board b : map.getBoards()) {
String boardName = b.getName();
boolean doShade = false;
if (boardSelection.equals(ALL_BOARDS)) {
doShade = true;
}
else if (boardSelection.equals(EXC_BOARDS)) {
doShade = true;
for (int i = 0; i < boardList.length && doShade; i++) {
doShade = !boardList[i].equals(boardName);
}
}
else if (boardSelection.equals(INC_BOARDS)) {
for (int i = 0; i < boardList.length && !doShade; i++) {
doShade = boardList[i].equals(boardName);
}
}
if (doShade) {
boardClip.add(new Area(b.bounds()));
}
}
}
}
public void setLaunchButtonVisibility() {
launch.setVisible(!isAlwaysOn());
}
/*
* -----------------------------------------------------------------------
* GameComponent Implementation
* -----------------------------------------------------------------------
*/
public void setup(boolean gameStarting) {
launch.setEnabled(gameStarting);
if (!gameStarting) {
boardClip = null;
}
}
public Command getRestoreCommand() {
return null;
}
public static class IconConfig implements ConfigurerFactory {
public Configurer getConfigurer(AutoConfigurable c, String key, String name) {
return new IconConfigurer(key, name, ((MapShader) c).launch.getAttributeValueString(ICON));
}
}
public void setAttribute(String key, Object value) {
if (NAME.equals(key)) {
setConfigureName((String) value);
if (launch.getAttributeValueString(TOOLTIP) == null) {
launch.setAttribute(TOOLTIP, value);
}
}
else if (ALWAYS_ON.equals(key)) {
if (value instanceof String) {
value = Boolean.valueOf((String) value);
}
alwaysOn = ((Boolean) value).booleanValue();
setLaunchButtonVisibility();
reset();
}
else if (STARTS_ON.equals(key)) {
if (value instanceof String) {
value = Boolean.valueOf((String) value);
}
startsOn = ((Boolean) value).booleanValue();
setLaunchButtonVisibility();
reset();
}
else if (BOARDS.equals(key)) {
boardSelection = (String) value;
}
else if (BOARD_LIST.equals(key)) {
if (value instanceof String) {
value = StringArrayConfigurer.stringToArray((String) value);
}
boardList = (String[]) value;
}
else if (TYPE.equals(key)) {
type = (String) value;
}
else if (DRAW_OVER.equals(key)) {
if (value instanceof String) {
value = Boolean.valueOf((String) value);
}
drawOver = ((Boolean) value).booleanValue();
}
else if (PATTERN.equals(key)) {
pattern = (String) value;
buildShadePattern();
buildTexture();
}
else if (COLOR.equals(key)) {
if (value instanceof String) {
value = ColorConfigurer.stringToColor((String) value);
}
// Bug 9969. Color configurer returns null if cancelled, so ignore a null.
// and leave pattern and texture unchanged
if (value != null) {
color = (Color) value;
buildShadePattern();
buildTexture();
}
}
else if (IMAGE.equals(key)) {
if (value instanceof File) {
value = ((File) value).getName();
}
imageName = (String) value;
buildShadePattern();
textures.clear();
buildTexture();
}
else if (SCALE_IMAGE.equals(key)) {
if (value instanceof String) {
value = Boolean.valueOf((String)value);
}
scaleImage = ((Boolean)value).booleanValue();
}
else if (BORDER.equals(key)) {
if (value instanceof String) {
value = Boolean.valueOf((String) value);
}
border = ((Boolean) value).booleanValue();
}
else if (BORDER_COLOR.equals(key)) {
if (value instanceof String) {
value = ColorConfigurer.stringToColor((String) value);
}
borderColor = (Color) value;
}
else if (BORDER_WIDTH.equals(key)) {
if (value instanceof String) {
value = Integer.valueOf((String) value);
}
borderWidth = ((Integer) value).intValue();
if (borderWidth < 0) {
borderWidth = 0;
}
stroke = null;
}
else if (OPACITY.equals(key)) {
if (value instanceof String) {
value = Integer.valueOf((String) value);
}
opacity = ((Integer) value).intValue();
if (opacity < 0 || opacity > 100) {
opacity = 100;
}
buildComposite();
}
else if (BORDER_OPACITY.equals(key)) {
if (value instanceof String) {
value = Integer.valueOf((String) value);
}
borderOpacity = ((Integer) value).intValue();
if (borderOpacity < 0 || borderOpacity > 100) {
borderOpacity = 100;
}
buildBorderComposite();
}
else {
launch.setAttribute(key, value);
}
}
public String getAttributeValueString(String key) {
if (NAME.equals(key)) {
return getConfigureName() + "";
}
else if (ALWAYS_ON.equals(key)) {
return String.valueOf(isAlwaysOn());
}
else if (STARTS_ON.equals(key)) {
return String.valueOf(isStartsOn());
}
else if (BOARDS.equals(key)) {
return boardSelection + "";
}
else if (BOARD_LIST.equals(key)) {
return StringArrayConfigurer.arrayToString(boardList);
}
else if (TYPE.equals(key)) {
return type + "";
}
else if (DRAW_OVER.equals(key)) {
return String.valueOf(drawOver);
}
else if (PATTERN.equals(key)) {
return pattern + "";
}
else if (COLOR.equals(key)) {
return ColorConfigurer.colorToString(color);
}
else if (IMAGE.equals(key)) {
return imageName;
}
else if (SCALE_IMAGE.equals(key)) {
return String.valueOf(scaleImage);
}
else if (BORDER.equals(key)) {
return String.valueOf(border);
}
else if (BORDER_COLOR.equals(key)) {
return ColorConfigurer.colorToString(borderColor);
}
else if (BORDER_WIDTH.equals(key)) {
return borderWidth + "";
}
else if (OPACITY.equals(key)) {
return opacity + "";
}
else if (BORDER_OPACITY.equals(key)) {
return borderOpacity + "";
}
else {
return launch.getAttributeValueString(key);
}
}
public VisibilityCondition getAttributeVisibility(String name) {
if (ICON.equals(name) || HOT_KEY.equals(name) || BUTTON_TEXT.equals(name) || STARTS_ON.equals(name) || TOOLTIP.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return !isAlwaysOn();
}
};
}
else if (BOARD_LIST.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return !boardSelection.equals(ALL_BOARDS);
}
};
}
else if (COLOR.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return !pattern.equals(TYPE_IMAGE);
}
};
}
else if (IMAGE.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return pattern.equals(TYPE_IMAGE);
}
};
}
else if (SCALE_IMAGE.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return pattern.equals(TYPE_IMAGE);
}};
}
else if (BORDER_COLOR.equals(name) || BORDER_WIDTH.equals(name) || BORDER_OPACITY.equals(name)) {
return new VisibilityCondition() {
public boolean shouldBeVisible() {
return border;
}
};
}
else {
return super.getAttributeVisibility(name);
}
}
public static String getConfigureTypeName() {
return Resources.getString("Editor.MapShader.component_type"); //$NON-NLS-1$
}
public void removeFrom(Buildable parent) {
GameModule.getGameModule().getToolBar().remove(launch);
GameModule.getGameModule().getGameState().removeGameComponent(this);
map.removeDrawComponent(this);
idMgr.remove(this);
}
public HelpFile getHelpFile() {
return HelpFile.getReferenceManualPage("Map.htm", "MapShading");
}
public void addTo(Buildable parent) {
GameModule.getGameModule().getToolBar().add(launch);
launch.setAlignmentY(0.0F);
GameModule.getGameModule().getGameState().addGameComponent(this);
map = (Map) parent;
map.addDrawComponent(this);
idMgr.add(this);
validator = idMgr;
setAttributeTranslatable(NAME, false);
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
/**
* Pieces that contribute to shading must implement this interface
*/
public static interface ShadedPiece {
/**
* Returns the Area to add to (or subtract from) the area drawn by the MapShader's.
* Area is assumed to be at zoom factor 1.0
* @param shader
* @return the Area contributed by the piece
*/
public Area getArea(MapShader shader);
}
}