/* * Copyright (c) 2014 tabletoptool.com team. * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html * * Contributors: * rptools.com team - initial implementation * tabletoptool.com team - further development */ package com.t3.client.ui.zone; import java.awt.AlphaComposite; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Paint; import java.awt.Point; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.Shape; import java.awt.Stroke; import java.awt.Toolkit; import java.awt.Transparency; import java.awt.dnd.DropTargetDragEvent; import java.awt.dnd.DropTargetDropEvent; import java.awt.dnd.DropTargetEvent; import java.awt.dnd.DropTargetListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; import java.awt.font.FontRenderContext; import java.awt.font.TextLayout; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.GeneralPath; import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.QuadCurve2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TooManyListenersException; import javax.imageio.ImageIO; import javax.swing.JComponent; import javax.swing.SwingUtilities; import org.apache.log4j.Logger; import com.t3.CodeTimer; import com.t3.MD5Key; import com.t3.client.AppActions; import com.t3.client.AppConstants; import com.t3.client.AppPreferences; import com.t3.client.AppState; import com.t3.client.AppStyle; import com.t3.client.AppUtil; import com.t3.client.ScreenPoint; import com.t3.client.T3Util; import com.t3.client.TabletopTool; import com.t3.client.TransferableHelper; import com.t3.client.tool.PointerTool; import com.t3.client.tool.StampTool; import com.t3.client.tool.drawing.FreehandExposeTool; import com.t3.client.tool.drawing.OvalExposeTool; import com.t3.client.tool.drawing.PolygonExposeTool; import com.t3.client.tool.drawing.RectangleExposeTool; import com.t3.client.ui.Scale; import com.t3.client.ui.Tool; import com.t3.client.ui.token.BarTokenOverlay; import com.t3.client.ui.token.BooleanTokenOverlay; import com.t3.client.ui.token.NewTokenDialog; import com.t3.client.walker.ZoneWalker; import com.t3.guid.GUID; import com.t3.guid.UniquelyIdentifiable; import com.t3.image.ImageUtil; import com.t3.model.AbstractPoint; import com.t3.model.Asset; import com.t3.model.AssetManager; import com.t3.model.CellPoint; import com.t3.model.ExposedAreaMetaData; import com.t3.model.Label; import com.t3.model.LightSource; import com.t3.model.ModelChangeEvent; import com.t3.model.ModelChangeListener; import com.t3.model.Path; import com.t3.model.Player; import com.t3.model.Token; import com.t3.model.TokenFootprint; import com.t3.model.Zone; import com.t3.model.ZonePoint; import com.t3.model.chat.TextMessage; import com.t3.model.drawing.Drawable; import com.t3.model.drawing.DrawableTexturePaint; import com.t3.model.drawing.DrawnElement; import com.t3.model.drawing.Pen; import com.t3.model.grid.Grid; import com.t3.model.grid.GridCapabilities; import com.t3.swing.ImageBorder; import com.t3.swing.ImageLabel; import com.t3.swing.SwingUtil; import com.t3.util.GraphicsUtil; import com.t3.util.ImageManager; import com.t3.util.StringUtil; import com.t3.util.TokenUtil; /** */ public class ZoneRenderer extends JComponent implements DropTargetListener, Comparable<ZoneRenderer>, UniquelyIdentifiable { private static final long serialVersionUID = 3832897780066104884L; private static final Logger log = Logger.getLogger(ZoneRenderer.class); public static final int MIN_GRID_SIZE = 10; private static LightSourceIconOverlay lightSourceIconOverlay = new LightSourceIconOverlay(); protected Zone zone; private final ZoneView zoneView; private Scale zoneScale; private final DrawableRenderer backgroundDrawableRenderer = new PartitionedDrawableRenderer(); private final DrawableRenderer objectDrawableRenderer = new PartitionedDrawableRenderer(); private final DrawableRenderer tokenDrawableRenderer = new PartitionedDrawableRenderer(); private final DrawableRenderer gmDrawableRenderer = new PartitionedDrawableRenderer(); private final List<ZoneOverlay> overlayList = new ArrayList<ZoneOverlay>(); private final Map<Zone.Layer, List<TokenLocation>> tokenLocationMap = new HashMap<Zone.Layer, List<TokenLocation>>(); private Set<GUID> selectedTokenSet = new LinkedHashSet<GUID>(); private final List<Set<GUID>> selectedTokenSetHistory = new ArrayList<Set<GUID>>(); private final List<LabelLocation> labelLocationList = new LinkedList<LabelLocation>(); private Map<Token, Set<Token>> tokenStackMap; private final Map<GUID, SelectionSet> selectionSetMap = new HashMap<GUID, SelectionSet>(); // private final Map<Token, TokenLocation> tokenLocationCache = Collections.synchronizedMap(new HashMap<Token, TokenLocation>()); private final Map<Token, TokenLocation> tokenLocationCache = new HashMap<Token, TokenLocation>(); private final List<TokenLocation> markerLocationList = new ArrayList<TokenLocation>(); private GeneralPath facingArrow; private final List<Token> showPathList = new ArrayList<Token>(); // Optimizations private final Map<GUID, BufferedImage> labelRenderingCache = new HashMap<GUID, BufferedImage>(); private final Map<Token, BufferedImage> replacementImageMap = new HashMap<Token, BufferedImage>(); private final Map<Token, BufferedImage> flipImageMap = new HashMap<Token, BufferedImage>(); private Token tokenUnderMouse; private ScreenPoint pointUnderMouse; private Zone.Layer activeLayer; private String loadingProgress; private boolean isLoaded; private BufferedImage fogBuffer; // I don't like this, at all, but it'll work for now, basically keep track of when the fog cache // needs to be flushed in the case of switching views private boolean flushFog = true; private Area exposedFogArea; // In screen space private BufferedImage miniImage; private BufferedImage backbuffer; private boolean drawBackground = true; private int lastX; private int lastY; private double lastScale; private Area visibleScreenArea; private final List<ItemRenderer> itemRenderList = new LinkedList<ItemRenderer>(); private PlayerView lastView; private Set<GUID> visibleTokenSet; private CodeTimer timer; public static enum TokenMoveCompletion { TRUE, FALSE, OTHER } public ZoneRenderer(Zone zone) { if (zone == null) { throw new IllegalArgumentException("Zone cannot be null"); } this.zone = zone; zone.addModelChangeListener(new ZoneModelChangeListener()); setFocusable(true); setZoneScale(new Scale()); zoneView = new ZoneView(zone); // DnD setTransferHandler(new TransferableHelper()); try { getDropTarget().addDropTargetListener(this); } catch (TooManyListenersException e1) { // Should never happen because the transfer handler fixes this problem. } // Focus addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { requestFocusInWindow(); } @Override public void mouseExited(MouseEvent e) { pointUnderMouse = null; } @Override public void mouseEntered(MouseEvent e) { } }); addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseMoved(MouseEvent e) { pointUnderMouse = new ScreenPoint(e.getX(), e.getY()); } }); // fps.start(); } public void showPath(Token token, boolean show) { if (show) { showPathList.add(token); } else { showPathList.remove(token); } } public void centerOn(Token token) { if (token == null) { return; } centerOn(new ZonePoint(token.getX(), token.getY())); TabletopTool.getFrame().getToolbox().setSelectedTool(token.isToken() ? PointerTool.class : StampTool.class); setActiveLayer(token.getLayer()); selectToken(token.getId()); requestFocusInWindow(); } public ZonePoint getCenterPoint() { return new ScreenPoint(getSize().width / 2, getSize().height / 2).convertToZone(this); } public boolean isPathShowing(Token token) { return showPathList.contains(token); } public void clearShowPaths() { showPathList.clear(); repaint(); } public Scale getZoneScale() { return zoneScale; } public void setZoneScale(Scale scale) { zoneScale = scale; invalidateCurrentViewCache(); scale.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent evt) { if (Scale.PROPERTY_SCALE.equals(evt.getPropertyName())) { tokenLocationCache.clear(); flushFog = true; } if (Scale.PROPERTY_OFFSET.equals(evt.getPropertyName())) { // flushFog = true; } visibleScreenArea = null; repaint(); } }); } /** * I _hate_ this method. But couldn't think of a better way to tell the * drawable renderer that a new image had arrived TODO: FIX THIS ! Perhaps * add a new app listener for when new images show up, add the drawable * renderer as a listener */ public void flushDrawableRenderer() { backgroundDrawableRenderer.flush(); objectDrawableRenderer.flush(); tokenDrawableRenderer.flush(); gmDrawableRenderer.flush(); } public ScreenPoint getPointUnderMouse() { return pointUnderMouse; } public void setMouseOver(Token token) { if (tokenUnderMouse == token) { return; } tokenUnderMouse = token; repaint(); } @Override public boolean isOpaque() { return false; } public void addMoveSelectionSet(String playerId, GUID keyToken, Set<GUID> tokenList, boolean clearLocalSelected) { // I'm not supposed to be moving a token when someone else is already moving it if (clearLocalSelected) { for (GUID guid : tokenList) { selectedTokenSet.remove(guid); } } selectionSetMap.put(keyToken, new SelectionSet(playerId, keyToken, tokenList)); repaint(); } public boolean hasMoveSelectionSetMoved(GUID keyToken, ZonePoint point) { SelectionSet set = selectionSetMap.get(keyToken); if (set == null) { return false; } Token token = zone.getToken(keyToken); int x = point.x - token.getX(); int y = point.y - token.getY(); return set.offsetX != x || set.offsetY != y; } public void updateMoveSelectionSet(GUID keyToken, ZonePoint offset) { SelectionSet set = selectionSetMap.get(keyToken); if (set == null) { return; } Token token = zone.getToken(keyToken); set.setOffset(offset.x - token.getX(), offset.y - token.getY()); repaint(); } public void toggleMoveSelectionSetWaypoint(GUID keyToken, ZonePoint location) { SelectionSet set = selectionSetMap.get(keyToken); if (set == null) { return; } set.toggleWaypoint(location); repaint(); } public ZonePoint getLastWaypoint(GUID keyToken) { SelectionSet set = selectionSetMap.get(keyToken); if (set == null) { return null; } return set.getLastWaypoint(); } public void removeMoveSelectionSet(GUID keyToken) { SelectionSet set = selectionSetMap.remove(keyToken); if (set == null) { return; } repaint(); } public void commitMoveSelectionSet(GUID keyTokenId) { // TODO: Quick hack to handle updating server state SelectionSet set = selectionSetMap.get(keyTokenId); removeMoveSelectionSet(keyTokenId); TabletopTool.serverCommand().stopTokenMove(getZone().getId(), keyTokenId); if (set == null) { return; } CodeTimer moveTimer = new CodeTimer("ZoneRenderer.commitMoveSelectionSet"); moveTimer.setEnabled(AppState.isCollectProfilingData() || log.isDebugEnabled()); moveTimer.setThreshold(1); moveTimer.start("setup"); Token keyToken = zone.getToken(keyTokenId); CellPoint originPoint = zone.getGrid().convert(new ZonePoint(keyToken.getX(), keyToken.getY())); Path<? extends AbstractPoint> path = set.getWalker() != null ? set.getWalker().getPath() : set.gridlessPath; Set<GUID> selectionSet = set.getTokens(); List<GUID> filteredTokens = new ArrayList<GUID>(); BigDecimal tmc = null; moveTimer.stop("setup"); moveTimer.start("eachtoken"); for (GUID tokenGUID : selectionSet) { Token token = zone.getToken(tokenGUID); // If the token has been deleted, the GUID will still be in the set but getToken() will return null. if (token == null) continue; CellPoint tokenCell = zone.getGrid().convert(new ZonePoint(token.getX(), token.getY())); int cellOffX = originPoint.x - tokenCell.x; int cellOffY = originPoint.y - tokenCell.y; token.applyMove(set.getOffsetX(), set.getOffsetY(), path != null ? path.derive(cellOffX, cellOffY) : null); flush(token); TabletopTool.serverCommand().putToken(zone.getId(), token); zone.putToken(token); // No longer need this version replacementImageMap.remove(token); // Only add certain tokens to the list to process in the move // Macro function(s). if (token.isToken() && token.isVisible()) { filteredTokens.add(tokenGUID); } } moveTimer.stop("eachtoken"); moveTimer.start("onTokenMove"); if (!filteredTokens.isEmpty()) { // run tokenMoved() for each token in the filtered selection list, canceling if it returns 1.0 for (GUID tokenGUID : filteredTokens) { Token token = zone.getToken(tokenGUID); //FIXMESOON this was an event call that is outdated /*tmc = TokenMoveFunctions.tokenMoved(token, path, filteredTokens); if (tmc != null && tmc == BigDecimal.ONE) { denyMovement(token); }*/ } } moveTimer.stop("onTokenMove"); moveTimer.start("onMultipleTokensMove"); // Multiple tokens, the list of tokens and call onMultipleTokensMove() macro function. if (filteredTokens != null && filteredTokens.size() > 1) { //FIXMESOON /* tmc = TokenMoveFunctions.multipleTokensMoved(filteredTokens); // now determine if the macro returned false and if so // revert each token's move to the last path. if (tmc != null && tmc == BigDecimal.ONE) { for (GUID tokenGUID : filteredTokens) { Token token = zone.getToken(tokenGUID); denyMovement(token); } }*/ } moveTimer.stop("onMultipleTokensMove"); moveTimer.start("updateTokenTree"); TabletopTool.getFrame().updateTokenTree(); moveTimer.stop("updateTokenTree"); if (moveTimer.isEnabled()) { String results = moveTimer.toString(); TabletopTool.getProfilingNoteFrame().addText(results); if (log.isDebugEnabled()) log.debug(results); moveTimer.clear(); } } /** * @param token */ private void denyMovement(final Token token) { Path<?> path = token.getLastPath(); if (path != null) { ZonePoint zp = null; if (path.getCellPath().get(0) instanceof CellPoint) { zp = zone.getGrid().convert((CellPoint) path.getCellPath().get(0)); } else { zp = (ZonePoint) path.getCellPath().get(0); } // Relocate token.setX(zp.x); token.setY(zp.y); // Do it again to cancel out the last move position token.setX(zp.x); token.setY(zp.y); // No more last path token.setLastPath(null); TabletopTool.serverCommand().putToken(zone.getId(), token); // Cache clearing flush(token); } } public boolean isTokenMoving(Token token) { for (SelectionSet set : selectionSetMap.values()) { if (set.contains(token)) { return true; } } return false; } protected void setViewOffset(int x, int y) { zoneScale.setOffset(x, y); } public void centerOn(ZonePoint point) { int x = point.x; int y = point.y; x = getSize().width / 2 - (int) (x * getScale()) - 1; y = getSize().height / 2 - (int) (y * getScale()) - 1; setViewOffset(x, y); repaint(); } public void centerOn(CellPoint point) { centerOn(zone.getGrid().convert(point)); } public void flush(Token token) { // This method can be called from a non-EDT thread so if that happens, make sure // we synchronize with the EDT. synchronized (tokenLocationCache) { tokenLocationCache.remove(token); } flipImageMap.remove(token); replacementImageMap.remove(token); labelRenderingCache.remove(token.getId()); // This should be smarter, but whatever visibleScreenArea = null; // This could also be smarter tokenStackMap = null; flushFog = true; renderedLightMap = null; renderedAuraMap = null; zoneView.flush(token); } public ZoneView getZoneView() { return zoneView; } /** * Clear internal caches and backbuffers */ public void flush() { if (zone.getBackgroundPaint() instanceof DrawableTexturePaint) { ImageManager.flushImage(((DrawableTexturePaint) zone.getBackgroundPaint()).getAssetId()); } ImageManager.flushImage(zone.getMapAssetId()); //MCL: I think these should be added, but I'm not sure so I'm not doing it. // tokenLocationMap.clear(); // tokenLocationCache.clear(); flushDrawableRenderer(); replacementImageMap.clear(); flipImageMap.clear(); fogBuffer = null; renderedLightMap = null; renderedAuraMap = null; isLoaded = false; } public void flushLight() { renderedLightMap = null; renderedAuraMap = null; zoneView.flush(); repaint(); } public void flushFog() { flushFog = true; visibleScreenArea = null; repaint(); } public Zone getZone() { return zone; } public void addOverlay(ZoneOverlay overlay) { overlayList.add(overlay); } public void removeOverlay(ZoneOverlay overlay) { overlayList.remove(overlay); } public void moveViewBy(int dx, int dy) { setViewOffset(getViewOffsetX() + dx, getViewOffsetY() + dy); } public void zoomReset(int x, int y) { zoneScale.zoomReset(x, y); TabletopTool.getFrame().getZoomStatusBar().update(); } public void zoomIn(int x, int y) { zoneScale.zoomIn(x, y); TabletopTool.getFrame().getZoomStatusBar().update(); } public void zoomOut(int x, int y) { zoneScale.zoomOut(x, y); TabletopTool.getFrame().getZoomStatusBar().update(); } public void setView(int x, int y, double scale) { setViewOffset(x, y); zoneScale.setScale(scale); TabletopTool.getFrame().getZoomStatusBar().update(); } public void enforceView(int x, int y, double scale, int gmWidth, int gmHeight) { int width = getWidth(); int height = getHeight(); // if (((double) width / height) < ((double) gmWidth / gmHeight)) if ((width * gmHeight) < (height * gmWidth)) { // Our aspect ratio is narrower than server's, so fit to width scale = scale * width / gmWidth; } else { // Our aspect ratio is shorter than server's, so fit to height scale = scale * height / gmHeight; } setScale(scale); centerOn(new ZonePoint(x, y)); } public void forcePlayersView() { ZonePoint zp = new ScreenPoint(getWidth() / 2, getHeight() / 2).convertToZone(this); TabletopTool.serverCommand().enforceZoneView(getZone().getId(), zp.x, zp.y, getScale(), getWidth(), getHeight()); } public void maybeForcePlayersView() { if (AppState.isPlayerViewLinked() && TabletopTool.getPlayer().isGM()) { forcePlayersView(); } } public BufferedImage getMiniImage(int size) { // if (miniImage == null && getTileImage() != // ImageManager.UNKNOWN_IMAGE) { // miniImage = new BufferedImage(size, size, Transparency.OPAQUE); // Graphics2D g = miniImage.createGraphics(); // g.setPaint(new TexturePaint(getTileImage(), new Rectangle(0, 0, // miniImage.getWidth(), miniImage.getHeight()))); // g.fillRect(0, 0, size, size); // g.dispose(); // } return miniImage; } @Override public void paintComponent(Graphics g) { if (timer == null) timer = new CodeTimer("ZoneRenderer.renderZone"); timer.setEnabled(AppState.isCollectProfilingData() || log.isDebugEnabled()); timer.clear(); timer.setThreshold(10); Graphics2D g2d = (Graphics2D) g; timer.start("paintComponent:createView"); PlayerView pl = getPlayerView(); timer.stop("paintComponent:createView"); renderZone(g2d, pl); int noteVPos = 20; if (!zone.isVisible()) { GraphicsUtil.drawBoxedString(g2d, "Map not visible to players", getSize().width / 2, noteVPos); noteVPos += 20; } if (AppState.isShowAsPlayer()) { GraphicsUtil.drawBoxedString(g2d, "Player View", getSize().width / 2, noteVPos); } if (timer.isEnabled()) { String results = timer.toString(); TabletopTool.getProfilingNoteFrame().addText(results); if (log.isDebugEnabled()) log.debug(results); timer.clear(); } } public PlayerView getPlayerView() { Player.Role role = TabletopTool.getPlayer().getRole(); if (role == Player.Role.GM && AppState.isShowAsPlayer()) { role = Player.Role.PLAYER; } return getPlayerView(role); } /** * The returned {@link PlayerView} contains a list of tokens that includes * all selected tokens that this player owns and that have their * <code>HasSight</code> checkbox enabled. * * @param role * @return */ public PlayerView getPlayerView(Player.Role role) { List<Token> selectedTokens = null; if (getSelectedTokenSet() != null && !getSelectedTokenSet().isEmpty()) { selectedTokens = getSelectedTokensList(); for (ListIterator<Token> iter = selectedTokens.listIterator(); iter.hasNext();) { Token token = iter.next(); if (!token.getHasSight() || !AppUtil.playerOwns(token)) { iter.remove(); } } if (selectedTokens.isEmpty()) selectedTokens = zone.getPlayerOwnedTokensWithSight(TabletopTool.getPlayer()); } else { selectedTokens = zone.getPlayerOwnedTokensWithSight(TabletopTool.getPlayer()); } return new PlayerView(role, selectedTokens); } public Rectangle fogExtents() { return zone.getExposedArea().getBounds(); } /** * Get a bounding box, in Zone coordinates, of all the elements in the zone. * This method was created by copying renderZone() and then replacing each * bit of rendering with a routine to simply aggregate the extents of the * object that would have been rendered. * * @return a new Rectangle with the bounding box of all the elements in the * Zone */ public Rectangle zoneExtents(PlayerView view) { // Can't initialize extents to any set x/y values, because // we don't know if the actual map contains that x/y. // So we need a flag to say extents is 'unset', and the best I // could come up with is checking for 'null' on each loop iteration. Rectangle extents = null; // We don't iterate over the layers in the same order as rendering // because its cleaner to group them by type and the order doesn't matter. // First background image extents // TODO: when the background image can be resized, fix this! if (zone.getMapAssetId() != null) { extents = new Rectangle(zone.getBoardX(), zone.getBoardY(), ImageManager.getImage(zone.getMapAssetId(), this).getWidth(), ImageManager.getImage(zone.getMapAssetId(), this).getHeight()); } // next, extents of drawing objects List<DrawnElement> drawableList = new LinkedList<DrawnElement>(); drawableList.addAll(zone.getBackgroundDrawnElements()); drawableList.addAll(zone.getObjectDrawnElements()); drawableList.addAll(zone.getDrawnElements()); if (view.isGMView()) { drawableList.addAll(zone.getGMDrawnElements()); } for (DrawnElement element : drawableList) { Drawable drawable = element.getDrawable(); Rectangle drawnBounds = new Rectangle(drawable.getBounds()); // Handle pen size // This slightly over-estimates the size of the pen, but we want to // make sure to include the anti-aliased edges. Pen pen = element.getPen(); int penSize = (int) Math.ceil((pen.getThickness() / 2) + 1); drawnBounds.setBounds(drawnBounds.x - penSize, drawnBounds.y - penSize, drawnBounds.width + (penSize * 2), drawnBounds.height + (penSize * 2)); if (extents == null) extents = drawnBounds; else extents.add(drawnBounds); } // now, add the stamps/tokens // tokens and stamps are the same thing, just treated differently // This loop structure is a hack: but the getStamps-type methods return unmodifiable lists, // so we can't concat them, and there are a fixed number of layers, so its not really extensible anyway. for (int layer = 0; layer < 4; layer++) { List<Token> stampList = null; switch (layer) { case 0: stampList = zone.getBackgroundStamps(); break; // background layer case 1: stampList = zone.getStampTokens(); break; // object layer case 2: if (!view.isGMView()) { // hidden layer continue; } else { stampList = zone.getGMStamps(); break; } case 3: stampList = zone.getTokens(); break; // token layer } for (Token element : stampList) { Rectangle drawnBounds = element.getBounds(zone); if (element.hasFacing()) { // Get the facing and do a quick fix to make the math easier: -90 is 'unrotated' for some reason Integer facing = element.getFacing() + 90; if (facing > 180) { facing -= 360; } // if 90 degrees, just swap w and h // also swap them if rotated more than 90 (optimization for non-90deg rotations) if (facing != 0 && facing != 180) { if (Math.abs(facing) >= 90) { drawnBounds.setSize(drawnBounds.height, drawnBounds.width); // swapping h and w } // if rotated to non-axis direction, assume the worst case 45 deg // also assumes the rectangle rotates around its center // This will usually makes the bounds bigger than necessary, but its quick. // Also, for quickness, we assume its a square token using the larger dimension // At 45 deg, the bounds of the square will be sqrt(2) bigger, and the UL corner will // shift by 1/2 of the length. // The size increase is: (sqrt*(2) - 1) * size ~= 0.42 * size. if (facing != 0 && facing != 180 && facing != 90 && facing != -90) { Integer size = Math.max(drawnBounds.width, drawnBounds.height); Integer x = drawnBounds.x - (int) (0.21 * size); Integer y = drawnBounds.y - (int) (0.21 * size); Integer w = drawnBounds.width + (int) (0.42 * size); Integer h = drawnBounds.height + (int) (0.42 * size); drawnBounds.setBounds(x, y, w, h); } } } // TODO: Handle auras here? if (extents == null) extents = drawnBounds; else extents.add(drawnBounds); } } if (zone.hasFog()) { if (extents == null) extents = fogExtents(); else extents.add(fogExtents()); } // TODO: What are token templates? //renderTokenTemplates(g2d, view); // TODO: Do lights make the area of interest larger? // see: renderLights(g2d, view); // TODO: Do auras make the area of interest larger? // see: renderAuras(g2d, view); return extents; } /** * This method clears {@link #renderedAuraMap}, {@link #renderedLightMap}, * {@link #visibleScreenArea}, and {@link #lastView}. It also flushes the * {@link #zoneView} and sets the {@link #flushFog} flag so that fog will be * recalculated. */ public void invalidateCurrentViewCache() { flushFog = true; renderedLightMap = null; renderedAuraMap = null; visibleScreenArea = null; lastView = null; if (zoneView != null) { zoneView.flush(); } } /** * This is the top-level method of the rendering pipeline that coordinates * all other calls. {@link #paintComponent(Graphics)} calls this method, * then adds the two optional strings, "Map not visible to players" and * "Player View" as appropriate. * * @param g2d * Graphics2D object normally passed in by * {@link #paintComponent(Graphics)} * @param view * PlayerView object that describes whether the view is a Player * or GM view */ public void renderZone(Graphics2D g2d, PlayerView view) { timer.start("setup"); g2d.setFont(AppStyle.labelFont); Object oldAA = SwingUtil.useAntiAliasing(g2d); Rectangle viewRect = new Rectangle(getSize().width, getSize().height); Area viewArea = new Area(viewRect); // much of the raster code assumes the user clip is set boolean resetClip = false; if (g2d.getClipBounds() == null) { g2d.setClip(0, 0, viewRect.width, viewRect.height); resetClip = true; } // Are we still waiting to show the zone ? if (isLoading()) { g2d.setColor(Color.black); g2d.fillRect(0, 0, viewRect.width, viewRect.height); GraphicsUtil.drawBoxedString(g2d, loadingProgress, viewRect.width / 2, viewRect.height / 2); return; } if (TabletopTool.getCampaign().isBeingSerialized()) { g2d.setColor(Color.black); g2d.fillRect(0, 0, viewRect.width, viewRect.height); GraphicsUtil.drawBoxedString(g2d, " Please Wait ", viewRect.width / 2, viewRect.height / 2); return; } if (zone == null) { return; } if (lastView != null && !lastView.equals(view)) { invalidateCurrentViewCache(); } lastView = view; // Clear internal state tokenLocationMap.clear(); markerLocationList.clear(); itemRenderList.clear(); timer.stop("setup"); // Calculations timer.start("calcs-1"); AffineTransform af = new AffineTransform(); af.translate(zoneScale.getOffsetX(), zoneScale.getOffsetY()); af.scale(getScale(), getScale()); // @formatter:off /* This is the new code that doesn't work. See below for newer code that _might_ work. ;-) if (visibleScreenArea == null && zoneView.isUsingVision()) { Area a = zoneView.getVisibleArea(view); if (a != null && !a.isEmpty()) visibleScreenArea = a; } exposedFogArea = new Area(zone.getExposedArea()); if (visibleScreenArea != null) { if (exposedFogArea != null) exposedFogArea.transform(af); visibleScreenArea.transform(af); } if (exposedFogArea == null || !zone.hasFog()) { // fully exposed (screen area) exposedFogArea = new Area(new Rectangle(0, 0, getSize().width, getSize().height)); } */ // @formatter:on if (visibleScreenArea == null && zoneView.isUsingVision()) { Area a = zoneView.getVisibleArea(view); if (a != null && !a.isEmpty()) visibleScreenArea = a.createTransformedArea(af); } timer.stop("calcs-1"); timer.start("calcs-2"); { // renderMoveSelectionSet() requires exposedFogArea to be properly set exposedFogArea = new Area(zone.getExposedArea()); if (exposedFogArea != null && zone.hasFog()) { if (visibleScreenArea != null && !visibleScreenArea.isEmpty()) exposedFogArea.intersect(visibleScreenArea); else { try { // Try to calculate the inverse transform and apply it. viewArea.transform(af.createInverse()); // If it works, restrict the exposedFogArea to the resulting rectangle. exposedFogArea.intersect(viewArea); } catch (NoninvertibleTransformException nte) { // If it doesn't work, ignore the intersection and produce an error (should never happen, right?) nte.printStackTrace(); } } exposedFogArea.transform(af); } else { exposedFogArea = viewArea; } } timer.stop("calcs-2"); // Rendering pipeline if (zone.drawBoard()) { timer.start("board"); renderBoard(g2d, view); timer.stop("board"); } if (Zone.Layer.BACKGROUND.isEnabled()) { List<DrawnElement> drawables = zone.getBackgroundDrawnElements(); if (!drawables.isEmpty()) { timer.start("drawableBackground"); renderDrawableOverlay(g2d, backgroundDrawableRenderer, view, drawables); timer.stop("drawableBackground"); } List<Token> background = zone.getBackgroundStamps(); if (!background.isEmpty()) { timer.start("tokensBackground"); renderTokens(g2d, background, view); timer.stop("tokensBackground"); } } if (Zone.Layer.OBJECT.isEnabled()) { // Drawables on the object layer are always below the grid, and... List<DrawnElement> drawables = zone.getObjectDrawnElements(); if (!drawables.isEmpty()) { timer.start("drawableObjects"); renderDrawableOverlay(g2d, objectDrawableRenderer, view, drawables); timer.stop("drawableObjects"); } } timer.start("grid"); renderGrid(g2d, view); timer.stop("grid"); if (Zone.Layer.OBJECT.isEnabled()) { // ... Images on the object layer are always ABOVE the grid. List<Token> stamps = zone.getStampTokens(); if (!stamps.isEmpty()) { timer.start("tokensStamp"); renderTokens(g2d, stamps, view); timer.stop("tokensStamp"); } } if (Zone.Layer.TOKEN.isEnabled()) { timer.start("lights"); renderLights(g2d, view); timer.stop("lights"); timer.start("auras"); renderAuras(g2d, view); timer.stop("auras"); } /** * The following sections used to handle rendering of the Hidden (i.e. * "GM") layer followed by the Token layer. The problem was that we want * all drawables to appear below all tokens, and the old configuration * performed the rendering in the following order: * <ol> * <li>Render Hidden-layer tokens * <li>Render Hidden-layer drawables * <li>Render Token-layer drawables * <li>Render Token-layer tokens * </ol> * That's fine for players, but clearly wrong if the view is for the GM. * We now use: * <ol> * <li>Render Token-layer drawables // Player-drawn images shouldn't * obscure GM's images? * <li>Render Hidden-layer drawables // GM could always use * "View As Player" if needed? * <li>Render Hidden-layer tokens * <li>Render Token-layer tokens * </ol> */ if (Zone.Layer.TOKEN.isEnabled()) { List<DrawnElement> drawables = zone.getDrawnElements(); if (!drawables.isEmpty()) { timer.start("drawableTokens"); renderDrawableOverlay(g2d, tokenDrawableRenderer, view, drawables); timer.stop("drawableTokens"); } if (view.isGMView()) { if (Zone.Layer.GM.isEnabled()) { drawables = zone.getGMDrawnElements(); if (!drawables.isEmpty()) { timer.start("drawableGM"); renderDrawableOverlay(g2d, gmDrawableRenderer, view, drawables); timer.stop("drawableGM"); } List<Token> stamps = zone.getGMStamps(); if (!stamps.isEmpty()) { timer.start("tokensGM"); renderTokens(g2d, stamps, view); timer.stop("tokensGM"); } } } List<Token> tokens = zone.getTokens(); if (!tokens.isEmpty()) { timer.start("tokens"); renderTokens(g2d, tokens, view); timer.stop("tokens"); } timer.start("unowned movement"); renderMoveSelectionSets(g2d, view, getUnOwnedMovementSet(view)); timer.stop("unowned movement"); // Moved below, after the renderFog() call... // timer.start("owned movement"); // renderMoveSelectionSets(g2d, view, getOwnedMovementSet(view)); // timer.stop("owned movement"); // Text associated with tokens being moved is added to a list to be drawn after, i.e. on top of, the tokens themselves. // So if one moving token is on top of another moving token, at least the textual identifiers will be visible. // timer.start("token name/labels"); // renderRenderables(g2d); // timer.stop("token name/labels"); } /** * FJE It's probably not appropriate for labels to be above everything, * including tokens. Above drawables, yes. Above tokens, no. (Although * in that case labels could be completely obscured. Hm.) */ // Drawing labels is slooooow. :( // Perhaps we should draw the fog first and use hard fog to determine whether labels need to be drawn? // (This method has it's own 'timer' calls) renderLabels(g2d, view); // (This method has it's own 'timer' calls) if (zone.hasFog()) renderFog(g2d, view); if (Zone.Layer.TOKEN.isEnabled()) { timer.start("owned movement"); renderMoveSelectionSets(g2d, view, getOwnedMovementSet(view)); timer.stop("owned movement"); // Text associated with tokens being moved is added to a list to be drawn after, i.e. on top of, the tokens themselves. // So if one moving token is on top of another moving token, at least the textual identifiers will be visible. timer.start("token name/labels"); renderRenderables(g2d); timer.stop("token name/labels"); } // if (zone.visionType ...) if (view.isGMView()) { timer.start("visionOverlayGM"); renderGMVisionOverlay(g2d, view); timer.stop("visionOverlayGM"); } else { timer.start("visionOverlayPlayer"); renderPlayerVisionOverlay(g2d, view); timer.stop("visionOverlayPlayer"); } timer.start("overlays"); for (int i = 0; i < overlayList.size(); i++) { String msg = null; ZoneOverlay overlay = overlayList.get(i); if (timer.isEnabled()) { msg = "overlays:" + overlay.getClass().getSimpleName(); timer.start(msg); } overlay.paintOverlay(this, g2d); if (timer.isEnabled()) timer.stop(msg); } timer.stop("overlays"); timer.start("renderCoordinates"); renderCoordinates(g2d, view); timer.stop("renderCoordinates"); timer.start("lightSourceIconOverlay.paintOverlay"); if (Zone.Layer.TOKEN.isEnabled()) { if (view.isGMView() && AppState.isShowLightSources()) { lightSourceIconOverlay.paintOverlay(this, g2d); } } timer.stop("lightSourceIconOverlay.paintOverlay"); // g2d.setColor(Color.red); // for (AreaMeta meta : getTopologyAreaData().getAreaList()) { // Area area = new Area(meta.getArea().getBounds()).createTransformedArea(AffineTransform.getScaleInstance(getScale(), getScale())); // area = area.createTransformedArea(AffineTransform.getTranslateInstance(zoneScale.getOffsetX(), zoneScale.getOffsetY())); // g2d.draw(area); // } SwingUtil.restoreAntiAliasing(g2d, oldAA); if (resetClip) { g2d.setClip(null); } } private void delayRendering(ItemRenderer renderer) { itemRenderList.add(renderer); } private void renderRenderables(Graphics2D g) { for (ItemRenderer renderer : itemRenderList) { renderer.render(g); } } public CodeTimer getCodeTimer() { return timer; } private Map<Paint, List<Area>> renderedLightMap; private void renderLights(Graphics2D g, PlayerView view) { // Setup timer.start("lights-1"); Graphics2D newG = (Graphics2D) g.create(); if (!view.isGMView() && visibleScreenArea != null) { Area clip = new Area(g.getClip()); clip.intersect(visibleScreenArea); newG.setClip(clip); } SwingUtil.useAntiAliasing(newG); timer.stop("lights-1"); timer.start("lights-2"); AffineTransform af = g.getTransform(); af.translate(getViewOffsetX(), getViewOffsetY()); af.scale(getScale(), getScale()); newG.setTransform(af); newG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, AppPreferences.getLightOverlayOpacity() / 255.0f)); timer.stop("lights-2"); if (renderedLightMap == null) { timer.start("lights-3"); // Organize Map<Paint, List<Area>> colorMap = new HashMap<Paint, List<Area>>(); List<DrawableLight> otherLightList = new LinkedList<DrawableLight>(); for (DrawableLight light : zoneView.getDrawableLights()) { if (light.getType() == LightSource.Type.NORMAL) { if (zone.getVisionType() == Zone.VisionType.NIGHT && light.getPaint() != null) { List<Area> areaList = colorMap.get(light.getPaint().getPaint()); if (areaList == null) { areaList = new ArrayList<Area>(); colorMap.put(light.getPaint().getPaint(), areaList); } areaList.add(new Area(light.getArea())); } } else { // I'm not a huge fan of this hard wiring, but I haven't thought of a better way yet, so this'll work fine for now otherLightList.add(light); } } timer.stop("lights-3"); timer.start("lights-4"); // Combine same colors to avoid ugly overlap // Avoid combining _all_ of the lights as the area adds are very expensive, just combine those that overlap for (List<Area> areaList : colorMap.values()) { List<Area> sourceList = new LinkedList<Area>(areaList); areaList.clear(); outter: while (sourceList.size() > 0) { Area area = sourceList.remove(0); for (ListIterator<Area> iter = sourceList.listIterator(); iter.hasNext();) { Area currArea = iter.next(); if (currArea.getBounds().intersects(area.getBounds())) { iter.remove(); area.add(currArea); sourceList.add(area); continue outter; } } // If we are here, we didn't find any other area to merge with areaList.add(area); } // Cut out the bright light if (areaList.size() > 0) { for (Area area : areaList) { for (Area brightArea : zoneView.getBrightLights()) { area.subtract(brightArea); } } } } renderedLightMap = new LinkedHashMap<Paint, List<Area>>(); for (Entry<Paint, List<Area>> entry : colorMap.entrySet()) { renderedLightMap.put(entry.getKey(), entry.getValue()); } timer.stop("lights-4"); } // Draw timer.start("lights-5"); for (Entry<Paint, List<Area>> entry : renderedLightMap.entrySet()) { newG.setPaint(entry.getKey()); for (Area area : entry.getValue()) { newG.fill(area); } } timer.stop("lights-5"); newG.dispose(); } private Map<Paint, Area> renderedAuraMap; private void renderAuras(Graphics2D g, PlayerView view) { // Setup timer.start("auras-1"); Graphics2D newG = (Graphics2D) g.create(); if (!view.isGMView() && visibleScreenArea != null) { Area clip = new Area(g.getClip()); clip.intersect(visibleScreenArea); newG.setClip(clip); } SwingUtil.useAntiAliasing(newG); timer.stop("auras-1"); timer.start("auras-2"); AffineTransform af = g.getTransform(); af.translate(getViewOffsetX(), getViewOffsetY()); af.scale(getScale(), getScale()); newG.setTransform(af); newG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, AppPreferences.getAuraOverlayOpacity() / 255.0f)); timer.stop("auras-2"); if (renderedAuraMap == null) { // Organize Map<Paint, List<Area>> colorMap = new HashMap<Paint, List<Area>>(); timer.start("auras-4"); Color paintColor = new Color(255, 255, 255, 150); for (DrawableLight light : zoneView.getLights(LightSource.Type.AURA)) { Paint paint = light.getPaint() != null ? light.getPaint().getPaint() : paintColor; List<Area> list = colorMap.get(paint); if (list == null) { list = new LinkedList<Area>(); list.add(new Area(light.getArea())); colorMap.put(paint, list); } else { list.get(0).add(new Area(light.getArea())); } } renderedAuraMap = new LinkedHashMap<Paint, Area>(); for (Entry<Paint, List<Area>> entry : colorMap.entrySet()) { renderedAuraMap.put(entry.getKey(), entry.getValue().get(0)); } timer.stop("auras-4"); } // Draw timer.start("auras-5"); for (Entry<Paint, Area> entry : renderedAuraMap.entrySet()) { newG.setPaint(entry.getKey()); newG.fill(entry.getValue()); } timer.stop("auras-5"); newG.dispose(); } /** * This outlines the area visible to the token under the cursor, clipped to * the current fog-of-war. This is appropriate for the player view, but the * GM sees everything. */ private void renderPlayerVisionOverlay(Graphics2D g, PlayerView view) { Graphics2D g2 = (Graphics2D) g.create(); if (zone.hasFog()) { Area clip = new Area(new Rectangle(getSize().width, getSize().height)); Area viewArea = new Area(exposedFogArea); List<Token> tokens = view.getTokens(); if (tokens != null && !tokens.isEmpty()) { for (Token tok : tokens) { ExposedAreaMetaData exposedMeta = zone.getExposedAreaMetaData(tok.getExposedAreaGUID()); viewArea.add(exposedMeta.getExposedAreaHistory()); } } if (!viewArea.isEmpty()) { clip.intersect(new Area(viewArea.getBounds2D())); } // Note: the viewArea doesn't need to be transform()'d because exposedFogArea has been already. g2.setClip(clip); } renderVisionOverlay(g2, view); g2.dispose(); } /** * Render the vision overlay as though the view were the GM. */ private void renderGMVisionOverlay(Graphics2D g, PlayerView view) { renderVisionOverlay(g, view); } /** * This outlines the area visible to the token under the cursor and shades * it with the halo color, if there is one. */ private void renderVisionOverlay(Graphics2D g, PlayerView view) { Area currentTokenVisionArea = getVisibleArea(tokenUnderMouse); if (currentTokenVisionArea == null) return; Area combined = new Area(currentTokenVisionArea); ExposedAreaMetaData meta = zone.getExposedAreaMetaData(tokenUnderMouse.getExposedAreaGUID()); Area tmpArea = new Area(meta.getExposedAreaHistory()); tmpArea.add(zone.getExposedArea()); if (zone.hasFog()) { if (tmpArea.isEmpty()) return; combined.intersect(tmpArea); } boolean isOwner = AppUtil.playerOwns(tokenUnderMouse); boolean tokenIsPC = tokenUnderMouse.getType() == Token.Type.PC; boolean strictOwnership = TabletopTool.getServerPolicy() == null ? false : TabletopTool.getServerPolicy().useStrictTokenManagement(); boolean showVisionAndHalo = isOwner || view.isGMView() || (tokenIsPC && !strictOwnership); // String player = TabletopTool.getPlayer().getName(); // System.err.print("tokenUnderMouse.ownedBy(" + player + "): " + isOwner); // System.err.print(", tokenIsPC: " + tokenIsPC); // System.err.print(", isGMView(): " + view.isGMView()); // System.err.println(", strictOwnership: " + strictOwnership); /* * The vision arc and optional halo-filled visible area shouldn't be * shown to everyone. If we are in GM view, or if we are the owner of * the token in question, or if the token is a PC and strict token * ownership is off... then the vision arc should be displayed. */ if (showVisionAndHalo) { AffineTransform af = new AffineTransform(); af.translate(zoneScale.getOffsetX(), zoneScale.getOffsetY()); af.scale(getScale(), getScale()); Area area = combined.createTransformedArea(af); g.setClip(this.getBounds()); Object oldAA = SwingUtil.useAntiAliasing(g); //g.setStroke(new BasicStroke(2)); g.setColor(new Color(255, 255, 255)); // outline around visible area g.draw(area); renderHaloArea(g, area); SwingUtil.restoreAntiAliasing(g, oldAA); } } private void renderHaloArea(Graphics2D g, Area visible) { boolean useHaloColor = tokenUnderMouse.getHaloColor() != null && AppPreferences.getUseHaloColorOnVisionOverlay(); if (tokenUnderMouse.getVisionOverlayColor() != null || useHaloColor) { Color visionColor = useHaloColor ? tokenUnderMouse.getHaloColor() : tokenUnderMouse.getVisionOverlayColor(); g.setColor(new Color(visionColor.getRed(), visionColor.getGreen(), visionColor.getBlue(), AppPreferences.getHaloOverlayOpacity())); g.fill(visible); } } private void renderLabels(Graphics2D g, PlayerView view) { timer.start("labels-1"); labelLocationList.clear(); for (Label label : zone.getLabels()) { ZonePoint zp = new ZonePoint(label.getX(), label.getY()); if (!zone.isPointVisible(zp, view)) { continue; } timer.start("labels-1.1"); ScreenPoint sp = ScreenPoint.fromZonePointRnd(this, zp.x, zp.y); Rectangle bounds = null; if (label.isShowBackground()) { bounds = GraphicsUtil.drawBoxedString(g, label.getLabel(), (int) sp.x, (int) sp.y, SwingUtilities.CENTER, GraphicsUtil.GREY_LABEL, label.getForegroundColor()); } else { FontMetrics fm = g.getFontMetrics(); int strWidth = SwingUtilities.computeStringWidth(fm, label.getLabel()); int x = (int) (sp.x - strWidth / 2); int y = (int) (sp.y - fm.getAscent()); g.setColor(label.getForegroundColor()); g.drawString(label.getLabel(), x, (int) sp.y); bounds = new Rectangle(x, y, strWidth, fm.getHeight()); } labelLocationList.add(new LabelLocation(bounds, label)); timer.stop("labels-1.1"); } timer.stop("labels-1"); } // Private cache variables just for renderFog() and no one else. :) Integer fogX = null; Integer fogY = null; private Area renderFog(Graphics2D g, PlayerView view) { Dimension size = getSize(); Area fogClip = new Area(new Rectangle(0, 0, size.width, size.height)); Area combined = null; // Optimization for panning if (!flushFog && fogX != null && fogY != null && (fogX != getViewOffsetX() || fogY != getViewOffsetY())) { // This optimization does not seem to keep the alpha channel correctly, and sometimes leaves // lines on some graphics boards, we'll leave it out for now // if (Math.abs(fogX - getViewOffsetX()) < size.width && Math.abs(fogY - getViewOffsetY()) < size.height) { // int deltaX = getViewOffsetX() - fogX; // int deltaY = getViewOffsetY() - fogY; // // Graphics2D buffG = fogBuffer.createGraphics(); // // buffG.setComposite(AlphaComposite.Src); // buffG.copyArea(0, 0, size.width, size.height, deltaX, deltaY); // buffG.dispose(); // // fogClip = new Area(); // if (deltaX < 0) { // fogClip.add(new Area(new Rectangle(size.width+deltaX, 0, -deltaX, size.height))); // } else if (deltaX > 0){ // fogClip.add(new Area(new Rectangle(0, 0, deltaX, size.height))); // } // // if (deltaY < 0) { // fogClip.add(new Area(new Rectangle(0, size.height + deltaY, size.width, -deltaY))); // } else if (deltaY > 0) { // fogClip.add(new Area(new Rectangle(0, 0, size.width, deltaY))); // } // } flushFog = true; } boolean cacheNotValid = (fogBuffer == null || fogBuffer.getWidth() != size.width || fogBuffer.getHeight() != size.height); timer.start("renderFog"); if (flushFog || cacheNotValid) { fogX = getViewOffsetX(); fogY = getViewOffsetY(); boolean newImage = false; if (cacheNotValid) { newImage = true; timer.start("renderFog-allocateBufferedImage"); fogBuffer = new BufferedImage(size.width, size.height, view.isGMView() ? Transparency.TRANSLUCENT : Transparency.BITMASK); timer.stop("renderFog-allocateBufferedImage"); } Graphics2D buffG = fogBuffer.createGraphics(); buffG.setClip(fogClip); SwingUtil.useAntiAliasing(buffG); // XXX Is this even needed? Immediately below is another call to fillRect() with the same dimensions! if (!newImage) { timer.start("renderFog-clearOldImage"); // Composite oldComposite = buffG.getComposite(); buffG.setComposite(AlphaComposite.Clear); buffG.fillRect(0, 0, size.width, size.height); // buffG.setComposite(oldComposite); timer.stop("renderFog-clearOldImage"); } timer.start("renderFog-fill"); // Fill double scale = getScale(); buffG.setPaint(zone.getFogPaint().getPaint(fogX, fogY, scale)); buffG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC, view.isGMView() ? .6f : 1f)); // JFJ this fixes the GM exposed area view. buffG.fillRect(0, 0, size.width, size.height); timer.stop("renderFog-fill"); // Cut out the exposed area AffineTransform af = new AffineTransform(); af.translate(fogX, fogY); af.scale(scale, scale); buffG.setTransform(af); // buffG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC, view.isGMView() ? .6f : 1f)); buffG.setComposite(AlphaComposite.getInstance(AlphaComposite.CLEAR)); timer.start("renderFog-visibleArea"); Area visibleArea = zoneView.getVisibleArea(view); timer.stop("renderFog-visibleArea"); String msg = null; if (timer.isEnabled()) { List<Token> list = view.getTokens(); msg = "renderFog-combined(" + (list == null ? 0 : list.size()) + ")"; } timer.start(msg); combined = zone.getExposedArea(view); timer.stop(msg); timer.start("renderFogArea"); Area exposedArea = null; Area tempArea = new Area(); boolean combinedView = !zoneView.isUsingVision() || TabletopTool.isPersonalServer() || !TabletopTool.getServerPolicy().isUseIndividualFOW() || view.isGMView(); if (view.getTokens() != null) { // if there are tokens selected combine the areas, then, if individual FOW is enabled // we pass the combined exposed area to build the soft FOW and visible area. for (Token tok : view.getTokens()) { ExposedAreaMetaData meta = zone.getExposedAreaMetaData(tok.getExposedAreaGUID()); exposedArea = meta.getExposedAreaHistory(); tempArea.add(new Area(exposedArea)); } if (combinedView) { // combined = zone.getExposedArea(view); buffG.fill(combined); renderFogArea(buffG, view, combined, visibleArea); renderFogOutline(buffG, view, combined); } else { // 'combined' already includes the area encompassed by 'tempArea', so just // use 'combined' instead in this block of code? tempArea.add(combined); buffG.fill(tempArea); renderFogArea(buffG, view, tempArea, visibleArea); renderFogOutline(buffG, view, tempArea); } } else { // No tokens selected, so if we are using Individual FOW, we build up all the owned tokens // exposed area's to build the soft FOW. if (combinedView) { if (combined.isEmpty()) { combined = zone.getExposedArea(); } buffG.fill(combined); renderFogArea(buffG, view, combined, visibleArea); renderFogOutline(buffG, view, combined); } else { Area myCombined = new Area(); List<Token> myToks = zone.getTokens(); for (Token tok : myToks) { if (!AppUtil.playerOwns(tok)) { // Only here if !isGMview() so should the tokens already be in PlayerView.getTokens()? continue; } ExposedAreaMetaData meta = zone.getExposedAreaMetaData(tok.getExposedAreaGUID()); exposedArea = meta.getExposedAreaHistory(); myCombined.add(new Area(exposedArea)); } buffG.fill(myCombined); renderFogArea(buffG, view, myCombined, visibleArea); renderFogOutline(buffG, view, myCombined); } } // renderFogArea(buffG, view, combined, visibleArea); timer.stop("renderFogArea"); // timer.start("renderFogOutline"); // renderFogOutline(buffG, view, combined); // timer.stop("renderFogOutline"); buffG.dispose(); flushFog = false; } timer.stop("renderFog"); g.drawImage(fogBuffer, 0, 0, this); return combined; } private void renderFogArea(final Graphics2D buffG, final PlayerView view, Area softFog, Area visibleArea) { if (zoneView.isUsingVision()) { buffG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC)); if (visibleArea != null && !visibleArea.isEmpty()) { buffG.setColor(new Color(0, 0, 0, AppPreferences.getFogOverlayOpacity())); // Fill in the exposed area buffG.fill(softFog); buffG.setComposite(AlphaComposite.getInstance(AlphaComposite.CLEAR)); Shape oldClip = buffG.getClip(); buffG.setClip(softFog); buffG.fill(visibleArea); buffG.setClip(oldClip); } else { buffG.setColor(new Color(0, 0, 0, 80)); buffG.fill(softFog); } } else { buffG.fill(softFog); buffG.setClip(softFog); } } private void renderFogOutline(final Graphics2D buffG, PlayerView view, Area softFog) { // if (false && AppPreferences.getUseSoftFogEdges()) { // float alpha = view.isGMView() ? AppPreferences.getFogOverlayOpacity() / 255.0f : 1f; // GraphicsUtil.renderSoftClipping(buffG, softFog, (int) (zone.getGrid().getSize() * getScale() * .25), alpha); // } else { if (visibleScreenArea != null) { // buffG.setClip(softFog); buffG.setTransform(new AffineTransform()); buffG.setComposite(AlphaComposite.Src); buffG.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); buffG.setStroke(new BasicStroke(1)); buffG.setColor(Color.BLACK); buffG.draw(visibleScreenArea); // buffG.setClip(oldClip); } } } public Area getVisibleArea(Token token) { return zoneView.getVisibleArea(token); } public boolean isLoading() { if (isLoaded) { // We're done, until the cache is cleared return false; } // Get a list of all the assets in the zone Set<MD5Key> assetSet = zone.getAllAssetIds(); assetSet.remove(null); // remove bad data // Make sure they are loaded int downloadCount = 0; int cacheCount = 0; boolean loaded = true; for (MD5Key id : assetSet) { // Have we gotten the actual data yet ? Asset asset = AssetManager.getAsset(id); if (asset == null) { AssetManager.getAssetAsynchronously(id); loaded = false; continue; } downloadCount++; // Have we loaded the image into memory yet ? Image image = ImageManager.getImage(asset.getId(), this); if (image == null || image == ImageManager.TRANSFERING_IMAGE) { loaded = false; continue; } cacheCount++; } loadingProgress = String.format(" Loading Map '%s' - %d/%d Loaded %d/%d Cached", zone.getName(), downloadCount, assetSet.size(), cacheCount, assetSet.size()); isLoaded = loaded; if (isLoaded) { // Notify the token tree that it should update TabletopTool.getFrame().updateTokenTree(); } return !isLoaded; } protected void renderDrawableOverlay(Graphics g, DrawableRenderer renderer, PlayerView view, List<DrawnElement> drawnElements) { Rectangle viewport = new Rectangle(zoneScale.getOffsetX(), zoneScale.getOffsetY(), getSize().width, getSize().height); List<DrawnElement> list = new ArrayList<DrawnElement>(); list.addAll(drawnElements); renderer.renderDrawables(g, list, viewport, getScale()); } protected void renderBoard(Graphics2D g, PlayerView view) { Dimension size = getSize(); if (backbuffer == null || backbuffer.getWidth() != size.width || backbuffer.getHeight() != size.height) { backbuffer = new BufferedImage(size.width, size.height, Transparency.OPAQUE); drawBackground = true; } Scale scale = getZoneScale(); if (scale.getOffsetX() != lastX || scale.getOffsetY() != lastY || scale.getScale() != lastScale) { drawBackground = true; } if (zone.isBoardChanged()) { drawBackground = true; zone.setBoardChanged(false); } if (drawBackground) { Graphics2D bbg = backbuffer.createGraphics(); // Background texture Paint paint = zone.getBackgroundPaint().getPaint(getViewOffsetX(), getViewOffsetY(), getScale(), this); bbg.setPaint(paint); bbg.fillRect(0, 0, size.width, size.height); // Map if (zone.getMapAssetId() != null) { BufferedImage mapImage = ImageManager.getImage(zone.getMapAssetId(), this); double scaleFactor = getScale(); bbg.drawImage(mapImage, getViewOffsetX() + (int) (zone.getBoardX() * scaleFactor), getViewOffsetY() + (int) (zone.getBoardY() * scaleFactor), (int) (mapImage.getWidth() * scaleFactor), (int) (mapImage.getHeight() * scaleFactor), null); } bbg.dispose(); drawBackground = false; } lastX = scale.getOffsetX(); lastY = scale.getOffsetY(); lastScale = scale.getScale(); g.drawImage(backbuffer, 0, 0, this); } protected void renderGrid(Graphics2D g, PlayerView view) { int gridSize = (int) (zone.getGrid().getSize() * getScale()); if (!AppState.isShowGrid() || gridSize < MIN_GRID_SIZE) { return; } zone.getGrid().draw(this, g, g.getClipBounds()); } protected void renderCoordinates(Graphics2D g, PlayerView view) { if (AppState.isShowCoordinates()) { zone.getGrid().drawCoordinatesOverlay(g, this); } } private Set<SelectionSet> getOwnedMovementSet(PlayerView view) { Set<SelectionSet> movementSet = new HashSet<SelectionSet>(); for (SelectionSet selection : selectionSetMap.values()) { if (selection.getPlayerId().equals(TabletopTool.getPlayer().getName())) { movementSet.add(selection); } } return movementSet; } private Set<SelectionSet> getUnOwnedMovementSet(PlayerView view) { Set<SelectionSet> movementSet = new HashSet<SelectionSet>(); for (SelectionSet selection : selectionSetMap.values()) { if (!selection.getPlayerId().equals(TabletopTool.getPlayer().getName())) { movementSet.add(selection); } } return movementSet; } protected void renderMoveSelectionSets(Graphics2D g, PlayerView view, Set<SelectionSet> movementSet) { if (selectionSetMap.isEmpty()) { return; } double scale = zoneScale.getScale(); boolean clipInstalled = false; for (SelectionSet set : movementSet) { Token keyToken = zone.getToken(set.getKeyToken()); if (keyToken == null) { // It was removed ? selectionSetMap.remove(set.getKeyToken()); continue; } // Hide the hidden layer if (keyToken.getLayer() == Zone.Layer.GM && !view.isGMView()) { continue; } ZoneWalker walker = set.getWalker(); for (GUID tokenGUID : set.getTokens()) { Token token = zone.getToken(tokenGUID); // Perhaps deleted? if (token == null) continue; // Don't bother if it's not visible if (!token.isVisible() && !view.isGMView()) continue; // ... or if it's visible only to the owner and that's not us! if (token.isVisibleOnlyToOwner() && !AppUtil.playerOwns(token)) continue; // ... or if it doesn't have an image to display. (Hm, should still show *something*?) Asset asset = AssetManager.getAsset(token.getImageAssetId()); if (asset == null) continue; // OPTIMIZE: combine this with the code in renderTokens() Rectangle footprintBounds = token.getBounds(zone); ScreenPoint newScreenPoint = ScreenPoint.fromZonePoint(this, footprintBounds.x + set.getOffsetX(), footprintBounds.y + set.getOffsetY()); BufferedImage image = ImageManager.getImage(token.getImageAssetId()); int scaledWidth = (int) (footprintBounds.width * scale); int scaledHeight = (int) (footprintBounds.height * scale); // Tokens are centered on the image center point int x = (int) (newScreenPoint.x); int y = (int) (newScreenPoint.y); // Vision visibility boolean isOwner = view.isGMView() || AppUtil.playerOwns(token); // || set.getPlayerId().equals(TabletopTool.getPlayer().getName()); if (!view.isGMView() && visibleScreenArea != null && !isOwner) { // FJE Um, why not just assign the clipping area at the top of the routine? if (!clipInstalled) { // Only show the part of the path that is visible Area visibleArea = new Area(g.getClipBounds()); visibleArea.intersect(visibleScreenArea); g = (Graphics2D) g.create(); g.setClip(new GeneralPath(visibleArea)); clipInstalled = true; // System.out.println("Adding Clip: " + TabletopTool.getPlayer().getName()); } } // Show path only on the key token if (token == keyToken) { if (!token.isStamp()) { renderPath(g, walker != null ? walker.getPath() : set.gridlessPath, token.getFootprint(zone.getGrid())); } } // handle flipping BufferedImage workImage = image; if (token.isFlippedX() || token.isFlippedY()) { workImage = new BufferedImage(image.getWidth(), image.getHeight(), image.getTransparency()); int workW = image.getWidth() * (token.isFlippedX() ? -1 : 1); int workH = image.getHeight() * (token.isFlippedY() ? -1 : 1); int workX = token.isFlippedX() ? image.getWidth() : 0; int workY = token.isFlippedY() ? image.getHeight() : 0; Graphics2D wig = workImage.createGraphics(); wig.drawImage(image, workX, workY, workW, workH, null); wig.dispose(); } // Draw token Dimension imgSize = new Dimension(workImage.getWidth(), workImage.getHeight()); SwingUtil.constrainTo(imgSize, footprintBounds.width, footprintBounds.height); int offsetx = 0; int offsety = 0; if (token.isSnapToScale()) { offsetx = (int) (imgSize.width < footprintBounds.width ? (footprintBounds.width - imgSize.width) / 2 * getScale() : 0); offsety = (int) (imgSize.height < footprintBounds.height ? (footprintBounds.height - imgSize.height) / 2 * getScale() : 0); } int tx = x + offsetx; int ty = y + offsety; AffineTransform at = new AffineTransform(); at.translate(tx, ty); if (token.hasFacing() && token.getShape() == Token.TokenShape.TOP_DOWN) { at.rotate(Math.toRadians(-token.getFacing() - 90), scaledWidth / 2 - token.getAnchor().x * scale - offsetx, scaledHeight / 2 - token.getAnchor().y * scale - offsety); // facing defaults to down, or -90 degrees } if (token.isSnapToScale()) { at.scale((double) imgSize.width / workImage.getWidth(), (double) imgSize.height / workImage.getHeight()); at.scale(getScale(), getScale()); } else { at.scale((double) scaledWidth / workImage.getWidth(), (double) scaledHeight / workImage.getHeight()); } g.drawImage(workImage, at, this); // Other details if (token == keyToken) { Rectangle bounds = new Rectangle(tx, ty, imgSize.width, imgSize.height); bounds.width *= getScale(); bounds.height *= getScale(); Grid grid = zone.getGrid(); boolean checkForFog = TabletopTool.getServerPolicy().isUseIndividualFOW() && zoneView.isUsingVision(); boolean showLabels = isOwner; if (checkForFog) { Path<? extends AbstractPoint> path = set.getWalker() != null ? set.getWalker().getPath() : set.gridlessPath; List<? extends AbstractPoint> thePoints = path.getCellPath(); /* * now that we have the last point, we can check to see * if it's gridless or not. If not gridless, get the * last point the token was at and see if the token's * footprint is inside the visible area to show the * label. */ if (thePoints.isEmpty()) { showLabels = false; } else { AbstractPoint lastPoint = thePoints.get(thePoints.size() - 1); Rectangle tokenRectangle = null; if (lastPoint instanceof CellPoint) { tokenRectangle = token.getFootprint(grid).getBounds(grid, (CellPoint) lastPoint); } else { Rectangle tokBounds = token.getBounds(zone); tokenRectangle = new Rectangle(); tokenRectangle.setBounds(lastPoint.x, lastPoint.y, (int) tokBounds.getWidth(), (int) tokBounds.getHeight()); } showLabels = showLabels || zoneView.getVisibleArea(view).intersects(tokenRectangle); } } else { boolean hasFog = zone.hasFog(); boolean fogIntersects = exposedFogArea.intersects(bounds); showLabels = showLabels || (visibleScreenArea == null && !hasFog); // no vision - fog showLabels = showLabels || (visibleScreenArea == null && hasFog && fogIntersects); // no vision + fog showLabels = showLabels || (visibleScreenArea != null && visibleScreenArea.intersects(bounds) && fogIntersects); // vision } if (showLabels) { // if the token is visible on the screen it will be in the location cache if (tokenLocationCache.containsKey(token)) { y += 10 + scaledHeight; x += scaledWidth / 2; if (!token.isStamp() && AppState.getShowMovementMeasurements()) { String distance = ""; if (walker != null) { // This wouldn't be true unless token.isSnapToGrid() && grid.isPathingSupported() int distanceTraveled = walker.getDistance(); if (distanceTraveled >= 1) { distance = Integer.toString(distanceTraveled); } } else { double c = 0; ZonePoint lastPoint = null; for (ZonePoint zp : set.gridlessPath.getCellPath()) { if (lastPoint == null) { lastPoint = zp; continue; } int a = lastPoint.x - zp.x; int b = lastPoint.y - zp.y; c += Math.hypot(a, b); lastPoint = zp; } c /= zone.getGrid().getSize(); // Number of "cells" c *= zone.getUnitsPerCell(); // "actual" distance traveled distance = String.format("%.1f", c); } if (!distance.isEmpty()) { delayRendering(new LabelRenderer(distance, x, y)); y += 20; } } if (set.getPlayerId() != null && set.getPlayerId().length() >= 1) { delayRendering(new LabelRenderer(set.getPlayerId(), x, y)); } } // !token.isStamp() } // showLabels } // token == keyToken } } } @SuppressWarnings("unchecked") public void renderPath(Graphics2D g, Path<? extends AbstractPoint> path, TokenFootprint footprint) { Object oldRendering = g.getRenderingHint(RenderingHints.KEY_ANTIALIASING); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); if (path.getCellPath().isEmpty()) { return; } Grid grid = zone.getGrid(); double scale = getScale(); Rectangle footprintBounds = footprint.getBounds(grid); if (path.getCellPath().get(0) instanceof CellPoint) { timer.start("renderPath-1"); CellPoint previousPoint = null; Point previousHalfPoint = null; Path<CellPoint> pathCP = (Path<CellPoint>) path; List<CellPoint> cellPath = pathCP.getCellPath(); Set<CellPoint> pathSet = new HashSet<CellPoint>(); List<ZonePoint> waypointList = new LinkedList<ZonePoint>(); for (CellPoint p : cellPath) { pathSet.addAll(footprint.getOccupiedCells(p)); if (pathCP.isWaypoint(p) && previousPoint != null) { ZonePoint zp = grid.convert(p); zp.x += footprintBounds.width / 2; zp.y += footprintBounds.height / 2; waypointList.add(zp); } previousPoint = p; } // Don't show the final path point as a waypoint, it's redundant, and ugly if (waypointList.size() > 0) { waypointList.remove(waypointList.size() - 1); } timer.stop("renderPath-1"); timer.start("renderPath-2"); Dimension cellOffset = zone.getGrid().getCellOffset(); for (CellPoint p : pathSet) { ZonePoint zp = grid.convert(p); zp.x += grid.getCellWidth() / 2 + cellOffset.width; zp.y += grid.getCellHeight() / 2 + cellOffset.height; highlightCell(g, zp, grid.getCellHighlight(), 1.0f); } for (ZonePoint p : waypointList) { ZonePoint zp = new ZonePoint(p.x + cellOffset.width, p.y + cellOffset.height); highlightCell(g, zp, AppStyle.cellWaypointImage, .333f); } // Line path if (grid.getCapabilities().isPathLineSupported()) { ZonePoint lineOffset = new ZonePoint(footprintBounds.x + footprintBounds.width / 2 - grid.getOffsetX(), footprintBounds.y + footprintBounds.height / 2 - grid.getOffsetY()); int xOffset = (int) (lineOffset.x * scale); int yOffset = (int) (lineOffset.y * scale); g.setColor(Color.blue); previousPoint = null; for (CellPoint p : cellPath) { if (previousPoint != null) { ZonePoint ozp = grid.convert(previousPoint); int ox = ozp.x; int oy = ozp.y; ZonePoint dzp = grid.convert(p); int dx = dzp.x; int dy = dzp.y; ScreenPoint origin = ScreenPoint.fromZonePoint(this, ox, oy); ScreenPoint destination = ScreenPoint.fromZonePoint(this, dx, dy); int halfx = (int) ((origin.x + destination.x) / 2); int halfy = (int) ((origin.y + destination.y) / 2); Point halfPoint = new Point(halfx, halfy); if (previousHalfPoint != null) { int x1 = previousHalfPoint.x + xOffset; int y1 = previousHalfPoint.y + yOffset; int x2 = (int) origin.x + xOffset; int y2 = (int) origin.y + yOffset; int xh = halfPoint.x + xOffset; int yh = halfPoint.y + yOffset; QuadCurve2D curve = new QuadCurve2D.Float(x1, y1, x2, y2, xh, yh); g.draw(curve); } previousHalfPoint = halfPoint; } previousPoint = p; } } timer.stop("renderPath-2"); } else { timer.start("renderPath-3"); // Zone point/gridless path // Line Color highlight = new Color(255, 255, 255, 80); Stroke highlightStroke = new BasicStroke(9); Stroke oldStroke = g.getStroke(); Object oldAA = SwingUtil.useAntiAliasing(g); ScreenPoint lastPoint = null; Path<ZonePoint> pathZP = (Path<ZonePoint>) path; List<ZonePoint> pathList = pathZP.getCellPath(); for (ZonePoint zp : pathList) { if (lastPoint == null) { lastPoint = ScreenPoint.fromZonePointRnd(this, zp.x + (footprintBounds.width / 2) * footprint.getScale(), zp.y + (footprintBounds.height / 2) * footprint.getScale()); continue; } ScreenPoint nextPoint = ScreenPoint.fromZonePoint(this, zp.x + (footprintBounds.width / 2) * footprint.getScale(), zp.y + (footprintBounds.height / 2) * footprint.getScale()); g.setColor(highlight); g.setStroke(highlightStroke); g.drawLine((int) lastPoint.x, (int) lastPoint.y, (int) nextPoint.x, (int) nextPoint.y); g.setStroke(oldStroke); g.setColor(Color.blue); g.drawLine((int) lastPoint.x, (int) lastPoint.y, (int) nextPoint.x, (int) nextPoint.y); lastPoint = nextPoint; } SwingUtil.restoreAntiAliasing(g, oldAA); // Waypoints boolean originPoint = true; for (ZonePoint p : pathList) { // Skip the first point (it's the path origin) if (originPoint) { originPoint = false; continue; } // Skip the final point if (p == pathList.get(pathList.size() - 1)) { continue; } p = new ZonePoint((int) (p.x + (footprintBounds.width / 2) * footprint.getScale()), (int) (p.y + (footprintBounds.height / 2) * footprint.getScale())); highlightCell(g, p, AppStyle.cellWaypointImage, .333f); } timer.stop("renderPath-3"); } g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldRendering); } public void highlightCell(Graphics2D g, ZonePoint point, BufferedImage image, float size) { Grid grid = zone.getGrid(); double cwidth = grid.getCellWidth() * getScale(); double cheight = grid.getCellHeight() * getScale(); double iwidth = cwidth * size; double iheight = cheight * size; ScreenPoint sp = ScreenPoint.fromZonePoint(this, point); g.drawImage(image, (int) (sp.x - iwidth / 2), (int) (sp.y - iheight / 2), (int) iwidth, (int) iheight, this); } /** * Get a list of tokens currently visible on the screen. The list is ordered * by location starting in the top left and going to the bottom right. * * @return */ public List<Token> getTokensOnScreen() { List<Token> list = new ArrayList<Token>(); // Always assume tokens, for now List<TokenLocation> tokenLocationListCopy = new ArrayList<TokenLocation>(); tokenLocationListCopy.addAll(getTokenLocations(Zone.Layer.TOKEN)); for (TokenLocation location : tokenLocationListCopy) { list.add(location.token); } // Sort by location on screen, top left to bottom right Collections.sort(list, new Comparator<Token>() { @Override public int compare(Token o1, Token o2) { if (o1.getY() < o2.getY()) { return -1; } if (o1.getY() > o2.getY()) { return 1; } if (o1.getX() < o2.getX()) { return -1; } if (o1.getX() > o2.getX()) { return 1; } return 0; } }); return list; } public Zone.Layer getActiveLayer() { return activeLayer != null ? activeLayer : Zone.Layer.TOKEN; } public void setActiveLayer(Zone.Layer layer) { activeLayer = layer; selectedTokenSet.clear(); repaint(); } /** * Get the token locations for the given layer, creates an empty list if * there are not locations for the given layer */ private List<TokenLocation> getTokenLocations(Zone.Layer layer) { List<TokenLocation> list = tokenLocationMap.get(layer); if (list == null) { list = new LinkedList<TokenLocation>(); tokenLocationMap.put(layer, list); } return list; } // TODO: I don't like this hardwiring protected Shape getCircleFacingArrow(int angle, int size) { int base = (int) (size * .75); int width = (int) (size * .35); facingArrow = new GeneralPath(); facingArrow.moveTo(base, -width); facingArrow.lineTo(size, 0); facingArrow.lineTo(base, width); facingArrow.lineTo(base, -width); GeneralPath gp = (GeneralPath) facingArrow.createTransformedShape(AffineTransform.getRotateInstance(-Math.toRadians(angle))); return gp.createTransformedShape(AffineTransform.getScaleInstance(getScale(), getScale())); } // TODO: I don't like this hardwiring protected Shape getSquareFacingArrow(int angle, int size) { int base = (int) (size * .75); int width = (int) (size * .35); facingArrow = new GeneralPath(); facingArrow.moveTo(0, 0); facingArrow.lineTo(-(size - base), -width); facingArrow.lineTo(-(size - base), width); facingArrow.lineTo(0, 0); GeneralPath gp = (GeneralPath) facingArrow.createTransformedShape(AffineTransform.getRotateInstance(-Math.toRadians(angle))); return gp.createTransformedShape(AffineTransform.getScaleInstance(getScale(), getScale())); } protected void renderTokens(Graphics2D g, List<Token> tokenList, PlayerView view) { Graphics2D clippedG = g; boolean isGMView = view.isGMView(); // speed things up timer.start("createClip"); if (!isGMView && visibleScreenArea != null && !tokenList.isEmpty() && tokenList.get(0).isToken()) { clippedG = (Graphics2D) g.create(); Area visibleArea = new Area(g.getClipBounds()); visibleArea.intersect(visibleScreenArea); clippedG.setClip(new GeneralPath(visibleArea)); } timer.stop("createClip"); // This is in screen coordinates Rectangle viewport = new Rectangle(0, 0, getSize().width, getSize().height); Rectangle clipBounds = g.getClipBounds(); double scale = zoneScale.getScale(); Set<GUID> tempVisTokens = new HashSet<GUID>(); // calculations boolean calculateStacks = !tokenList.isEmpty() && !tokenList.get(0).isStamp() && tokenStackMap == null; if (calculateStacks) { tokenStackMap = new HashMap<Token, Set<Token>>(); } // TODO: I (Craig) have commented out the clearing of the tokenLocationCache.clear() for now as // it introduced a more serious bug with resizing. // Clearing the cache here removes a bug in which campaigns are not initially drawn. Why? // Is that because the rendering pipeline thinks they've already been drawn so isn't forced to // re-render them? So how does this cache get filled then? It's not part of the campaign // state... // tokenLocationCache.clear(); List<Token> tokenPostProcessing = new ArrayList<Token>(tokenList.size()); for (Token token : tokenList) { timer.start("tokenlist-1"); try { if (token.isStamp() && isTokenMoving(token)) { continue; } // Don't bother if it's not visible // NOTE: Not going to use zone.isTokenVisible as it is very slow. In fact, it's faster // to just draw the tokens and let them be clipped if (!token.isVisible() && !isGMView) { continue; } if (token.isVisibleOnlyToOwner() && !AppUtil.playerOwns(token)) { continue; } } finally { // This ensures that the timer is always stopped timer.stop("tokenlist-1"); } timer.start("tokenlist-1.1"); TokenLocation location = tokenLocationCache.get(token); if (location != null && !location.maybeOnscreen(viewport)) { timer.stop("tokenlist-1.1"); continue; } timer.stop("tokenlist-1.1"); timer.start("tokenlist-1a"); Rectangle footprintBounds = token.getBounds(zone); timer.stop("tokenlist-1a"); timer.start("tokenlist-1b"); BufferedImage image = ImageManager.getImage(token.getImageAssetId(), this); timer.stop("tokenlist-1b"); timer.start("tokenlist-1c"); double scaledWidth = (footprintBounds.width * scale); double scaledHeight = (footprintBounds.height * scale); // if (!token.isStamp()) { // // Fit inside the grid // scaledWidth --; // scaledHeight --; // } ScreenPoint tokenScreenLocation = ScreenPoint.fromZonePoint(this, footprintBounds.x, footprintBounds.y); timer.stop("tokenlist-1c"); timer.start("tokenlist-1d"); // Tokens are centered on the image center point double x = tokenScreenLocation.x; double y = tokenScreenLocation.y; Rectangle2D origBounds = new Rectangle2D.Double(x, y, scaledWidth, scaledHeight); Area tokenBounds = new Area(origBounds); if (token.hasFacing() && token.getShape() == Token.TokenShape.TOP_DOWN) { double sx = scaledWidth / 2 + x - (token.getAnchor().x * scale); double sy = scaledHeight / 2 + y - (token.getAnchor().x * scale); tokenBounds.transform(AffineTransform.getRotateInstance(Math.toRadians(-token.getFacing() - 90), sx, sy)); // facing defaults to down, or -90 degrees } timer.stop("tokenlist-1d"); timer.start("tokenlist-1e"); try { location = new TokenLocation(tokenBounds, origBounds, token, x, y, footprintBounds.width, footprintBounds.height, scaledWidth, scaledHeight); tokenLocationCache.put(token, location); // Too small ? if (location.scaledHeight < 1 || location.scaledWidth < 1) { continue; } // Vision visibility if (!isGMView && token.isToken() && zoneView.isUsingVision()) { if (!GraphicsUtil.intersects(visibleScreenArea, location.bounds)) { continue; } } } finally { // This ensures that the timer is always stopped timer.stop("tokenlist-1e"); } // Markers timer.start("renderTokens:Markers"); if (token.isMarker() && canSeeMarker(token)) { markerLocationList.add(location); } timer.stop("renderTokens:Markers"); // Stacking check if (calculateStacks) { timer.start("tokenStack"); // System.out.println(token.getName() + " - " + location.boundsCache); Set<Token> tokenStackSet = null; for (TokenLocation currLocation : getTokenLocations(Zone.Layer.TOKEN)) { // Are we covering anyone ? // System.out.println("\t" + currLocation.token.getName() + " - " + location.boundsCache.contains(currLocation.boundsCache)); if (location.boundsCache.contains(currLocation.boundsCache)) { if (tokenStackSet == null) { tokenStackSet = new HashSet<Token>(); tokenStackMap.put(token, tokenStackSet); tokenStackSet.add(token); } tokenStackSet.add(currLocation.token); if (tokenStackMap.get(currLocation.token) != null) { tokenStackSet.addAll(tokenStackMap.get(currLocation.token)); tokenStackMap.remove(currLocation.token); } } } timer.stop("tokenStack"); } // Keep track of the location on the screen // Note the order -- the top most token is at the end of the list timer.start("renderTokens:Locations"); Zone.Layer layer = token.getLayer(); List<TokenLocation> locationList = getTokenLocations(layer); if (locationList != null) { locationList.add(location); } timer.stop("renderTokens:Locations"); // Add the token to our visible set. tempVisTokens.add(token.getId()); // Only draw if we're visible // NOTE: this takes place AFTER resizing the image, that's so that the user // suffers a pause only once while scaling, and not as new tokens are // scrolled onto the screen timer.start("renderTokens:OnscreenCheck"); if (!location.bounds.intersects(clipBounds)) { timer.stop("renderTokens:OnscreenCheck"); continue; } timer.stop("renderTokens:OnscreenCheck"); // Moving ? timer.start("renderTokens:ShowMovement"); if (isTokenMoving(token)) { BufferedImage replacementImage = replacementImageMap.get(token); if (replacementImage == null) { replacementImage = ImageUtil.rgbToGrayscale(image); replacementImageMap.put(token, replacementImage); } image = replacementImage; } timer.stop("renderTokens:ShowMovement"); // Previous path timer.start("renderTokens:ShowPath"); if (showPathList.contains(token) && token.getLastPath() != null) { renderPath(g, token.getLastPath(), token.getFootprint(zone.getGrid())); } timer.stop("renderTokens:ShowPath"); timer.start("tokenlist-4"); // Halo (TOPDOWN, CIRCLE) if (token.hasHalo() && (token.getShape() == Token.TokenShape.TOP_DOWN || token.getShape() == Token.TokenShape.CIRCLE)) { Stroke oldStroke = clippedG.getStroke(); clippedG.setStroke(new BasicStroke(AppPreferences.getHaloLineWidth())); clippedG.setColor(token.getHaloColor()); clippedG.draw(location.bounds); clippedG.setStroke(oldStroke); } timer.stop("tokenlist-4"); timer.start("tokenlist-5"); // handle flipping BufferedImage workImage = image; if (token.isFlippedX() || token.isFlippedY()) { workImage = flipImageMap.get(token); if (workImage == null) { workImage = new BufferedImage(image.getWidth(), image.getHeight(), image.getTransparency()); int workW = image.getWidth() * (token.isFlippedX() ? -1 : 1); int workH = image.getHeight() * (token.isFlippedY() ? -1 : 1); int workX = token.isFlippedX() ? image.getWidth() : 0; int workY = token.isFlippedY() ? image.getHeight() : 0; Graphics2D wig = workImage.createGraphics(); wig.drawImage(image, workX, workY, workW, workH, null); wig.dispose(); flipImageMap.put(token, workImage); } } timer.stop("tokenlist-5"); timer.start("tokenlist-6"); // Position Dimension imgSize = new Dimension(workImage.getWidth(), workImage.getHeight()); SwingUtil.constrainTo(imgSize, footprintBounds.width, footprintBounds.height); int offsetx = 0; int offsety = 0; if (token.isSnapToScale()) { offsetx = (int) (imgSize.width < footprintBounds.width ? (footprintBounds.width - imgSize.width) / 2 * getScale() : 0); offsety = (int) (imgSize.height < footprintBounds.height ? (footprintBounds.height - imgSize.height) / 2 * getScale() : 0); } double tx = location.x + offsetx; double ty = location.y + offsety; AffineTransform at = new AffineTransform(); at.translate(tx, ty); // Rotated if (token.hasFacing() && token.getShape() == Token.TokenShape.TOP_DOWN) { at.rotate(Math.toRadians(-token.getFacing() - 90), location.scaledWidth / 2 - (token.getAnchor().x * scale) - offsetx, location.scaledHeight / 2 - (token.getAnchor().y * scale) - offsety); // facing defaults to down, or -90 degrees } // Draw the token if (token.isSnapToScale()) { at.scale(((double) imgSize.width) / workImage.getWidth(), ((double) imgSize.height) / workImage.getHeight()); at.scale(getScale(), getScale()); } else { at.scale((scaledWidth) / workImage.getWidth(), (scaledHeight) / workImage.getHeight()); } timer.stop("tokenlist-6"); timer.start("tokenlist-7"); clippedG.drawImage(workImage, at, this); timer.stop("tokenlist-7"); timer.start("tokenlist-8"); // Halo (SQUARE) // XXX Why are square halos drawn separately?! if (token.hasHalo() && token.getShape() == Token.TokenShape.SQUARE) { Stroke oldStroke = g.getStroke(); clippedG.setStroke(new BasicStroke(AppPreferences.getHaloLineWidth())); clippedG.setColor(token.getHaloColor()); clippedG.draw(new Rectangle2D.Double(location.x, location.y, location.scaledWidth, location.scaledHeight)); clippedG.setStroke(oldStroke); } // Facing ? // TODO: Optimize this by doing it once per token per facing if (token.hasFacing()) { Token.TokenShape tokenType = token.getShape(); switch (tokenType) { case CIRCLE: Shape arrow = getCircleFacingArrow(token.getFacing(), footprintBounds.width / 2); double cx = location.x + location.scaledWidth / 2; double cy = location.y + location.scaledHeight / 2; clippedG.translate(cx, cy); clippedG.setColor(Color.yellow); clippedG.fill(arrow); clippedG.setColor(Color.darkGray); clippedG.draw(arrow); clippedG.translate(-cx, -cy); break; case SQUARE: int facing = token.getFacing(); while (facing < 0) { facing += 360; } // TODO: this should really be done in Token.setFacing() but I didn't want to take the chance of breaking something, so change this when it's safe to break stuff facing %= 360; arrow = getSquareFacingArrow(facing, footprintBounds.width / 2); cx = location.x + location.scaledWidth / 2; cy = location.y + location.scaledHeight / 2; // Find the edge of the image // TODO: Man, this is horrible, there's gotta be a better way to do this double xp = location.scaledWidth / 2; double yp = location.scaledHeight / 2; if (facing >= 45 && facing <= 135 || facing >= 225 && facing <= 315) { xp = (int) (yp / Math.tan(Math.toRadians(facing))); if (facing > 180) { xp = -xp; yp = -yp; } } else { yp = (int) (xp * Math.tan(Math.toRadians(facing))); if (facing > 90 && facing < 270) { xp = -xp; yp = -yp; } } cx += xp; cy -= yp; clippedG.translate(cx, cy); clippedG.setColor(Color.yellow); clippedG.fill(arrow); clippedG.setColor(Color.darkGray); clippedG.draw(arrow); clippedG.translate(-cx, -cy); break; } } timer.stop("tokenlist-8"); timer.start("tokenlist-9"); // Set up the graphics so that the overlay can just be painted. Graphics2D locg = (Graphics2D) clippedG.create((int) location.x, (int) location.y, (int) Math.ceil(location.scaledWidth), (int) Math.ceil(location.scaledHeight)); Rectangle bounds = new Rectangle(0, 0, (int) Math.ceil(location.scaledWidth), (int) Math.ceil(location.scaledHeight)); // Check each of the set values for (String state : TabletopTool.getCampaign().getTokenStatesMap().keySet()) { boolean stateValue = token.hasState(state); BooleanTokenOverlay overlay = TabletopTool.getCampaign().getTokenStatesMap().get(state); if (! (overlay == null || overlay.isMouseover() && token != tokenUnderMouse || !overlay.showPlayer(token, TabletopTool.getPlayer()))) { overlay.paintOverlay(locg, token, bounds, stateValue); } } timer.stop("tokenlist-9"); timer.start("tokenlist-10"); for (String bar : TabletopTool.getCampaign().getTokenBarsMap().keySet()) { Float barValue = token.getBar(bar); BarTokenOverlay overlay = TabletopTool.getCampaign().getTokenBarsMap().get(bar); if (overlay == null || overlay.isMouseover() && token != tokenUnderMouse || !overlay.showPlayer(token, TabletopTool.getPlayer())) { continue; } overlay.paintOverlay(locg, token, bounds, barValue); } // endfor locg.dispose(); timer.stop("tokenlist-10"); timer.start("tokenlist-11"); // Keep track of which tokens have been drawn so we can perform post-processing on them later // (such as selection borders and names/labels) if (getActiveLayer().equals(token.getLayer())) tokenPostProcessing.add(token); timer.stop("tokenlist-11"); // DEBUGGING // ScreenPoint tmpsp = ScreenPoint.fromZonePoint(this, new ZonePoint(token.getX(), token.getY())); // g.setColor(Color.red); // g.drawLine(tmpsp.x, 0, tmpsp.x, getSize().height); // g.drawLine(0, tmpsp.y, getSize().width, tmpsp.y); } timer.start("tokenlist-12"); boolean useIF = TabletopTool.getServerPolicy().isUseIndividualFOW(); // Selection and labels for (Token token : tokenPostProcessing) { TokenLocation location = tokenLocationCache.get(token); if (location == null) continue; Area bounds = location.bounds; // TODO: This isn't entirely accurate as it doesn't account for the actual text // to be in the clipping bounds, but I'll fix that later if (!bounds.getBounds().intersects(clipBounds)) { continue; } Rectangle footprintBounds = token.getBounds(zone); boolean isSelected = selectedTokenSet.contains(token.getId()); if (isSelected) { ScreenPoint sp = ScreenPoint.fromZonePoint(this, footprintBounds.x, footprintBounds.y); double width = footprintBounds.width * getScale(); double height = footprintBounds.height * getScale(); ImageBorder selectedBorder = token.isStamp() ? AppStyle.selectedStampBorder : AppStyle.selectedBorder; if (highlightCommonMacros.contains(token)) { selectedBorder = AppStyle.commonMacroBorder; } if (!AppUtil.playerOwns(token)) { selectedBorder = AppStyle.selectedUnownedBorder; } if (useIF && !token.isStamp() && zoneView.isUsingVision()) { Tool tool = TabletopTool.getFrame().getToolbox().getSelectedTool(); if (tool instanceof RectangleExposeTool // XXX Change to use marker interface such as ExposeTool? || tool instanceof OvalExposeTool || tool instanceof FreehandExposeTool || tool instanceof PolygonExposeTool) selectedBorder = AppConstants.FOW_TOOLS_BORDER; } if (token.hasFacing() && (token.getShape() == Token.TokenShape.TOP_DOWN || token.isStamp())) { AffineTransform oldTransform = clippedG.getTransform(); // Rotated clippedG.translate(sp.x, sp.y); clippedG.rotate(Math.toRadians(-token.getFacing() - 90), width / 2 - (token.getAnchor().x * scale), height / 2 - (token.getAnchor().y * scale)); // facing defaults to down, or -90 degrees selectedBorder.paintAround(clippedG, 0, 0, (int) width, (int) height); clippedG.setTransform(oldTransform); } else { selectedBorder.paintAround(clippedG, (int) sp.x, (int) sp.y, (int) width, (int) height); } // Remove labels from the cache if the corresponding tokens are deselected } else if (!AppState.isShowTokenNames() && labelRenderingCache.containsKey(token.getId())) { labelRenderingCache.remove(token.getId()); } // Token names and labels boolean showCurrentTokenLabel = AppState.isShowTokenNames() || token == tokenUnderMouse; if (showCurrentTokenLabel) { GUID tokId = token.getId(); int offset = 3; // Keep it from tramping on the token border. ImageLabel background; Color foreground; if (token.isVisible()) { if (token.getType() == Token.Type.NPC) { background = GraphicsUtil.BLUE_LABEL; foreground = Color.WHITE; } else { background = GraphicsUtil.GREY_LABEL; foreground = Color.BLACK; } } else { background = GraphicsUtil.DARK_GREY_LABEL; foreground = Color.WHITE; } String name = token.getName(); if (isGMView && token.getGMName() != null && !StringUtil.isEmpty(token.getGMName())) { name += " (" + token.getGMName() + ")"; } if (!view.equals(lastView) || !labelRenderingCache.containsKey(tokId)) { // if ((lastView != null && !lastView.equals(view)) || !labelRenderingCache.containsKey(tokId)) { boolean hasLabel = false; // Calculate image dimensions FontMetrics fm = g.getFontMetrics(); Font f = g.getFont(); int strWidth = SwingUtilities.computeStringWidth(fm, name); int width = strWidth + GraphicsUtil.BOX_PADDINGX * 2; int height = fm.getHeight() + GraphicsUtil.BOX_PADDINGY * 2; int labelHeight = height; // If token has a label (in addition to name). if (token.getLabel() != null && token.getLabel().trim().length() > 0) { hasLabel = true; height = height * 2; // Double the image height for two boxed strings. int labelWidth = SwingUtilities.computeStringWidth(fm, token.getLabel()) + GraphicsUtil.BOX_PADDINGX * 2; width = (width > labelWidth) ? width : labelWidth; } // Set up the image BufferedImage labelRender = new BufferedImage(width, height, Transparency.TRANSLUCENT); Graphics2D gLabelRender = labelRender.createGraphics(); gLabelRender.setFont(f); // Match font used in the main graphics context. gLabelRender.setRenderingHints(g.getRenderingHints()); // Match rendering style. // Draw name and label to image if (hasLabel) { GraphicsUtil.drawBoxedString(gLabelRender, token.getLabel(), width / 2, height - (labelHeight / 2), SwingUtilities.CENTER, background, foreground); } GraphicsUtil.drawBoxedString(gLabelRender, name, width / 2, labelHeight / 2, SwingUtilities.CENTER, background, foreground); // Add image to cache labelRenderingCache.put(tokId, labelRender); } // Create LabelRenderer using cached label. Rectangle r = bounds.getBounds(); delayRendering(new LabelRenderer(name, r.x + r.width / 2, r.y + r.height + offset, SwingUtilities.CENTER, background, foreground, tokId)); } } timer.stop("tokenlist-12"); timer.start("tokenlist-13"); // Stacks if (!tokenList.isEmpty() && !tokenList.get(0).isStamp()) { // TODO: find a cleaner way to indicate token layer if (tokenStackMap != null) { // FIXME Needed to prevent NPE but how can it be null? for (Token token : tokenStackMap.keySet()) { Area bounds = getTokenBounds(token); if (bounds == null) { // token is offscreen continue; } BufferedImage stackImage = AppStyle.stackImage; clippedG.drawImage(stackImage, bounds.getBounds().x + bounds.getBounds().width - stackImage.getWidth() + 2, bounds.getBounds().y - 2, null); } } } // Markers // for (TokenLocation location : getMarkerLocations()) { // BufferedImage stackImage = AppStyle.markerImage; // g.drawImage(stackImage, location.bounds.getBounds().x, location.bounds.getBounds().y, null); // } if (clippedG != g) { clippedG.dispose(); } timer.stop("tokenlist-13"); visibleTokenSet = Collections.unmodifiableSet(tempVisTokens); } private boolean canSeeMarker(Token token) { return TabletopTool.getPlayer().isGM() || !StringUtil.isEmpty(token.getNotes()); } public Set<GUID> getSelectedTokenSet() { return selectedTokenSet; } /** * Convenience method to return a set of tokens filtered by ownership. * * @param tokenSet * the set of GUIDs to filter */ public Set<GUID> getOwnedTokens(Set<GUID> tokenSet) { Set<GUID> ownedTokens = new LinkedHashSet<GUID>(); if (tokenSet != null) { for (GUID guid : tokenSet) { if (!AppUtil.playerOwns(zone.getToken(guid))) { continue; } ownedTokens.add(guid); } } return ownedTokens; } /** * A convienence method to get selected tokens ordered by name * * @return List<Token> */ public List<Token> getSelectedTokensList() { List<Token> tokenList = new ArrayList<Token>(); for (GUID g : selectedTokenSet) { if (zone.getToken(g) != null) { tokenList.add(zone.getToken(g)); } } // Commented out to preserve selection order // Collections.sort(tokenList, Token.NAME_COMPARATOR); return tokenList; } /** * A convenience method to get selected tokens * that are also owned by the current player * @return List<Token> */ public List<Token> getSelectedOwnedTokensList() { List<Token> tokenList = new ArrayList<Token>(); for (GUID g : selectedTokenSet) { if (zone.getToken(g) != null && zone.getToken(g).isOwner(TabletopTool.getPlayer().getName())) { tokenList.add(zone.getToken(g)); } } // Commented out to preserve selection order // Collections.sort(tokenList, Token.NAME_COMPARATOR); return tokenList; } public boolean isTokenSelectable(GUID tokenGUID) { if (tokenGUID == null) { return false; } Token token = zone.getToken(tokenGUID); if (token == null) { return false; } if (!zone.isTokenVisible(token)) { if (AppUtil.playerOwns(token)) { return true; } return false; } return true; } public void deselectToken(GUID tokenGUID) { addToSelectionHistory(selectedTokenSet); selectedTokenSet.remove(tokenGUID); TabletopTool.getFrame().resetTokenPanels(); // flushFog = true; // could call flushFog() but also clears visibleScreenArea and I don't know if we want that... repaint(); } public boolean selectToken(GUID tokenGUID) { if (!isTokenSelectable(tokenGUID)) { return false; } addToSelectionHistory(selectedTokenSet); selectedTokenSet.add(tokenGUID); TabletopTool.getFrame().resetTokenPanels(); // flushFog = true; repaint(); return true; } public void selectTokens(Collection<GUID> tokens) { for (GUID tokenGUID : tokens) { if (!isTokenSelectable(tokenGUID)) { continue; } selectedTokenSet.add(tokenGUID); } addToSelectionHistory(selectedTokenSet); repaint(); TabletopTool.getFrame().resetTokenPanels(); } /** * Screen space rectangle */ public void selectTokens(Rectangle rect) { List<GUID> selectedList = new LinkedList<GUID>(); for (TokenLocation location : getTokenLocations(getActiveLayer())) { if (rect.intersects(location.bounds.getBounds())) { selectedList.add(location.token.getId()); } } selectTokens(selectedList); } public void clearSelectedTokens() { addToSelectionHistory(selectedTokenSet); clearShowPaths(); selectedTokenSet.clear(); TabletopTool.getFrame().resetTokenPanels(); repaint(); } public void undoSelectToken() { // System.out.println("num history items: " + selectedTokenSetHistory.size()); // for (Set<GUID> set : selectedTokenSetHistory) { // System.out.println("history item"); // for (GUID guid : set) { // System.out.println(zone.getToken(guid).getName()); // } // } if (selectedTokenSetHistory.size() > 0) { selectedTokenSet = selectedTokenSetHistory.remove(0); // user may have deleted some of the tokens that are contained in the selection history. // find them and filter them otherwise the selectionSet will have orphaned GUIDs and // they will cause NPE Set<GUID> invalidTokenSet = new HashSet<GUID>(); for (GUID guid : selectedTokenSet) { if (zone.getToken(guid) == null) { invalidTokenSet.add(guid); } } selectedTokenSet.removeAll(invalidTokenSet); // if there is no token left in the set, undo again if (selectedTokenSet.size() == 0) { undoSelectToken(); } } // TODO: if selection history is empty, notify the selection panel to // disable the undo button. TabletopTool.getFrame().resetTokenPanels(); repaint(); } private void addToSelectionHistory(Set<GUID> selectionSet) { // don't add empty selections to history if (selectionSet.size() == 0) { return; } Set<GUID> history = new HashSet<GUID>(selectionSet); selectedTokenSetHistory.add(0, history); // limit the history to a certain size if (selectedTokenSetHistory.size() > 20) { selectedTokenSetHistory.subList(20, selectedTokenSetHistory.size() - 1).clear(); } } public void cycleSelectedToken(int direction) { List<Token> visibleTokens = getTokensOnScreen(); Set<GUID> selectedTokenSet = getSelectedTokenSet(); Integer newSelection = null; if (visibleTokens.size() == 0) { return; } if (selectedTokenSet.size() == 0) { newSelection = 0; } else { // Find the first selected token on the screen for (int i = 0; i < visibleTokens.size(); i++) { Token token = visibleTokens.get(i); if (!isTokenSelectable(token.getId())) { continue; } if (getSelectedTokenSet().contains(token.getId())) { newSelection = i; break; } } // Pick the next newSelection += direction; } if (newSelection < 0) { newSelection = visibleTokens.size() - 1; } if (newSelection >= visibleTokens.size()) { newSelection = 0; } // Make the selection clearSelectedTokens(); selectToken(visibleTokens.get(newSelection).getId()); } /** * Convenience function to check if a player owns all the tokens in the * selection set * * @return true if every token in selectedTokenSet is owned by the player */ public boolean playerOwnsAllSelected() { if (selectedTokenSet.isEmpty()) { return false; } for (GUID tokenGUID : selectedTokenSet) { if (!AppUtil.playerOwns(zone.getToken(tokenGUID))) { return false; } } return true; } public Area getTokenBounds(Token token) { TokenLocation location = tokenLocationCache.get(token); if (location != null && !location.maybeOnscreen(new Rectangle(0, 0, getSize().width, getSize().height))) { location = null; } return location != null ? location.bounds : null; } public Area getMarkerBounds(Token token) { for (TokenLocation location : markerLocationList) { if (location.token == token) { return location.bounds; } } return null; } public Rectangle getLabelBounds(Label label) { for (LabelLocation location : labelLocationList) { if (location.label == label) { return location.bounds; } } return null; } /** * Returns the token at screen location x, y (not cell location). * <p> * TODO: Add a check so that tokens owned by the current player are given * priority. * * @param x * @param y * @return */ public Token getTokenAt(int x, int y) { List<TokenLocation> locationList = new ArrayList<TokenLocation>(); locationList.addAll(getTokenLocations(getActiveLayer())); Collections.reverse(locationList); for (TokenLocation location : locationList) { if (location.bounds.contains(x, y)) { return location.token; } } return null; } public Token getMarkerAt(int x, int y) { List<TokenLocation> locationList = new ArrayList<TokenLocation>(); locationList.addAll(markerLocationList); Collections.reverse(locationList); for (TokenLocation location : locationList) { if (location.bounds.contains(x, y)) { return location.token; } } return null; } public List<Token> getTokenStackAt(int x, int y) { Token token = getTokenAt(x, y); if (token == null || tokenStackMap == null || !tokenStackMap.containsKey(token)) { return null; } List<Token> tokenList = new ArrayList<Token>(tokenStackMap.get(token)); Collections.sort(tokenList, Token.COMPARE_BY_NAME); return tokenList; } /** * Returns the label at screen location x, y (not cell location). To get the * token at a cell location, use getGameMap() and use that. * * @param x * @param y * @return */ public Label getLabelAt(int x, int y) { List<LabelLocation> labelList = new ArrayList<LabelLocation>(); labelList.addAll(labelLocationList); Collections.reverse(labelList); for (LabelLocation location : labelList) { if (location.bounds.contains(x, y)) { return location.label; } } return null; } public int getViewOffsetX() { return zoneScale.getOffsetX(); } public int getViewOffsetY() { return zoneScale.getOffsetY(); } public void adjustGridSize(int delta) { zone.getGrid().setSize(Math.max(0, zone.getGrid().getSize() + delta)); repaint(); } public void moveGridBy(int dx, int dy) { int gridOffsetX = zone.getGrid().getOffsetX(); int gridOffsetY = zone.getGrid().getOffsetY(); gridOffsetX += dx; gridOffsetY += dy; if (gridOffsetY > 0) { gridOffsetY = gridOffsetY - (int) zone.getGrid().getCellHeight(); } if (gridOffsetX > 0) { gridOffsetX = gridOffsetX - (int) zone.getGrid().getCellWidth(); } zone.getGrid().setOffset(gridOffsetX, gridOffsetY); repaint(); } /** * Since the map can be scaled, this is a convenience method to find out * what cell is at this location. * * @param screenPoint * Find the cell for this point. * @return The cell coordinates of the passed screen point. */ public CellPoint getCellAt(ScreenPoint screenPoint) { ZonePoint zp = screenPoint.convertToZone(this); return zone.getGrid().convert(zp); } public void setScale(double scale) { if (zoneScale.getScale() != scale) { /* * MCL: I think it is correct to clear these caches (if not more). */ tokenLocationCache.clear(); invalidateCurrentViewCache(); zoneScale.zoomScale(getWidth() / 2, getHeight() / 2, scale); TabletopTool.getFrame().getZoomStatusBar().update(); } } public double getScale() { return zoneScale.getScale(); } public double getScaledGridSize() { // Optimize: only need to calc this when grid size or scale changes return getScale() * zone.getGrid().getSize(); } /** * This makes sure that any image updates get refreshed. This could be a * little smarter. */ @Override public boolean imageUpdate(Image img, int infoflags, int x, int y, int w, int h) { repaint(); return super.imageUpdate(img, infoflags, x, y, w, h); } private interface ItemRenderer { public void render(Graphics2D g); } /** * Represents a delayed label render */ private class LabelRenderer implements ItemRenderer { private final String text; private int x; private final int y; private final int align; private final Color foreground; private final ImageLabel background; public LabelRenderer(String text, int x, int y) { this.text = text; this.x = x; this.y = y; // Defaults this.align = SwingUtilities.CENTER; this.background = GraphicsUtil.GREY_LABEL; this.foreground = Color.black; } @SuppressWarnings("unused") public LabelRenderer(String text, int x, int y, int align, ImageLabel background, Color foreground) { this(text, x, y, align, background, foreground, null); } public LabelRenderer(String text, int x, int y, int align, ImageLabel background, Color foreground, GUID tId) { this.text = text; this.x = x; this.y = y; this.align = align; this.foreground = foreground; this.background = background; } @Override public void render(Graphics2D g) { GraphicsUtil.drawBoxedString(g, text, x, y, align, background, foreground); } } /** * Represents a movement set */ private class SelectionSet { private final HashSet<GUID> selectionSet = new HashSet<GUID>(); private final GUID keyToken; private final String playerId; private ZoneWalker walker; private final Token token; private Path<ZonePoint> gridlessPath; // Pixel distance from keyToken's origin private int offsetX; private int offsetY; public SelectionSet(String playerId, GUID tokenGUID, Set<GUID> selectionList) { selectionSet.addAll(selectionList); keyToken = tokenGUID; this.playerId = playerId; token = zone.getToken(tokenGUID); if (token.isSnapToGrid() && zone.getGrid().getCapabilities().isSnapToGridSupported()) { if (zone.getGrid().getCapabilities().isPathingSupported()) { CellPoint tokenPoint = zone.getGrid().convert(new ZonePoint(token.getX(), token.getY())); walker = zone.getGrid().createZoneWalker(); walker.setWaypoints(tokenPoint, tokenPoint); } } else { gridlessPath = new Path<ZonePoint>(); gridlessPath.addPathCell(new ZonePoint(token.getX(), token.getY())); } } public ZoneWalker getWalker() { return walker; } public GUID getKeyToken() { return keyToken; } public Set<GUID> getTokens() { return selectionSet; } public boolean contains(Token token) { return selectionSet.contains(token.getId()); } public void setOffset(int x, int y) { offsetX = x; offsetY = y; ZonePoint zp = new ZonePoint(token.getX() + x, token.getY() + y); if (ZoneRenderer.this.zone.getGrid().getCapabilities().isPathingSupported() && token.isSnapToGrid()) { CellPoint point = zone.getGrid().convert(zp); walker.replaceLastWaypoint(point); } else { if (gridlessPath.getCellPath().size() > 1) { gridlessPath.replaceLastPoint(zp); } else { gridlessPath.addPathCell(zp); } } } /** * Add the waypoint if it is a new waypoint. If it is an old waypoint * remove it. * * @param location * The point where the waypoint is toggled. */ public void toggleWaypoint(ZonePoint location) { // CellPoint cp = renderer.getZone().getGrid().convert(new ZonePoint(dragStartX, dragStartY)); if (walker != null && token.isSnapToGrid() && getZone().getGrid() != null) { walker.toggleWaypoint(getZone().getGrid().convert(location)); } else { gridlessPath.addWayPoint(location); gridlessPath.addPathCell(location); } } /** * Retrieves the last waypoint, or if there isn't one then the start * point of the first path segment. * * @param location */ public ZonePoint getLastWaypoint() { ZonePoint zp; if (walker != null && token.isSnapToGrid() && getZone().getGrid() != null) { CellPoint cp = walker.getLastPoint(); zp = getZone().getGrid().convert(cp); } else { zp = gridlessPath.getLastJunctionPoint(); } return zp; } public int getOffsetX() { return offsetX; } public int getOffsetY() { return offsetY; } public String getPlayerId() { return playerId; } } private class TokenLocation { public Area bounds; public Token token; public Rectangle boundsCache; public double scaledHeight; public double scaledWidth; public double x; public double y; public int offsetX; public int offsetY; /** * Construct a TokenLocation object that caches where images are stored * and what their size is so that the next rendering pass can use that * information to optimize the drawing. * * @param bounds * @param origBounds * (unused) * @param token * @param x * @param y * @param width * (unused) * @param height * (unused) * @param scaledWidth * @param scaledHeight */ public TokenLocation(Area bounds, Rectangle2D origBounds, Token token, double x, double y, int width, int height, double scaledWidth, double scaledHeight) { this.bounds = bounds; this.token = token; this.scaledWidth = scaledWidth; this.scaledHeight = scaledHeight; this.x = x; this.y = y; offsetX = getViewOffsetX(); offsetY = getViewOffsetY(); boundsCache = bounds.getBounds(); } public boolean maybeOnscreen(Rectangle viewport) { int deltaX = getViewOffsetX() - offsetX; int deltaY = getViewOffsetY() - offsetY; boundsCache.x += deltaX; boundsCache.y += deltaY; offsetX = getViewOffsetX(); offsetY = getViewOffsetY(); timer.start("maybeOnsceen"); if (!boundsCache.intersects(viewport)) { timer.stop("maybeOnsceen"); return false; } timer.stop("maybeOnsceen"); return true; } } private static class LabelLocation { public Rectangle bounds; public Label label; public LabelLocation(Rectangle bounds, Label label) { this.bounds = bounds; this.label = label; } } // // DROP TARGET LISTENER /* * (non-Javadoc) * * @see * java.awt.dnd.DropTargetListener#dragEnter(java.awt.dnd.DropTargetDragEvent * ) */ @Override public void dragEnter(DropTargetDragEvent dtde) { } /* * (non-Javadoc) * * @see * java.awt.dnd.DropTargetListener#dragExit(java.awt.dnd.DropTargetEvent) */ @Override public void dragExit(DropTargetEvent dte) { } /* * (non-Javadoc) * * @see java.awt.dnd.DropTargetListener#dragOver * (java.awt.dnd.DropTargetDragEvent) */ @Override public void dragOver(DropTargetDragEvent dtde) { } private void addTokens(List<Token> tokens, ZonePoint zp, List<Boolean> configureTokens, boolean showDialog) { GridCapabilities gridCaps = zone.getGrid().getCapabilities(); boolean isGM = TabletopTool.getPlayer().isGM(); List<String> failedPaste = new ArrayList<String>(tokens.size()); List<GUID> selectThese = new ArrayList<GUID>(tokens.size()); ScreenPoint sp = ScreenPoint.fromZonePoint(this, zp); Point dropPoint = new Point((int) sp.x, (int) sp.y); SwingUtilities.convertPointToScreen(dropPoint, this); int tokenIndex = 0; for (Token token : tokens) { boolean configureToken = configureTokens.get(tokenIndex++); // Get the snap to grid value for the current prefs and abilities token.setSnapToGrid(gridCaps.isSnapToGridSupported() && AppPreferences.getTokensStartSnapToGrid()); if (token.isSnapToGrid()) { zp = zone.getGrid().convert(zone.getGrid().convert(zp)); } token.setX(zp.x); token.setY(zp.y); // Set the image properties if (configureToken) { BufferedImage image = ImageManager.getImageAndWait(token.getImageAssetId()); token.setShape(TokenUtil.guessTokenType(image)); token.setWidth(image.getWidth(null)); token.setHeight(image.getHeight(null)); token.setFootprint(zone.getGrid(), zone.getGrid().getDefaultFootprint()); } // Always set the layer token.setLayer(getActiveLayer()); // He who drops, owns, if there are not players already set // and if there are already players set, add the current one to the list. // (Cannot use AppUtil.playerOwns() since that checks 'isStrictTokenManagement' and we want real ownership here. if (!isGM && (!token.hasOwners() || !token.isOwner(TabletopTool.getPlayer().getName()))) { token.addOwner(TabletopTool.getPlayer().getName()); } // Token type Rectangle size = token.getBounds(zone); switch (getActiveLayer()) { case TOKEN: // Players can't drop invisible tokens token.setVisible(!isGM || AppPreferences.getNewTokensVisible()); if (AppPreferences.getTokensStartFreesize()) { token.setSnapToScale(false); } break; case BACKGROUND: token.setShape(Token.TokenShape.TOP_DOWN); token.setSnapToScale(!AppPreferences.getBackgroundsStartFreesize()); token.setSnapToGrid(AppPreferences.getBackgroundsStartSnapToGrid()); token.setVisible(AppPreferences.getNewBackgroundsVisible()); // Center on drop point if (!token.isSnapToScale() && !token.isSnapToGrid()) { token.setX(token.getX() - size.width / 2); token.setY(token.getY() - size.height / 2); } break; case OBJECT: token.setShape(Token.TokenShape.TOP_DOWN); token.setSnapToScale(!AppPreferences.getObjectsStartFreesize()); token.setSnapToGrid(AppPreferences.getObjectsStartSnapToGrid()); token.setVisible(AppPreferences.getNewObjectsVisible()); // Center on drop point if (!token.isSnapToScale() && !token.isSnapToGrid()) { token.setX(token.getX() - size.width / 2); token.setY(token.getY() - size.height / 2); } break; } // FJE Yes, this looks redundant. But calling getType() retrieves the type of // the Token and returns NPC if the type can't be determined (raw image, // corrupted token file, etc). So retrieving it and then turning around and // setting it ensures it has a valid value without necessarily changing what // it was. :) Token.Type type = token.getType(); token.setType(type); // Token type if (isGM) { // Check the name (after Token layer is set as name relies on layer) Token tokenNameUsed = zone.getTokenByName(token.getName()); token.setName(T3Util.nextTokenId(zone, token, tokenNameUsed != null)); if (getActiveLayer() == Zone.Layer.TOKEN) { if (AppPreferences.getShowDialogOnNewToken() || showDialog) { NewTokenDialog dialog = new NewTokenDialog(token, dropPoint.x, dropPoint.y); dialog.showDialog(); if (!dialog.isSuccess()) { continue; } } } } else { // Player dropped, ensure it's a PC token // (Why? Couldn't a Player drop an RPTOK that represents an NPC, such as for a summoned monster? // Unfortunately, we can't know at this point whether the original input was an RPTOK or not.) token.setType(Token.Type.PC); // For Players, check to see if the name is already in use. If it is already in use, make sure the current Player // owns the token being duplicated (to avoid subtle ways of manipulating someone else's token!). Token tokenNameUsed = zone.getTokenByName(token.getName()); if (tokenNameUsed != null) { if (!AppUtil.playerOwns(tokenNameUsed)) { failedPaste.add(token.getName()); continue; } String newName = T3Util.nextTokenId(zone, token, tokenNameUsed != null); token.setName(newName); } } // Make sure all the assets are transfered for (MD5Key id : token.getAllImageAssets()) { Asset asset = AssetManager.getAsset(id); if (asset == null) { log.error("Could not find image for asset: " + id); continue; } T3Util.uploadAsset(asset); } // Save the token and tell everybody about it zone.putToken(token); TabletopTool.serverCommand().putToken(zone.getId(), token); selectThese.add(token.getId()); } // For convenience, select them clearSelectedTokens(); selectTokens(selectThese); if (!isGM) TabletopTool.addMessage(TextMessage.gm("Tokens dropped onto map '" + zone.getName() + "' by player " + TabletopTool.getPlayer())); if (!failedPaste.isEmpty()) { String mesg = "Failed to paste token(s) with duplicate name(s): " + failedPaste; TextMessage msg = TextMessage.gm(mesg); TabletopTool.addMessage(msg); // msg.setChannel(Channel.ME); // TabletopTool.addMessage(msg); } // Copy them to the clipboard so that we can quickly copy them onto the map AppActions.copyTokens(tokens); requestFocusInWindow(); repaint(); } /* * (non-Javadoc) * * @see java.awt.dnd.DropTargetListener#drop * (java.awt.dnd.DropTargetDropEvent) */ @Override public void drop(DropTargetDropEvent dtde) { ZonePoint zp = new ScreenPoint((int) dtde.getLocation().getX(), (int) dtde.getLocation().getY()).convertToZone(this); TransferableHelper th = (TransferableHelper) getTransferHandler(); List<Token> tokens = th.getTokens(); if (tokens != null && !tokens.isEmpty()) addTokens(tokens, zp, th.getConfigureTokens(), false); } public Set<GUID> getVisibleTokenSet() { return visibleTokenSet; } /* * (non-Javadoc) * * @see java.awt.dnd.DropTargetListener#dropActionChanged * (java.awt.dnd.DropTargetDragEvent) */ @Override public void dropActionChanged(DropTargetDragEvent dtde) { } // // ZONE MODEL CHANGE LISTENER private class ZoneModelChangeListener implements ModelChangeListener { @Override public void modelChanged(ModelChangeEvent event) { Object evt = event.getEvent(); if (evt == Zone.Event.TOPOLOGY_CHANGED) { flushFog(); flushLight(); } if (evt == Zone.Event.TOKEN_CHANGED || evt == Zone.Event.TOKEN_REMOVED || evt == Zone.Event.TOKEN_ADDED) { if (event.getArg() instanceof List<?>) { @SuppressWarnings("unchecked") List<Token> list = (List<Token>) (event.getArg()); for (Token token : list) { flush(token); } } else { flush((Token) event.getArg()); } } if (evt == Zone.Event.FOG_CHANGED) { flushFog = true; } TabletopTool.getFrame().updateTokenTree(); repaint(); } } // // COMPARABLE @Override public int compareTo(ZoneRenderer o) { if (o != this) { return (int) (zone.getCreationTime() - o.zone.getCreationTime()); } return 0; } // Begin token common macro identification private List<Token> highlightCommonMacros = new ArrayList<Token>(); public List<Token> getHighlightCommonMacros() { return highlightCommonMacros; } public void setHighlightCommonMacros(List<Token> affectedTokens) { highlightCommonMacros = affectedTokens; repaint(); } // End token common macro identification // // IMAGE OBSERVER // private final ImageObserver drawableObserver = new ImageObserver() { // public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) { // ZoneRenderer.this.flushDrawableRenderer(); // TabletopTool.getFrame().refresh(); // return true; // } // }; /** * Our goal with this method (which overrides the parent's method) is to * create a custom mouse pointer that represents a group of tokens selected * on the map. The idea is to provide some feedback to the user that they * have more than one token selected at the current time. * <p> * Unfortunately, while our custom cursor appears to be created correctly, * it is never properly applied as the mouse pointer so there is no visual * effect. Hence it's currently commented out by using an "if (false)" * around the code block. * * @see java.awt.Component#setCursor(java.awt.Cursor) */ @SuppressWarnings("unused") @Override public void setCursor(Cursor cursor) { // System.out.println("Setting cursor on ZoneRenderer: " + cursor.toString()); if (false && cursor == Cursor.getDefaultCursor()) { // if (custom == null) custom = createCustomCursor("image/cursor.png", "Group"); cursor = custom; } super.setCursor(cursor); } private Cursor custom = null; public Cursor createCustomCursor(String resource, String tokenName) { Cursor c = null; try { // Dimension d = Toolkit.getDefaultToolkit().getBestCursorSize(16, 16); // On OSX returns any size up to 1/2 of (screen width, screen height) // System.out.println("Best cursor size: " + d); BufferedImage img = ImageIO.read(TabletopTool.class.getResourceAsStream(resource)); Font font = AppStyle.labelFont; Graphics2D z = (Graphics2D) this.getGraphics(); z.setFont(font); FontRenderContext frc = z.getFontRenderContext(); TextLayout tl = new TextLayout(tokenName, font, frc); Rectangle textbox = tl.getPixelBounds(null, 0, 0); // Now create a larger BufferedImage that will hold both the existing cursor and a token name // Use the larger of the image width or string width, and the height of the image + the height of the string // to represent the bounding box of the 'arrow+tokenName' Rectangle bounds = new Rectangle(Math.max(img.getWidth(), textbox.width), img.getHeight() + textbox.height); BufferedImage cursor = new BufferedImage(bounds.width, bounds.height, Transparency.TRANSLUCENT); Graphics2D g2d = cursor.createGraphics(); g2d.setFont(font); g2d.setComposite(z.getComposite()); g2d.setStroke(z.getStroke()); g2d.setPaintMode(); z.dispose(); Object oldAA = SwingUtil.useAntiAliasing(g2d); // g2d.setTransform( ((Graphics2D)this.getGraphics()).getTransform() ); // g2d.drawImage(img, null, 0, 0); g2d.drawImage(img, new AffineTransform(1f, 0f, 0f, 1f, 0, 0), null); // Draw the arrow at 1:1 resolution g2d.translate(0, img.getHeight() + textbox.height / 2); // g2d.transform(new AffineTransform(0.5f, 0f, 0f, 0.5f, 0, 0)); // Why do I need this to scale down the text?? g2d.setColor(Color.BLACK); GraphicsUtil.drawBoxedString(g2d, tokenName, 0, 0, SwingUtilities.LEFT); // The text draw here is not nearly as nice looking as normal // g2d.setBackground(Color.BLACK); // g2d.setColor(Color.WHITE); // g2d.fillRect(0, bounds.height-textbox.height, textbox.width, textbox.height); // g2d.drawString(tokenName, 0F, bounds.height - descent); g2d.dispose(); c = Toolkit.getDefaultToolkit().createCustomCursor(cursor, new Point(0, 0), tokenName); SwingUtil.restoreAntiAliasing(g2d, oldAA); img.flush(); // Try to be friendly about memory usage. ;-) cursor.flush(); } catch (Exception e) { } return c; } @Override public GUID getId() { return this.getZone().getId(); } }