diff --git a/src/main/java/net/rptools/lib/MathUtil.java b/src/main/java/net/rptools/lib/MathUtil.java new file mode 100644 index 0000000000..08940ab2a3 --- /dev/null +++ b/src/main/java/net/rptools/lib/MathUtil.java @@ -0,0 +1,150 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.lib; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/* Utility class for useful mathematical methods */ +public class MathUtil { + private static final Logger log = LogManager.getLogger(MathUtil.class); + + /** + * Faster version of absolute for integers + * + * @param val + * @return absolute value of val + */ + public static int abs(int val) { + return (val >> 31 ^ val) - (val >> 31); + } + + /** + * Faster version of absolute + * + * @param val + * @return absolute value of val + */ + public static T abs(T val) { + return (T) (val.floatValue() < 0 ? -1 * val.doubleValue() : val); + } + + /** + * Returns a truncated double with the specified number of decimal places + * + * @param value to be truncated + * @param decimalPlaces number of decimal places to use + * @return truncated double value + */ + public static double doublePrecision(double value, int decimalPlaces) { + double d = Double.parseDouble(String.format("%." + decimalPlaces + "f", value)); + log.debug("value: " + value + ", decimalPlaces: " + decimalPlaces + " -> " + d); + return d; + } + + public static boolean isDouble(Object o) { + return o.getClass().isAssignableFrom(Double.class); + } + + public static boolean isFloat(Object o) { + return o.getClass().isAssignableFrom(Float.class); + } + + public static boolean isInt(Object o) { + return o.getClass().isAssignableFrom(Integer.class); + } + + public static boolean isNumber(Object o) { + return o.getClass().isAssignableFrom(Number.class); + } + + /** + * Checks that a value lies within a specified tolerance. Useful for checking if a value is "close + * enough" + * + * @param checkValue to be checked + * @param referenceValue to be checked against + * @param tolerance variance allowed + * @return true if the value is within ± tolerance + */ + public static boolean inTolerance(double checkValue, double referenceValue, double tolerance) { + return checkValue <= referenceValue + tolerance && checkValue >= referenceValue - tolerance; + } + + /** + * Uses Generics Maps a value in one range to its equivalent in a second range + * + * @param valueToMap value in the first range that needs to be converted + * @param in_min the minimum value for the original range + * @param in_max the maximum value for the original range + * @param out_min the minimum value for the target range + * @param out_max the maximum value for the target range + * @return the equivalent value of valueToMap in the target range + * @param + */ + public static T mapToRange( + T valueToMap, T in_min, T in_max, T out_min, T out_max) { + Number mapValue = (Number) valueToMap; + Number inMin = (Number) in_min; + Number inMax = (Number) in_max; + Number outMin = (Number) out_min; + Number outMax = (Number) out_max; + Number result; + if (isFloat(valueToMap)) { + result = + (Number) + ((mapValue.floatValue() - inMin.floatValue()) + * (outMax.floatValue() - outMin.floatValue()) + / (inMax.floatValue() - inMin.floatValue()) + + outMin.floatValue()); + } else { + result = + (Number) + ((mapValue.doubleValue() - inMin.doubleValue()) + * (outMax.doubleValue() - outMin.doubleValue()) + / (inMax.doubleValue() - inMin.doubleValue()) + + outMin.doubleValue()); + } + return (T) result; + } + + /** + * Constrains an integer between an upper and lower limit + * + * @param value + * @param lowBound + * @param highBound + * @return + */ + public static int constrainInt(int value, int lowBound, int highBound) { + return Math.max(lowBound, Math.min(highBound, value)); + } + + /** + * Constrains a Number between an upper and lower limit + * + * @param value + * @param lowBound + * @param highBound + * @return + * @param + */ + public static T constrainNumber(T value, T lowBound, T highBound) { + return (T) + (Number) + Math.max( + lowBound.doubleValue(), Math.min(highBound.doubleValue(), value.doubleValue())); + } +} diff --git a/src/main/java/net/rptools/lib/image/ImageUtil.java b/src/main/java/net/rptools/lib/image/ImageUtil.java index afce6c607d..e55b7e3dd1 100644 --- a/src/main/java/net/rptools/lib/image/ImageUtil.java +++ b/src/main/java/net/rptools/lib/image/ImageUtil.java @@ -15,30 +15,25 @@ package net.rptools.lib.image; import com.twelvemonkeys.image.ResampleOp; -import java.awt.AlphaComposite; -import java.awt.Color; -import java.awt.Composite; -import java.awt.Graphics2D; -import java.awt.Image; -import java.awt.MediaTracker; -import java.awt.Transparency; +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.awt.image.ColorModel; import java.awt.image.ImageObserver; import java.awt.image.PixelGrabber; -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.util.Arrays; import java.util.Map; import javax.imageio.ImageIO; -import javax.swing.ImageIcon; -import javax.swing.JPanel; +import javax.swing.*; +import net.rptools.lib.MathUtil; import net.rptools.maptool.client.AppPreferences; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.model.*; +import net.rptools.maptool.util.ImageManager; +import net.rptools.parser.ParserException; import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -47,64 +42,135 @@ * @author trevor */ public class ImageUtil { - private static final Logger log = LogManager.getLogger(); - public static final String HINT_TRANSPARENCY = "hintTransparency"; - - // TODO: perhaps look at reintroducing this later - // private static GraphicsConfiguration graphicsConfig = - // GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration(); - public static final FilenameFilter SUPPORTED_IMAGE_FILE_FILTER = (dir, name) -> { name = name.toLowerCase(); return Arrays.asList(ImageIO.getReaderFileSuffixes()).contains(name); }; - // public static void setGraphicsConfiguration(GraphicsConfiguration config) { - // graphicsConfig = config; - // } - // - /** - * Load the image. Does not create a graphics configuration compatible version. - * - * @param file the file with the image in it - * @throws IOException when the image can't be read in the file - * @return an {@link Image} from the content of the file - */ - public static Image getImage(File file) throws IOException { - return bytesToImage(FileUtils.readFileToByteArray(file), file.getCanonicalPath()); - } + private static final Logger log = LogManager.getLogger(); - /** - * Load the image in the classpath. Does not create a graphics configuration compatible version. - * - * @param image the resource name of the image file - * @throws IOException when the image can't be read in the file - * @return an {@link Image} from the content of the file - */ - public static Image getImage(String image) throws IOException { - ByteArrayOutputStream dataStream = new ByteArrayOutputStream(8192); + private static final JPanel observer = new JPanel(); + private static final int[][] outlineNeighborMap = { + {0, -1, 100}, // N + {1, 0, 100}, // E + {0, 1, 100}, // S + {-1, 0, 100} // W + , + {-1, -1}, // NW + {1, -1}, // NE + {-1, 1}, // SW + {1, 1}, // SE + }; + private static RenderingHints renderingHintsQuality; - int bite; - InputStream inStream = ImageUtil.class.getClassLoader().getResourceAsStream(image); - if (inStream == null) { - throw new IOException("Image not found: " + image); + public static BufferedImage replaceColor(BufferedImage src, int sourceRGB, int replaceRGB) { + for (int y = 0; y < src.getHeight(); y++) { + for (int x = 0; x < src.getWidth(); x++) { + int rawRGB = src.getRGB(x, y); + int rgb = rawRGB & 0xffffff; + int alpha = rawRGB & 0xff000000; + + if (rgb == sourceRGB) { + src.setRGB(x, y, alpha | replaceRGB); + } + } } - inStream = new BufferedInputStream(inStream); - while ((bite = inStream.read()) >= 0) { - dataStream.write(bite); + return src; + } + + public static BufferedImage negativeImage(BufferedImage originalImage) { + // Get the dimensions of the image + int width = originalImage.getWidth(); + int height = originalImage.getHeight(); + // Create a new BufferedImage for the negative image + BufferedImage negativeImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + // Loop through each pixel of the original image and convert it to its negative + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int negativeRGB = negativeColourInt(originalImage.getRGB(x, y)); + negativeImage.setRGB(x, y, negativeRGB); + } } - return bytesToImage(dataStream.toByteArray(), image); + return negativeImage; } - public static BufferedImage getCompatibleImage(String image) throws IOException { - return getCompatibleImage(image, null); + public static int negativeColourInt(int rgb) { + int r = 255 - ((rgb >> 16) & 0xFF); + int g = 255 - ((rgb >> 8) & 0xFF); + int b = 255 - (rgb & 0xFF); + return (r << 16) | (g << 8) | b; } - public static BufferedImage getCompatibleImage(String image, Map hints) - throws IOException { - return createCompatibleImage(getImage(image), hints); + /** + * Scales the provided image with the ZoneRenderer scale, the token footprint, and the token's + * layout scale factors. + * + * @param bi Image to scale + * @return Scaled bufferedImage + */ + public static BufferedImage getScaledTokenImage(BufferedImage bi, Token token, ZoneRenderer zr) { + Grid grid = zr.getZone().getGrid(); + double zoneS = zr.getScale(); + return getScaledTokenImage(bi, token, grid, zoneS); + } + + public static BufferedImage getScaledTokenImage( + BufferedImage img, Token token, Grid grid, double zoom) { + + double imgW = img.getWidth(); + double imgH = img.getHeight(); + if (token.isSnapToScale()) { + TokenFootprint footprint = token.getFootprint(grid); + Rectangle2D footprintBounds = footprint.getBounds(grid); + // except gridless, this should be 1 for footprints larger than the grid + double fpS = footprint.getScale(); + double fpW, fpH; + // size: multiply by zoom level to prevent multiple scaling ops which lose definition, i.e. + // scale once + if (GridFactory.getGridType(grid).equals(GridFactory.NONE)) { + fpW = fpH = grid.getSize() * fpS * zoom; // all gridless are relative to the grid size + } else { + fpW = footprintBounds.getWidth() * fpS * zoom; + fpH = footprintBounds.getHeight() * fpS * zoom; + } + double sXY = token.getSizeScale(); + double sX = token.getScaleX(); + double sY = token.getScaleY(); + // scale to fit image inside footprint bounds using the dimension that needs the most scaling, + // i.e. lowest ratio + double imageFootprintRatio; + if (token.getShape() == Token.TokenShape.FIGURE && grid.isIsometric()) { + // uses double footprint height + imageFootprintRatio = Math.min(fpW / imgW, fpH * 2 / imgH); + } else { + imageFootprintRatio = Math.min(fpW / imgW, fpH / imgH); + } + // combine with token scale properties + if (sX != 1 || sY != 1 || sXY != 1 || imageFootprintRatio != 1) { + int outputWidth = (int) Math.ceil(imgW * sXY * sX * imageFootprintRatio); + int outputHeight = (int) Math.ceil(imgH * sXY * sY * imageFootprintRatio); + token.setWidth(outputWidth); + token.setHeight(outputHeight); + try { + return ImageUtil.scaleBufferedImage(img, outputWidth, outputHeight); + } catch (Exception e) { + log.debug(e.getLocalizedMessage(), e); + return img; + } + } + } else { + Rectangle b = token.getBounds(grid.getZone()); + try { + return ImageUtil.scaleBufferedImage( + img, (int) Math.ceil(b.width * zoom), (int) Math.ceil(b.height * zoom)); + } catch (Exception e) { + log.debug(e.getLocalizedMessage(), e); + return img; + } + } + return img; // fallback, return original } /** @@ -124,10 +190,6 @@ public static BufferedImage createCompatibleImage(Image img, Map return createCompatibleImage(img, img.getWidth(null), img.getHeight(null), hints); } - public static BufferedImage createCompatibleImage(int width, int height, int transparency) { - return new BufferedImage(width, height, transparency); - } - /** * Create a copy of the image that is compatible with the current graphics context and scaled to * the supplied size @@ -247,6 +309,10 @@ public static int pickBestTransparency(BufferedImage image) { return foundTransparent ? Transparency.BITMASK : Transparency.OPAQUE; } + public static BufferedImage createCompatibleImage(int width, int height, int transparency) { + return new BufferedImage(width, height, transparency); + } + /** * Convert a BufferedImage to byte[] in the jpg format. * @@ -280,42 +346,6 @@ public static byte[] imageToBytes(BufferedImage image, String format) throws IOE return outStream.toByteArray(); } - private static final JPanel observer = new JPanel(); - - /** - * Converts a byte array into an {@link Image} instance. - * - * @param imageBytes bytes to convert - * @param imageName name of image - * @return the image - * @throws IOException if image could not be loaded - */ - public static Image bytesToImage(byte[] imageBytes, String imageName) throws IOException { - if (imageBytes == null) { - throw new IOException("Could not load image - no data provided"); - } - boolean interrupted = false; - Throwable exception = null; - Image image; - image = ImageIO.read(new ByteArrayInputStream(imageBytes)); - MediaTracker tracker = new MediaTracker(observer); - tracker.addImage(image, 0); - do { - try { - interrupted = false; - tracker.waitForID(0); // This is the only method that throws an exception - } catch (InterruptedException t) { - interrupted = true; - } catch (Throwable t) { - exception = t; - } - } while (interrupted); - if (image == null) { - throw new IOException("Could not load image " + imageName, exception); - } - return image; - } - public static void clearImage(BufferedImage image) { if (image == null) { return; @@ -335,18 +365,6 @@ public static void clearImage(BufferedImage image) { } } - private static final int[][] outlineNeighborMap = { - {0, -1, 100}, // N - {1, 0, 100}, // E - {0, 1, 100}, // S - {-1, 0, 100} // W - , - {-1, -1}, // NW - {1, -1}, // NE - {-1, 1}, // SW - {1, 1}, // SE - }; - public static BufferedImage createOutline(BufferedImage sourceImage, Color color) { if (sourceImage == null) { return null; @@ -389,6 +407,23 @@ public static BufferedImage createOutline(BufferedImage sourceImage, Color color return image; } + /** + * Returns the provided image flipped according to the token's flip-states + * + * @param image The image to be processed + * @param token The token containing the flip-states + * @return A modified image, or the original image if no processing performed. + */ + public static BufferedImage flipTokenImage(BufferedImage image, Token token) { + int direction = (token.isFlippedX() ? 1 : 0) + (token.isFlippedY() ? 2 : 0); + image = flipCartesian(image, direction); + if (token.isFlippedIso()) { + return IsometricGrid.isoImage(image); + } else { + return image; + } + } + /** * Flip the image and return a new image * @@ -396,13 +431,17 @@ public static BufferedImage createOutline(BufferedImage sourceImage, Color color * @param direction 0-nothing, 1-horizontal, 2-vertical, 3-both * @return flipped BufferedImage */ - public static BufferedImage flip(BufferedImage image, int direction) { - BufferedImage workImage = - new BufferedImage(image.getWidth(), image.getHeight(), image.getTransparency()); - + public static BufferedImage flipCartesian(BufferedImage image, int direction) { boolean flipHorizontal = (direction & 1) == 1; boolean flipVertical = (direction & 2) == 2; + if (!flipHorizontal && !flipVertical) { + return image; + } + + BufferedImage workImage = + new BufferedImage(image.getWidth(), image.getHeight(), image.getTransparency()); + int workW = image.getWidth() * (flipHorizontal ? -1 : 1); int workH = image.getHeight() * (flipVertical ? -1 : 1); int workX = flipHorizontal ? image.getWidth() : 0; @@ -415,21 +454,203 @@ public static BufferedImage flip(BufferedImage image, int direction) { return workImage; } - public static ImageIcon scaleImage(ImageIcon icon, int w, int h) { - int nw = icon.getIconWidth(); - int nh = icon.getIconHeight(); + /** + * Load the image. Does not create a graphics configuration compatible version. + * + * @param file the file with the image in it + * @return an {@link Image} from the content of the file + * @throws IOException when the image can't be read in the file + */ + public static Image getImage(File file) throws IOException { + return bytesToImage(FileUtils.readFileToByteArray(file), file.getCanonicalPath()); + } - if (icon.getIconWidth() > w) { - nw = w; - nh = (nw * icon.getIconHeight()) / icon.getIconWidth(); + /** + * Converts a byte array into an {@link Image} instance. + * + * @param imageBytes bytes to convert + * @param imageName name of image + * @return the image + * @throws IOException if image could not be loaded + */ + public static Image bytesToImage(byte[] imageBytes, String imageName) throws IOException { + if (imageBytes == null) { + throw new IOException("Could not load image - no data provided"); + } + boolean interrupted = false; + Throwable exception = null; + Image image; + image = ImageIO.read(new ByteArrayInputStream(imageBytes)); + MediaTracker tracker = new MediaTracker(observer); + tracker.addImage(image, 0); + do { + try { + interrupted = false; + tracker.waitForID(0); // This is the only method that throws an exception + } catch (InterruptedException t) { + interrupted = true; + } catch (Throwable t) { + exception = t; + } + } while (interrupted); + if (image == null) { + throw new IOException("Could not load image " + imageName, exception); } + return image; + } - if (nh > h) { - nh = h; - nw = (icon.getIconWidth() * nh) / icon.getIconHeight(); + public static BufferedImage getCompatibleImage(String image) throws IOException { + return getCompatibleImage(image, null); + } + + public static BufferedImage getCompatibleImage(String image, Map hints) + throws IOException { + return createCompatibleImage(getImage(image), hints); + } + + /** + * Load the image in the classpath. Does not create a graphics configuration compatible version. + * + * @param image the resource name of the image file + * @return an {@link Image} from the content of the file + * @throws IOException when the image can't be read in the file + */ + public static Image getImage(String image) throws IOException { + ByteArrayOutputStream dataStream = new ByteArrayOutputStream(8192); + + int bite; + InputStream inStream = ImageUtil.class.getClassLoader().getResourceAsStream(image); + if (inStream == null) { + throw new IOException("Image not found: " + image); + } + inStream = new BufferedInputStream(inStream); + while ((bite = inStream.read()) >= 0) { + dataStream.write(bite); + } + return bytesToImage(dataStream.toByteArray(), image); + } + + public static double getIsoFigureHeightOffset(Token token, Rectangle2D footprintBounds) { + if (token.getShape().equals(Token.TokenShape.FIGURE) && !token.isFlippedIso()) { + double imageFitRatio = getIsoFigureScaleFactor(token, footprintBounds); + double th = token.getHeight() * imageFitRatio; + return footprintBounds.getHeight() - th; } + return 0; + } - return new ImageIcon(icon.getImage().getScaledInstance(nw, nh, Image.SCALE_DEFAULT)); + /** + * Use width ratio unless height exceeds double footprint height + * + * @param token Token + * @param footprintBounds Rectangle + * @return double + */ + public static double getIsoFigureScaleFactor(Token token, Rectangle2D footprintBounds) { + return Math.min( + footprintBounds.getWidth() / token.getWidth(), + footprintBounds.getHeight() * 2 / token.getHeight()); + } + + /** + * Gets the token image; applies flipping, scaling, and image rotation, but not facing. + * + * @param token Token + * @param zr ZoneRenderer + * @return modified image + */ + public static BufferedImage getTokenRenderImage(Token token, ZoneRenderer zr) { + BufferedImage image = getTokenImage(token, zr); + + int flipDirection = (token.isFlippedX() ? 1 : 0) + (token.isFlippedY() ? 2 : 0); + image = flipCartesian(image, flipDirection); + if (token.isFlippedIso() && zr.getZone().getGrid().isIsometric()) { + image = flipIsometric(image, true); + } + + image = getScaledTokenImage(image, token, zr); + + if (token.getImageRotation() != 0) { + image = rotateImage(image, token.getImageRotation()); + } + return image; + } + + /** + * Checks to see if token has an image table and references that if the token has a facing + * otherwise uses basic image + * + * @param token the token to get the image from. + * @return BufferedImage + */ + public static BufferedImage getTokenImage(Token token, ZoneRenderer zr) { + BufferedImage image = null; + // Get the basic image + if (token.getHasImageTable() + && token.hasFacing() + && token.getImageTableName() != null + && zr.getZone().getGrid().isIsometric()) { + LookupTable lookupTable = + MapTool.getCampaign().getLookupTableMap().get(token.getImageTableName()); + if (lookupTable != null) { + try { + LookupTable.LookupEntry result = + lookupTable.getLookup(Integer.toString(token.getFacing())); + if (result != null) { + image = ImageManager.getImage(result.getImageId(), zr); + } + } catch (ParserException p) { + // do nothing + } + } + } + + if (image == null) { + // Adds zr as observer so we can repaint once the image is ready. Fixes #1700. + image = ImageManager.getImage(token.getImageAssetId(), zr); + } + return image; + } + + public static BufferedImage flipIsometric(BufferedImage image, boolean toRhombus) { + BufferedImage workImage; + boolean isSquished = + MathUtil.inTolerance(image.getHeight(), image.getWidth() / 2d, image.getHeight() * 0.05); + if (image.getWidth() != image.getHeight()) { + int maxDim = Math.max(image.getWidth(), image.getHeight()); + int w, h = 1; + if (toRhombus) { + // make it square and centred + w = h = maxDim; + } else { + if (!isSquished) { + w = maxDim; + h = (int) Math.ceil(maxDim / 2d); + } else { + w = image.getWidth(); + w = (int) Math.ceil(image.getWidth() / 2d); + } + } + workImage = new BufferedImage(w, h, image.getTransparency()); + Graphics2D wig = workImage.createGraphics(); + wig.drawImage( + image, + (workImage.getWidth() - image.getWidth()) / 2, + (workImage.getHeight() - image.getHeight()) / 2, + image.getWidth(), + image.getHeight(), + null); + wig.dispose(); + image = workImage; + } + if (toRhombus) { + image = rotateImage(image, 45); + image = scaleBufferedImage(image, image.getWidth(), image.getHeight() / 2); + } else { + image = scaleBufferedImage(image, image.getWidth(), image.getWidth()); + image = rotateImage(image, -45); + } + return image; } /** @@ -445,4 +666,60 @@ public static BufferedImage scaleBufferedImage(BufferedImage image, int width, i new ResampleOp(width, height, AppPreferences.renderQuality.get().getResampleOpFilter()); return resampleOp.filter(image, null); } + + public static ImageIcon scaleImageIcon(ImageIcon icon, int w, int h) { + int nw = icon.getIconWidth(); + int nh = icon.getIconHeight(); + + if (icon.getIconWidth() > w) { + nw = w; + nh = (nw * icon.getIconHeight()) / icon.getIconWidth(); + } + + if (nh > h) { + nh = h; + nw = (icon.getIconWidth() * nh) / icon.getIconHeight(); + } + + return new ImageIcon(icon.getImage().getScaledInstance(nw, nh, Image.SCALE_DEFAULT)); + } + + public static RenderingHints getRenderingHintsQuality() { + if (renderingHintsQuality == null) { + renderingHintsQuality = + new RenderingHints(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + renderingHintsQuality.put( + RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); + renderingHintsQuality.put( + RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + renderingHintsQuality.put( + RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + renderingHintsQuality.put(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE); + } + return renderingHintsQuality; + } + + public static BufferedImage rotateImage(BufferedImage img, double degrees) { + double rads = Math.toRadians(degrees); + double sin = Math.abs(Math.sin(rads)), cos = Math.abs(Math.cos(rads)); + int w = img.getWidth(); + int h = img.getHeight(); + int newWidth = (int) Math.floor(w * cos + h * sin); + int newHeight = (int) Math.floor(h * cos + w * sin); + + BufferedImage rotated = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = rotated.createGraphics(); + g2d.setRenderingHints(getRenderingHintsQuality()); + AffineTransform at = new AffineTransform(); + at.translate((newWidth - w) / 2.0, (newHeight - h) / 2.0); + + double x = w / 2.0; + double y = h / 2.0; + + at.rotate(rads, x, y); + g2d.setTransform(at); + g2d.drawImage(img, 0, 0, null); + g2d.dispose(); + return rotated; + } } diff --git a/src/main/java/net/rptools/maptool/client/functions/TokenImage.java b/src/main/java/net/rptools/maptool/client/functions/TokenImage.java index f9fe2186d2..7fbbe226bc 100644 --- a/src/main/java/net/rptools/maptool/client/functions/TokenImage.java +++ b/src/main/java/net/rptools/maptool/client/functions/TokenImage.java @@ -110,8 +110,8 @@ public Object childEvaluate( FunctionUtil.paramAsFloat(functionName, args, 0, true); token = FunctionUtil.getTokenFromParam(resolver, functionName, args, 1, 2); - MapTool.serverCommand().updateTokenProperty(token, Token.Update.setTokenOpacity, strOpacity); - return token.getTokenOpacity(); + MapTool.serverCommand().updateTokenProperty(token, Token.Update.setOpacity, strOpacity); + return token.getOpacity(); } if (functionName.equalsIgnoreCase("getTokenOpacity")) { @@ -121,7 +121,7 @@ public Object childEvaluate( FunctionUtil.checkNumberParam(functionName, args, 0, 2); token = FunctionUtil.getTokenFromParam(resolver, functionName, args, 0, 1); - return token.getTokenOpacity(); + return token.getOpacity(); } if (functionName.equalsIgnoreCase("setTokenImage")) { diff --git a/src/main/java/net/rptools/maptool/client/functions/TokenPropertyFunctions.java b/src/main/java/net/rptools/maptool/client/functions/TokenPropertyFunctions.java index 23ac082b60..bf712f3e46 100644 --- a/src/main/java/net/rptools/maptool/client/functions/TokenPropertyFunctions.java +++ b/src/main/java/net/rptools/maptool/client/functions/TokenPropertyFunctions.java @@ -114,6 +114,7 @@ private TokenPropertyFunctions() { "setNotes", "getTokenLayoutProps", "setTokenLayoutProps", + "setExtendedTokenLayoutProps", "setTokenSnapToGrid", "getAllowsURIAccess", "setAllowsURIAccess", @@ -929,24 +930,85 @@ public Object childEvaluate( */ if (functionName.equalsIgnoreCase("getTokenLayoutProps")) { FunctionUtil.checkNumberParam(functionName, parameters, 0, 3); - String delim = parameters.size() > 0 ? parameters.get(0).toString() : ","; + String delim = parameters.size() > 0 ? parameters.get(0).toString() : ";"; Token token = FunctionUtil.getTokenFromParam(resolver, functionName, parameters, 1, 2); - Double scale = token.getSizeScale(); - int xOffset = token.getAnchorX(); - int yOffset = token.getAnchorY(); + String scale = String.valueOf(token.getSizeScale()); + String xOffset = String.valueOf(token.getAnchorX()); + String yOffset = String.valueOf(token.getAnchorY()); + String rotation = String.valueOf(token.getImageRotation()); + String scaleX = String.valueOf(token.getScaleX()); + String scaleY = String.valueOf(token.getScaleY()); + String footprintScaleValue = + String.valueOf( + token.getFootprint(token.getZoneRenderer().getZone().getGrid()).getScale()); if ("json".equals(delim)) { JsonObject jarr = new JsonObject(); jarr.addProperty("scale", scale); jarr.addProperty("xOffset", xOffset); jarr.addProperty("yOffset", yOffset); + jarr.addProperty("scaleX", scaleX); + jarr.addProperty("rotation", rotation); + jarr.addProperty("scaleY", scaleY); + jarr.addProperty("footprintScale", footprintScaleValue); return jarr; } else { - return "scale=" + scale + delim + "xOffset=" + xOffset + delim + "yOffset=" + yOffset; + StringBuilder sb = new StringBuilder(); + sb.append("scale=" + scale + delim); + sb.append("xOffset=" + xOffset + delim); + sb.append("yOffset=" + yOffset + delim); + sb.append("rotation=" + rotation + delim); + sb.append("scaleX=" + scaleX + delim); + sb.append("scaleY=" + scaleY + delim); + sb.append("footprintScale=" + footprintScaleValue); + return sb.toString(); + } + } + /* + * setExtendedTokenLayoutProps(StrProp/JSON Object, token: currentToken(), mapName = current map) + */ + if (functionName.equalsIgnoreCase("setExtendedTokenLayoutProps")) { + FunctionUtil.checkNumberParam(functionName, parameters, 1, 3); + Token token = FunctionUtil.getTokenFromParam(resolver, functionName, parameters, 1, 2); + JsonObject json; + try { // try for json object + json = FunctionUtil.paramAsJsonObject("setExtendedTokenLayoutProps", parameters, 0); + } catch (ParserException pe) { + try { // try for strProp + json = + JSONMacroFunctions.getInstance() + .getJsonObjectFunctions() + .fromStrProp( + FunctionUtil.paramAsString( + "setExtendedTokenLayoutProps", parameters, 0, true), + ";"); + } catch (ParserException pe2) { + throw new ParserException( + I18N.getText( + "macro.function.input.illegalArgumentType", "unknown", "JSON Object/StrProp")); + } + } + if (json != null && json.isJsonObject()) { + JsonObject jobj = json.getAsJsonObject(); + for (String s : jobj.keySet()) { + switch (s.toLowerCase()) { + case "scale" -> token.setSizeScale(jobj.get(s).getAsDouble()); + case "xoffset" -> token.setAnchor(jobj.get(s).getAsInt(), token.getAnchorY()); + case "yoffset" -> token.setAnchor(token.getAnchorX(), jobj.get(s).getAsInt()); + case "scalex" -> token.setScaleX(jobj.get(s).getAsDouble()); + case "scaley" -> token.setScaleY(jobj.get(s).getAsDouble()); + case "rotation" -> token.setImageRotation(jobj.get(s).getAsDouble()); + default -> { + continue; + } + } + } + return true; + } else { + return false; } } - /* * setTokenLayoutProps(scale, xOffset, yOffset, token: currentToken(), mapName = current map) */ diff --git a/src/main/java/net/rptools/maptool/client/swing/SpinnerSliderPaired.java b/src/main/java/net/rptools/maptool/client/swing/SpinnerSliderPaired.java new file mode 100644 index 0000000000..1035d777e2 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/swing/SpinnerSliderPaired.java @@ -0,0 +1,567 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.swing; + +import java.beans.*; +import java.text.ParseException; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import javax.swing.*; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import javax.swing.event.SwingPropertyChangeSupport; +import net.rptools.lib.MathUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +/** + * Utility class for linking a numeric JSpinner, JSlider, and a variable. The slider and spinner are + * tied together with the class NumericModel. It holds delegates for the two component models which + * update each other.The default relationship between spinner value and slider value is 1:1 and + * utilises a Functional Interface to translate values between the two. This can be customised by + * setting your own Functions, e.g. an example for 1:100 + * + *
+ * Function<Number, Integer> spinnerToSlider =
+ * number -> ((Number) number.doubleValue * 100).intValue
+ * Function<Integer, Number> sliderToSpinner = + * number -> ((Number) i).doubleValue()/100d + *
+ * + *

Similarly:
+ * Providing a Consumer<Number> for a linked property will result in the property + * being updated with the spinner value.
+ * Providing a Supplier<Number> for a linked property can be used to update the + * spinner value by calling update(). PropertyChangeSupport and VetoableChangeSupport + * have been implemented on the spinner and slider models for the methods;
+ * setMinimum,
+ * setMaximum, and
+ * setValue. + */ +public class SpinnerSliderPaired { + // constructors + public SpinnerSliderPaired(JSpinner spinner, JSlider slider) { + this(spinner, slider, null); + } + + public SpinnerSliderPaired(JSpinner spinner, JSlider slider, Consumer propertySetter) { + this(spinner, slider, propertySetter, (n) -> n.intValue(), (i) -> ((Number) i).doubleValue()); + log.debug("spinner-slider pair using default relationship."); + } + + public SpinnerSliderPaired( + JSpinner spinner, + JSlider slider, + Consumer propertySetter, + Function spinnerToSlider, + Function sliderToSpinner) { + // set funcitional relationship + setSpinnerToSlider(spinnerToSlider); + setSliderToSpinner(sliderToSpinner); + // set the controls + setLinkedSpinner(spinner); + setLinkedSlider(slider); + // set the property setter + setPropertySetter(propertySetter); + + commonModel.setDelegateValues(); + getLinkedSlider().setModel(commonModel.sliderModelDelegate); + getLinkedSpinner().setModel(commonModel.spinnerModelDelegate); + + log.debug("new spinner-slider pair: " + this.toString()); + } + + // @formatter:off + // spotless:off + private static final Logger log = LogManager.getLogger(SpinnerSliderPaired.class); + // Property Change Support + protected SwingPropertyChangeSupport pcs = new SwingPropertyChangeSupport(this); + protected VetoableChangeSupport vcs = new VetoableChangeSupport(this); + public void addVetoableChangeListener(VetoableChangeListener listener) { vcs.addVetoableChangeListener(listener); } + protected void removeVetoableChangeListener(VetoableChangeListener listener){ vcs.addVetoableChangeListener(listener); } + public void addPropertyChangeListener(PropertyChangeListener listener) { pcs.addPropertyChangeListener(listener); } + protected void removePropertyChangeListener(PropertyChangeListener listener) { pcs.removePropertyChangeListener(listener); } + + // controls + private transient JSpinner linkedSpinner; + private transient JSlider linkedSlider; + public JSpinner getLinkedSpinner(){ return linkedSpinner; } + public JSlider getLinkedSlider(){ return linkedSlider; } + private void setLinkedSlider(@NotNull JSlider slider){ + this.linkedSlider = slider; + getLinkedSlider().addMouseWheelListener( + e -> { incrementValue((int) Math.round(e.getPreciseWheelRotation())); + }); + } + private void setLinkedSpinner(JSpinner spinner){ + this.linkedSpinner = spinner; + getLinkedSpinner().addChangeListener(spinnerEditListener); + getLinkedSpinner().addMouseWheelListener( + e -> { incrementValue( e.getPreciseWheelRotation()); + }); + } + // option to set the spinner to loop/wrap at end values + private boolean spinnerWraps = false; + public void setSpinnerWraps(boolean b){ this.spinnerWraps = b; } + + // property setter + private Consumer propertySetter; + private Supplier propertyGetter; + private String propertyName = "Property"; + public Consumer getPropertySetter() { return propertySetter; } + public Supplier getPropertyGetter() { return propertyGetter; } + public void setPropertySetter(Consumer propertySetter) {this.propertySetter = propertySetter; } + public void setPropertyGetter(Supplier propertyGetter) {this.propertyGetter = propertyGetter; } + public void setPropertyName(String s){ this.propertyName = s; } + private void setProperty(Number n){ + if(propertyGetter != null){ + if(propertyGetter.get().doubleValue() != n.doubleValue() && propertySetter != null){ + propertySetter.accept(n); + log.debug(propertyName + " set to " + n); + } + } + } + public void update(){ if(propertyGetter != null){ setValue(propertyGetter.get()); } } + // Instance of NumericModel that ties the two controls together + private NumericModel commonModel = new NumericModel(); + // functions that define the relationship between spinner and slider values + private Function sliderToSpinner; + private Function spinnerToSlider; + public Function getSpinnerToSlider() { return spinnerToSlider; } + public void setSpinnerToSlider(Function function) { spinnerToSlider = function; } + public Function getSliderToSpinner() { return sliderToSpinner; } + public void setSliderToSpinner(Function function) { sliderToSpinner = function; } + + // public methods pointing to model methods + public Number getNextValue(){ return commonModel.getNextValue(); } + public Number getPreviousValue(){ return commonModel.getPreviousValue(); } + public int getSliderValue(){ return commonModel.getNumber(true).intValue(); } + public double getSpinnerValue(){ return commonModel.getNumber(false).doubleValue(); } + public void incrementValue(Number n){ commonModel.incrementValue(n);} + public void setMaximum(Number n){ commonModel.setMaximum(n);} + public void setMinimum(Number n){ commonModel.setMinimum(n);} + public void setValue(Number n){ commonModel.setValue(n);} + //@formatter:on + // spotless:on + // The shared model - two delegates controlled from above + public class NumericModel { + public NumericModel() {} + + // initialise delegates with linked spinner values + private void setDelegateValues() { + if (getLinkedSpinner() == null) { + return; + } + if (spinnerToSlider == null) { // default 1:1 relationship + sliderToSpinner = i -> ((Number) i).doubleValue(); + spinnerToSlider = n -> n.intValue(); + } + SpinnerNumberModel spinModel = (SpinnerNumberModel) getLinkedSpinner().getModel(); + spinnerModelDelegate = + new NumericSpinnerModel( + spinModel.getNumber().doubleValue(), + ((Number) spinModel.getMinimum()).doubleValue(), + ((Number) spinModel.getMaximum()).doubleValue(), + spinModel.getStepSize().doubleValue()); + sliderModelDelegate = + new NumericSliderModel( + spinnerToSlider.apply(spinnerModelDelegate.getNumber()), + 0, + spinnerToSlider.apply(((Number) spinnerModelDelegate.getMinimum())), + spinnerToSlider.apply(((Number) spinnerModelDelegate.getMaximum()))); + } + + // a method for every flavour + private void incrementValue(Number delta) { + PropertyChangeEvent pce = new PropertyChangeEvent(this, "increment", getNumber(false), delta); + try { + vcs.fireVetoableChange(pce); + spinnerModelDelegate.increment(delta); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage(), e); + } + } + + private void setStepSize(Number n) { + PropertyChangeEvent pce = new PropertyChangeEvent(this, "stepsize", getNumber(false), n); + try { + vcs.fireVetoableChange(pce); + spinnerModelDelegate.setStepSize(n); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage(), e); + } + } + + private void setValue(Number n) { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "value", getNumber(MathUtil.isInt(n)), n); + try { + vcs.fireVetoableChange(pce); + if (MathUtil.isInt(n)) { + sliderModelDelegate.setValue(n.intValue()); + } else { + spinnerModelDelegate.setValue(n); + } + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage(), e); + } + } + + private void setMaximum(Number n) { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "value", getNumber(MathUtil.isInt(n)), n); + try { + vcs.fireVetoableChange(pce); + if (MathUtil.isInt(n)) { + sliderModelDelegate.setMaximum(n.intValue()); + } else { + spinnerModelDelegate.setMaximum((Comparable) n); + } + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage(), e); + } + } + + private void setMinimum(Number n) { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "value", getNumber(MathUtil.isInt(n)), n); + try { + vcs.fireVetoableChange(pce); + if (MathUtil.isInt(n)) { + sliderModelDelegate.setMinimum(n.intValue()); + } else { + spinnerModelDelegate.setMinimum((Comparable) n); + } + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage(), e); + } + } + + private Number getNextValue() { + return (Number) spinnerModelDelegate.getNextValue(); + } + + private Number getPreviousValue() { + return (Number) spinnerModelDelegate.getPreviousValue(); + } + + private T getNumber(boolean asInt) { + if (asInt) { + return (T) spinnerToSlider.apply(spinnerModelDelegate.getNumber()); + } else { + return (T) spinnerModelDelegate.getNumber(); + } + } + + // Delegate classes with aditional setters and normal setters redirected + public NumericSpinnerModel spinnerModelDelegate = new NumericSpinnerModel(0d, 0d, 100d, 1d); + + private class NumericSpinnerModel extends SpinnerNumberModel { + public NumericSpinnerModel(double value, double minimum, double maximum, double stepSize) { + super(value, minimum, maximum, stepSize); + } + + public void setMax(@NotNull Number n) { + super.setMaximum(n.doubleValue()); + sliderModelDelegate.setMaximum(spinnerToSlider.apply((Number) super.getMaximum())); + } + + public void setMin(@NotNull Number n) { + super.setMinimum(n.doubleValue()); + sliderModelDelegate.setMinimum(spinnerToSlider.apply((Number) super.getMinimum())); + } + + public void setVal(@NotNull Number n) { + super.setValue(n.doubleValue()); + setProperty(n); + sliderModelDelegate.setValue(spinnerToSlider.apply(super.getNumber())); + } + + public void setStep(@NotNull Number n) { + super.setStepSize(n); + } + + public void incr(@NotNull Number n) { + double newVal = getNumber().doubleValue() + n.doubleValue(); + double max = ((Number) getMaximum()).doubleValue(); + double min = ((Number) getMinimum()).doubleValue(); + if (spinnerWraps && (newVal < min || newVal > max)) { + if (newVal < min) { + newVal = newVal - min + max; + } else { + newVal = newVal - max + min; + } + } else { + newVal = Math.min(max, Math.max(newVal, min)); + } + setValue(newVal); + } + + public void increment(@NotNull Number n) { + n = MathUtil.doublePrecision(n.doubleValue(), 4); + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "increment", super.getNumber(), n); + vcs.fireVetoableChange(pce); + incr(n); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + + @Override + public Object getNextValue() { + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "increment", super.getNumber(), super.getStepSize()); + vcs.fireVetoableChange(pce); + incr(getStepSize()); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + return getNumber(); + } + + @Override + public Object getPreviousValue() { + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "increment", super.getNumber(), super.getStepSize()); + vcs.fireVetoableChange(pce); + incr(-getStepSize().doubleValue()); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + return super.getNumber(); + } + + @Override + public void setMinimum(Comparable minimum) { + minimum = MathUtil.doublePrecision(((Number) minimum).doubleValue(), 4); + if (((Number) this.getMinimum()).doubleValue() != ((Number) minimum).doubleValue()) { + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "minimum", super.getNumber(), minimum); + vcs.fireVetoableChange(pce); + setMin((Number) minimum); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public void setMaximum(Comparable maximum) { + maximum = MathUtil.doublePrecision(((Number) maximum).doubleValue(), 4); + if (((Number) this.getMaximum()).doubleValue() != ((Number) maximum).doubleValue()) { + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "maximum", super.getNumber(), maximum); + vcs.fireVetoableChange(pce); + setMax((Number) maximum); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public void setValue(Object value) { + value = MathUtil.doublePrecision(((Number) value).doubleValue(), 4); + if (this.getNumber().doubleValue() != ((Number) value).doubleValue()) { + try { + PropertyChangeEvent pce = + new PropertyChangeEvent(this, "value", super.getNumber(), value); + vcs.fireVetoableChange(pce); + setVal((Number) value); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public String toString() { + return "spinnerModelDelegate{" + + "min=" + + getMinimum() + + ", max=" + + getMaximum() + + ", val=" + + getValue() + + ", stepSize=" + + getStepSize() + + '}'; + } + } + ; + + public NumericSliderModel sliderModelDelegate = new NumericSliderModel(0, 0, 0, 100); + + private class NumericSliderModel extends DefaultBoundedRangeModel { + public NumericSliderModel(int value, int extent, int min, int max) { + super(value, extent, min, max); + } + + public void setMax(@NotNull Number n) { + super.setMaximum(n.intValue()); + spinnerModelDelegate.setMaximum(sliderToSpinner.apply(n.intValue()).doubleValue()); + } + + public void setMin(@NotNull Number n) { + super.setMinimum(n.intValue()); + spinnerModelDelegate.setMinimum(sliderToSpinner.apply(n.intValue()).doubleValue()); + } + + public void setVal(@NotNull Number n) { + super.setValue(n.intValue()); + spinnerModelDelegate.setValue(sliderToSpinner.apply(n.intValue()).doubleValue()); + } + + @Override + public void setValue(int n) { + if (this.getValue() != n) { + try { + PropertyChangeEvent pce = new PropertyChangeEvent(this, "value", super.getValue(), n); + vcs.fireVetoableChange(pce); + setVal((Number) n); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public void setMinimum(int n) { + if (this.getValue() != n) { + try { + PropertyChangeEvent pce = new PropertyChangeEvent(this, "value", super.getMinimum(), n); + vcs.fireVetoableChange(pce); + setMin((Number) n); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public void setMaximum(int n) { + if (this.getValue() != n) { + try { + PropertyChangeEvent pce = new PropertyChangeEvent(this, "value", super.getMaximum(), n); + vcs.fireVetoableChange(pce); + setMax((Number) n); + pcs.firePropertyChange(pce); + } catch (PropertyVetoException e) { + log.info(e.getMessage()); + } + } + } + + @Override + public String toString() { + return "sliderModelDelegate{" + + "min=" + + super.getMinimum() + + ", max=" + + super.getMaximum() + + ", val=" + + super.getValue() + + ", extent=" + + super.getExtent() + + ", isAdjusting=" + + super.getValueIsAdjusting() + + '}'; + } + } + ; + + // spotless:on + // @formatter:on + @Override + public String toString() { + return "NumericModel{" + + spinnerModelDelegate.toString() + + ", " + + sliderModelDelegate.toString() + + '}'; + } + } + + @Override + public String toString() { + return "SpinnerSliderPaired{" + + "spinnerName=" + + getLinkedSpinner().getName() + + ", sliderName=" + + getLinkedSlider().getName() + + ", propertySetterSet=" + + (propertySetter != null) + + ", sliderToSpinner(0)=" + + sliderToSpinner.apply(0) + + ", sliderToSpinner(1)=" + + sliderToSpinner.apply(1) + + ", spinnerToSlider(0)=" + + spinnerToSlider.apply(0) + + ", spinnerToSlider(1)=" + + spinnerToSlider.apply(1) + + ", spinnerToSlider(0)=" + + spinnerToSlider.apply(0) + + ", sliderToSpinner(" + + spinnerToSlider.apply(0) + + ")=" + + sliderToSpinner.apply(spinnerToSlider.apply(0)) + + ", spinnerToSlider(1)=" + + spinnerToSlider.apply(1) + + ", sliderToSpinner(" + + spinnerToSlider.apply(1) + + ")=" + + sliderToSpinner.apply(spinnerToSlider.apply(1)) + + '}'; + } + + private ChangeListener spinnerEditListener = + new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + JSpinner spinner = (JSpinner) e.getSource(); + try { + spinner.commitEdit(); + } catch (ParseException pe) { + // Edited value is invalid, revert the spinner to the last valid value, + JComponent editor = spinner.getEditor(); + if (editor instanceof JSpinner.NumberEditor) { + ((JSpinner.NumberEditor) editor).getTextField().setValue(spinner.getValue()); + } + return; + } + } + }; +} diff --git a/src/main/java/net/rptools/maptool/client/tool/StampTool.java b/src/main/java/net/rptools/maptool/client/tool/StampTool.java index 4995f79de4..a7af2a0b4e 100644 --- a/src/main/java/net/rptools/maptool/client/tool/StampTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/StampTool.java @@ -1346,8 +1346,7 @@ public void dragTo(int mouseX, int mouseY, boolean lockAspectRatio, boolean snap // For snap-to-grid tokens (except background stamps) we anchor at the center of the token. final var isSnapToGridAndAnchoredAtCenter = - tokenBeingResized.isSnapToGrid() - && tokenBeingResized.getLayer().anchorSnapToGridAtCenter(); + tokenBeingResized.isSnapToGrid() && tokenBeingResized.getLayer().isSnapToGridAtCenter(); final var snapToGridMultiplier = isSnapToGridAndAnchoredAtCenter ? 2 : 1; var widthIncrease = adjustment.x * snapToGridMultiplier; var heightIncrease = adjustment.y * snapToGridMultiplier; diff --git a/src/main/java/net/rptools/maptool/client/ui/token/BooleanTokenOverlay.java b/src/main/java/net/rptools/maptool/client/ui/token/BooleanTokenOverlay.java index dbe39f0b2d..5c8d1ed8a5 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/BooleanTokenOverlay.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/BooleanTokenOverlay.java @@ -55,7 +55,7 @@ protected BooleanTokenOverlay(String aName) { public void paintOverlay(Graphics2D g, Token token, Rectangle bounds, Object value) { if (FunctionUtil.getBooleanValue(value)) { // Apply Alpha Transparency - float opacity = token.getTokenOpacity(); + float opacity = token.getOpacity(); if (opacity < 1.0f) g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java index e5ffed9a83..ad7b0f8d88 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java @@ -109,7 +109,7 @@ public class EditTokenDialog extends AbeillePanel { RessourceManager.getBigIcon(Icons.EDIT_TOKEN_REFRESH_ON); private static final ImageIcon REFRESH_ICON_OFF = RessourceManager.getBigIcon(Icons.EDIT_TOKEN_REFRESH_OFF); - // private CharSheetController controller; + private final RSyntaxTextArea xmlStatblockRSyntaxTextArea = new RSyntaxTextArea(2, 2); private final RSyntaxTextArea textStatblockRSyntaxTextArea = new RSyntaxTextArea(2, 2); private final WordWrapCellRenderer propertyCellRenderer = new WordWrapCellRenderer(); @@ -119,7 +119,6 @@ public class EditTokenDialog extends AbeillePanel { private ImageAssetPanel imagePanel; private final LibraryManager libraryManager = new LibraryManager(); - // private final Toolbox toolbox = new Toolbox(); private HeroLabData heroLabData; private AutoGenerateTopologySwingWorker autoGenerateTopologySwingWorker = new AutoGenerateTopologySwingWorker(false, Color.BLACK); @@ -199,6 +198,26 @@ public void closeDialog() { super.closeDialog(); } }; + getTokenLayoutPanel().reset(token); + getFlippedX() + .addChangeListener( + e -> + getTokenLayoutPanel() + .getHelper() + .setTokenFlipX(((JCheckBox) e.getSource()).isSelected())); + getFlippedY() + .addChangeListener( + e -> + getTokenLayoutPanel() + .getHelper() + .setTokenFlipY(((JCheckBox) e.getSource()).isSelected())); + getFlippedIso() + .addChangeListener( + e -> + getTokenLayoutPanel() + .getHelper() + .setTokenFlipIso(((JCheckBox) e.getSource()).isSelected())); + getTokenTopologyPanel().reset(token); bind(token); @@ -214,7 +233,7 @@ public void closeDialog() { validateLibTokenURIAccess(getNameField().getName()); var combo = getStatSheetCombo(); combo.removeAllItems(); - // Default Entry + /* Default Entry */ var defaultSS = new StatSheet(null, I18N.getText("token.statSheet.useDefault"), null, Set.of(), null); combo.addItem(defaultSS); @@ -260,27 +279,26 @@ private void validateLibTokenURIAccess(String name) { @Override public void bind(final Token token) { - // ICON + /* ICON */ getTokenIconPanel().setImageId(token.getImageAssetId()); - - // NOTES, GM NOTES. Due to the way things happen on different gui threads, the type must be set - // before the text - // otherwise the wrong values can get populated when the tab change listener fires. + /* NOTES, GM NOTES. Due to the way things happen on different gui threads, the type must be set + before the text + otherwise the wrong values can get populated when the tab change listener fires. */ getGMNotesEditor().setTextType(token.getGmNotesType()); getGMNotesEditor().setText(token.getGMNotes()); getPlayerNotesEditor().setTextType(token.getNotesType()); getPlayerNotesEditor().setText(token.getNotes()); - // TYPE + /* TYPE */ getTypeCombo().setSelectedItem(token.getType()); - // SIGHT + /* SIGHT */ updateSightTypeCombo(); - // Image Tables + /* Image Tables */ updateImageTableCombo(); - // STATES + /* STATES */ Component barPanel = null; updateStatesPanel(); Component[] statePanels = getStatesPanel().getComponents(); @@ -296,7 +314,7 @@ public void bind(final Token token) { } } - // BARS + /* BARS */ if (barPanel != null) { Component[] barComponents = ((Container) barPanel).getComponents(); JCheckBox cb = null; @@ -324,29 +342,27 @@ public void bind(final Token token) { } } - // OWNER LIST + /* OWNER LIST */ EventQueue.invokeLater(() -> getOwnerList().setModel(new OwnerListModel())); - // SPEECH TABLE + /* SPEECH TABLE */ EventQueue.invokeLater(() -> getSpeechTable().setModel(new SpeechTableModel(token))); - // Player player = MapTool.getPlayer(); - // boolean editable = player.isGM() || - // !MapTool.getServerPolicy().useStrictTokenManagement() || - // token.isOwner(player.getName()); - // getAllPlayersCheckBox().setSelected(token.isOwnedByAll()); - - // OTHER + /* OTHER */ getShapeCombo().setSelectedItem(token.getShape()); setSizeCombo(token); + getSnapToGrid().setSelected(token.isSnapToGrid()); + getFlippedIso().setSelected(token.isFlippedIso()); + getFlippedX().setSelected(token.isFlippedX()); + getFlippedY().setSelected(token.isFlippedY()); - // Updates the Property Type list. + /* Updates the Property Type list. */ updatePropertyTypeCombo(); - // Set the selected item in Property Type list. Triggers a itemStateChanged event if index != 0 + /* Set the selected item in Property Type list. Triggers a itemStateChanged event if index != 0 */ getPropertyTypeCombo().setSelectedItem(token.getPropertyType()); - // If index == 0, the itemStateChanged event wasn't triggered, so we update. Fix #1504 + /* If index == 0, the itemStateChanged event wasn't triggered, so we update. Fix #1504 */ if (getPropertyTypeCombo().getSelectedIndex() == 0) { updatePropertiesTable((String) getPropertyTypeCombo().getSelectedItem()); } @@ -361,11 +377,11 @@ public void bind(final Token token) { getTokenLayoutPanel().setToken(token); getImageTableCombo().setSelectedItem(token.getImageTableName()); getTokenOpacitySlider() - .setValue(new BigDecimal(token.getTokenOpacity()).multiply(new BigDecimal(100)).intValue()); + .setValue(new BigDecimal(token.getOpacity()).multiply(new BigDecimal(100)).intValue()); getTerrainModifier().setText(Double.toString(token.getTerrainModifier())); getTerrainModifierOperationComboBox().setSelectedItem(token.getTerrainModifierOperation()); - // Get tokens ignored list, match to the index in the JList then select them. + /* Get tokens ignored list, match to the index in the JList then select them. */ getTerrainModifiersIgnoredList() .setSelectedIndices( token.getTerrainModifiersIgnored().stream() @@ -381,7 +397,7 @@ public void bind(final Token token) { getUniqueLightSourcesTextPane() .setText(new LightSyntax().stringifyLights(token.getUniqueLightSources())); - // Jamz: Init the Topology tab... + /* Jamz: Init the Topology tab... */ JTabbedPane tabbedPane = getTabbedPane(); String topologyTitle = I18N.getText("EditTokenDialog.tab.vbl"); @@ -395,7 +411,7 @@ public void bind(final Token token) { getVisibilityToleranceSpinner().setValue(token.getAlwaysVisibleTolerance()); getJtsMethodComboBox().setSelectedItem(getTokenTopologyPanel().getJtsMethod()); - // Reset scale + /* Reset scale */ getTokenTopologyPanel().setScale(1d); } else { tabbedPane.setEnabledAt(tabbedPane.indexOfTab(topologyTitle), false); @@ -438,7 +454,7 @@ public void bind(final Token token) { getAllowURLAccess().setSelected(token.getAllowURIAccess()); - // Jamz: Init the Hero Lab tab... + /* Jamz: Init the Hero Lab tab... */ heroLabData = token.getHeroLabData(); String heroLabTitle = I18N.getString("EditTokenDialog.tab.hero"); @@ -485,8 +501,6 @@ public void bind(final Token token) { ((JLabel) getComponent("lastModified")).setText(heroLabData.getLastModifiedDateString()); EventQueue.invokeLater(this::loadHeroLabImageList); - - // loadHeroLabImageList(); } else { tabbedPane.setEnabledAt(tabbedPane.indexOfTab(heroLabTitle), false); if (tabbedPane.getSelectedIndex() == tabbedPane.indexOfTab(heroLabTitle)) { @@ -494,8 +508,8 @@ public void bind(final Token token) { } } - // we will disable the Owner only visible check box if the token is not - // visible to players to signify the relationship + /* we will disable the Owner only visible check box if the token is not + visible to players to signify the relationship */ ActionListener tokenVisibleActionListener = actionEvent -> { AbstractButton abstractButton = (AbstractButton) actionEvent.getSource(); @@ -505,23 +519,6 @@ public void bind(final Token token) { }; getVisibleCheckBox().addActionListener(tokenVisibleActionListener); - // Character Sheets - // controller = null; - // String form = - // MapTool.getCampaign().getCharacterSheets().get(token.getPropertyType()); - // if (form == null) - // return; - // URL formUrl = getClass().getClassLoader().getResource(form); - // if (formUrl == null) - // return; - // controller = new CharSheetController(formUrl, null); - // HashMap properties = new HashMap(); - // for (String prop : token.getPropertyNames()) - // properties.put(prop, token.getProperty(prop)); - // controller.setData(properties); - // controller.getPanel().setName("characterSheet"); - // replaceComponent("sheetPanel", "characterSheet", controller.getPanel()); - super.bind(token); } @@ -599,6 +596,7 @@ public void initTokenIconPanel() { public ImageAssetPanel getTokenIconPanel() { if (imagePanel == null) { imagePanel = new ImageAssetPanel(); + imagePanel.setAllowEmptyImage(false); replaceComponent("mainPanel", "tokenImage", imagePanel); } @@ -696,7 +694,7 @@ public JComboBox getImageTableCombo() { } public void initTokenOpacitySlider() { - getTokenOpacitySlider().addChangeListener(new SliderListener()); + getTokenOpacitySlider().addChangeListener(new OpacitySliderListener()); } public JSlider getTokenOpacitySlider() { @@ -721,7 +719,11 @@ public JList getTerrainModifiersIgnoredList() { public void setUniqueLightSourcesEnabled(boolean enabled) { getUniqueLightSourcesTextPane().setEnabled(enabled); - getLabel("uniqueLightSourcesLabel").setEnabled(enabled); + getUniqueLightSourcesPanel().setEnabled(enabled); + } + + public JPanel getUniqueLightSourcesPanel() { + return (JPanel) getComponent("uniqueLightSourcesPanel"); } public JTextPane getUniqueLightSourcesTextPane() { @@ -747,7 +749,7 @@ public void initOKButton() { public boolean commit() { Token token = getModel(); - if (getNameField().getText().equals("")) { + if (getNameField().getText().isEmpty()) { MapTool.showError("msg.error.emptyTokenName"); return false; } @@ -757,34 +759,34 @@ public boolean commit() { if (getPropertyTable().isEditing()) { getPropertyTable().getCellEditor().stopCellEditing(); } - // Commit the changes to the token properties - // If no map available, cancel the commit. Fixes #1646. + /* Commit the changes to the token properties */ + /* If no map available, cancel the commit. Fixes #1646. */ if (!super.commit() || MapTool.getFrame().getCurrentZoneRenderer() == null) { return false; } - // TYPE - // Only update this if it actually changed + /* TYPE */ + /* Only update this if it actually changed */ if (getTypeCombo().getSelectedItem() != token.getType()) { token.setType((Token.Type) getTypeCombo().getSelectedItem()); } - // NOTES + /* NOTES */ token.setGMNotes(getGMNotesEditor().getText()); token.setGmNotesType(getGMNotesEditor().getTextType()); token.setNotes(getPlayerNotesEditor().getText()); token.setNotesType(getPlayerNotesEditor().getTextType()); - // SIZE + /* SIZE */ token.setSnapToScale(getSizeCombo().getSelectedIndex() != 0); if (getSizeCombo().getSelectedIndex() > 0) { Grid grid = MapTool.getFrame().getCurrentZoneRenderer().getZone().getGrid(); token.setFootprint(grid, (TokenFootprint) getSizeCombo().getSelectedItem()); } - // Other + /* Other */ token.setPropertyType((String) getPropertyTypeCombo().getSelectedItem()); token.setSightType((String) getSightTypeCombo().getSelectedItem()); token.setImageTableName((String) getImageTableCombo().getSelectedItem()); - token.setTokenOpacity( + token.setOpacity( new BigDecimal(getTokenOpacitySlider().getValue()) .divide(new BigDecimal(100)) .floatValue()); @@ -792,7 +794,7 @@ public boolean commit() { try { token.setTerrainModifier(Double.parseDouble(getTerrainModifier().getText())); } catch (NumberFormatException e) { - // User didn't enter a valid float... + /* User didn't enter a valid float... */ token.setTerrainModifier(1); } @@ -810,7 +812,7 @@ public boolean commit() { token.addUniqueLightSource(lightSource); } - // Get the states + /* Get the states */ Component[] stateComponents = getStatesPanel().getComponents(); Container barPanel = null; for (Component stateComponent : stateComponents) { @@ -824,9 +826,9 @@ public boolean commit() { String state = cb.getText(); token.setState(state, cb.isSelected() ? Boolean.TRUE : Boolean.FALSE); } - } // endfor + } - // BARS + /* BARS */ if (barPanel != null) { for (var barContainer : barPanel.getComponents()) { var barComponents = ((Container) barContainer).getComponents(); @@ -843,9 +845,8 @@ public boolean commit() { * 100)); } } - // Ownership - // If the token is owned by all and we are a player don't alter the ownership - // list. + /* OWNERSHIP */ + /* If the token is owned by all and we are a player don't alter the ownership list. */ if (MapTool.getPlayer().isGM() || !token.isOwnedByAll()) { token.clearAllOwners(); @@ -856,8 +857,7 @@ public boolean commit() { token.addOwner((String) selectable.getObject()); } } - // If we are not a GM and the only non GM owner make sure we can't - // take our selves off of the owners list + /* If we are not a GM and we are the only non-GM owner, make sure we cannot remove ourself from the owners list */ if (!MapTool.getPlayer().isGM()) { boolean hasPlayer = token.isOwnedByAny(MapTool.getNonGMs()); if (!hasPlayer) { @@ -865,10 +865,10 @@ public boolean commit() { } } } - // SHAPE + /* SHAPE */ token.setShape((Token.TokenShape) getShapeCombo().getSelectedItem()); - // Stat Sheet + /* Stat Sheet */ var ss = (StatSheet) getStatSheetCombo().getSelectedItem(); if (ss == null || (ss.name() == null && ss.namespace() == null)) { token.useDefaultStatSheet(); @@ -881,37 +881,38 @@ public boolean commit() { token.setStatSheet(new StatSheetProperties(ssManager.getId(ss), location)); } - // Macros + /* Macros */ token.setSpeechMap(((KeyValueTableModel) getSpeechTable().getModel()).getMap()); - // Properties + /* Properties */ ((TokenPropertyTableModel) getPropertyTable().getModel()).applyTo(token); - // Charsheet + /* Charsheet */ if (getCharSheetPanel().getImageId() != null) { MapToolUtil.uploadAsset(AssetManager.getAsset(getCharSheetPanel().getImageId())); } token.setCharsheetImage(getCharSheetPanel().getImageId()); - // IMAGE + /* IMAGE */ if (!token.getImageAssetId().equals(getTokenIconPanel().getImageId())) { MapToolUtil.uploadAsset(AssetManager.getAsset(getTokenIconPanel().getImageId())); token.setImageAsset(null, getTokenIconPanel().getImageId()); // Default image for now } - // PORTRAIT + /* PORTRAIT */ if (getPortraitPanel().getImageId() != null) { - // Make sure the server has the image + /* Make sure the server has the image */ if (!MapTool.getCampaign().containsAsset(getPortraitPanel().getImageId())) { MapTool.serverCommand().putAsset(AssetManager.getAsset(getPortraitPanel().getImageId())); } } token.setPortraitImage(getPortraitPanel().getImageId()); - // LAYOUT - token.setSizeScale(getTokenLayoutPanel().getSizeScale()); - token.setAnchor(getTokenLayoutPanel().getAnchorX(), getTokenLayoutPanel().getAnchorY()); + /* LAYOUT */ + getTokenLayoutPanel().getHelper().commitChanges(token); - // TOPOLOGY + token.setSnapToGrid(getSnapToGrid().isSelected()); + + /* TOPOLOGY */ for (final var type : Zone.TopologyType.values()) { token.setMaskTopology(type, getTokenTopologyPanel().getTopology(type)); } @@ -925,17 +926,12 @@ public boolean commit() { token.setHeroLabData(heroLabData); - // URI Access + /* URI Access */ token.setAllowURIAccess(getAllowURLAccess().isEnabled() && getAllowURLAccess().isSelected()); - // OTHER + /* OTHER */ tokenSaved = true; - // Character Sheet - // Map properties = controller.getData(); - // for (String prop : token.getPropertyNames()) - // token.setProperty(prop, properties.get(prop)); - - // Update UI + /* Update UI */ MapTool.getFrame().updateTokenTree(); MapTool.getFrame().resetTokenPanels(); @@ -969,7 +965,7 @@ public PropertyTable getPropertyTable() { } private void updateStatesPanel() { - // Group the states first into individual panels + /* Group the states first into individual panels */ List overlays = new ArrayList(MapTool.getCampaign().getTokenStatesMap().values()); Map groups = new TreeMap(); @@ -979,7 +975,7 @@ private void updateStatesPanel() { groups.put("", noGroupPanel); for (BooleanTokenOverlay overlay : overlays) { String group = overlay.getGroup(); - if (group != null && (group = group.trim()).length() != 0) { + if (group != null && !(group = group.trim()).isEmpty()) { JPanel panel = groups.get(group); if (panel == null) { panel = @@ -991,17 +987,17 @@ private void updateStatesPanel() { } } - // Add the group panels and bar panel to the states panel + /* Add the group panels and bar panel to the states panel */ JPanel statesPanel = getStatesPanel(); MigLayout layout = new MigLayout("wrap", "[fill,grow]"); statesPanel.setLayout(layout); statesPanel.removeAll(); - // Add the individual check boxes. + /* Add the individual check boxes. */ for (BooleanTokenOverlay state : overlays) { String group = state.getGroup(); var panel = groups.get(""); - if (group != null && (group = group.trim()).length() != 0) { + if (group != null && !(group = group.trim()).isEmpty()) { panel = groups.get(group); } panel.add(new JCheckBox(state.getName())); @@ -1015,8 +1011,8 @@ private void updateStatesPanel() { JPanel barPanel = new JPanel(new MigLayout("wrap 2", "[fill,grow][fill,grow]")); barPanel.setName("bar"); - // Add sliders to the bar panel - if (MapTool.getCampaign().getTokenBarsMap().size() > 0) { + /* Add sliders to the bar panel */ + if (!MapTool.getCampaign().getTokenBarsMap().isEmpty()) { barPanel.setBorder( BorderFactory.createTitledBorder(I18N.getText("CampaignPropertiesDialog.tab.bars"))); @@ -1181,6 +1177,22 @@ public JCheckBox getAllowURLAccess() { return (JCheckBox) getComponent("@allowURIAccess"); } + public JCheckBox getSnapToGrid() { + return getCheckBox("@isSnapToGrid"); + } + + public JCheckBox getFlippedIso() { + return getCheckBox("@isFlippedIso"); + } + + public JCheckBox getFlippedX() { + return getCheckBox("@isFlippedX"); + } + + public JCheckBox getFlippedY() { + return getCheckBox("@isFlippedY"); + } + public void initSpeechPanel() { getSpeechClearAllButton() .addActionListener( @@ -1214,10 +1226,10 @@ public String getToolTipText(MouseEvent event) { } }; propertyTable.setFillsViewportHeight(true); // XXX This is Java6-only -- need - // Java5 solution + /* Java5 solution */ propertyTable.setName("propertiesTable"); - // wrap button and functionality + /* wrap button and functionality */ JPanel buttonsAndPropertyTable = new JPanel(); buttonsAndPropertyTable.setLayout(new BorderLayout()); JCheckBox wrapToggle = new JCheckBox(I18N.getString("EditTokenDialog.msg.wrap")); @@ -1229,17 +1241,13 @@ public String getToolTipText(MouseEvent event) { buttonsAndPropertyTable.add(wrapToggle, BorderLayout.PAGE_END); PropertyPane pane = new PropertyPane(propertyTable); - // pane.setPreferredSize(new Dimension(100, 300)); buttonsAndPropertyTable.add(pane, BorderLayout.CENTER); replaceComponent("propertiesPanel", "propertiesTable", buttonsAndPropertyTable); } - public void initTokenDetails() { - // tokenGMNameLabel = panel.getLabel("tokenGMNameLabel"); - } - public void initTokenLayoutPanel() { - TokenLayoutPanel layoutPanel = new TokenLayoutPanel(); + TokenLayoutRenderPanel layoutPanel = new TokenLayoutRenderPanel(); + new TokenLayoutPanelHelper(this, layoutPanel, getOKButton()); layoutPanel.setMinimumSize(new Dimension(150, 125)); layoutPanel.setPreferredSize(new Dimension(150, 125)); layoutPanel.setName("tokenLayout"); @@ -1275,8 +1283,8 @@ public ImageAssetPanel getCharSheetPanel() { return (ImageAssetPanel) getComponent("charsheet"); } - public TokenLayoutPanel getTokenLayoutPanel() { - return (TokenLayoutPanel) getComponent("tokenLayout"); + public TokenLayoutRenderPanel getTokenLayoutPanel() { + return (TokenLayoutRenderPanel) getComponent("tokenLayout"); } public TokenTopologyPanel getTokenTopologyPanel() { @@ -1526,7 +1534,7 @@ public void initHeroLabImageList() { if (heroLabData != null) { getTokenIconPanel().setImageId(heroLabData.getImageAssetID(index)); - getTokenLayoutPanel().setTokenImage(heroLabData.getImageAssetID(index)); + getTokenLayoutPanel().getHelper().setTokenImageId(heroLabData.getImageAssetID(index)); } }); @@ -1569,25 +1577,25 @@ public void loadHeroLabImageList() { * Initialize the Hero Lab statblock tabs */ public void initStatBlocks() { - // Setup the HTML panel + /* Setup the HTML panel */ JEditorPane statblockPane = getHtmlStatblockEditor(); HTMLEditorKit kit = new HTMLEditorKit(); HTMLDocument statblockDoc = (HTMLDocument) kit.createDefaultDocument(); statblockPane.setEditorKit(kit); statblockPane.setDocument(statblockDoc); - // We need this property as the kit can't handle { @@ -1655,11 +1663,11 @@ public void initStatBlocks() { textStatblockRSyntaxTextArea.setText(heroLabData.getStatBlock_text()); textStatblockRSyntaxTextArea.setCaretPosition(0); - // Update the images + /* Update the images */ MD5Key tokenImageKey = heroLabData.getTokenImage(); if (tokenImageKey != null) { getTokenIconPanel().setImageId(tokenImageKey); - getTokenLayoutPanel().setTokenImage(tokenImageKey); + getTokenLayoutPanel().getHelper().setTokenImageId(tokenImageKey); } MD5Key portraitAssetKeY = heroLabData.getPortraitImage(); @@ -1672,8 +1680,8 @@ public void initStatBlocks() { getCharSheetPanel().setImageId(handoutAssetKey); } - // If NPC, lets not overwrite the Name, it may be "Creature 229" or such, GM - // name is enough + /* If NPC, lets not overwrite the Name, it may be "Creature 229" or such, GM + name is enough */ ((JTextField) getComponent("@GMName")).setText(heroLabData.getName()); if (heroLabData.isAlly()) { getTypeCombo().setSelectedItem(Type.PC); @@ -1682,13 +1690,13 @@ public void initStatBlocks() { getTypeCombo().setSelectedItem(Type.NPC); } - // Update image list + /* Update image list */ loadHeroLabImageList(); } } }); - // Setup xPath searching for XML StatBlock + /* Setup xPath searching for XML StatBlock */ JTextField xmlStatblockSearchTextField = (JTextField) getComponent("xmlStatblockSearchTextField"); JButton xmlStatblockSearchButton = (JButton) getComponent("xmlStatblockSearchButton"); @@ -1722,7 +1730,7 @@ public void keyPressed(KeyEvent e) { xmlStatblockRSyntaxTextArea.setCaretPosition(0); }); - // Setup regular expression searching for TEXT StatBlock + /* Setup regular expression searching for TEXT StatBlock */ JTextField textStatblockSearchTextField = (JTextField) getComponent("textStatblockSearchTextField"); JButton textStatblockSearchButton = (JButton) getComponent("textStatblockSearchButton"); @@ -1749,9 +1757,6 @@ public void keyPressed(KeyEvent e) { SearchContext context = new SearchContext(); context.setSearchFor(searchText); context.setRegularExpression(true); - // context.setMatchCase(matchCaseCB.isSelected()); - // context.setSearchForward(forward); - // context.setWholeWord(false); SearchEngine.find(textStatblockRSyntaxTextArea, context).wasFound(); }); @@ -1869,7 +1874,7 @@ public Map getMap() { Map map = new HashMap(); for (Association row : rowList) { - if (row.getLeft() == null || row.getLeft().trim().length() == 0) { + if (row.getLeft() == null || row.getLeft().trim().isEmpty()) { continue; } map.put(row.getLeft(), row.getRight()); @@ -1878,7 +1883,7 @@ public Map getMap() { } } - // needed to change the popup for properties + /* needed to change the popup for properties */ private static class MTMultilineStringExComboBox extends MultilineStringExComboBox { final ResourceBundle a = ResourceBundle.getBundle("com.jidesoft.combobox.combobox"); @@ -1895,7 +1900,7 @@ public PopupPanel createPopupComponent() { } } - // the cell editor for property popups + /* the cell editor for property popups */ private static class MTMultilineStringCellEditor extends MultilineStringCellEditor { protected MTMultilineStringExComboBox createMultilineStringComboBox() { @@ -1907,7 +1912,7 @@ protected MTMultilineStringExComboBox createMultilineStringComboBox() { } } - // the property popup table + /* the property popup table */ private static class MTMultilineStringPopupPanel extends PopupPanel { private RSyntaxTextArea j = createTextArea(); @@ -1918,7 +1923,7 @@ public MTMultilineStringPopupPanel() { public MTMultilineStringPopupPanel(String paramString) { this.setResizable(true); - // Set the color style via Theme + /* Set the color style via Theme */ try { File themeFile = new File( @@ -1928,7 +1933,7 @@ public MTMultilineStringPopupPanel(String paramString) { j.revalidate(); } catch (IOException e) { - e.printStackTrace(); + log.debug(e.getLocalizedMessage(), e); } JScrollPane localJScrollPane = new RTextScrollPane(j); localJScrollPane.setVerticalScrollBarPolicy(22); @@ -1953,7 +1958,6 @@ public MTMultilineStringPopupPanel(String paramString) { syntaxComboBox.addActionListener( e -> j.setSyntaxEditingStyle(syntaxComboBox.getSelectedItem().toString())); - // content.add(wrapToggle); add(syntaxComboBox, BorderLayout.BEFORE_FIRST_LINE); add(wrapToggle, BorderLayout.AFTER_LAST_LINE); } @@ -1980,14 +1984,14 @@ protected RSyntaxTextArea createTextArea() { } } - // cell renderer for properties table + /* cell renderer for properties table */ private static class WordWrapCellRenderer extends RSyntaxTextArea implements TableCellRenderer { WordWrapCellRenderer() { setLineWrap(false); setWrapStyleWord(true); - // Set the color style via Theme + /* Set the color style via Theme */ try { File themeFile = new File( @@ -1997,7 +2001,7 @@ private static class WordWrapCellRenderer extends RSyntaxTextArea implements Tab revalidate(); } catch (IOException e) { - e.printStackTrace(); + log.debug(e.getLocalizedMessage(), e); } } @@ -2036,14 +2040,14 @@ protected Void doInBackground() { publish(generatedTopology); } - // Nothing to do, so nothing to publish. + /* Nothing to do, so nothing to publish. */ return null; } @Override protected void process(List areaChunk) { if (!isCancelled()) { - final var newArea = areaChunk.get(areaChunk.size() - 1); + final var newArea = areaChunk.getLast(); final var optimizedArea = TokenVBL.simplifyArea( newArea, @@ -2062,7 +2066,7 @@ protected void done() { } } - class SliderListener implements ChangeListener { + class OpacitySliderListener implements ChangeListener { public void stateChanged(ChangeEvent e) { JSlider source = (JSlider) e.getSource(); @@ -2088,13 +2092,13 @@ public Component getListCellRendererComponent( (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); try { ImageIcon finalImage = - ImageUtil.scaleImage( + ImageUtil.scaleImageIcon( new ImageIcon(ImageManager.getImageAndWait(heroLabData.getImageAssetID(index))), 250, 175); label.setIcon(finalImage); } catch (Exception e) { - e.printStackTrace(); + log.debug(e.getLocalizedMessage(), e); } label.setIconTextGap(10); label.setHorizontalTextPosition(JLabel.LEFT); @@ -2103,8 +2107,7 @@ public Component getListCellRendererComponent( } } - // // - // HANDLER + /* HANDLER */ public static class MouseHandler extends MouseAdapter { HtmlEditorSplit source; @@ -2152,8 +2155,7 @@ public void mouseClicked(MouseEvent e) { } } - // // - // MODELS + /* MODELS */ private class TokenPropertyTableModel extends AbstractPropertyTableModel implements NavigableModel { @@ -2203,8 +2205,7 @@ public void applyTo(Token token) { @Override public boolean isNavigableAt(int rowIndex, int columnIndex) { - // make the property name column non-navigable so that tab takes you - // directly to the next property value cell. + /* make the property name column non-navigable so that tab takes you directly to the next property value cell. */ return (columnIndex != 0); } diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanel.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanel.java deleted file mode 100644 index c721955573..0000000000 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanel.java +++ /dev/null @@ -1,230 +0,0 @@ -/* - * This software Copyright by the RPTools.net development team, and - * licensed under the Affero GPL Version 3 or, at your option, any later - * version. - * - * MapTool Source Code is distributed in the hope that it will be - * useful, but WITHOUT ANY WARRANTY; without even the implied warranty - * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * - * You should have received a copy of the GNU Affero General Public - * License * along with this source Code. If not, please visit - * and specifically the Affero license - * text at . - */ -package net.rptools.maptool.client.ui.token.dialog.edit; - -import java.awt.Color; -import java.awt.Dimension; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Point; -import java.awt.Rectangle; -import java.awt.TexturePaint; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.awt.event.MouseMotionAdapter; -import java.awt.geom.Area; -import java.awt.image.BufferedImage; -import javax.swing.JPanel; -import javax.swing.SwingUtilities; -import net.rptools.lib.MD5Key; -import net.rptools.maptool.client.AppStyle; -import net.rptools.maptool.client.MapTool; -import net.rptools.maptool.client.swing.SwingUtil; -import net.rptools.maptool.client.ui.theme.Images; -import net.rptools.maptool.client.ui.theme.RessourceManager; -import net.rptools.maptool.language.I18N; -import net.rptools.maptool.model.Token; -import net.rptools.maptool.model.Token.TokenShape; -import net.rptools.maptool.model.Zone; -import net.rptools.maptool.util.ImageManager; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Support class used by the token editor dialog on the "Properties" tab to allow a token's image to - * be moved around within a one-cell grid area. Scaling is supported using the mousewheel and - * position is supported using left-drag. We should add rotation ability using Shift-mousewheel as - * well. - * - * @author trevor - */ -public class TokenLayoutPanel extends JPanel { - private static final Logger log = LogManager.getLogger(TokenLayoutPanel.class); - private Token token; - private int dragOffsetX; - private int dragOffsetY; - private MD5Key tokenImage; - - public TokenLayoutPanel() { - addMouseWheelListener( - e -> { - int wheelMovement = e.getWheelRotation(); - // Not for snap-to-scale - if (!token.isSnapToScale() || wheelMovement == 0) { - return; - } - double delta = wheelMovement > 0 ? -.1 : .1; - if (SwingUtil.isShiftDown(e)) { - // Nothing yet, as changing the facing isn't the right way to handle it -- - // the image itself really should be rotated. And it's probably better to - // not simply store a Transform but to create a new image. We could - // store an AffineTransform until the dialog is closed and then create - // the new image. But the amount of rotation needs to be saved so - // that future adjustments can return back to the original image (as - // a way of reducing round off error from multiple rotations). - } - double scale = token.getSizeScale() + delta; - log.debug(() -> "wheel=" + wheelMovement + ", delta=" + delta); - - // Range - scale = Math.max(.1, scale); - scale = Math.min(3, scale); - token.setSizeScale(scale); - repaint(); - }); - addMouseListener( - new MouseAdapter() { - String old; - - @Override - public void mousePressed(MouseEvent e) { - dragOffsetX = e.getX(); - dragOffsetY = e.getY(); - } - - @Override - public void mouseEntered(MouseEvent e) { - old = MapTool.getFrame().getStatusMessage(); - MapTool.getFrame() - .setStatusMessage(I18N.getString("EditTokenDialog.status.layout.instructions")); - } - - @Override - public void mouseExited(MouseEvent e) { - if (old != null) MapTool.getFrame().setStatusMessage(old); - } - - @Override - public void mouseClicked(MouseEvent e) { - if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { - dragOffsetX = 0; - dragOffsetY = 0; - token.setAnchor(0, 0); - token.setSizeScale(1.0); - repaint(); - } - } - }); - addMouseMotionListener( - new MouseMotionAdapter() { - @Override - public void mouseDragged(MouseEvent e) { - int dx = e.getX() - dragOffsetX; - int dy = e.getY() - dragOffsetY; - - Zone zone = MapTool.getFrame().getCurrentZoneRenderer().getZone(); - - int gridSize = zone.getGrid().getSize(); - int halfGridSize = gridSize / 2; - int maxXoff = Math.max(halfGridSize, token.getBounds(zone).width - gridSize); - int maxYoff = Math.max(halfGridSize, token.getBounds(zone).height - gridSize); - - int offX = Math.min(maxXoff, Math.max(token.getAnchor().x + dx, -maxXoff)); - int offY = Math.min(maxYoff, Math.max(token.getAnchor().y + dy, -maxYoff)); - - token.setAnchor(offX, offY); - dragOffsetX = e.getX(); - dragOffsetY = e.getY(); - repaint(); - } - }); - } - - public double getSizeScale() { - return token.getSizeScale(); - } - - public int getAnchorX() { - return token.getAnchor().x; - } - - public int getAnchorY() { - return token.getAnchor().y; - } - - public void setToken(Token token) { - this.token = new Token(token); - setTokenImage(token.getImageAssetId()); - } - - public MD5Key getTokenImage() { - return tokenImage; - } - - public void setTokenImage(MD5Key tokenImage) { - this.tokenImage = tokenImage; - } - - @Override - protected void paintComponent(Graphics g) { - Dimension size = getSize(); - Zone zone = MapTool.getFrame().getCurrentZoneRenderer().getZone(); - - // Gather info - BufferedImage image = ImageManager.getImage(getTokenImage()); - - Rectangle tokenSize = token.getBounds(zone); - Dimension imgSize = new Dimension(image.getWidth(), image.getHeight()); - - // If figure we need to calculate an additional offset for the token height - double iso_ho = 0; - if (token.getShape() == TokenShape.FIGURE) { - double th = token.getHeight() * (double) tokenSize.width / token.getWidth(); - iso_ho = tokenSize.height - th; - tokenSize = new Rectangle(tokenSize.x, tokenSize.y - (int) iso_ho, tokenSize.width, (int) th); - } - - SwingUtil.constrainTo(imgSize, tokenSize.width, tokenSize.height); - - Point centerPoint = new Point(size.width / 2, size.height / 2); - Graphics2D g2d = (Graphics2D) g; - - var panelTexture = RessourceManager.getImage(Images.TEXTURE_PANEL); - // Background - ((Graphics2D) g) - .setPaint( - new TexturePaint( - panelTexture, - new Rectangle(0, 0, panelTexture.getWidth(), panelTexture.getHeight()))); - g2d.fillRect(0, 0, size.width, size.height); - AppStyle.shadowBorder.paintWithin((Graphics2D) g, 0, 0, size.width, size.height); - - // Grid - if (zone.getGrid().getCapabilities().isSnapToGridSupported()) { - Area gridShape = zone.getGrid().getCellShape(); - int offsetX = (size.width - gridShape.getBounds().width) / 2; - int offsetY = (size.height - gridShape.getBounds().height) / 2; - g2d.setColor(Color.black); - - // Add horizontal and vertical lines to help with centering - g2d.drawLine( - 0, (size.height - (int) iso_ho) / 2, size.width, (size.height - (int) iso_ho) / 2); - g2d.drawLine(size.width / 2, 0, size.width / 2, (size.height - (int) iso_ho)); - - offsetY = offsetY - (int) (iso_ho / 2); - g2d.translate(offsetX, offsetY); - g2d.draw(gridShape); - g2d.translate(-offsetX, -offsetY); - } - // Token - g2d.drawImage( - image, - centerPoint.x - imgSize.width / 2 + token.getAnchor().x, - centerPoint.y - imgSize.height / 2 + token.getAnchor().y, - imgSize.width, - imgSize.height, - this); - } -} diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanelHelper.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanelHelper.java new file mode 100644 index 0000000000..7ca5faa91e --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutPanelHelper.java @@ -0,0 +1,1310 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.token.dialog.edit; + +import com.formdev.flatlaf.FlatLaf; +import com.formdev.flatlaf.extras.FlatSVGIcon; +import com.formdev.flatlaf.extras.components.FlatButton; +import java.awt.*; +import java.awt.event.*; +import java.awt.geom.*; +import java.awt.image.BufferedImage; +import java.beans.PropertyChangeListener; +import java.beans.PropertyVetoException; +import java.text.DecimalFormat; +import java.util.*; +import java.util.function.Function; +import javax.swing.*; +import net.rptools.lib.MD5Key; +import net.rptools.lib.MathUtil; +import net.rptools.lib.image.ImageUtil; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.swing.AbeillePanel; +import net.rptools.maptool.client.swing.GenericDialog; +import net.rptools.maptool.client.swing.SpinnerSliderPaired; +import net.rptools.maptool.client.swing.VerticalLabel; +import net.rptools.maptool.client.ui.theme.Images; +import net.rptools.maptool.client.ui.theme.RessourceManager; +import net.rptools.maptool.language.I18N; +import net.rptools.maptool.model.*; +import net.rptools.maptool.util.GraphicsUtil; +import net.rptools.maptool.util.ImageManager; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Support class that does much of the heavy lifting for TokenLayoutPanel. Links control components + * and the mouse events to property changes in the reference token. Stores a bunch of useful values. + * Disables the default OK button click on "Enter" when editing spinner fields. + * + * @author 2024 - Reverend/Bubblobill + */ +public class TokenLayoutPanelHelper { + private static final Logger log = LogManager.getLogger(TokenLayoutPanelHelper.class); + + public TokenLayoutPanelHelper( + AbeillePanel parentAbeillePanel, TokenLayoutRenderPanel renderPane, AbstractButton okBtn) { + parent = parentAbeillePanel; + renderPanel = renderPane; + renderPanel.setHelper(this); + setOKButton((JButton) okBtn); + init(); + + parent.addComponentListener( + new ComponentAdapter() { + private void doStuff() { + if (parentRoot == null) { + setParentRoot(parent.getRootPane()); + } + iFeelDirty(); + } + + @Override + public void componentMoved(ComponentEvent e) { + super.componentMoved(e); + doStuff(); + } + + @Override + public void componentShown(ComponentEvent e) { + super.componentShown(e); + doStuff(); + } + + @Override + public void componentResized(ComponentEvent e) { + super.componentResized(e); + doStuff(); + } + }); + } + + public void init() { + getSizeCombo().addItemListener(sizeListener); + initButtons(); + initSpinners(); + initSliders(); + pairControls(); + } + + enum FlipState { + HORIZONTAL, + ISOMETRIC, + VERTICAL + } + + EnumSet flipStates = EnumSet.noneOf(FlipState.class); + private static final UIDefaults UI_DEFAULTS = UIManager.getDefaults(); + private static final double DEFAULT_FONT_SIZE = UI_DEFAULTS.getFont("defaultFont").getSize2D(); + private static final int ICON_SIZE = (int) (DEFAULT_FONT_SIZE * 2 + 4); + + Grid grid = MapTool.getFrame().getCurrentZoneRenderer().getZone().getGrid(); + RenderBits renderBits = new RenderBits(); + boolean isoGrid = grid.isIsometric(); + boolean noGrid = GridFactory.getGridType(grid).equals(GridFactory.NONE); + boolean squareGrid = GridFactory.getGridType(grid).equals(GridFactory.SQUARE); + boolean hexGrid = false; + boolean isIsoFigure = false; + int gridSize = grid.getSize(); + double cellHeight = grid.getCellHeight(); + double cellWidth = grid.getCellWidth(); + private static final CellPoint ORIGIN = new CellPoint(0, 0); + private Token originalToken, mirrorToken; + private BufferedImage tokenImage; + TokenFootprint footprint; + Rectangle2D footprintBounds; + Set occupiedCells; + ArrayList cellCentres; + /* controls/components */ + AbeillePanel parent; + private JRootPane parentRoot; + private final TokenLayoutRenderPanel renderPanel; + private JComboBox sizeCombo; + private JLabel scaleLabel; + private JSpinner anchorXSpinner, anchorYSpinner, rotationSpinner, scaleSpinner, zoomSpinner; + private JSlider anchorXSlider, anchorYSlider, rotationSlider, scaleSlider, zoomSlider; + private AbstractButton scaleButton, okButton; + + public void setOKButton(JButton b) { + okButton = b; + } + + public void setParentRoot(JRootPane rp) { + parentRoot = rp; + } + + private final String helpText = assembleHelpText(); + + /* Component Getters */ + public JComboBox getSizeCombo() { + if (sizeCombo == null) sizeCombo = (JComboBox) parent.getComponent("size"); + return sizeCombo; + } + + /* Labels */ + public JLabel getScaleLabel() { + if (scaleLabel == null) scaleLabel = parent.getLabel("scaleLabel"); + return scaleLabel; + } + + /* Spinners */ + public JSpinner getAnchorXSpinner() { + if (anchorXSpinner == null) anchorXSpinner = parent.getSpinner("anchorXSpinner"); + return anchorXSpinner; + } + + public JSpinner getAnchorYSpinner() { + if (anchorYSpinner == null) anchorYSpinner = parent.getSpinner("anchorYSpinner"); + return anchorYSpinner; + } + + public JSpinner getRotationSpinner() { + if (rotationSpinner == null) rotationSpinner = parent.getSpinner("rotationSpinner"); + return rotationSpinner; + } + + public JSpinner getScaleSpinner() { + if (scaleSpinner == null) scaleSpinner = parent.getSpinner("scaleSpinner"); + return scaleSpinner; + } + + public JSpinner getZoomSpinner() { + if (zoomSpinner == null) zoomSpinner = parent.getSpinner("zoomSpinner"); + return zoomSpinner; + } + + /* Sliders */ + public JSlider getAnchorXSlider() { + if (anchorXSlider == null) anchorXSlider = (JSlider) parent.getComponent("anchorXSlider"); + return anchorXSlider; + } + + public JSlider getAnchorYSlider() { + if (anchorYSlider == null) anchorYSlider = (JSlider) parent.getComponent("anchorYSlider"); + return anchorYSlider; + } + + public JSlider getRotationSlider() { + if (rotationSlider == null) rotationSlider = (JSlider) parent.getComponent("rotationSlider"); + return rotationSlider; + } + + public JSlider getScaleSlider() { + if (scaleSlider == null) scaleSlider = (JSlider) parent.getComponent("scaleSlider"); + return scaleSlider; + } + + public JSlider getZoomSlider() { + if (zoomSlider == null) zoomSlider = (JSlider) parent.getComponent("zoomSlider"); + return zoomSlider; + } + + /* Buttons */ + public AbstractButton getScaleButton() { + if (scaleButton == null) scaleButton = parent.getButton("scaleButton"); + return scaleButton; + } + + /* Panel */ + public TokenLayoutRenderPanel getRenderPanel() { + return renderPanel; + } + + /* Linked controls */ + SpinnerSliderPaired anchorXPair, anchorYPair, rotationPair, scalePair, zoomPair; + + /* Token value getters pointing to the mirror token */ + public double getTokenSizeScale() { + return mirrorToken.getSizeScale(); + } + + public double getTokenScaleX() { + return mirrorToken.getScaleX(); + } + + public double getTokenScaleY() { + return mirrorToken.getScaleY(); + } + + public double getTokenImageRotation() { + return mirrorToken.getImageRotation(); + } + + public int getTokenAnchorX() { + return mirrorToken.getAnchorX(); + } + + public int getTokenAnchorY() { + return mirrorToken.getAnchorY(); + } + + public boolean getTokenFlippedX() { + return mirrorToken.isFlippedX(); + } + + public boolean getTokenFlippedY() { + return mirrorToken.isFlippedY(); + } + + public boolean getTokenFlippedIso() { + return mirrorToken.isFlippedIso(); + } + + /* Token value setters pointing to the mirror token */ + protected void setTokenScaleX(double scale) { + mirrorToken.setScaleX(scale); + } + + protected void setTokenScaleY(double scale) { + mirrorToken.setScaleY(scale); + } + + protected void setTokenSizeScale(double scale) { + mirrorToken.setSizeScale(scale); + } + + protected void setTokenAnchorX(Number x) { + mirrorToken.setAnchorX(x.intValue()); + } + + protected void setTokenAnchorY(Number y) { + mirrorToken.setAnchorY(y.intValue()); + } + + protected void setTokenImageRotation(Number value) { + mirrorToken.setImageRotation(MathUtil.doublePrecision(value.doubleValue(), 4)); + } + + protected void setTokenFlipIso(Boolean b) { + mirrorToken.setFlippedIso(b); + if (flipStates.contains(FlipState.ISOMETRIC) && !b) { + flipStates.remove(FlipState.ISOMETRIC); + } else if (!flipStates.contains(FlipState.ISOMETRIC) && b) { + flipStates.add(FlipState.ISOMETRIC); + } + iFeelDirty(); + } + + protected void setTokenFlipX(Boolean b) { + mirrorToken.setFlippedX(b); + if (flipStates.contains(FlipState.HORIZONTAL) && !b) { + flipStates.remove(FlipState.HORIZONTAL); + } else if (!flipStates.contains(FlipState.HORIZONTAL) && b) { + flipStates.add(FlipState.HORIZONTAL); + } + iFeelDirty(); + } + + protected void setTokenFlipY(Boolean b) { + mirrorToken.setFlippedY(b); + if (flipStates.contains(FlipState.VERTICAL) && !b) { + flipStates.remove(FlipState.VERTICAL); + } else if (!flipStates.contains(FlipState.VERTICAL) && b) { + flipStates.add(FlipState.VERTICAL); + } + iFeelDirty(); + } + + /** + * There is one spinner/slider for three different scale settings. This sets the value for the + * active axis/axes + * + * @param n (Number) the scale value being set + */ + protected void setScaleByAxis(Number n) { + double value = MathUtil.doublePrecision(n.doubleValue(), 4); + log.debug("scaleAxis: " + scaleAxis + " -> " + value); + switch (scaleAxis) { + case 0 -> setTokenSizeScale(value); + case 1 -> setTokenScaleX(value); + case 2 -> setTokenScaleY(value); + } + } + + /** + * There is one spinner/slider for three different scale settings. This returns the value for the + * active axis/axes + * + * @return double value for appropriate scale + */ + protected Number getScaleByAxis() { + if (scaleAxis == 0) { + return getTokenSizeScale(); + } else if (scaleAxis == 1) { + return getTokenScaleX(); + } else { + return getTokenScaleY(); + } + } + + /* used to determine which scale is being edited: 0 is XY, 1 is X, 2 is Y */ + private int scaleAxis = 0; + + /** + * These functions serve to link the scale and zoom sliders and spinners and allow a useful + * representation of values < 100%. Slider ranges from -200 to 0 are for values below 100% and 0 + * to 200 for 100% to 300% + */ + Function percentSliderToSpinner = + i -> + i <= 0 + ? MathUtil.mapToRange(((Number) i).doubleValue(), -200.0, 0.0, 0.0, 1.0) + : MathUtil.mapToRange(((Number) i).doubleValue(), 0.0, 200.0, 1.0, 3.0).doubleValue(); + + Function percentSpinnerToSlider = + d -> + d.doubleValue() <= 1 + ? MathUtil.mapToRange(d.doubleValue(), 0.0, 1.0, -200, 0).intValue() + : MathUtil.mapToRange(d.doubleValue(), 1.0, 3.0, 0, 200).intValue(); + + private void storeFlipDirections() { + if (getTokenFlippedIso()) { + flipStates.add(FlipState.ISOMETRIC); + } + if (getTokenFlippedX()) { + flipStates.add(FlipState.HORIZONTAL); + } + if (getTokenFlippedY()) { + flipStates.add(FlipState.VERTICAL); + } + } + + void resetPanel() { + setToken(originalToken, false); + renderPanel.setInitialScale(1d); + renderPanel.calcZoomFactor(); + } + + void resetPanelToDefault() { + setToken(originalToken, true); + } + + /* Listeners */ + PropertyChangeListener controlListener = + evt -> { + log.debug("controlListener " + evt); + if (evt.getPropertyName().toLowerCase().contains("spinnervalue")) { + iFeelDirty(); + } else if (evt.getPropertyName().toLowerCase().contains("flip")) { + storeFlipDirections(); + } + }; + + FocusListener focusListener = + new FocusListener() { + /* Toggle "Enter" closing the window. */ + @Override + public void focusGained(FocusEvent e) { + ((JComponent) e.getComponent()).getRootPane().setDefaultButton(null); + } + + @Override + public void focusLost(FocusEvent e) { + ((JComponent) e.getComponent()).getRootPane().setDefaultButton((JButton) okButton); + } + }; + ItemListener sizeListener = + new ItemListener() { + @Override + public void itemStateChanged(ItemEvent e) { + if (mirrorToken != null) { + setFootprint((TokenFootprint) getSizeCombo().getSelectedItem()); + } + } + }; + + /** Mark the rendering panel in need of repainting */ + void iFeelDirty() { + Rectangle panelBounds = getRenderPanel().getBounds(); + RepaintManager.currentManager(getRenderPanel()) + .addDirtyRegion( + getRenderPanel(), panelBounds.x, panelBounds.y, panelBounds.width, panelBounds.height); + } + + public void setTokenImageId(MD5Key tokenImageKey) { + tokenImage = ImageManager.getImage(tokenImageKey); + } + + protected BufferedImage getTokenImage() { + return tokenImage; + } + + public void commitChanges(Token tok) { + tok.setAnchor(getTokenAnchorX(), getTokenAnchorY()); + tok.setSizeScale(getTokenSizeScale()); + tok.setScaleX(getTokenScaleX()); + tok.setScaleY(getTokenScaleY()); + tok.setImageRotation(getTokenImageRotation()); + tok.setFlippedX(flipStates.contains(FlipState.HORIZONTAL)); + tok.setFlippedY(flipStates.contains(FlipState.VERTICAL)); + tok.setFlippedIso(flipStates.contains(FlipState.ISOMETRIC)); + } + + public void setToken(Token token, boolean useDefaults) { + grid = MapTool.getFrame().getCurrentZoneRenderer().getZone().getGrid(); + isoGrid = grid.isIsometric(); + hexGrid = grid.isHex(); + noGrid = GridFactory.getGridType(grid).equals(GridFactory.NONE); + squareGrid = GridFactory.getGridType(grid).equals(GridFactory.SQUARE); + renderBits = new RenderBits(); + gridSize = grid.getSize(); + cellHeight = grid.getCellHeight(); + cellWidth = grid.getCellWidth(); + + this.originalToken = new Token(token, true); // duplicate for resetting purposes + /* The mirror token exists so we can write token changes without committing them prior to clicking OK */ + this.mirrorToken = new Token(token, false); + + if (useDefaults) { + mirrorToken.setImageRotation(0); + mirrorToken.setSizeScale(1d); + mirrorToken.setScaleX(1d); + mirrorToken.setScaleY(1d); + mirrorToken.setAnchor(0, 0); + } + + if (mirrorToken.getFootprint(grid) != getSizeCombo().getSelectedItem()) { + TokenFootprint tmpFP = (TokenFootprint) getSizeCombo().getSelectedItem(); + if (tmpFP != null) { + mirrorToken.setFootprint(grid, grid.getFootprint(tmpFP.getId())); + } + } + + isIsoFigure = + isoGrid && mirrorToken.getShape() == Token.TokenShape.FIGURE && !mirrorToken.isFlippedIso(); + + tokenImage = ImageManager.getImage(mirrorToken.getImageAssetId()); + setFootprint(mirrorToken.getFootprint(grid)); + + getAnchorXSlider().setMinimum((int) -Math.ceil(footprintBounds.getWidth())); + getAnchorXSlider().setMaximum((int) Math.ceil(footprintBounds.getWidth())); + if (isIsoFigure) { + /* Allow more vertical travel for iso figures */ + getAnchorYSlider().setMinimum((int) -Math.ceil(1.4 * footprintBounds.getHeight())); + getAnchorYSlider().setMaximum((int) Math.ceil(1.4 * footprintBounds.getHeight())); + } else { + getAnchorYSlider().setMinimum((int) -Math.ceil(footprintBounds.getHeight())); + getAnchorYSlider().setMaximum((int) Math.ceil(footprintBounds.getHeight())); + } + /* align mouse drag bounds with sliders */ + getRenderPanel().setMaxXoff(getAnchorXSlider().getMaximum()); + getRenderPanel().setMaxYoff(getAnchorYSlider().getMaximum()); + + storeFlipDirections(); + + /* Assign Suppliers and Consumers to the linked controls and add a PropertyChangeListener */ + anchorXPair.setPropertySetter(this::setTokenAnchorX); + anchorXPair.setPropertyGetter(this::getTokenAnchorX); + anchorXPair.setPropertyName("AnchorX"); + anchorXPair.addPropertyChangeListener(controlListener); + + anchorYPair.setPropertySetter(this::setTokenAnchorY); + anchorYPair.setPropertyGetter(this::getTokenAnchorY); + anchorYPair.setPropertyName("AnchorY"); + anchorYPair.addPropertyChangeListener(controlListener); + + rotationPair.setPropertySetter(this::setTokenImageRotation); + rotationPair.setPropertyGetter(this::getTokenImageRotation); + rotationPair.setPropertyName("Rotation"); + rotationPair.addPropertyChangeListener(controlListener); + + scalePair.setPropertySetter(this::setScaleByAxis); + scalePair.setPropertyGetter(this::getScaleByAxis); + scalePair.setPropertyName("Scale"); + scalePair.addPropertyChangeListener(controlListener); + + zoomPair.setPropertySetter(getRenderPanel().getZoomConsumer()); + zoomPair.setPropertyGetter(getRenderPanel().getZoomSupplier()); + zoomPair.setPropertyName("Zoom"); + zoomPair.addPropertyChangeListener(controlListener); + + setControlValues(); + getRenderPanel() + .addComponentListener( + new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent e) { + super.componentShown(e); + renderBits.init(); + } + }); + } + + private void setFootprint(TokenFootprint fp) { + this.footprint = fp; + setCentredFootprintBounds(); + occupiedCells = footprint.getOccupiedCells(ORIGIN); + Rectangle2D aggregateBounds = new Rectangle2D.Double(); + cellCentres = new ArrayList<>(occupiedCells.size()); + for (CellPoint cp : occupiedCells) { + cellCentres.add(grid.getCellCenter(cp)); + aggregateBounds.add(grid.getBounds(cp)); + } + double xFix = -aggregateBounds.getCenterX(); + double yFix = -aggregateBounds.getCenterY(); + cellCentres.replaceAll(pt -> new Point2D.Double(pt.getX() + xFix, pt.getY() + yFix)); + } + + private void setCentredFootprintBounds() { + if (grid == null) { + return; + } + if (!noGrid) { + footprintBounds = footprint.getBounds(grid, ORIGIN); + footprintBounds = + new Rectangle2D.Double( + -footprintBounds.getWidth() / 2d, + -footprintBounds.getHeight() / 2d, + footprintBounds.getWidth(), + footprintBounds.getHeight()); + } else { + double factor = footprint.getScale(); + footprintBounds = + new Rectangle2D.Double( + -gridSize / 2d * factor, + -gridSize / 2d * factor, + gridSize * factor, + gridSize * factor); + } + } + + /** Link the spinners to the sliders and join them in matrimonial bliss */ + private void pairControls() { + anchorXPair = new SpinnerSliderPaired(getAnchorXSpinner(), getAnchorXSlider()); + anchorYPair = new SpinnerSliderPaired(getAnchorYSpinner(), getAnchorYSlider()); + + rotationPair = new SpinnerSliderPaired(getRotationSpinner(), getRotationSlider()); + rotationPair.setSpinnerWraps(true); + + scalePair = + new SpinnerSliderPaired( + getScaleSpinner(), + getScaleSlider(), + null, + percentSpinnerToSlider, + percentSliderToSpinner); + scalePair.addVetoableChangeListener( + evt -> { + if (evt.getPropertyName().toLowerCase().contains("value") + && evt.getNewValue().getClass().isAssignableFrom(Double.class) + && ((Number) evt.getNewValue()).doubleValue() < 0.1) { + throw new PropertyVetoException("Minimum scale value reached", evt); + } + }); + zoomPair = + new SpinnerSliderPaired( + getZoomSpinner(), + getZoomSlider(), + null, + percentSpinnerToSlider, + percentSliderToSpinner); + zoomPair.addVetoableChangeListener( + evt -> { + if (evt.getPropertyName().toLowerCase().contains("value") + && evt.getNewValue().getClass().isAssignableFrom(Double.class) + && ((Number) evt.getNewValue()).doubleValue() < 0.34) { + throw new PropertyVetoException("Minimum zoom value reached", evt); + } + }); + anchorXPair.addPropertyChangeListener(evt -> iFeelDirty()); + anchorYPair.addPropertyChangeListener(evt -> iFeelDirty()); + rotationPair.addPropertyChangeListener(evt -> iFeelDirty()); + scalePair.addPropertyChangeListener(evt -> iFeelDirty()); + zoomPair.addPropertyChangeListener(evt -> iFeelDirty()); + } + + public void initSpinners() { + /* models */ + getAnchorXSpinner().setModel(new SpinnerNumberModel(0d, -200d, 200d, 1d)); + getAnchorYSpinner().setModel(new SpinnerNumberModel(0d, -200d, 200d, 1d)); + getRotationSpinner().setModel(new SpinnerNumberModel(0d, 0d, 360d, 1d)); + getScaleSpinner().setModel(new SpinnerNumberModel(1d, 0d, 3d, 0.1)); + getZoomSpinner().setModel(new SpinnerNumberModel(1d, 0d, 3d, 0.1)); + getZoomSpinner().setVisible(false); + /* editors */ + getAnchorXSpinner().setEditor(new JSpinner.NumberEditor(anchorXSpinner, "0")); + getAnchorYSpinner().setEditor(new JSpinner.NumberEditor(anchorYSpinner, "0")); + getRotationSpinner().setEditor(new JSpinner.NumberEditor(rotationSpinner, "0.0")); + getScaleSpinner().setEditor(new JSpinner.NumberEditor(scaleSpinner, "0%")); + + ((JSpinner.NumberEditor) getAnchorXSpinner().getEditor()).getTextField().setColumns(3); + ((JSpinner.NumberEditor) getAnchorYSpinner().getEditor()).getTextField().setColumns(3); + ((JSpinner.NumberEditor) getRotationSpinner().getEditor()).getTextField().setColumns(3); + ((JSpinner.NumberEditor) getScaleSpinner().getEditor()).getTextField().setColumns(4); + + /* listeners */ + ((JSpinner.NumberEditor) getAnchorXSpinner().getEditor()) + .getTextField() + .addFocusListener(focusListener); + ((JSpinner.NumberEditor) getAnchorYSpinner().getEditor()) + .getTextField() + .addFocusListener(focusListener); + ((JSpinner.NumberEditor) getRotationSpinner().getEditor()) + .getTextField() + .addFocusListener(focusListener); + ((JSpinner.NumberEditor) getScaleSpinner().getEditor()) + .getTextField() + .addFocusListener(focusListener); + } + + public void initButtons() { + /* icons */ + createButtonIcons(); + + scaleButton = new FlatButton(); + parent.replaceComponent("scalePanel", "scaleButton", scaleButton); + getScaleButton().addActionListener(new ScaleButtonListener()); + getScaleButton().setIcon(scaleIcons[scaleAxis]); + getScaleLabel().setText(I18N.getString("sightLight.optionLabel.scale") + " "); + + FlatButton layoutHelpButton = new FlatButton(); + layoutHelpButton.setButtonType(FlatButton.ButtonType.help); + parent.replaceComponent("layoutTabPanel", "layoutHelpButton", layoutHelpButton); + layoutHelpButton.setToolTipText(helpText); + layoutHelpButton.addActionListener(e -> showHelp()); + layoutHelpButton.addMouseListener( + new MouseAdapter() { + @Override + public void mouseExited(MouseEvent e) { + super.mouseExited(e); + iFeelDirty(); + } + }); + } + + private String assembleHelpText() { + String rowText = "%s%s"; + String caption = ""; + return caption.formatted(I18N.getString("EditTokenDialog.layout.help.caption")) + + rowText.formatted( + I18N.getString("Mouse.leftDrag"), + I18N.getString("EditTokenDialog.layout.help.moveImage")) + + rowText.formatted( + I18N.getString("Mouse.rightDrag"), + I18N.getString("EditTokenDialog.layout.help.moveView")) + + rowText.formatted( + I18N.getString("Mouse.leftDoubleClick"), + I18N.getString("EditTokenDialog.layout.help.reset")) + + rowText.formatted( + I18N.getString("Mouse.rightDoubleClick"), + I18N.getString("EditTokenDialog.layout.help.resetDefaults")) + + rowText.formatted( + I18N.getString("Mouse.wheel"), I18N.getString("EditTokenDialog.layout.help.scaleImage")) + + rowText.formatted( + I18N.getString("Mouse.shiftWheel"), + I18N.getString("EditTokenDialog.layout.help.rotateImage")) + + rowText.formatted( + I18N.getString("Mouse.ctrlWheel"), + I18N.getString("EditTokenDialog.layout.help.zoomView")); + } + + private void showHelp() { + JPanel helpPanel = new JPanel(new BorderLayout()); + GenericDialog gd = new GenericDialog("Layout Controls", MapTool.getFrame(), helpPanel, true); + gd.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + JButton okayButton = new JButton(); + okayButton.setText(I18N.getString("Button.ok")); + + JLabel helpTextContainer = new JLabel(); + helpTextContainer.setText(helpText); + helpPanel.add(new JScrollPane(helpTextContainer), BorderLayout.NORTH); + helpPanel.add(okayButton, BorderLayout.SOUTH); + okayButton.addActionListener( + e -> { + gd.closeDialog(); + iFeelDirty(); + }); + gd.showDialog(); + } + + public void initSliders() { + getAnchorXSlider().setModel(new DefaultBoundedRangeModel(0, gridSize, -gridSize, gridSize)); + getAnchorYSlider().setModel(new DefaultBoundedRangeModel(0, gridSize, -gridSize, gridSize)); + getScaleSlider().setModel(new DefaultBoundedRangeModel(0, 0, -200, 200)); + getZoomSlider().setModel(new DefaultBoundedRangeModel(0, 0, -200, 200)); + getRotationSlider().setModel(new DefaultBoundedRangeModel(0, 360, 0, 360)); + + class VertLabel extends VerticalLabel { + public double divisor = 1; + + public VertLabel(String text) { + super(text); + } + + public void setDivisor(double divisor) { + this.divisor = divisor; + } + + protected void paintComponent(Graphics g) { + Graphics2D g2d = (Graphics2D) g; + if (isRotated()) + g2d.rotate( + Math.toRadians(-90 * getRotation() / divisor), + getPreferredSize().getWidth(), + getPreferredSize().getHeight()); + + super.paintComponent(g2d); + g2d.dispose(); + } + } + Dictionary offSetYLabels = new Hashtable<>(); + Dictionary offSetXLabels = new Hashtable<>(); + for (int i = -1200; i <= 1200; i += 50) { + JLabel label1 = new JLabel(String.valueOf(i)); + offSetYLabels.put(i, label1); + VertLabel label2 = new VertLabel(String.valueOf(i)); + label2.setRotation(VerticalLabel.ROTATE_RIGHT); + label2.setDivisor(3d); + offSetXLabels.put(i, label2); + } + getAnchorYSlider().setLabelTable(offSetYLabels); + getAnchorXSlider().setLabelTable(offSetXLabels); + + DecimalFormat df = new DecimalFormat("##0%"); + Dictionary pctLabels = new Hashtable<>(); + pctLabels.put(-200, new JLabel(df.format(0))); + pctLabels.put(-100, new JLabel(df.format(0.5))); + pctLabels.put(0, new JLabel(df.format(1))); + pctLabels.put(100, new JLabel(df.format(2))); + pctLabels.put(200, new JLabel(df.format(3))); + + getScaleSlider().setLabelTable(pctLabels); + getZoomSlider().setLabelTable(pctLabels); + + setupSlider(getAnchorXSlider(), 50, 25); + setupSlider(getAnchorYSlider(), 50, 25); + setupSlider(getRotationSlider(), 60, 12); + setupSlider(getScaleSlider(), 100, 20); + setupSlider(getZoomSlider(), 100, 20); + } + + private void setupSlider(JSlider slider, int majorTick, int minorTick) { + slider.setMajorTickSpacing(majorTick); + slider.setMinorTickSpacing(minorTick); + slider.setPaintTicks(true); + slider.setPaintLabels(true); + } + + /** Set controls to match token values */ + private void setControlValues() { + getAnchorXSpinner().setValue(getTokenAnchorX()); + getAnchorYSpinner().setValue(getTokenAnchorY()); + scaleAxis = 0; + getScaleButton().setIcon(scaleIcons[scaleAxis]); + getScaleSpinner().setValue(getTokenSizeScale()); + getRotationSpinner().setValue(getTokenImageRotation()); + getZoomSpinner().setValue(1d); + } + + ImageIcon[] scaleIcons = new ImageIcon[3]; + + private void createButtonIcons() { + String iconBase = "net/rptools/maptool/client/image/"; + scaleIcons[0] = new FlatSVGIcon(iconBase + "scale.svg", ICON_SIZE, ICON_SIZE); + scaleIcons[1] = new FlatSVGIcon(iconBase + "scaleHor.svg", ICON_SIZE, ICON_SIZE); + scaleIcons[2] = new FlatSVGIcon(iconBase + "scaleVert.svg", ICON_SIZE, ICON_SIZE); + } + + private class ScaleButtonListener implements ActionListener { + @Override + public void actionPerformed(ActionEvent e) { + scaleAxis = scaleAxis == 2 ? 0 : scaleAxis + 1; + getScaleButton().setIcon(scaleIcons[scaleAxis]); + switch (scaleAxis) { + case 0 -> { + getScaleSlider().setValue(percentSpinnerToSlider.apply(getTokenSizeScale())); + getScaleLabel().setText(I18N.getString("sightLight.optionLabel.scale") + " "); + } + case 1 -> { + getScaleSlider().setValue(percentSpinnerToSlider.apply(getTokenScaleX())); + getScaleLabel().setText(I18N.getString("sightLight.optionLabel.scale") + "-X"); + } + case 2 -> { + getScaleSlider().setValue(percentSpinnerToSlider.apply(getTokenScaleY())); + getScaleLabel().setText(I18N.getString("sightLight.optionLabel.scale") + "-Y"); + } + } + } + } + + /** + * Just a convenient place to hold a bunch of stuff that is not token related but purely for the + * rendering panel + */ + class RenderBits { + RenderBits() { + if (FlatLaf.isLafDark()) { + panelTexture = ImageUtil.negativeImage(panelTexture); + for (int i = 0; i < colours.length; i++) { + colours[i] = new Color(ImageUtil.negativeColourInt(colours[i].getRGB())); + } + } + + backgroundTexture = + new TexturePaint( + panelTexture, new Rectangle(0, 0, panelTexture.getWidth(), panelTexture.getHeight())); + setStrokeArrays(); + gridShapeFill = createGridShape(false); + gridShapeOutline = createGridShape(true); + } + + void init() { + Dimension size = getRenderPanel().getSize(); + viewBounds = getRenderPanel().getVisibleRect(); + viewOffset = getRenderPanel().getViewOffset(); + centrePoint = + new Point2D.Double( + size.width / 2d + viewOffset.getX(), size.height / 2d + viewOffset.getY()); + + if (zoomFactor != getRenderPanel().getZoomFactor()) { + zoomFactor = getRenderPanel().getZoomFactor(); + constrainedZoom = MathUtil.constrainNumber(zoomFactor, 1, 1.6d).floatValue(); + setStrokeArrays(); + } + + if (tokenImage == null) { + /* just to avoid Div/0 if called before image loaded */ + tokenImage = + new BufferedImage( + (int) footprintBounds.getWidth(), + (int) footprintBounds.getHeight(), + BufferedImage.TYPE_4BYTE_ABGR_PRE); + } + iso_figure_ho = ImageUtil.getIsoFigureHeightOffset(mirrorToken, footprintBounds); + + workImage = ImageUtil.getScaledTokenImage(tokenImage, mirrorToken, grid, zoomFactor); + workImage = getFlippedImage(workImage); + workImage = ImageUtil.rotateImage(workImage, mirrorToken.getImageRotation()); + } + + private void setStrokeArrays() { + if (solidStrokes == null) { + solidStrokes = new BasicStroke[strokeModels.length]; + dashedStrokes = new BasicStroke[strokeModels.length]; + } + float thickest = strokeModels[0].getLineWidth(); + float thinnest = strokeModels[3].getLineWidth(); + boolean mapValues = constrainedZoom != 1f; + for (int i = 0; i < strokeModels.length; i++) { + BasicStroke model = strokeModels[i]; + float useWidth; + if (mapValues) { + useWidth = + MathUtil.mapToRange( + model.getLineWidth(), + thinnest, + thickest, + thinnest * constrainedZoom, + thickest * constrainedZoom); + solidStrokes[i] = new BasicStroke(useWidth, model.getEndCap(), model.getLineJoin()); + dashedStrokes[i] = + new BasicStroke( + useWidth, + model.getEndCap(), + model.getLineJoin(), + model.getMiterLimit(), + model.getDashArray(), + model.getDashPhase()); + } + } + } + + static final RenderingHints RENDERING_HINTS = ImageUtil.getRenderingHintsQuality(); + static final float LINE_SIZE = (float) (DEFAULT_FONT_SIZE / 12f); + Rectangle2D viewBounds; + Point2D viewOffset, centrePoint; + double zoomFactor = 1, iso_figure_ho = 0; + float constrainedZoom = 1; + private static BufferedImage panelTexture = RessourceManager.getImage(Images.TEXTURE_PANEL); + BufferedImage workImage; + static TexturePaint backgroundTexture; + Shape centreMark, gridShapeFill, gridShapeOutline; + BasicStroke[] solidStrokes, dashedStrokes; + BasicStroke[] strokeModels = + new BasicStroke[] { + new BasicStroke( + LINE_SIZE * 2.35f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 1f, + new float[] {4f, 6f}, + 2f), + new BasicStroke( + LINE_SIZE * 1.63f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 1f, + new float[] {3.5f, 6.5f}, + 1.5f), + new BasicStroke( + LINE_SIZE * 1.45f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 1f, + new float[] {2f, 8f}, + 1.5f), + new BasicStroke( + LINE_SIZE, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 1f, + new float[] {2.5f, 7.5f}, + 1.5f) + }; + public Color[] colours = + new Color[] {Color.YELLOW, Color.RED, Color.BLUE, Color.BLACK, Color.DARK_GRAY}; + + /** + * Returns an image flipped according to the token's flip properties + * + * @param bi Image to flip + * @return Flipped bufferedImage + */ + protected BufferedImage getFlippedImage(BufferedImage bi) { + log.debug("getFlippedImage - flipStates: " + flipStates); + int direction = + (flipStates.contains(FlipState.HORIZONTAL) ? 1 : 0) + + (flipStates.contains(FlipState.VERTICAL) ? 2 : 0); + if (direction != 0) { + bi = ImageUtil.flipCartesian(bi, direction); + } + if (flipStates.contains(FlipState.ISOMETRIC)) { + bi = ImageUtil.flipIsometric(bi, true); + } + return bi; + } + + private Shape createGridShape(boolean trueSize) { + return GraphicsUtil.createGridShape( + GridFactory.getGridType(grid), (trueSize ? grid.getSize() : grid.getSize() - 8)); + } + + /** Itty bitty cross to show the dead-centre of the footprint */ + void createCentreMark() { + double aperture = Math.max(cellHeight, cellWidth) / 7.0; + double r = aperture / 4.0; + Path2D path = new Path2D.Double(); + path.moveTo(-r, -r); + path.lineTo(r, r); + path.moveTo(-r, r); + path.lineTo(r, -r); + centreMark = path; + } + + protected void paintCentreMark(Graphics g) { + if (centreMark == null) { + createCentreMark(); + } + Graphics2D g2d = (Graphics2D) g; + g2d.translate(centrePoint.getX(), centrePoint.getY()); + g2d.scale(constrainedZoom, constrainedZoom); + g2d.setStroke( + new BasicStroke( + constrainedZoom * 2.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL, 10f)); + g2d.setColor(colours[4]); + Composite oldAc = g2d.getComposite(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacities[0])); + g2d.draw(centreMark); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacities[2])); + g2d.setStroke( + new BasicStroke( + constrainedZoom * 1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL, 10f)); + g2d.setColor(colours[3]); + g2d.draw(centreMark); + g2d.setComposite(oldAc); + renderBits.paintShapeOutLine(g2d, centreMark, true, true); + g2d.dispose(); + } + + /** + * Used for gridless maps, paints concentric rings with an interval of the grid size. Paints a + * ring of a different colour at the radius associated with the footprint scale. Also paints + * some radial lines to assist with alignment + * + * @param g graphics object + * @param zoomFactor zoom level applied to view + */ + void paintRings(Graphics g, double zoomFactor) { + Graphics2D g2d = (Graphics2D) g.create(); + /* used with no grid. A set of rings and radial lines. */ + TokenFootprint fp = mirrorToken.getFootprint(grid); + Rectangle2D fpCellBounds = fp.getBounds(grid, ORIGIN); + fpCellBounds = + new Rectangle2D.Double( + 0, + 0, + fpCellBounds.getWidth() * footprint.getScale(), + fpCellBounds.getHeight() * footprint.getScale()); + + double cx = viewBounds.getCenterX() + viewOffset.getX(); + double cy = viewBounds.getCenterY() + viewOffset.getY(); + + double gap = grid.getSize() * zoomFactor; + double maxRadius = Math.hypot(this.viewBounds.getWidth(), this.viewBounds.getHeight()); + double currentRadius = gap / 2d; + double tokenRadius = fpCellBounds.getCenterX() * zoomFactor; + Line2D lineLong = new Line2D.Double(cx + currentRadius / 2d, cy, maxRadius, cy); + Line2D lineShort = new Line2D.Double(cx + gap * 2d, cy, maxRadius, cy); + + /* draw radial lines */ + for (double i = 0; i < 24; i++) { + if (i % 6 == 0) { + continue; /* skip cardinal lines */ + } + paintShapeOutLine( + g2d, + AffineTransform.getRotateInstance(Math.TAU / 24 * i, cx, cy) + .createTransformedShape(i % 2 == 1 ? lineShort : lineLong), + false, + true); + } + + /* draw rings */ + while (currentRadius < maxRadius) { + Ellipse2D e = + new Ellipse2D.Double( + cx - currentRadius, cy - currentRadius, 2 * currentRadius, 2 * currentRadius); + paintShapeOutLine(g2d, e, true, true); + currentRadius += gap; + } + paintShapeOutLine( + g2d, + new Ellipse2D.Double( + cx - tokenRadius, cy - tokenRadius, tokenRadius * 2, tokenRadius * 2), + true, + false); + g2d.dispose(); + } + + /** + * Horizontal and vertical lines oriented on cx/cy + * + * @param g graphics object + * @param rotation used to draw lines on rotated images + * @param solid Draw as solid else dashed + * @param colourSet1 Use colour set 1 else 2 + */ + void paintCentreLines(Graphics g, double rotation, boolean solid, boolean colourSet1) { + /* create cross-hair with a central gap */ + double cx = centrePoint.getX(); + double cy = centrePoint.getY(); + Rectangle2D r = viewBounds; + double x = r.getX() - 1, + y = r.getY() - 1, + w = x + r.getWidth() + 2, + h = y + r.getHeight() + 2; + + double aperture = Math.max(cellHeight, cellWidth) / 5.0; + Path2D lines = new Path2D.Double(); + lines.moveTo(cx, y - h); + lines.lineTo(cx, cy - aperture); + lines.moveTo(cx, cy + aperture); + lines.lineTo(cx, h * 2); + lines.moveTo(x - w, cy); + lines.lineTo(cx - aperture, cy); + lines.moveTo(cx + aperture, cy); + lines.lineTo(2 * w, cy); + Shape s; + if (!MathUtil.inTolerance(rotation, 0, 0.009)) { + s = + AffineTransform.getRotateInstance(Math.toRadians(rotation), cx, cy) + .createTransformedShape(lines); + s = + AffineTransform.getTranslateInstance( + getTokenAnchorX() * zoomFactor, getTokenAnchorY() * zoomFactor) + .createTransformedShape(s); + } else { + s = lines; + } + paintShapeOutLine(g, s, solid, colourSet1); + } + + /* + Each line is drawn as a sequence of overlapping lines. + The following arrays are used to define each stroke + */ + float[] opacities = new float[] {0.15f, 0.85f, 0.6f, 1f}; + float[] strokeWidths = + new float[] {2f * LINE_SIZE, 1.5f * LINE_SIZE, LINE_SIZE, 0.5f * LINE_SIZE}; + float[] dashPhases = + new float[] { + 2f * LINE_SIZE + 2f, 2f * LINE_SIZE + 1.75f, 2f * LINE_SIZE + 1f, 2f * LINE_SIZE + 1.25f + }; + float[][] dashes = + new float[][] { + {4f * LINE_SIZE + 4f, 6f}, + {4f * LINE_SIZE + 3.5f, 6.5f}, + {4f * LINE_SIZE + 2f, 8f}, + {4f * LINE_SIZE + 2.5f, 7.5f} + }; + + void paintShapeOutLine(Graphics g, Shape shp, boolean solid, boolean colourSet1) { + Graphics2D g2d = (Graphics2D) g.create(); + Composite oldAc = g2d.getComposite(); + AlphaComposite ac; + for (int i = 0; i < 4; i++) { + switch (i) { + case 0, 1 -> g2d.setColor(colourSet1 ? colours[2] : colours[3]); + case 2, 3 -> g2d.setColor(colourSet1 ? colours[0] : colours[1]); + } + g2d.setStroke( + solid + ? new BasicStroke(strokeWidths[i]) + : new BasicStroke( + strokeWidths[i], + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 1f, + dashes[i], + dashPhases[i])); + + ac = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacities[i]); + g2d.setComposite(ac); + g2d.draw(shp); + } + g2d.setComposite(oldAc); + g2d.dispose(); + } + + void paintFootprint(Graphics g, double zoomFactor) { + if (noGrid) { + paintRings(g, zoomFactor); + return; + } + + Graphics2D g2d = (Graphics2D) g.create(); + Shape oldClip = g2d.getClip(); + AffineTransform oldXform = g2d.getTransform(); + g2d.setRenderingHints(RENDERING_HINTS); + + g2d.translate(centrePoint.getX(), centrePoint.getY()); + g2d.scale(zoomFactor, zoomFactor); + + Shape tmpShape; + + Area clipArea = new Area(g2d.getClipBounds()); + Area tmpClip = new Area(), fpArea = new Area(); + g2d.setStroke(new BasicStroke(1f)); + g2d.setColor(colours[4]); + + double footprintScale = footprint.getScale(); + double yCorrection = + isIsoFigure ? footprintScale * footprintBounds.getHeight() / 2d * zoomFactor : 0; + + Shape scaledOutline, scaledFill; + /* for drawing sub-cell-sizes */ + if (footprintScale < 1d) { + scaledOutline = + AffineTransform.getScaleInstance(footprintScale, footprintScale) + .createTransformedShape(gridShapeOutline); + scaledFill = + AffineTransform.getScaleInstance(footprintScale, footprintScale) + .createTransformedShape(gridShapeFill); + } else { + scaledOutline = gridShapeOutline; + scaledFill = gridShapeFill; + } + for (Point2D pt : cellCentres) { + AffineTransform ptXform = + AffineTransform.getTranslateInstance(pt.getX(), pt.getY() + yCorrection); + if (footprintScale < 1) { + g2d.draw(ptXform.createTransformedShape(gridShapeOutline)); + } + g2d.draw(ptXform.createTransformedShape(scaledOutline)); + tmpShape = ptXform.createTransformedShape(scaledFill); + fpArea.add(new Area(tmpShape)); + } + tmpClip.subtract(fpArea); + g2d.setClip(tmpClip); + g2d.setPaint(FlatLaf.isLafDark() ? new Color(1f, 1f, 1f, 0.35f) : new Color(0, 0, 0, 0.35f)); + g2d.setClip(fpArea); + g2d.fill(fpArea); + + g2d.setClip(clipArea); + paintShapeOutLine(g2d, fpArea, true, true); + g2d.setTransform(oldXform); + g2d.setClip(oldClip); + + g2d.dispose(); + paintExtraGuides(g); + } + + void paintExtraGuides(Graphics g) { + if (noGrid) { + return; + } + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setRenderingHints(RENDERING_HINTS); + + g2d.translate( + centrePoint.getX(), + centrePoint.getY() + (isIsoFigure ? footprintBounds.getHeight() * zoomFactor / 2d : 0)); + + g2d.setPaint(colours[4]); + g2d.setStroke( + new BasicStroke( + 0.5f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 10f, + new float[] {0.5f, 1.75f}, + 0)); + + double limit = Math.hypot(g2d.getClipBounds().getWidth(), g2d.getClipBounds().getHeight()); + double radius = footprintBounds.getHeight() * zoomFactor; + Rectangle2D bounds = gridShapeOutline.getBounds2D(); + while (radius < limit) { + radius += 2 * cellHeight * zoomFactor; + Shape s = + AffineTransform.getScaleInstance( + radius / bounds.getHeight(), radius / bounds.getHeight()) + .createTransformedShape(gridShapeOutline); + g2d.draw(s); + } + g2d.dispose(); + } + + void paintToken(Graphics g, boolean translucent) { + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setRenderingHints(RENDERING_HINTS); + if (centreMark == null) { + createCentreMark(); + } + + g2d.translate(centrePoint.getX(), centrePoint.getY()); + g2d.translate(getTokenAnchorX() * zoomFactor, getTokenAnchorY() * zoomFactor); + + Composite oldAc = g2d.getComposite(); + + AffineTransform imageXform = + AffineTransform.getTranslateInstance( + -workImage.getWidth() / 2d, -workImage.getHeight() / 2d); + + if (translucent) { + AlphaComposite ac = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.4f); + g2d.setComposite(ac); + g2d.drawImage(workImage, imageXform, null); + g2d.setComposite(oldAc); + } else { + g2d.drawImage(workImage, imageXform, null); + } + g2d.dispose(); + double rotAngle = getTokenImageRotation(); + if (rotAngle > 0.01 && rotAngle < 359.99) { + paintCentreLines(g, rotAngle, false, true); + } + } + } +} diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutRenderPanel.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutRenderPanel.java new file mode 100644 index 0000000000..5715776b3d --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenLayoutRenderPanel.java @@ -0,0 +1,356 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.token.dialog.edit; + +import java.awt.*; +import java.awt.event.*; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.swing.*; +import net.rptools.lib.MathUtil; +import net.rptools.maptool.client.AppStyle; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.swing.SwingUtil; +import net.rptools.maptool.language.I18N; +import net.rptools.maptool.model.Token; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Support class used by the token editor dialog on the "Properties" tab to allow a token's image to + * be moved around within a one-cell grid area. Scaling is supported using the mousewheel and + * position is supported using left-drag. We should add rotation ability using Shift-mousewheel as + * well. + * + * @author trevor + */ +public class TokenLayoutRenderPanel extends JPanel { + private static final Logger log = LogManager.getLogger(TokenLayoutRenderPanel.class); + + public TokenLayoutRenderPanel() { + evtTarget = MouseTarget.NONE; + addRenderPaneListeners(); + setFocusable(true); + addComponentListener( + new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent e) { + super.componentShown(e); + size = getSize(); + requestFocus(); + } + + @Override + public void componentResized(ComponentEvent e) { + super.componentResized(e); + size = getSize(); + zoomFactor = -1; + calcZoomFactor(); + } + }); + log.debug("New TokenLayoutPanel"); + } + + private TokenLayoutPanelHelper helper; + + protected void setHelper(TokenLayoutPanelHelper h) { + helper = h; + } + + public TokenLayoutPanelHelper getHelper() { + return helper; + } + + private enum MouseTarget { + NONE, + IMAGE_OFFSET, + ROTATION, + SCALE, + VIEW_OFFSET, + ZOOM + } + + private MouseTarget evtTarget; + private Token token; + private int viewOffsetX = 0, viewOffsetY = 0, dragStartX, dragStartY; + private int maxXoff = 100, maxYoff = 100; + + public void setMaxXoff(int maxX) { + maxXoff = maxX; + } + + public void setMaxYoff(int maxY) { + maxYoff = maxY; + } + + private double zoomFactor = -1.0; + + public void setInitialScale(double initialScale) { + this.initialScale = initialScale; + } + + private double initialScale = 1.0; + public Supplier zoom = () -> zoomFactor; + public Consumer zoomSet = d -> zoomFactor = MathUtil.doublePrecision(d.doubleValue(), 4); + + Supplier getZoomSupplier() { + return zoom; + } + + Consumer getZoomConsumer() { + return zoomSet; + } + + public double getZoomFactor() { + return zoom.get().doubleValue(); + } + + private void setZoomFactor(double d) { + d = MathUtil.doublePrecision(d, 4); + if (d == zoomFactor) { + return; + } + if (helper.zoomPair.getSpinnerValue() != zoomFactor) { + helper.zoomPair.setValue(d); + } + } + + Dimension size = getSize(); + + protected Point2D.Double getViewOffset() { + return new Point2D.Double(viewOffsetX, viewOffsetY); + } + + public void setToken(Token token) { + log.debug("Setting token"); + zoomFactor = -1d; + this.token = token; + helper.setToken(token, false); + setInitialScale(helper.getTokenSizeScale()); + calcZoomFactor(); + } + + public void reset(Token token) { + setToken(token); + } + + /** Work out a zoom factor to fit the token on screen with a half cell border */ + protected void calcZoomFactor() { + Rectangle2D fpBounds = + new Rectangle2D.Double( + helper.footprintBounds.getX(), + helper.footprintBounds.getY(), + helper.footprintBounds.getWidth(), + helper.footprintBounds.getHeight()); + if (token == null || getSize().height == 0 || size == null) { + return; + } + fpBounds.setRect( + fpBounds.getWidth() / 2d, + fpBounds.getHeight() / 2d, + fpBounds.getWidth(), + fpBounds.getHeight()); + if (helper.getTokenAnchorX() != 0 || helper.getTokenAnchorY() != 0) { + fpBounds.add( + new Rectangle2D.Double( + fpBounds.getX() + helper.getTokenAnchorX(), + fpBounds.getY() + helper.getTokenAnchorY(), + fpBounds.getWidth() + helper.getTokenAnchorX(), + fpBounds.getHeight() + helper.getTokenAnchorX())); + } + if (getHelper().isIsoFigure) { + double th = token.getHeight() * fpBounds.getWidth() / token.getWidth(); + double iso_ho = fpBounds.getHeight() - th; + fpBounds.add( + new Rectangle2D.Double( + fpBounds.getX(), fpBounds.getY() - iso_ho, fpBounds.getWidth(), th)); + } + double fitWidth = fpBounds.getWidth() + helper.grid.getCellWidth() / 2; + double fitHeight = fpBounds.getHeight() + helper.grid.getCellHeight() / 2; + // which axis has the least space to grow + boolean scaleToWidth = size.getWidth() - fitWidth < size.getHeight() - fitHeight; + // set the zoom-factor + double newZoom = scaleToWidth ? size.getWidth() / fitWidth : size.getHeight() / fitHeight; + + setZoomFactor(newZoom); + log.debug( + "calculated ZoomFactor: " + + zoomFactor + + "\nSize: " + + size + + "\nFootprint bounds: " + + fpBounds); + } + + private void addRenderPaneListeners() { + log.debug("addRenderPaneListeners"); + addMouseListener( + new MouseAdapter() { + String old; + + @Override + public void mouseReleased(MouseEvent e) { + super.mouseReleased(e); + dragStartX = -1; + dragStartY = -1; + evtTarget = MouseTarget.NONE; + helper.iFeelDirty(); + } + + @Override + public void mousePressed(MouseEvent e) { + dragStartX = e.getX(); + dragStartY = e.getY(); + if (SwingUtilities.isLeftMouseButton(e)) { + // start token drag + evtTarget = MouseTarget.IMAGE_OFFSET; + } else if (SwingUtilities.isRightMouseButton(e)) { + // start view drag + evtTarget = MouseTarget.VIEW_OFFSET; + } + } + + @Override + public void mouseEntered(MouseEvent e) { + old = MapTool.getFrame().getStatusMessage(); + MapTool.getFrame() + .setStatusMessage(I18N.getString("EditTokenDialog.status.layout.instructions")); + } + + @Override + public void mouseExited(MouseEvent e) { + if (old != null) MapTool.getFrame().setStatusMessage(old); + evtTarget = MouseTarget.NONE; + helper.iFeelDirty(); + } + + @Override + public void mouseClicked(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { + viewOffsetX = 0; + viewOffsetY = 0; + maxXoff = 100; + maxYoff = 100; + helper.resetPanel(); + evtTarget = MouseTarget.NONE; + } + if (SwingUtilities.isRightMouseButton(e) && e.getClickCount() == 2) { + viewOffsetX = 0; + viewOffsetY = 0; + maxXoff = 100; + maxYoff = 100; + helper.resetPanelToDefault(); + evtTarget = MouseTarget.NONE; + } + } + }); + addMouseWheelListener( + new MouseWheelListener() { + @Override + public void mouseWheelMoved(MouseWheelEvent e) { + double delta = e.getPreciseWheelRotation(); + if (delta == 0) { + return; + } + evtTarget = + SwingUtil.isShiftDown(e) + ? MouseTarget.ROTATION + : SwingUtil.isControlDown(e) ? MouseTarget.ZOOM : MouseTarget.SCALE; + + switch (evtTarget) { + case MouseTarget.ROTATION -> { + helper.rotationPair.incrementValue(delta); + } + case MouseTarget.ZOOM -> { + helper.zoomPair.incrementValue(delta); + setZoomFactor(helper.zoomPair.getSpinnerValue()); + } + case MouseTarget.SCALE -> { + // Only for NOT snap-to-scale + if (!token.isSnapToScale()) { + return; + } + helper.scalePair.incrementValue(delta / 16d); + } + default -> log.debug("Defaulting - invalid mouse event target."); + } + evtTarget = MouseTarget.NONE; + helper.iFeelDirty(); + } + }); + addMouseMotionListener( + new MouseMotionAdapter() { + @Override + public void mouseDragged(MouseEvent e) { + int dx = e.getX() - dragStartX; + int dy = e.getY() - dragStartY; + if (evtTarget == MouseTarget.IMAGE_OFFSET) { + int offX = MathUtil.constrainInt(helper.getTokenAnchorX() + dx, -maxXoff, maxXoff); + int offY = MathUtil.constrainInt(helper.getTokenAnchorY() + dy, -maxYoff, maxYoff); + helper.anchorXPair.setValue(offX); + helper.anchorYPair.setValue(offY); + + } else if (evtTarget == MouseTarget.VIEW_OFFSET) { + // drag view + viewOffsetX += dx; + viewOffsetY += dy; + repaint(); + } + dragStartX = e.getX(); + dragStartY = e.getY(); + } + }); + } + + @Override + protected void paintBorder(Graphics g) { + Graphics2D g2d = (Graphics2D) g; + AppStyle.shadowBorder.paintWithin(g2d, 0, 0, getSize().width, getSize().height); + super.paintBorder(g); + } + + @Override + protected void paintComponent(Graphics g) { + helper.renderBits.init(); + if (helper.renderBits.viewBounds == null) { + return; + } + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHints(TokenLayoutPanelHelper.RenderBits.RENDERING_HINTS); + // Cleanup + g2d.clearRect(0, 0, size.width, size.height); + // Background + g2d.setPaint(TokenLayoutPanelHelper.RenderBits.backgroundTexture); + g2d.fillRect(0, 0, size.width, size.height); + + if (helper.getTokenImage() == null || size.width == 0) { + return; + } + if (zoomFactor == -1) { + calcZoomFactor(); + return; + } + // Footprint + helper.renderBits.paintFootprint(g, zoomFactor); + // Add horizontal and vertical lines to help with centering + helper.renderBits.paintCentreLines(g, 0, true, false); + // Token + helper.renderBits.paintToken(g, evtTarget.equals(MouseTarget.IMAGE_OFFSET)); + helper.renderBits.paintCentreMark(g); + g2d.dispose(); + } +} diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.form b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.form index 57752636be..a28d3ff1a7 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.form +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.form @@ -1,16 +1,16 @@
- + - + - + @@ -19,7 +19,7 @@ - + @@ -27,6 +27,7 @@ + @@ -38,7 +39,7 @@ - + @@ -550,429 +551,849 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + - - + + + + + + + + + + + + + + + + + + + - + - - + - - + + + + + + + + + + + + + + + + + + - + - - - - - - - + + - + - - - - - - - - - - + + - + - - - - - - - - + + - + - - - - - + + - + - - - - - - + + - + - - - - - - - + + + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + - - - - - - - - + + - + - + - - + + + - - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + - - + + - + - + - - - - - + - + - + + + - - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - - - - - + + - + - - - - - + + + - + - - - - - - + + + + + + + + + + + + + + + + + + - + - - + - - + + + + + + + + + + + + + + + + + + + + + - + - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - + + - + - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + - + + - + - + - - - - - - - + + - - - + - - - - - - - - - - - - - - - - + + - + - + @@ -1358,7 +1779,7 @@ - + @@ -1457,7 +1878,7 @@ - + @@ -1487,11 +1908,6 @@ - - - - - diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.java index 30bee1f16a..d2a0dc7bb7 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenPropertiesDialog.java @@ -23,11 +23,11 @@ public class TokenPropertiesDialog { private JPanel headPanel; private JPanel buttonPanel; private JLabel tokenImage; - private JTabbedPane tabPanel; private HtmlEditorSplit gmNotesEditor; private HtmlEditorSplit playerNotesEditor; private JComboBox comboBox1; private JComboBox comboBox2; + private JTabbedPane tabPanel; private JLabel ownershipList; public JComponent getRootComponent() { diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/TokenLocation.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/TokenLocation.java index 1f95f76940..dd4b65b8cd 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/TokenLocation.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/TokenLocation.java @@ -20,7 +20,7 @@ import net.rptools.lib.CodeTimer; import net.rptools.maptool.model.Token; -class TokenLocation { +public class TokenLocation { private final ZoneRenderer renderer; public Area bounds; diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index 380057c1e7..b73557b822 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -38,6 +38,7 @@ import javax.swing.*; import net.rptools.lib.CodeTimer; import net.rptools.lib.MD5Key; +import net.rptools.lib.image.ImageUtil; import net.rptools.maptool.client.*; import net.rptools.maptool.client.events.ZoneLoaded; import net.rptools.maptool.client.functions.TokenMoveFunctions; @@ -57,6 +58,7 @@ import net.rptools.maptool.client.ui.token.BarTokenOverlay; import net.rptools.maptool.client.ui.token.dialog.create.NewTokenDialog; import net.rptools.maptool.client.ui.zone.*; +import net.rptools.maptool.client.ui.zone.renderer.tokenRender.TokenRenderer; import net.rptools.maptool.client.walker.ZoneWalker; import net.rptools.maptool.events.MapToolEventBus; import net.rptools.maptool.language.I18N; @@ -148,6 +150,7 @@ public class ZoneRenderer extends JComponent implements DropTargetListener { private final ZoneCompositor compositor; private final GridRenderer gridRenderer; private final HaloRenderer haloRenderer; + private final TokenRenderer tokenRenderer; private final LightsRenderer lightsRenderer; private final DarknessRenderer darknessRenderer; private final LumensRenderer lumensRenderer; @@ -176,6 +179,7 @@ public ZoneRenderer(Zone zone) { this.compositor = new ZoneCompositor(); this.gridRenderer = new GridRenderer(); this.haloRenderer = new HaloRenderer(); + this.tokenRenderer = new TokenRenderer(); this.lightsRenderer = new LightsRenderer(renderHelper, zone, zoneView); this.darknessRenderer = new DarknessRenderer(renderHelper, zoneView); this.lumensRenderer = new LumensRenderer(renderHelper, zone, zoneView); @@ -285,7 +289,7 @@ public void setZoneScale(Scale scale) { scale.addPropertyChangeListener( evt -> { if (Scale.PROPERTY_SCALE.equals(evt.getPropertyName())) { - tokenLocationCache.clear(); + clearZoomDependantCaches(); } if (Scale.PROPERTY_OFFSET.equals(evt.getPropertyName())) { // flushFog = true; @@ -892,6 +896,7 @@ public void renderZone(Graphics2D g2d, PlayerView view) { if (!compositor.isInitialised()) compositor.setRenderer(this); if (!gridRenderer.isInitialised()) gridRenderer.setRenderer(this); if (!haloRenderer.isInitialised()) haloRenderer.setRenderer(this); + if (!tokenRenderer.isInitialised()) tokenRenderer.setRenderer(this); Rectangle viewRect = new Rectangle(getSize().width, getSize().height); @@ -1455,19 +1460,8 @@ protected void showBlockedMoves(Graphics2D g, PlayerView view, Set footprintBounds = token.getBounds(zone); } // Draw token - double iso_ho = 0; + double iso_ho = ImageUtil.getIsoFigureHeightOffset(token, footprintBounds) * getScale(); Dimension imgSize = new Dimension(workImage.getWidth(), workImage.getHeight()); - if (token.getShape() == TokenShape.FIGURE) { - double th = token.getHeight() * (double) footprintBounds.width / token.getWidth(); - iso_ho = footprintBounds.height - th; - footprintBounds = - new Rectangle( - footprintBounds.x, - footprintBounds.y - (int) iso_ho, - footprintBounds.width, - (int) th); - iso_ho = iso_ho * getScale(); - } SwingUtil.constrainTo(imgSize, footprintBounds.width, footprintBounds.height); int offsetx = 0; @@ -2015,60 +2009,6 @@ private List getTokenLocations(Zone.Layer layer) { return tokenLocationMap.computeIfAbsent(layer, k -> new LinkedList<>()); } - // TODO: I don't like this hardwiring - protected Shape getFigureFacingArrow(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() / 2)); - } - - // 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 tokenList, PlayerView view) { renderTokens(g, tokenList, view, false); } @@ -2118,7 +2058,6 @@ protected void renderTokens( // 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 tokenPostProcessing = new ArrayList(tokenList.size()); for (Token token : tokenList) { @@ -2165,12 +2104,6 @@ protected void renderTokens( 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"); @@ -2186,14 +2119,8 @@ protected void renderTokens( double sx = scaledWidth / 2 + x - (token.getAnchor().x * scale); double sy = scaledHeight / 2 + y - (token.getAnchor().y * scale); tokenBounds.transform( - AffineTransform.getRotateInstance( - Math.toRadians(token.getFacingInDegrees()), sx, sy)); // facing - // defaults - // to - // down, - // or - // -90 - // degrees + AffineTransform.getRotateInstance(Math.toRadians(token.getFacingInDegrees()), sx, sy)); + // facing defaults to down or -90 degrees } timer.stop("tokenlist-1d"); @@ -2228,7 +2155,6 @@ protected void renderTokens( } // Markers timer.start("renderTokens:Markers"); - // System.out.println("Token " + token.getName() + " is a marker? " + token.isMarker()); if (token.isMarker() && canSeeMarker(token)) { markerLocationList.add(location); } @@ -2237,13 +2163,10 @@ protected void renderTokens( // Stacking check if (calculateStacks) { timer.start("tokenStack"); - // System.out.println(token.getName() + " - " + location.boundsCache); Set 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(); @@ -2384,11 +2307,6 @@ protected void renderTokens( // Rotated if (token.hasFacing() && token.getShape() == Token.TokenShape.TOP_DOWN) { - // Jamz: Test, rotate on NW corner - // at.rotate(Math.toRadians(token.getFacingInDegrees()), (token.getAnchor().x * scale) - - // offsetx, - // (token.getAnchor().y * scale) - offsety); - at.rotate( Math.toRadians(token.getFacingInDegrees()), location.scaledWidth / 2 - (token.getAnchor().x * scale) - offsetx, @@ -2414,9 +2332,12 @@ protected void renderTokens( haloRenderer.renderHalo(tokenG, token, location); // Calculate alpha Transparency from token and use opacity for indicating that token is moving - float opacity = token.getTokenOpacity(); + float opacity = token.getOpacity(); if (isTokenMoving(token)) opacity = opacity / 2.0f; - + Map renderInfo = new HashMap<>(); + renderInfo.put(TokenRenderer.OPACITY, opacity); + renderInfo.put(TokenRenderer.GRAPHICS, tokenG); + renderInfo.put(TokenRenderer.LOCATION, location); // Finally render the token image timer.start("tokenlist-7"); if (!isGMView && zoneView.isUsingVision() && (token.getShape() == Token.TokenShape.FIGURE)) { @@ -2425,24 +2346,14 @@ protected void renderTokens( // the cell intersects visible area so if (zone.getGrid().checkCenterRegion(cb.getBounds(), visibleScreenArea)) { // if we can see the centre, draw the whole token - Composite oldComposite = tokenG.getComposite(); - if (opacity < 1.0f) { - tokenG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); - } - tokenG.drawImage(workImage, at, this); - tokenG.setComposite(oldComposite); - // g.draw(cb); // debugging + tokenRenderer.renderToken(token, renderInfo); + } else { // else draw the clipped token Area cellArea = new Area(visibleScreenArea); cellArea.intersect(cb); - tokenG.setClip(cellArea); - Composite oldComposite = tokenG.getComposite(); - if (opacity < 1.0f) { - tokenG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); - } - tokenG.drawImage(workImage, at, this); - tokenG.setComposite(oldComposite); + renderInfo.put(TokenRenderer.CLIP, cellArea); + tokenRenderer.renderToken(token, renderInfo); } } } else if (!isGMView && zoneView.isUsingVision() && token.isAlwaysVisible()) { @@ -2452,142 +2363,27 @@ protected void renderTokens( // if we can see a portion of the stamp/token, draw the whole thing, defaults to 2/9ths if (zone.getGrid() .checkRegion(cb.getBounds(), visibleScreenArea, token.getAlwaysVisibleTolerance())) { - Composite oldComposite = tokenG.getComposite(); - if (opacity < 1.0f) { - tokenG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); - } - tokenG.drawImage(workImage, at, this); - tokenG.setComposite(oldComposite); + tokenRenderer.renderToken(token, renderInfo); } else { // else draw the clipped stamp/token // This will only show the part of the token that does not have VBL on it // as any VBL on the token will block LOS, affecting the clipping. Area cellArea = new Area(visibleScreenArea); cellArea.intersect(cb); - tokenG.setClip(cellArea); - Composite oldComposite = tokenG.getComposite(); - if (opacity < 1.0f) { - tokenG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); - } - tokenG.drawImage(workImage, at, this); - tokenG.setComposite(oldComposite); + renderInfo.put(TokenRenderer.CLIP, cellArea); + tokenRenderer.renderToken(token, renderInfo); } } } else { // fallthrough normal token rendered against visible area - Composite oldComposite = tokenG.getComposite(); - if (opacity < 1.0f) { - tokenG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); - } - tokenG.drawImage(workImage, at, this); - tokenG.setComposite(oldComposite); + tokenRenderer.renderToken(token, renderInfo); } 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 + // Facing if (token.hasFacing()) { - Token.TokenShape tokenType = token.getShape(); - switch (tokenType) { - case FIGURE: - if (token.getHasImageTable() && !AppPreferences.forceFacingArrow.get()) { - break; - } - Shape arrow = getFigureFacingArrow(token.getFacing(), footprintBounds.width / 2); - - if (!zone.getGrid().isIsometric()) { - arrow = getCircleFacingArrow(token.getFacing(), footprintBounds.width / 2); - } - - double fx = location.x + location.scaledWidth / 2; - double fy = location.y + location.scaledHeight / 2; - - tokenG.translate(fx, fy); - if (token.getFacing() < 0) { - tokenG.setColor(Color.yellow); - } else { - tokenG.setColor(ZoneRendererConstants.TRANSLUCENT_YELLOW); - } - tokenG.fill(arrow); - tokenG.setColor(Color.darkGray); - tokenG.draw(arrow); - tokenG.translate(-fx, -fy); - break; - case TOP_DOWN: - if (!AppPreferences.forceFacingArrow.get()) { - break; - } - case CIRCLE: - arrow = getCircleFacingArrow(token.getFacing(), footprintBounds.width / 2); - if (zone.getGrid().isIsometric()) { - arrow = getFigureFacingArrow(token.getFacing(), footprintBounds.width / 2); - } - - double cx = location.x + location.scaledWidth / 2; - double cy = location.y + location.scaledHeight / 2; - - tokenG.translate(cx, cy); - tokenG.setColor(Color.yellow); - tokenG.fill(arrow); - tokenG.setColor(Color.darkGray); - tokenG.draw(arrow); - tokenG.translate(-cx, -cy); - break; - case SQUARE: - if (zone.getGrid().isIsometric()) { - arrow = getFigureFacingArrow(token.getFacing(), footprintBounds.width / 2); - cx = location.x + location.scaledWidth / 2; - cy = location.y + location.scaledHeight / 2; - } else { - 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; - } - - tokenG.translate(cx, cy); - tokenG.setColor(Color.yellow); - tokenG.fill(arrow); - tokenG.setColor(Color.darkGray); - tokenG.draw(arrow); - tokenG.translate(-cx, -cy); - break; - } + tokenRenderer.paintFacingArrow(tokenG, token, footprintBounds, location); } timer.stop("tokenlist-8"); @@ -2653,7 +2449,7 @@ protected void renderTokens( } overlay.paintOverlay(locg, token, bounds, barValue); - } // endfor + } locg.dispose(); timer.stop("tokenlist-10"); @@ -2664,13 +2460,6 @@ protected void renderTokens( 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 = MapTool.getServerPolicy().isUseIndividualFOW(); @@ -2844,10 +2633,6 @@ protected void renderTokens( } // 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(); @@ -3142,13 +2927,18 @@ public void setScale(double scale) { /* * MCL: I think it is correct to clear these caches (if not more). */ - tokenLocationCache.clear(); - invalidateCurrentViewCache(); + clearZoomDependantCaches(); zoneScale.zoomScale(getWidth() / 2, getHeight() / 2, scale); MapTool.getFrame().getZoomStatusBar().update(); } } + private void clearZoomDependantCaches() { + tokenLocationCache.clear(); + invalidateCurrentViewCache(); + tokenRenderer.zoomChanged(); + } + public double getScale() { return zoneScale.getScale(); } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/FacingArrowRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/FacingArrowRenderer.java new file mode 100644 index 0000000000..e4ab988ac7 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/FacingArrowRenderer.java @@ -0,0 +1,234 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.zone.renderer.tokenRender; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.geom.Path2D; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import net.rptools.lib.CodeTimer; +import net.rptools.maptool.client.AppPreferences; +import net.rptools.maptool.client.ui.zone.renderer.TokenLocation; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.events.MapToolEventBus; +import net.rptools.maptool.model.Grid; +import net.rptools.maptool.model.Token; +import net.rptools.maptool.model.Zone; +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; + +public class FacingArrowRenderer { + private static final Logger log = LogManager.getLogger(FacingArrowRenderer.class); + private final CodeTimer timer; + private final Map> quivers = new HashMap<>(); + Rectangle footprintBounds; + Token.TokenShape tokenType; + ArrowType arrowType; + double scale; + private boolean initialised = false; + private ZoneRenderer renderer; + private boolean isIsometric; + private ArrayList fillColours = new ArrayList<>(); + private Color fillColour = Color.YELLOW; + private Color borderColour = Color.DARK_GRAY; + + FacingArrowRenderer() { + new MapToolEventBus().getMainEventBus().register(this); + for (int i = 0; i <= 90; i++) { + fillColours.add(new Color(1 - 0.5f / 90f * i, 1 - 0.5f / 90f * i, 0)); + } + for (int i = 89; i >= 0; i--) { + fillColours.add(fillColours.get(i)); + } + timer = CodeTimer.get(); + } + + public boolean isInitialised() { + return initialised; + } + + double getScale() { + return scale; + } + + public void setScale(double scale) { + this.scale = scale; + } + + public void paintArrow( + Graphics2D tokenG, + Token token, + Rectangle footprintBounds_, + TokenLocation location, + ZoneRenderer zoneRenderer) { + if (!renderer.equals(zoneRenderer)) { + setRenderer(zoneRenderer); + } + tokenType = token.getShape(); + if (tokenType.equals(Token.TokenShape.TOP_DOWN) && !AppPreferences.forceFacingArrow.get()) { + return; + } + if (tokenType.equals(Token.TokenShape.FIGURE) + && token.getHasImageTable() + && !AppPreferences.forceFacingArrow.get()) { + return; + } + + timer.start("ArrowRenderer-paintArrow"); + AffineTransform oldAT = tokenG.getTransform(); + Grid grid = renderer.getZone().getGrid(); + footprintBounds = footprintBounds_; + double facing = token.getFacing(); + facing = isIsometric ? facing + 45 : facing; + while (facing < 0) { + facing += 360; + } + if (facing > 360) { + facing %= 360; + } + double radFacing = Math.toRadians(facing); + double cx = location.x + location.scaledWidth / 2d; + double cy = location.y + location.scaledHeight / 2d; + + Shape facingArrow = getArrow(token); + + facingArrow = AffineTransform.getRotateInstance(-radFacing).createTransformedShape(facingArrow); + facingArrow = + AffineTransform.getScaleInstance(getScale(), isIsometric ? getScale() / 2d : getScale()) + .createTransformedShape(facingArrow); + + if (tokenType.equals(Token.TokenShape.SQUARE) && !isIsometric) { + double xp = location.scaledWidth / 2; + double yp = location.scaledHeight / 2; + if (facing >= 45 && facing <= 135 || facing >= 225 && facing <= 315) { + xp = yp / Math.tan(Math.toRadians(facing)); + if (facing > 180) { + xp = -xp; + yp = -yp; + } + } else { + yp = xp * Math.tan(Math.toRadians(facing)); + if (facing > 90 && facing < 270) { + xp = -xp; + yp = -yp; + } + } + cx += xp; + cy -= yp; + } + tokenG.translate(cx, cy); + + if (tokenType.equals(Token.TokenShape.FIGURE) && facing <= 180) { + tokenG.setColor(fillColours.get((int) facing)); + } else { + tokenG.setColor(fillColour); + } + + tokenG.fill(facingArrow); + tokenG.setColor(borderColour); + tokenG.draw(facingArrow); + + tokenG.setTransform(oldAT); + timer.stop("ArrowRenderer-paintArrow"); + } + + public void setRenderer(ZoneRenderer zoneRenderer) { + timer.start("ArrowRenderer-init"); + renderer = zoneRenderer; + Zone zone = renderer.getZone(); + isIsometric = zone.getGrid().isIsometric(); + scale = renderer.getScale(); + initialised = true; + timer.stop("ArrowRenderer-init"); + } + + private Shape getArrow(Token token) { + if ((!AppPreferences.forceFacingArrow.get() && tokenType.equals(Token.TokenShape.TOP_DOWN)) + || (!AppPreferences.forceFacingArrow.get() && token.getHasImageTable())) { + return null; + } + Shape arrow = new Path2D.Double(); + if (isIsometric) { + arrowType = ArrowType.ISOMETRIC; + } else { + switch (tokenType) { + case FIGURE, CIRCLE -> arrowType = ArrowType.CIRCLE; + case SQUARE -> arrowType = ArrowType.SQUARE; + default -> arrowType = ArrowType.NONE; + } + } + double size = footprintBounds.getWidth() / 2d; + Map quiver; + if (quivers.containsKey(arrowType)) { + quiver = quivers.get(arrowType); + } else { + quiver = new HashMap<>(); + } + if (quiver.containsKey(size)) { + return quiver.get(size); + } else { + switch (arrowType) { + case CIRCLE -> arrow = getCircleFacingArrow(size); + case ISOMETRIC -> arrow = getFigureFacingArrow(size); + case SQUARE -> arrow = getSquareFacingArrow(size); + } + quiver.put(size, arrow); + quivers.put(arrowType, quiver); + } + return arrow; + } + + protected Shape getCircleFacingArrow(double size) { + double base = size * .75; + double y = size * .35; + Path2D facingArrow = new Path2D.Double(); + facingArrow.moveTo(base, -y); + facingArrow.lineTo(size, 0); + facingArrow.lineTo(base, y); + facingArrow.lineTo(base, -y); + return facingArrow; + } + + protected Shape getFigureFacingArrow(double size) { + double base = size * .75; + double y = size * .35; + Path2D facingArrow = new Path2D.Double(); + facingArrow.moveTo(base, -y); + facingArrow.lineTo(size, 0); + facingArrow.lineTo(base, y); + facingArrow.lineTo(base, -y); + return facingArrow; + } + + protected Shape getSquareFacingArrow(double size) { + double base = size * .75; + double y = size * .35; + Path2D facingArrow = new Path2D.Double(); + facingArrow.moveTo(0, 0); + facingArrow.lineTo(-(size - base), -y); + facingArrow.lineTo(-(size - base), y); + facingArrow.lineTo(0, 0); + return facingArrow; + } + + private enum ArrowType { + CIRCLE, + ISOMETRIC, + SQUARE, + NONE + } +} diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/TokenRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/TokenRenderer.java new file mode 100644 index 0000000000..b112f8eccd --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/tokenRender/TokenRenderer.java @@ -0,0 +1,220 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.zone.renderer.tokenRender; + +import com.google.common.eventbus.Subscribe; +import java.awt.*; +import java.awt.geom.*; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; +import net.rptools.lib.CodeTimer; +import net.rptools.lib.image.ImageUtil; +import net.rptools.maptool.client.ui.zone.renderer.TokenLocation; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.events.MapToolEventBus; +import net.rptools.maptool.model.*; +import net.rptools.maptool.model.zones.GridChanged; +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; + +public class TokenRenderer { + public static final String GRAPHICS = "graphics"; + public static final String LOCATION = "location"; + public static final String OPACITY = "opacity"; + public static final String CLIP = "clip"; + private static final Logger log = LogManager.getLogger(TokenRenderer.class); + private final CodeTimer timer; + private final Map renderImageMap = new HashMap<>(); + private final Map tokenStateMap = new HashMap<>(); + private Graphics2D g2d; + private TokenLocation location; + private Token token; + private float opacity = 1f; + private Area clip = null; + private ZoneRenderer renderer; + private Grid grid; + private double scale; + private boolean isoFigure = false; + private boolean canSpin = false; + private BufferedImage renderImage; + private boolean initialised = false; + public FacingArrowRenderer facingArrowRenderer; + + public TokenRenderer() { + facingArrowRenderer = new FacingArrowRenderer(); + new MapToolEventBus().getMainEventBus().register(this); + timer = CodeTimer.get(); + } + + public boolean isInitialised() { + return initialised; + } + + public void setRenderer(ZoneRenderer zoneRenderer) { + timer.start("TokenRenderer-init"); + renderer = zoneRenderer; + Zone zone = renderer.getZone(); + grid = zone.getGrid(); + + scale = renderer.getScale(); + initialised = true; + timer.stop("TokenRenderer-init"); + } + + public void renderToken(Token token, Map renderInfo) { + timer.start("TokenRenderer-renderToken"); + this.token = token; + if (haveSufficientRenderInfo(renderInfo)) { + compareStates(); + paintTokenImage(); + } else { + log.debug("Not enough render info in " + renderInfo); + } + timer.stop("TokenRenderer-renderToken"); + } + + private boolean haveSufficientRenderInfo(Map renderInfo) { + try { + // has minimum keys required + if (!(renderInfo.containsKey(GRAPHICS) && renderInfo.containsKey(LOCATION))) { + return false; + } + g2d = (Graphics2D) renderInfo.get(GRAPHICS); + location = (TokenLocation) renderInfo.get(LOCATION); + if (renderInfo.containsKey(OPACITY)) { + opacity = (float) renderInfo.get(OPACITY); + } else { + opacity = 1f; + } + if (renderInfo.containsKey(CLIP)) { + clip = (Area) renderInfo.get(CLIP); + } else { + clip = null; + } + } catch (ClassCastException cce) { + log.debug(cce.getLocalizedMessage(), cce); + return false; + } + return true; + } + + private void compareStates() { + TokenState currentState = createRecord(token); + isoFigure = + grid.isIsometric() + && !currentState.flippedIso + && currentState.shape.equals(Token.TokenShape.FIGURE); + canSpin = currentState.shape.equals(Token.TokenShape.TOP_DOWN); + boolean updateStoredImage; + if (!tokenStateMap.containsKey(token)) { + updateStoredImage = true; + } else { + updateStoredImage = !tokenStateMap.get(token).equals(currentState); + } + tokenStateMap.put(token, currentState); + if (updateStoredImage || !renderImageMap.containsKey(token)) { + renderImage = ImageUtil.getTokenRenderImage(token, renderer); + renderImageMap.put(token, renderImage); + } else { + renderImage = renderImageMap.get(token); + } + } + + private void paintTokenImage() { + timer.start("TokenRenderer-paintTokenImage"); + Shape oldClip = g2d.getClip(); + AffineTransform oldAT = g2d.getTransform(); + Composite oldComposite = g2d.getComposite(); + // centre image + double imageCx = -renderImage.getWidth() / 2d; + double imageCy = -renderImage.getHeight() / (isoFigure ? 4d / 3d : 2d); + AffineTransform imageTransform = + AffineTransform.getTranslateInstance( + imageCx + token.getAnchorX() * scale, imageCy + token.getAnchorY() * scale); + + if (clip != null) { + g2d.setClip(clip); + } + g2d.translate(location.x + location.scaledWidth / 2d, location.y + location.scaledHeight / 2d); + + if (token.hasFacing() && canSpin) { + g2d.rotate(Math.toRadians(token.getFacingInDegrees())); + } + if (opacity < 1.0f) { + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); + } + + g2d.drawImage(renderImage, imageTransform, renderer); + g2d.setStroke(new BasicStroke(1f)); + + g2d.setComposite(oldComposite); + g2d.setTransform(oldAT); + g2d.setClip(oldClip); + timer.stop("TokenRenderer-paintTokenImage"); + } + + private TokenState createRecord(Token token) { + return new TokenState( + token.getFacing(), + token.getImageRotation(), + token.getScaleX(), + token.getScaleY(), + token.getSizeScale(), + token.isFlippedIso(), + token.isFlippedX(), + token.isFlippedY(), + token.getShape(), + token.isSnapToScale(), + token.getFootprint(grid)); + } + + public void zoomChanged() { + if (initialised) { + renderImageMap.clear(); + scale = renderer.getScale(); + facingArrowRenderer.setScale(scale); + } + } + + @Subscribe + private void onGridChanged(GridChanged event) { + if (this.initialised) { + setRenderer(this.renderer); + facingArrowRenderer.setRenderer(this.renderer); + } + } + + public void paintFacingArrow( + Graphics2D tokenG, Token token, Rectangle footprintBounds, TokenLocation location) { + if (!facingArrowRenderer.isInitialised()) { + facingArrowRenderer.setRenderer(renderer); + } + facingArrowRenderer.paintArrow(tokenG, token, footprintBounds, location, renderer); + } + + private record TokenState( + int facing, + double imageRotation, + double scaleX, + double scaleY, + double sizeScale, + boolean flippedIso, + boolean flippedX, + boolean flippedY, + Token.TokenShape shape, + boolean snapToScale, + TokenFootprint footprint) {} +} diff --git a/src/main/java/net/rptools/maptool/model/Token.java b/src/main/java/net/rptools/maptool/model/Token.java index 530610473a..c730c6da46 100644 --- a/src/main/java/net/rptools/maptool/model/Token.java +++ b/src/main/java/net/rptools/maptool/model/Token.java @@ -46,6 +46,7 @@ import net.rptools.lib.MD5Key; import net.rptools.lib.image.ImageUtil; import net.rptools.lib.transferable.TokenTransferData; +import net.rptools.maptool.client.AppPreferences; import net.rptools.maptool.client.AppUtil; import net.rptools.maptool.client.MapTool; import net.rptools.maptool.client.MapToolVariableResolver; @@ -94,6 +95,8 @@ public class Token implements Cloneable { public static final String NUM_ON_BOTH = "Both"; public static final String LIB_TOKEN_PREFIX = "lib:"; + private static final int OWNER_TYPE_ALL = 1; + private static final int OWNER_TYPE_LIST = 0; private boolean beingImpersonated = false; private GUID exposedAreaGUID = new GUID(); @@ -187,6 +190,7 @@ public enum Update { setScaleX, setScaleY, setScaleXY, + setImageRotation, setNotes, setGMNotes, saveMacro, @@ -202,7 +206,7 @@ public enum Update { setVisible, setVisibleOnlyToOwner, setIsAlwaysVisible, - setTokenOpacity, + setOpacity, setTerrainModifier, setTerrainModifierOperation, setTerrainModifiersIgnored, @@ -232,32 +236,30 @@ public enum Update { private int x; private int y; private int z; + private Integer facing = null; + private int lastX; + private int lastY; + private Path lastPath; + // Token Layout private int anchorX; private int anchorY; - private double sizeScale = 1; + private double scaleX = 1; + private double scaleY = 1; + private double imageRotation = 0; + private boolean snapToScale = true; // Is size based on footprint - private int lastX; - private int lastY; - private Path lastPath; - - private boolean snapToScale = true; // Whether the scaleX and scaleY represent snap-to-grid // measurements - // These are the original image width and height private int width; private int height; private int isoWidth; private int isoHeight; - private double scaleX = 1; - private double scaleY = 1; - private Map sizeMap = new HashMap<>(); - private boolean snapToGrid = true; // Whether the token snaps to the current grid or is free - // floating + private boolean snapToGrid = true; // Is token position constrained to grid increments private boolean isVisible = true; private boolean visibleOnlyToOwner = false; @@ -266,24 +268,17 @@ public enum Update { // before token is shown over FoW private boolean isAlwaysVisible = false; // Controls whether a Token is shown over VBL - // region Topology masks - + // Topology masks private Area vbl; private Area hillVbl; private Area pitVbl; private Area coverVbl; private Area mbl; - // endregion - private String name = ""; private Set ownerList = new HashSet<>(); - private int ownerType; - private static final int OWNER_TYPE_ALL = 1; - private static final int OWNER_TYPE_LIST = 0; - private String tokenShape = TokenShape.SQUARE.toString(); private String tokenType = Type.NPC.toString(); private String layer = Zone.Layer.getDefaultPlayerLayer().toString(); @@ -292,16 +287,15 @@ public enum Update { private String propertyType = MapTool.getCampaign().getCampaignProperties().getDefaultTokenPropertyType(); - private Integer facing = null; - private Integer haloColorValue; - private transient Color haloColor; + private transient Color haloColor = + new Color(ImageUtil.negativeColourInt(AppPreferences.defaultGridColor.getDefault().getRGB())); private Integer visionOverlayColorValue; private transient Color visionOverlayColor; // Jamz: allow token alpha channel modification - private @Nonnull Float tokenOpacity = 1.0f; + private @Nonnull Float opacity = 1.0f; private String speechName = ""; @@ -331,9 +325,9 @@ public String toString() { private Set terrainModifiersIgnored = new HashSet<>(Collections.singletonList(TerrainModifierOperation.NONE)); - private boolean isFlippedX; - private boolean isFlippedY; - private Boolean isFlippedIso = false; + private boolean flippedX; + private boolean flippedY; + private Boolean flippedIso = false; private MD5Key charsheetImage; private MD5Key portraitImage; @@ -427,9 +421,6 @@ public Token(Token token) { height = token.height; isoWidth = token.isoWidth; isoHeight = token.isoHeight; - scaleX = token.scaleX; - scaleY = token.scaleY; - facing = token.facing; tokenShape = token.tokenShape; tokenType = token.tokenType; haloColorValue = token.haloColorValue; @@ -455,9 +446,16 @@ public Token(Token token) { gmNotesType = token.gmNotesType; label = token.label; - isFlippedX = token.isFlippedX; - isFlippedY = token.isFlippedY; - isFlippedIso = token.isFlippedIso; + anchorX = token.anchorX; + anchorY = token.anchorY; + facing = token.facing; + flippedX = token.flippedX; + flippedY = token.flippedY; + flippedIso = token.flippedIso; + imageRotation = token.imageRotation; + scaleX = token.scaleX; + scaleY = token.scaleY; + sizeScale = token.sizeScale; layer = token.layer; @@ -465,9 +463,6 @@ public Token(Token token) { charsheetImage = token.charsheetImage; portraitImage = token.portraitImage; - anchorX = token.anchorX; - anchorY = token.anchorY; - sizeScale = token.sizeScale; sightType = token.sightType; hasSight = token.hasSight; propertyType = token.propertyType; @@ -508,7 +503,7 @@ public Token(Token token) { exposedAreaGUID = token.exposedAreaGUID; heroLabData = token.heroLabData; - tokenOpacity = token.tokenOpacity; + opacity = token.opacity; terrainModifier = token.terrainModifier; terrainModifierOperation = token.terrainModifierOperation; terrainModifiersIgnored.addAll(token.terrainModifiersIgnored); @@ -701,8 +696,8 @@ public Color getHaloColor() { /** * @return The token opacity, in the range [0.0f, 1.0f]. */ - public float getTokenOpacity() { - return tokenOpacity; + public float getOpacity() { + return opacity; } /** @@ -710,7 +705,7 @@ public float getTokenOpacity() { * * @param alpha the float of the opacity. */ - public void setTokenOpacity(float alpha) { + public void setOpacity(float alpha) { if (alpha > 1.0f) { alpha = 1.0f; } @@ -718,7 +713,7 @@ public void setTokenOpacity(float alpha) { alpha = 0.0f; } - tokenOpacity = alpha; + opacity = alpha; } /** @@ -852,47 +847,6 @@ public void setLayer(Zone.Layer layer) { actualLayer = layer; } - public boolean hasFacing() { - return facing != null; - } - - public void setFacing(int facing) { - while (facing > 180 || facing < -179) { - facing += facing > 180 ? -360 : 0; - facing += facing < -179 ? 360 : 0; - } - this.facing = facing; - } - - public void removeFacing() { - this.facing = null; - } - - /** - * Facing is in the map space where 0 degrees is along the X axis to the right and proceeding CCW - * for positive angles. - * - *

Round/Square tokens that have no facing set, return null. Top Down tokens default to -90. - * - * @return null or angle in degrees - */ - public int getFacing() { - // -90° is natural alignment. TODO This should really be a per grid setting - return facing == null ? -90 : facing; - } - - /** - * This returns the rotation of the facing of the token from the default facing of down or -90. - * - *

Positive for CW and negative for CCW. The range is currently from -270° (inclusive) to +90° - * (exclusive), but callers should not rely on this. - * - * @return angle in degrees - */ - public int getFacingInDegrees() { - return -getFacing() - 90; - } - public boolean getHasSight() { return hasSight; } @@ -1299,22 +1253,6 @@ public Path getLastPath() { return lastPath; } - public double getScaleX() { - return scaleX; - } - - public double getScaleY() { - return scaleY; - } - - public void setScaleX(double scaleX) { - this.scaleX = scaleX; - } - - public void setScaleY(double scaleY) { - this.scaleY = scaleY; - } - /** * Returns whether the token is constrained to a pre-defined grid size. * @@ -1525,12 +1463,12 @@ public Area getTransformedMaskTopology(Area areaToTransform) { atArea.concatenate(AffineTransform.getScaleInstance(scalerX, scalerY)); // Lets account for flipped images... - if (isFlippedX) { + if (flippedX) { atArea.concatenate(AffineTransform.getScaleInstance(-1.0, 1.0)); atArea.concatenate(AffineTransform.getTranslateInstance(-getWidth(), 0)); } - if (isFlippedY) { + if (flippedY) { atArea.concatenate(AffineTransform.getScaleInstance(1.0, -1.0)); atArea.concatenate(AffineTransform.getTranslateInstance(0, -getHeight())); } @@ -1580,13 +1518,10 @@ public Rectangle getBounds(Zone zone) { footprintBounds.x = getX(); footprintBounds.y = getY(); } else { - if (getLayer().anchorSnapToGridAtCenter()) { + if (getLayer().isSnapToGridAtCenter()) { // Center it on the footprint - footprintBounds.x -= (w - footprintBounds.width) / 2; - footprintBounds.y -= (h - footprintBounds.height) / 2; - } else { - // footprintBounds.x -= zone.getGrid().getSize()/2; - // footprintBounds.y -= zone.getGrid().getSize()/2; + footprintBounds.x -= (int) ((w - footprintBounds.width) / 2d); + footprintBounds.y -= (int) ((h - footprintBounds.height) / 2d); } } footprintBounds.width = (int) w; // perhaps make this a double @@ -1612,7 +1547,7 @@ public ZonePoint getDragAnchor(Zone zone) { Grid grid = zone.getGrid(); int dragAnchorX, dragAnchorY; if (isSnapToGrid() && grid.getCapabilities().isSnapToGridSupported()) { - if (!getLayer().isStampLayer() || !getLayer().anchorSnapToGridAtCenter() || isSnapToScale()) { + if (!getLayer().isStampLayer() || !getLayer().isSnapToGridAtCenter() || isSnapToScale()) { Point2D.Double centerOffset = grid.getCenterOffset(); dragAnchorX = getX() + (int) centerOffset.x; dragAnchorY = getY() + (int) centerOffset.y; @@ -1676,7 +1611,7 @@ public ZonePoint getSnappedPoint(Zone zone) { Point2D.Double offset = getSnapToUnsnapOffset(zone); double newX = getX() + offset.x; double newY = getY() + offset.y; - if (grid.getCapabilities().isSnapToGridSupported() || !getLayer().anchorSnapToGridAtCenter()) { + if (grid.getCapabilities().isSnapToGridSupported() || !getLayer().isSnapToGridAtCenter()) { return grid.convert( grid.convert(new ZonePoint((int) Math.ceil(newX), (int) Math.ceil(newY)))); } else { @@ -1709,13 +1644,13 @@ private Point2D.Double getSnapToUnsnapOffset(Zone zone) { double offsetX, offsetY; Rectangle tokenBounds = getBounds(zone); Grid grid = zone.getGrid(); - if (grid.getCapabilities().isSnapToGridSupported() || !getLayer().anchorSnapToGridAtCenter()) { - if (!getLayer().anchorSnapToGridAtCenter() || isSnapToScale()) { + if (grid.getCapabilities().isSnapToGridSupported() || !getLayer().isSnapToGridAtCenter()) { + if (!getLayer().isSnapToGridAtCenter() || isSnapToScale()) { TokenFootprint footprint = getFootprint(grid); Rectangle footprintBounds = footprint.getBounds(grid); double footprintOffsetX = 0; double footprintOffsetY = 0; - if (getLayer().anchorSnapToGridAtCenter()) { + if (getLayer().isSnapToGridAtCenter()) { // Non-background tokens can have an offset from top left corner footprintOffsetX = tokenBounds.width - footprintBounds.width; footprintOffsetY = tokenBounds.height - footprintBounds.height; @@ -2160,33 +2095,6 @@ public void setNotesType(String type) { notesType = type; } - public boolean isFlippedY() { - return isFlippedY; - } - - public void setFlippedY(boolean isFlippedY) { - this.isFlippedY = isFlippedY; - } - - public boolean isFlippedX() { - return isFlippedX; - } - - public void setFlippedX(boolean isFlippedX) { - this.isFlippedX = isFlippedX; - } - - public boolean isFlippedIso() { - if (isFlippedIso != null) { - return isFlippedIso; - } - return false; - } - - public void setFlippedIso(boolean isFlippedIso) { - this.isFlippedIso = isFlippedIso; - } - public Color getVisionOverlayColor() { if (visionOverlayColor == null && visionOverlayColorValue != null) { visionOverlayColor = new Color(visionOverlayColorValue); @@ -2208,6 +2116,7 @@ public String toString() { return "Token: " + id; } + // Token Layout - Getters/Setters public void setAnchor(int x, int y) { anchorX = x; anchorY = y; @@ -2218,27 +2127,127 @@ public Point getAnchor() { } public int getAnchorX() { - return anchorX; + return this.anchorX; + } + + public void setAnchorX(int xAnchor) { + this.anchorX = xAnchor; } public int getAnchorY() { - return anchorY; + return this.anchorY; + } + + public void setAnchorY(int yAnchor) { + this.anchorY = yAnchor; + } + + public boolean isFlippedIso() { + if (flippedIso != null) { + return flippedIso; + } + return false; + } + + public void setFlippedIso(boolean flippedIso) { + this.flippedIso = flippedIso; + } + + public boolean isFlippedX() { + return flippedX; + } + + public void setFlippedX(boolean flippedX) { + this.flippedX = flippedX; + } + + public boolean isFlippedY() { + return flippedY; + } + + public void setFlippedY(boolean flippedY) { + this.flippedY = flippedY; + } + + public double getImageRotation() { + return this.imageRotation; + } + + public void setImageRotation(double angle) { + this.imageRotation = angle; + } + + public double getScaleX() { + return this.scaleX; + } + + public void setScaleX(double scaleX) { + this.scaleX = scaleX; + } + + public double getScaleY() { + return this.scaleY; + } + + public void setScaleY(double scaleY) { + this.scaleY = scaleY; } /** - * @return the scale of the token layout + * @return the scale of the image in token layout */ public double getSizeScale() { - return sizeScale; + return this.sizeScale; } /** - * Set the scale of the token layout + * Set the scale of the image in token layout * * @param scale the scale of the token */ public void setSizeScale(double scale) { - sizeScale = scale; + this.sizeScale = scale; + } + + public boolean hasFacing() { + return facing != null; + } + + public void setFacing(int direction) { + while (direction > 180 || direction < -179) { + direction += direction > 180 ? -360 : 0; + direction += direction < -179 ? 360 : 0; + } + this.facing = direction; + } + + public void removeFacing() { + this.facing = null; + } + + /** + * Facing is in the map space where 0 degrees is along the X axis to the right and proceeding CCW + * for positive angles. + * + *

Round/Square tokens that have no facing set, return null. Top Down tokens default to -90. + * + * @return null or angle in degrees + */ + public int getFacing() { + // -90° is natural alignment. TODO This should really be a per grid setting + return facing == null ? -90 : facing; + } + + /** + * This returns the rotation of the facing of the token from the default facing of down or -90. + * + *

Positive for CW and negative for CCW. The range is currently from -270° (inclusive) to +90° + * (exclusive), but callers should not rely on this. + * + * @return angle in degrees + */ + public int getFacingInDegrees() { + return -getFacing() - 90; } /** @@ -2260,8 +2269,8 @@ public TokenTransferData toTransferData() { td.put(TokenTransferData.ASSET_ID, imageAssetMap.get(null)); td.put(TokenTransferData.Z, z); td.put(TokenTransferData.SNAP_TO_SCALE, snapToScale); - td.put(TokenTransferData.WIDTH, scaleX); - td.put(TokenTransferData.HEIGHT, scaleY); + td.put(TokenTransferData.WIDTH, scaleX); // this makes no sense + td.put(TokenTransferData.HEIGHT, scaleY); // ditto td.put(TokenTransferData.SNAP_TO_GRID, snapToGrid); td.put(TokenTransferData.OWNER_TYPE, ownerType); td.put(TokenTransferData.VISIBLE_OWNER_ONLY, visibleOnlyToOwner); @@ -2583,8 +2592,8 @@ protected Object readResolve() { if (speechMap == null) { speechMap = new HashMap<>(); } - if (isFlippedIso == null) { - isFlippedIso = false; + if (flippedIso == null) { + flippedIso = false; } if (hasImageTable == null) { hasImageTable = false; @@ -2623,10 +2632,10 @@ protected Object readResolve() { } // Pre 1.13 - if (tokenOpacity == null) { - tokenOpacity = 1.f; + if (opacity == null) { + opacity = 1.f; } - tokenOpacity = Math.max(0.f, Math.min(tokenOpacity, 1.f)); + opacity = Math.max(0.f, Math.min(opacity, 1.f)); return this; } @@ -2834,8 +2843,8 @@ public void updateProperty(Zone zone, Update update, List case setIsAlwaysVisible: setIsAlwaysVisible(parameters.get(0).getBoolValue()); break; - case setTokenOpacity: - setTokenOpacity(Float.parseFloat(parameters.get(0).getStringValue())); + case setOpacity: + setOpacity(Float.parseFloat(parameters.get(0).getStringValue())); break; case setTerrainModifier: setTerrainModifier(parameters.get(0).getDoubleValue()); @@ -2967,17 +2976,23 @@ public static Token fromDto(TokenDto dto) { token.lastY = dto.getLastY(); token.y = dto.getY(); token.z = dto.getZ(); - token.anchorX = dto.getAnchorX(); - token.anchorY = dto.getAnchorY(); - token.sizeScale = dto.getSizeScale(); token.lastPath = dto.hasLastPath() ? Path.fromDto(dto.getLastPath()) : null; token.snapToScale = dto.getSnapToScale(); token.width = dto.getWidth(); token.height = dto.getHeight(); token.isoWidth = dto.getIsoWidth(); token.isoHeight = dto.getIsoHeight(); + // Layout + token.anchorX = dto.getAnchorX(); + token.anchorY = dto.getAnchorY(); token.scaleX = dto.getScaleX(); token.scaleY = dto.getScaleY(); + token.sizeScale = dto.getSizeScale(); + token.flippedX = dto.getFlippedX(); + token.flippedY = dto.getFlippedY(); + token.flippedIso = dto.getFlippedIso(); + token.facing = dto.hasFacing() ? dto.getFacing().getValue() : null; + dto.getSizeMapMap().forEach((k, v) -> token.sizeMap.put(k, GUID.valueOf(v))); token.snapToGrid = dto.getSnapToGrid(); token.isVisible = dto.getIsVisible(); @@ -2997,22 +3012,19 @@ public static Token fromDto(TokenDto dto) { token.tokenType = dto.getTokenType(); token.layer = dto.getLayer(); token.propertyType = dto.getPropertyType(); - token.facing = dto.hasFacing() ? dto.getFacing().getValue() : null; token.haloColorValue = dto.hasHaloColor() ? dto.getHaloColor().getValue() : null; token.visionOverlayColorValue = dto.hasVisionOverlayColor() ? dto.getVisionOverlayColor().getValue() : null; - token.tokenOpacity = dto.getTokenOpacity(); + token.opacity = dto.getTokenOpacity(); token.speechName = dto.getSpeechName(); token.terrainModifier = dto.getTerrainModifier(); token.terrainModifierOperation = - Token.TerrainModifierOperation.valueOf(dto.getTerrainModifierOperation().name()); + TerrainModifierOperation.valueOf(dto.getTerrainModifierOperation().name()); token.terrainModifiersIgnored.addAll( dto.getTerrainModifiersIgnoredList().stream() - .map(m -> Token.TerrainModifierOperation.valueOf(m.name())) + .map(m -> TerrainModifierOperation.valueOf(m.name())) .collect(Collectors.toList())); - token.isFlippedX = dto.getIsFlippedX(); - token.isFlippedY = dto.getIsFlippedY(); - token.isFlippedIso = dto.getIsFlippedIso(); + token.charsheetImage = dto.hasCharsheetImage() ? new MD5Key(dto.getCharsheetImage().getValue()) : null; token.portraitImage = @@ -3079,9 +3091,14 @@ public TokenDto toDto() { dto.setLastY(lastY); dto.setY(y); dto.setZ(z); + if (facing != null) { + dto.setFacing(Int32Value.of(facing)); + } dto.setAnchorX(anchorX); dto.setAnchorY(anchorY); dto.setSizeScale(sizeScale); + dto.setScaleX(scaleX); + dto.setScaleY(scaleY); if (lastPath != null) { dto.setLastPath(lastPath.toDto()); } @@ -3090,8 +3107,6 @@ public TokenDto toDto() { dto.setHeight(height); dto.setIsoWidth(isoWidth); dto.setIsoHeight(isoHeight); - dto.setScaleX(scaleX); - dto.setScaleY(scaleY); sizeMap.forEach((k, v) -> dto.putSizeMap(k, v.toString())); dto.setSnapToGrid(snapToGrid); dto.setIsVisible(isVisible); @@ -3121,16 +3136,14 @@ public TokenDto toDto() { dto.setTokenType(tokenType); dto.setLayer(layer); dto.setPropertyType(propertyType); - if (facing != null) { - dto.setFacing(Int32Value.of(facing)); - } + if (haloColorValue != null) { dto.setHaloColor(Int32Value.of(haloColorValue)); } if (visionOverlayColorValue != null) { dto.setVisionOverlayColor(Int32Value.of(visionOverlayColorValue)); } - dto.setTokenOpacity(tokenOpacity); + dto.setTokenOpacity(opacity); dto.setSpeechName(speechName); dto.setTerrainModifier(terrainModifier); dto.setTerrainModifierOperation( @@ -3139,9 +3152,9 @@ public TokenDto toDto() { terrainModifiersIgnored.stream() .map(m -> TerrainModifierOperationDto.valueOf(m.name())) .collect(Collectors.toList())); - dto.setIsFlippedX(isFlippedX); - dto.setIsFlippedY(isFlippedY); - dto.setIsFlippedIso(isFlippedIso); + dto.setFlippedX(flippedX); + dto.setFlippedY(flippedY); + dto.setFlippedIso(flippedIso); if (charsheetImage != null) { dto.setCharsheetImage(StringValue.of(charsheetImage.toString())); } diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index 9a19fe2e2b..60dd611381 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -252,7 +252,7 @@ public boolean supportsVision() { * @return {@code true} if the {@code Token} instances on the layer should be resized relative * to their center rather than their corner. */ - public boolean anchorSnapToGridAtCenter() { + public boolean isSnapToGridAtCenter() { return this != BACKGROUND; } diff --git a/src/main/java/net/rptools/maptool/util/GraphicsUtil.java b/src/main/java/net/rptools/maptool/util/GraphicsUtil.java index a2fa0ee24c..a2c7f9aef0 100644 --- a/src/main/java/net/rptools/maptool/util/GraphicsUtil.java +++ b/src/main/java/net/rptools/maptool/util/GraphicsUtil.java @@ -27,10 +27,7 @@ import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.Shape; -import java.awt.geom.Area; -import java.awt.geom.GeneralPath; -import java.awt.geom.Path2D; -import java.awt.geom.Point2D; +import java.awt.geom.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -44,6 +41,7 @@ import net.rptools.maptool.client.ui.theme.Images; import net.rptools.maptool.client.ui.theme.RessourceManager; import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.model.GridFactory; /** */ public class GraphicsUtil { @@ -359,14 +357,9 @@ public static void renderSoftClipping(Graphics2D g, Shape shape, int width, doub RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); // Faster without antialiasing, and looks just as good - // float alpha = (float)initialAlpha / width / 6; float alpha = .04f; g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)); for (int i = 1; i < width; i += 2) { - // if (alpha * i < .2) { - // // Too faded to see anyway, don't waste cycles on it - // continue; - // } g2.setStroke(new BasicStroke(i)); g2.draw(shape); } @@ -377,8 +370,8 @@ public static Area createLine(int width, Point2D... points) { if (points.length < 2) { throw new IllegalArgumentException("Must supply at least two points"); } - List bottomList = new ArrayList(points.length); - List topList = new ArrayList(points.length); + List bottomList = new ArrayList<>(points.length); + List topList = new ArrayList<>(points.length); for (int i = 0; i < points.length; i++) { double angle = @@ -397,17 +390,13 @@ public static Area createLine(int width, Point2D... points) { double bottomAngle = (angle + delta / 2) % 360; double topAngle = bottomAngle + 180; - // System.out.println(angle + " - " + delta + " - " + bottomAngle + " - " + topAngle); - bottomList.add(getPointAtVector(points[i], bottomAngle, width)); topList.add(getPointAtVector(points[i], topAngle, width)); } - // System.out.println(bottomList); - // System.out.println(topList); Collections.reverse(topList); GeneralPath path = new GeneralPath(); - Point2D initialPoint = bottomList.remove(0); + Point2D initialPoint = bottomList.removeFirst(); path.moveTo((float) initialPoint.getX(), (float) initialPoint.getY()); for (Point2D point : bottomList) { @@ -423,10 +412,6 @@ public static Area createLine(int width, Point2D... points) { private static Point2D getPointAtVector(Point2D point, double angle, double length) { double x = point.getX() + length * Math.cos(Math.toRadians(angle)); double y = point.getY() - length * Math.sin(Math.toRadians(angle)); - - // System.out.println(point + " - " + angle + " - " + x + "x" + y + " - " + - // Math.cos(Math.toRadians(angle)) + " - " + Math.sin(Math.toRadians(angle)) + " - " + - // Math.toRadians(angle)); return new Point2D.Double(x, y); } @@ -435,8 +420,6 @@ public static void main(String[] args) { new Point2D[] { new Point(20, 20), new Point(50, 50), new Point(80, 20), new Point(100, 100) }; - // final Point2D[] points = new Point2D[]{new Point(50, 50), new Point(20, 20), new Point(20, - // 100), new Point(50,75)}; final Area line = createLine(10, points); JFrame f = new JFrame(); @@ -464,4 +447,60 @@ protected void paintComponent(Graphics g) { f.setVisible(true); // System.out.println(area.equals(area2)); } + + public static Shape createGridShape(String gridType, double size) { + final Shape gridShape; + int sides = 0; + double startAngle = 0; + double increment; + double skew = 0; + double hScale = 1; + double vScale = 1; + final double root2 = Math.sqrt(2d); + final double root3 = Math.sqrt(3d); + switch (gridType) { + case GridFactory.HEX_HORI -> { + sides = 6; + startAngle = Math.TAU / 12; + hScale = vScale = root3 / 3d; + } + case GridFactory.HEX_VERT -> { + sides = 6; + hScale = vScale = root3 / 3d; + } + case GridFactory.ISOMETRIC -> { + sides = 4; + vScale = 0.5; + } + case GridFactory.ISOMETRIC_HEX -> { + sides = 6; + startAngle = Math.TAU / 24; + hScale = vScale = root3 / 3d; + skew = Math.toRadians(30d); + } + case GridFactory.NONE -> { + return new Ellipse2D.Double(-size / 2d, -size / 2d, size, size); + } + case GridFactory.SQUARE -> { + sides = 4; + hScale = vScale = root2 / 2d; + startAngle = Math.TAU / 8d; + } + } + increment = Math.TAU / sides; + Path2D path = new Path2D.Double(); + path.moveTo(Math.cos(startAngle) * size * hScale, Math.sin(startAngle) * size * vScale); + for (int i = 1; i < sides; i++) { + path.lineTo( + Math.cos(startAngle + i * increment) * size * hScale, + Math.sin(startAngle + i * increment) * size * vScale); + } + path.closePath(); + if (skew != 0) { + gridShape = AffineTransform.getShearInstance(skew, 0).createTransformedShape(path); + } else { + gridShape = path; + } + return gridShape; + } } diff --git a/src/main/proto/data_transfer_objects.proto b/src/main/proto/data_transfer_objects.proto index ab9281e245..35b602458f 100644 --- a/src/main/proto/data_transfer_objects.proto +++ b/src/main/proto/data_transfer_objects.proto @@ -305,6 +305,12 @@ message TokenDto { int32 anchor_x = 9; int32 anchor_y = 10; double size_scale = 11; + double scale_x = 20; + double scale_y = 21; + double image_rotation = 73; + bool flipped_x = 45; + bool flipped_y = 46; + bool flipped_iso = 47; int32 last_x = 12; int32 last_y = 13; PathDto last_path = 14; @@ -313,8 +319,6 @@ message TokenDto { int32 height = 17; int32 iso_width = 18; int32 iso_height = 19; - double scale_x = 20; - double scale_y = 21; map size_map = 22; bool snap_to_grid = 23; bool is_visible = 24; @@ -342,9 +346,6 @@ message TokenDto { double terrain_modifier = 42; TerrainModifierOperationDto terrain_modifier_operation = 43; repeated TerrainModifierOperationDto terrain_modifiers_ignored = 44; - bool is_flipped_x = 45; - bool is_flipped_y = 46; - bool is_flipped_iso = 47; google.protobuf.StringValue charsheet_image = 48; google.protobuf.StringValue portrait_image = 49; repeated LightSourceDto unique_light_sources = 72; @@ -639,7 +640,7 @@ enum TokenUpdateDto { setVisible = 34; setVisibleOnlyToOwner = 35; setIsAlwaysVisible = 36; - setTokenOpacity = 37; + setOpacity = 37; setTerrainModifier = 38; setTerrainModifierOperation = 39; setTerrainModifiersIgnored = 40; diff --git a/src/main/resources/net/rptools/maptool/client/image/scale.svg b/src/main/resources/net/rptools/maptool/client/image/scale.svg new file mode 100644 index 0000000000..32c8b5fa42 --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/image/scale.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/net/rptools/maptool/client/image/scaleHor.svg b/src/main/resources/net/rptools/maptool/client/image/scaleHor.svg new file mode 100644 index 0000000000..c722aeb276 --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/image/scaleHor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/net/rptools/maptool/client/image/scaleVert.svg b/src/main/resources/net/rptools/maptool/client/image/scaleVert.svg new file mode 100644 index 0000000000..ff31922842 --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/image/scaleVert.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index 9d25a11103..0c82b297d2 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -128,6 +128,7 @@ Button.install = Install Button.runmacro = RUN MACRO Button.hide = Hide +Label.footprint = Footprint Label.lights = Vision: Label.name = Name: Label.gmname = GM Name: @@ -202,10 +203,29 @@ Label.board = Board Label.visibility = Visibility Label.pathfilename = Path/Filename: Label.allowURIAccess = Allow URI Access +Label.rotation = Rotation +Label.snapToGrid = Snap to Grid +Label.zoom = Zoom + +Mouse.leftClick = Left-click +Mouse.leftDoubleClick = Double-left-click +Mouse.rightClick = Right-click +Mouse.rightDoubleClick = Double-right-click +Mouse.shiftLeftClick = Shift-left-click +Mouse.shiftRightClick = Shift-right-click +Mouse.ctrlLeftClick = Ctrl-left-click +Mouse.ctrlRightClick = Ctrl-right-click +Mouse.altLeftClick = Alt-left-click +Mouse.altRightClick = Alt-right-click +Mouse.leftDrag = Left-drag +Mouse.rightDrag = Right-drag +Mouse.wheel = Mouse-wheel +Mouse.shiftWheel = Shift-mouse-wheel +Mouse.ctrlWheel = Ctrl-mouse-wheel +Mouse.altWheel = Alt-mouse-wheel CampaignPropertyDialog.error.parenthesis = Missing right parenthesis ")" in the following property: {0} -ColorPicker.tooltip.gridSnap = Snap to Grid ColorPicker.tooltip.lineType = Line Type ColorPicker.tooltip.eraser = Eraser ColorPicker.tooltip.penWidth = Pen Width @@ -273,7 +293,7 @@ EditTokenDialog.label.gmnotes = GM Notes EditTokenDialog.menu.notes.sendChat = Send to Chat EditTokenDialog.menu.notes.sendEmit = Send as Emit EditTokenDialog.label.shape = Shape: -EditTokenDialog.label.snaptogrid = Snap To Grid: + EditTokenDialog.label.size = Size: EditTokenDialog.label.visible = Visible to players: EditTokenDialog.label.properties = Properties: @@ -285,7 +305,7 @@ EditTokenDialog.label.image = Image Table: EditTokenDialog.label.uniqueLightSources = Unique Light Sources: EditTokenDialog.label.opacity = Token Opacity: EditTokenDialog.label.opacity.tooltip = Change the opacity of the token. -EditTokenDialog.label.opacity.100 = 100% +EditTokenDialog.label.opacity.100 = 100% EditTokenDialog.label.terrain.ignore = Ignore Terrain: EditTokenDialog.label.terrain.ignore.tooltip= Select any terrain modifier types this token should ignore. EditTokenDialog.label.statSheet = Stat Sheet @@ -295,7 +315,7 @@ EditTokenDialog.border.title.layout = Layout EditTokenDialog.border.title.portrait = Portrait EditTokenDialog.border.title.handout = Handout EditTokenDialog.border.title.charsheet = Charsheet -EditTokenDialog.status.layout.instructions = Mouse Wheel to zoom; double-LClick to reset position and zoom +EditTokenDialog.status.layout.instructions = Mouse Wheel to scale; double-LClick to reset position and zoom EditTokenDialog.label.allplayers = All Players EditTokenDialog.tab.properties = Properties EditTokenDialog.tab.vbl = Topology @@ -371,6 +391,17 @@ EditTokenDialog.button.hero.refresh.tooltip.off = Refresh data from Hero L EditTokenDialog.libTokenURI.error.notLibToken = {0} is not a valid name for a lib:Token. EditTokenDialog.libTokenURI.error.reserved = lib:Tokens can not be named {0} if you want to enable URI access. +EditTokenDialog.layout.help.caption = Mouse Controls +EditTokenDialog.layout.help.moveView = Move the viewport. +EditTokenDialog.layout.help.zoomView = Magnify the viewport. +EditTokenDialog.layout.help.rotateImage = Adjust the rotation of the token image. +EditTokenDialog.layout.help.scaleImage = Change the size of the token image. +EditTokenDialog.layout.help.moveImage = Change the position of the token image. +EditTokenDialog.layout.help.reset = Revert to starting values. +EditTokenDialog.layout.help.resetDefaults = Revert to default values. +EditTokenDialog.layout.scaleButton.tooltip = Switch between horizontal, vertical, or both. + + MapPropertiesDialog.label.playerMapAlias = Display Name: MapPropertiesDialog.label.playerMapAlias.tooltip = This is how players will see the map identified. Must be unique within campaign. MapPropertiesDialog.label.Name.tooltip = This is how the map is referred to in macros and is only visible to the GM. @@ -814,7 +845,6 @@ CampaignPropertiesDialog.button.importPredefined = Import Predefined CampaignPropertiesDialog.button.importPredefined.tooltip = Import predefined properties and settings for the selected system CampaignPropertiesDialog.button.import.tooltip = Import predefined properties from a file CampaignPropertiesDialog.button.export.tooltip = Export campaign properties to file -CampaignPropertiesDialog.export.message = Campaign properties will be exported in JSON format and cannot be imported. CampaignPropertiesDialog.combo.importPredefined.tooltip = System to import properties for CampaignPropertiesDialog.button.import = Import Predefined campaignPropertiesDialog.newTokenTypeName = Enter the name for the new token type @@ -2017,8 +2047,6 @@ macro.function.general.unknownProperty = Error executing "{0}": the macro.function.general.unknownTokenOnMap = Error executing "{0}": the token name or id "{1}" is unknown on map "{2}". macro.function.general.wrongNumParam = Function "{0}" requires exactly {1} parameters; {2} were provided. macro.function.general.listCannotBeEmpty = {0}: string list at argument {1} cannot be empty -macro.function.general.wrongParamType = A parameter is the wrong type. -macro.function.general.paramCannotBeEmpty = A parameter is empty. # Token Distance functions # I.e. ONE_TWO_ONE or ONE_ONE_ONE macro.function.getDistance.invalidMetric = Invalid metric type "{0}". @@ -2663,7 +2691,6 @@ token.popup.menu.lights.clearAll = Clear All token.popup.menu.auras.clear = Clear Auras Only token.popup.menu.auras.clearGM = Clear GM Auras Only token.popup.menu.auras.clearOwner = Clear Owner Auras Only -token.popup.menu.snap = Snap to grid token.popup.menu.visible = Visible to players token.popup.menu.impersonate = Impersonate token.popup.menu.move = Move

%s