/*
* $Id$
*
* Copyright (c) 2000-2012 by Rodney Kinney, Brent Easton, Joel Uckelman
*
* 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.counters;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Component;
import java.awt.Composite;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.KeyStroke;
import VASSAL.build.module.Map;
import VASSAL.build.module.documentation.HelpFile;
import VASSAL.command.ChangeTracker;
import VASSAL.command.Command;
import VASSAL.configure.BooleanConfigurer;
import VASSAL.configure.ColorConfigurer;
import VASSAL.configure.DoubleConfigurer;
import VASSAL.configure.IntConfigurer;
import VASSAL.configure.NamedHotKeyConfigurer;
import VASSAL.configure.StringConfigurer;
import VASSAL.i18n.PieceI18nData;
import VASSAL.tools.NamedKeyStroke;
import VASSAL.tools.SequenceEncoder;
import VASSAL.tools.image.ImageUtils;
/**
* Displays a movement trail indicating where a piece has been moved
*/
public class Footprint extends MovementMarkable {
public static final String ID = "footprint;";
private KeyCommand[] commands;
// State Variables (Saved in logfile/sent to opponent)
protected boolean globalVisibility = false; // Shared trail visibility (if globallyVisible == true)
protected String startMapId = ""; // Map Id trail started on
// List of points
protected List<Point> pointList = new ArrayList<Point>();
// Type Variables (Configured in Ed)
protected NamedKeyStroke trailKey; // Control Key to invoke
protected String menuCommand; // Menu Command
protected boolean initiallyVisible = false; // Are Trails initially visible?
protected boolean globallyVisible = false; // Are Trails shared between players?
protected int circleRadius; // Radius of trail point circle
protected int selectedTransparency; // Transparency of trail when unit is selected
protected int unSelectedTransparency; // Transparency of trail when unit is selected/unselected
protected Color lineColor; // Color of Trail lines
protected Color fillColor; // Color of Trail circle fill
protected int edgePointBuffer; // How far Off-map to draw trail points (pixels)?
protected int edgeDisplayBuffer; // How far Off-map to draw trail lines (pixels)?
// Defaults for Type variables
protected static final char DEFAULT_TRAIL_KEY = 'T';
protected static final String DEFAULT_MENU_COMMAND = "Movement Trail";
protected static final Boolean DEFAULT_INITIALLY_VISIBLE = Boolean.FALSE;
protected static final Boolean DEFAULT_GLOBALLY_VISIBLE = Boolean.FALSE;
protected static final int DEFAULT_CIRCLE_RADIUS = 10;
protected static final Color DEFAULT_FILL_COLOR = Color.WHITE;
protected static final Color DEFAULT_LINE_COLOR = Color.BLACK;
protected static final int DEFAULT_SELECTED_TRANSPARENCY = 100;
protected static final int DEFULT_UNSELECTED_TRANSPARENCY = 50;
protected static final int DEFAULT_EDGE_POINT_BUFFER = 20;
protected static final int DEFAULT_EDGE_DISPLAY_BUFFER = 30;
protected static final float LINE_WIDTH = 1.0f;
// Local Variables
protected Rectangle myBoundingBox;
protected Font font;
protected double lastZoom;
protected boolean localVisibility;
protected double lineWidth;
private KeyCommand showTrailCommand;
public Footprint() {
super(Footprint.ID, null);
}
public Footprint(String type, GamePiece p) {
mySetType(type);
setInner(p);
}
/** @deprecated Use {@link #pointList} directly. */
@Deprecated
protected Enumeration<Point> getPointList() {
return Collections.enumeration(pointList);
}
public void mySetState(String newState) {
pointList.clear();
final SequenceEncoder.Decoder ss =
new SequenceEncoder.Decoder(newState, ';');
globalVisibility = ss.nextBoolean(initiallyVisible);
startMapId = ss.nextToken("");
final int items = ss.nextInt(0);
for (int i = 0; i < items; i++) {
final String point = ss.nextToken("");
if (point.length() != 0) {
final SequenceEncoder.Decoder sp =
new SequenceEncoder.Decoder(point, ',');
final int x = sp.nextInt(0);
final int y = sp.nextInt(0);
pointList.add(new Point(x, y));
}
}
}
public String myGetState() {
final SequenceEncoder se = new SequenceEncoder(';');
se.append(globalVisibility)
.append(startMapId)
.append(pointList.size());
for (Point p : pointList) {
se.append(p.x + "," + p.y);
}
return se.getValue();
}
/**
* Type is the character command that toggles footprint visiblity
*/
public void mySetType(String type) {
final SequenceEncoder.Decoder st = new SequenceEncoder.Decoder(type, ';');
st.nextToken();
trailKey = st.nextNamedKeyStroke(DEFAULT_TRAIL_KEY);
menuCommand = st.nextToken(DEFAULT_MENU_COMMAND);
initiallyVisible = st.nextBoolean(DEFAULT_INITIALLY_VISIBLE.booleanValue());
globallyVisible = st.nextBoolean(DEFAULT_GLOBALLY_VISIBLE.booleanValue());
circleRadius = st.nextInt(DEFAULT_CIRCLE_RADIUS);
fillColor = st.nextColor(DEFAULT_FILL_COLOR);
lineColor = st.nextColor(DEFAULT_LINE_COLOR);
selectedTransparency = st.nextInt(DEFAULT_SELECTED_TRANSPARENCY);
unSelectedTransparency = st.nextInt(DEFULT_UNSELECTED_TRANSPARENCY);
edgePointBuffer = st.nextInt(DEFAULT_EDGE_POINT_BUFFER);
edgeDisplayBuffer = st.nextInt(DEFAULT_EDGE_DISPLAY_BUFFER);
lineWidth = st.nextDouble(LINE_WIDTH);
commands = null;
showTrailCommand = null;
if (initiallyVisible) {
localVisibility = true;
globalVisibility = true;
}
}
public String myGetType() {
SequenceEncoder se = new SequenceEncoder(';');
se.append(trailKey)
.append(menuCommand)
.append(initiallyVisible)
.append(globallyVisible)
.append(circleRadius)
.append(fillColor)
.append(lineColor)
.append(selectedTransparency)
.append(unSelectedTransparency)
.append(edgePointBuffer)
.append(edgeDisplayBuffer)
.append(lineWidth);
return ID + se.getValue();
}
public void setProperty(Object key, Object val) {
if (Properties.MOVED.equals(key)) {
setMoved(Boolean.TRUE.equals(val));
piece.setProperty(key, val); // Pass on to MovementMarkable
myBoundingBox = null;
}
else {
super.setProperty(key, val);
}
}
@Override
public Object getLocalizedProperty(Object key) {
if (Properties.MOVED.equals(key)) {
final Object value = piece.getProperty(key);
return value == null ? super.getProperty(key) : value;
}
return super.getLocalizedProperty(key);
}
public Object getProperty(Object key) {
// If this piece has a real MovementMarkable trait,
// use it to store the MOVED status
if (Properties.MOVED.equals(key)) {
final Object value = piece.getProperty(key);
return value == null ? super.getProperty(key) : value;
}
return super.getProperty(key);
}
/**
* setMoved is called with an argument of true each time the piece is moved.
* The argument is false when the unit is marked as not moved.
*/
public void setMoved(boolean justMoved) {
if (justMoved) {
recordCurrentPosition();
final Map map = getMap();
startMapId = map != null ? map.getId() : null;
}
else {
clearTrail();
}
redraw();
}
protected void recordCurrentPosition() {
final Point here = this.getPosition();
if (pointList.isEmpty() ||
!pointList.get(pointList.size()-1).equals(here)) {
addPoint(here);
}
else {
myBoundingBox = null;
}
}
protected void clearTrail() {
pointList.clear();
addPoint(getPosition());
localVisibility = initiallyVisible;
globalVisibility = initiallyVisible;
}
public HelpFile getHelpFile() {
return HelpFile.getReferenceManualPage("MovementTrail.htm");
}
/**
* Add Point to list and adjust the overall boundingBox to encompass the
* trail.
*/
protected void addPoint(Point p) {
pointList.add(p);
myBoundingBox = null;
}
private Rectangle getBB() {
final Rectangle bb = piece.boundingBox();
final Point pos = piece.getPosition();
bb.x += pos.x;
bb.y += pos.y;
final int circleDiameter = 2*circleRadius;
final Rectangle pr = new Rectangle();
for (final Point p: pointList) {
pr.setBounds(
p.x - circleRadius, p.y - circleRadius, circleDiameter, circleDiameter
);
bb.add(pr);
}
bb.x -= pos.x;
bb.y -= pos.y;
return bb;
}
public void redraw() {
final Map m = getMap();
if (m != null) {
m.repaint(getMyBoundingBox());
}
}
public String getDescription() {
return "Movement trail";
}
// FIXME: This method is inefficient.
public void draw(Graphics g, int x, int y, Component obs, double zoom) {
piece.draw(g, x, y, obs, zoom);
// Do nothing when piece is not on a map, we are drawing the map
// to something other than its normal view, or the trail is invisible,
if (getMap() == null || getMap().getView() != obs || !isTrailVisible()) {
return;
}
/*
* If we have changed Maps, then start a new trail. Note that this check is
* here because setMoved is called before the piece has been moved.
*/
final String currentMap = getMap().getId();
if (!currentMap.equals(startMapId)) {
startMapId = currentMap;
clearTrail();
return;
}
// Anything to draw?
if (pointList.isEmpty()) {
return;
}
/*
* If we are asked to be drawn at a different zoom from the current map zoom
* setting, then don't draw the trail as it will be in the wrong place.
* (i.e. Mouse-over viewer)
*/
double mapZoom = zoom;
if (this.getMap() != null) {
mapZoom = getMap().getZoom();
if (zoom != mapZoom) {
return;
}
}
final Graphics2D g2d = (Graphics2D) g;
final boolean selected = Boolean.TRUE.equals(
Decorator.getOutermost(this).getProperty(Properties.SELECTED));
final int transparencyPercent = Math.max(0, Math.min(100,
selected ? selectedTransparency : unSelectedTransparency));
final float transparency = transparencyPercent / 100.0f;
final Composite oldComposite = g2d.getComposite();
final Stroke oldStroke = g2d.getStroke();
final Color oldColor = g2d.getColor();
/*
* newClip is an overall clipping region made up of the Map itself and a
* border of edgeDisplayBuffer pixels. No drawing at all outside this area.
* mapRect is made of the Map and a edgePointBuffer pixel border. Trail
* points are not drawn outside this area.
*/
final int mapHeight = getMap().mapSize().height;
final int mapWidth = getMap().mapSize().width;
final int edgeHeight =
Integer.parseInt(getMap().getAttributeValueString(Map.EDGE_HEIGHT));
final int edgeWidth =
Integer.parseInt(getMap().getAttributeValueString(Map.EDGE_WIDTH));
final int edgeClipHeight = Math.min(edgeHeight, edgeDisplayBuffer);
final int edgeClipWidth = Math.min(edgeWidth, edgeDisplayBuffer);
final int clipX = edgeWidth - edgeClipWidth;
final int clipY = edgeHeight - edgeClipHeight;
final int width = mapWidth - 2*(edgeWidth + edgeClipWidth);
final int height = mapHeight - 2*(edgeHeight + edgeClipHeight);
final Rectangle newClip = new Rectangle(
(int) (clipX * zoom),
(int) (clipY * zoom),
(int) (width * zoom),
(int) (height * zoom)
);
final Rectangle circleRect = new Rectangle(
edgeWidth - edgePointBuffer,
edgeHeight - edgePointBuffer,
mapWidth + 2 * edgePointBuffer,
mapHeight + 2 * edgePointBuffer
);
final Rectangle visibleRect = getMap().getView().getVisibleRect();
final Shape oldClip = g2d.getClip();
g2d.setClip(newClip.intersection(visibleRect));
if (oldClip != null) {
g2d.setClip(oldClip.getBounds().intersection(g.getClipBounds()));
}
g2d.setComposite(
AlphaComposite.getInstance(AlphaComposite.SRC_OVER, transparency));
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
final float thickness = Math.max(1.0f, (float)(zoom*lineWidth));
g2d.setStroke(new BasicStroke(thickness));
g2d.setColor(lineColor);
final Point here = getPosition();
/*
* Draw the tracks between trail points
*/
int x1, y1, x2, y2;
final Iterator<Point> i = pointList.iterator();
Point cur = i.next(), next;
while (i.hasNext()) {
next = i.next();
x1 = (int)(cur.x * zoom);
y1 = (int)(cur.y * zoom);
x2 = (int)(next.x * zoom);
y2 = (int)(next.y * zoom);
drawTrack(g, x1, y1, x2, y2, zoom);
cur = next;
}
if (!here.equals(cur)) {
x1 = (int)(cur.x * zoom);
y1 = (int)(cur.y * zoom);
x2 = (int)(here.x * zoom);
y2 = (int)(here.y * zoom);
drawTrack(g, x1, y1, x2, y2, zoom);
}
/*
* And draw the points themselves.
*/
int elementCount = -1;
for (final Point p : pointList) {
++elementCount;
if (circleRect.contains(p) && !p.equals(here)) {
drawPoint(g, p, zoom, elementCount);
// Is there an Icon to draw in the circle?
Image image = getTrailImage(elementCount);
x1 = (int)((p.x - circleRadius) * zoom);
y1 = (int)((p.y - circleRadius) * zoom);
if (selected && image != null) {
if (zoom == 1.0) {
g.drawImage(image, x1, y1, obs);
}
else {
Image scaled =
ImageUtils.transform((BufferedImage) image, zoom, 0.0);
g.drawImage(scaled, x1, y1, obs);
}
}
// Or some text?
final String text = getTrailText(elementCount);
if (selected && text != null) {
if (font == null || lastZoom != mapZoom) {
x1 = (int)(p.x * zoom);
y1 = (int)(p.y * zoom);
final Font font =
new Font("Dialog", Font.PLAIN, (int)(circleRadius * 1.4 * zoom));
Labeler.drawLabel(g, text, x1, y1, font, Labeler.CENTER,
Labeler.CENTER, lineColor, null, null);
}
lastZoom = mapZoom;
}
}
}
g2d.setComposite(oldComposite);
g2d.setStroke(oldStroke);
g2d.setColor(oldColor);
g.setClip(oldClip);
}
/**
* Draw a Circle at the given point.
* Override this method to do something different (eg. display an Icon)
*/
protected void drawPoint(Graphics g, Point p, double zoom, int elementCount) {
final int x = (int)((p.x - circleRadius) * zoom);
final int y = (int)((p.y - circleRadius) * zoom);
final int radius = (int)(2 * circleRadius * zoom);
g.setColor(fillColor);
g.fillOval(x, y, radius, radius);
g.setColor(lineColor);
g.drawOval(x, y, radius, radius);
}
/**
* Draw a track from one Point to another.
* Don't draw under the circle as it shows
* through with transparency turned on.
*/
protected void drawTrack(Graphics g, int x1, int y1, int x2, int y2, double zoom) {
double lastSqrt = -1;
int lastDistSq = -1;
int distSq = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);
if (distSq != lastDistSq) {
lastDistSq = distSq;
lastSqrt = Math.sqrt(distSq);
}
final int xDiff = (int) ((circleRadius * (x2 - x1) * zoom) / lastSqrt);
final int yDiff = (int) ((circleRadius * (y2 - y1) * zoom) / lastSqrt);
g.drawLine(x1 + xDiff, y1 + yDiff, x2 - xDiff, y2 - yDiff);
}
/**
* Override this method to return an Image to display within each trail circle
*/
protected Image getTrailImage(int elementCount) {
return null;
}
/**
* Override this method to return text to display within each trail circle.
* Note, there will normally be only room for 1 character.
*/
protected String getTrailText(int elementCount) {
return null;
}
/**
* Global Visibility means all players see the same trail
* Local Visibility means each player controls their own trail visibility
*/
protected boolean isTrailVisible() {
if (globallyVisible) {
return globalVisibility || trailKey == null;
}
else {
return localVisibility || trailKey == null;
}
}
/**
* Return a bounding box covering the whole trail if it is visible, otherwise
* just return the standard piece bounding box
*/
public Rectangle boundingBox() {
return isTrailVisible() && getMap() != null ?
new Rectangle(getMyBoundingBox()) : piece.boundingBox();
}
/**
* Return the boundingBox including the trail
*/
public Rectangle getMyBoundingBox() {
if (myBoundingBox == null) {
final Rectangle bb = piece.boundingBox();
final Point pos = piece.getPosition();
bb.x += pos.x;
bb.y += pos.y;
final int circleDiameter = 2*circleRadius;
final Rectangle pr = new Rectangle();
for (final Point p: pointList) {
pr.setBounds(
p.x - circleRadius, p.y - circleRadius, circleDiameter, circleDiameter
);
bb.add(pr);
}
bb.x -= pos.x;
bb.y -= pos.y;
myBoundingBox = bb;
}
return myBoundingBox;
}
public Shape getShape() {
return piece.getShape();
}
public String getName() {
return piece.getName();
}
public KeyCommand[] myGetKeyCommands() {
if (commands == null) {
if (trailKey != null && ! trailKey.isNull()) {
showTrailCommand = new KeyCommand(menuCommand, trailKey, Decorator.getOutermost(this), this);
}
if (showTrailCommand != null
&& menuCommand.length() > 0) {
commands = new KeyCommand[]{showTrailCommand};
}
else {
commands = new KeyCommand[0];
}
}
if (showTrailCommand != null) {
showTrailCommand.setEnabled(getMap() != null);
}
return commands;
}
public Command myKeyEvent(KeyStroke stroke) {
myGetKeyCommands();
if (showTrailCommand != null
&& showTrailCommand.matches(stroke)) {
final ChangeTracker tracker = new ChangeTracker(this);
if (globallyVisible) {
globalVisibility = !globalVisibility;
}
else {
localVisibility = !localVisibility;
}
redraw();
return tracker.getChangeCommand();
}
return null;
}
public PieceEditor getEditor() {
return new Ed(this);
}
public PieceI18nData getI18nData() {
final PieceI18nData data = super.getI18nData();
data.add(menuCommand, "Show Movement Trail command");
return data;
}
/**
* Key Command Global Visibility Circle Radius Fill Color Line Color Selected
* Transparency Unselected Transparency Edge Buffer Display Limit Edge Buffer
* Point Limit
*/
protected static class Ed implements PieceEditor {
private NamedHotKeyConfigurer trailKeyInput;
private JPanel controls;
private StringConfigurer mc;
private BooleanConfigurer iv;
private BooleanConfigurer gv;
private IntConfigurer cr;
private ColorConfigurer fc;
private ColorConfigurer lc;
private IntConfigurer st;
private IntConfigurer ut;
private IntConfigurer pb;
private IntConfigurer db;
private DoubleConfigurer lw;
public Ed(Footprint p) {
controls = new JPanel();
controls.setLayout(new BoxLayout(controls, BoxLayout.Y_AXIS));
Box b;
trailKeyInput = new NamedHotKeyConfigurer(null, "Key Command: ", p.trailKey);
controls.add(trailKeyInput.getControls());
mc = new StringConfigurer(null, "Menu Command: ", p.menuCommand);
controls.add(mc.getControls());
iv = new BooleanConfigurer(null, "Trails start visible?",
Boolean.valueOf(p.initiallyVisible));
controls.add(iv.getControls());
gv = new BooleanConfigurer(null, "Trails are visible to all players?",
Boolean.valueOf(p.globallyVisible));
controls.add(gv.getControls());
cr = new IntConfigurer(null, "Circle Radius: ", p.circleRadius);
controls.add(cr.getControls());
fc = new ColorConfigurer(null, "Circle Fill Color: ", p.fillColor);
controls.add(fc.getControls());
lc = new ColorConfigurer(null, "Line Color: ", p.lineColor);
controls.add(lc.getControls());
lw = new DoubleConfigurer(null,"Line thickness: ", p.lineWidth);
controls.add(lw.getControls());
st = new IntConfigurer(null, "Selected Unit Trail Transparency (0-100): ", p.selectedTransparency);
controls.add(st.getControls());
ut = new IntConfigurer(null, "Unselected Unit Trail Transparency (0-100): ", p.unSelectedTransparency);
controls.add(ut.getControls());
b = Box.createHorizontalBox();
pb = new IntConfigurer(null, "Display Trail Points Off-map for ", p.edgePointBuffer);
b.add(pb.getControls());
b.add(new JLabel("pixels"));
controls.add(b);
b = Box.createHorizontalBox();
db = new IntConfigurer(null, "Display Trails Off-map for ", p.edgeDisplayBuffer);
b.add(db.getControls());
b.add(new JLabel("pixels"));
controls.add(b);
}
public String getState() {
return String.valueOf(gv.booleanValue().booleanValue());
}
public String getType() {
final SequenceEncoder se = new SequenceEncoder(';');
se.append(ID)
.append(trailKeyInput.getValueString())
.append(mc.getValueString())
.append(iv.getValueString())
.append(gv.getValueString())
.append(cr.getValueString())
.append(fc.getValueString())
.append(lc.getValueString())
.append(st.getValueString())
.append(ut.getValueString())
.append(pb.getValueString())
.append(db.getValueString())
.append(lw.getValueString());
return se.getValue();
}
public Component getControls() {
return controls;
}
}
}