0

I am writing a Poker game and am currently trying to write a class that produces an image of a given hand. I initially just produced an image by combining the images of each of the 5 cards alongside each other. This was the result:

enter image description here

I then decided that it would be much nicer to display the hand with the cards stacked on top of each other and fanned out, as one would hold a hand of cards.

This is the best I have been able to do so far:

enter image description here

As you can see, the last 3 cards look as they should, but the first three cards are being cut off to the left of the of the third card.

Here is my code as of now (It isn't the cleanest as I have just been trying to get it to work, whatever it takes)

private static final int CARD_WIDTH = 500;
private static final int CARD_HEIGHT = 726;
private static final double ROTATION = 20.0;

public void createImage(HandOfCards hand) throws IOException {
    int handImageWidth = (int) (2 * (Math.sin(degreesToRadian(ROTATION)) * CARD_HEIGHT + Math.cos(degreesToRadian(ROTATION)) * CARD_WIDTH)- CARD_WIDTH);
    int handImageHeight = (int) (CARD_HEIGHT + Math.sin(degreesToRadian(ROTATION)) * CARD_WIDTH); 

    BufferedImage handImage = new BufferedImage(handImageWidth, handImageHeight, BufferedImage.TYPE_INT_ARGB);
    Graphics2D graphics = (Graphics2D) handImage.getGraphics();

    int xPos = handImageWidth / 2 - CARD_WIDTH / 2;
    int yPos = 0;
    int xAnchor = CARD_WIDTH; 
    int yAnchor = CARD_HEIGHT;

    double rotation = -ROTATION;        
    for (int i = 0; i < HandOfCards.HAND_SIZE; i++) {
        if (i == 3) xAnchor = 0;

        PlayingCard card = hand.getCard(i);
        BufferedImage cardImage = ImageIO.read(new File("cardImages/" + card + ".png"));

        AffineTransform transform = new AffineTransform();
        transform.rotate(degreesToRadian(rotation), xAnchor, yAnchor);
        AffineTransformOp transformOp = new AffineTransformOp(transform, AffineTransformOp.TYPE_BILINEAR);
        cardImage = transformOp.filter(cardImage, null);

        graphics.drawImage(cardImage, xPos, yPos, null);
        rotation += ROTATION / 2;
    }

private double degreesToRadian(double degrees) {
    return (degrees * Math.PI) / 180.0;
}

EDIT

Just to make things clearer, here is the result when the loop is only executed first (only the first card is drawn) and the background is coloured to show the size of the entire image.

enter image description here

KOB
  • 4,084
  • 9
  • 44
  • 88

1 Answers1

1

The reason for the behavior that you are observing is stated in the documentation of AffineTransformOp#filter:

The coordinates of the rectangle returned by getBounds2D(BufferedImage) are not necessarily the same as the coordinates of the BufferedImage returned by this method. If the upper-left corner coordinates of the rectangle are negative then this part of the rectangle is not drawn.

And when you print the bounds of each card with a statement like

System.out.println("Bounds: "+transformOp.getBounds2D(cardImage));

you will see that the bounds are negative (as one might expect when rotating the cards to the left).

This can be avoided by adjusting the AffineTransform to always result in positive bounds, and calling the filter method with a non-null destination image (in your case: with the image that will contain the hand - i.e. all card images).


(This ^ was the actual answer to the question. The remaining part may be ignored, or considered as evidence that I have too much free time)


That being said, I'd like to suggest a different solution, because there are some issues with the current approach, on different levels.


On the highest level: Why do you want to create this image at all? I guess you're implementing a card-playing game. And during such a game, you will have possibly hundreds of different "hands". Why do you want to create a new image for each hand?

Instead of creating an image for each hand, you can simply draw the rotated images directly. Roughly speaking: Instead of painting the images into the Graphics of a new image, you can simply draw them into the Graphics of your JPanel where you are acutally painting the hands.

But considering that the difference is only the Graphics object that you are painting into, this is something that can easily be changed later (if implemented accordingly), and maybe there is an actual reason for you to create these images.


On the lowest level: the function degreesToRadian should entirely be replaced with Math.toRadians.


Below is an example, implemented as a MCVE. (This means that it does not use the HandOfCards and PlayingCards classes. Instead, it operates on a list of BufferedImage objects. These images are actually downloaded from wikipedia, at runtime).

The core of this example is the RotatedPlayingCardsPainter. It allows you to paint the (rotated) cards into a Graphics2D. One advantage of this approach may become obvious when you try it out: You can use the slider to dynamically change the angle between the cards. (Imagine some sort of fancy animation for your game here...)

(But if you wish, it also contains a createImage method that allows you to create an image, as originally done in the question)

When you read the code, you will see that the AffineTransform instance for each card is created in the createTransform method. There, I added some arbitrary, magic factor to slighly shift the cards against each other, and give them a more "fan-like" appearance.

Compare this image, without the magic factor

Rotated cards no magic

to the one with the magic factor:

Rotated playing cards

I think that the latter looks more "realistic", but that may be a matter of taste.


Another side note: A drawback of drawing the images directly (as compared to the AffineTransformOp approach) is that the borders of the images may look jagged, regardless of the filtering- and antialiasing-settings. This is due to the fact that there is nothing to interpolate with at the border of the images. In the given program, this is circumvented with the addBorder method, which adds a 1-pixel transparent border to the image, to make sure that it looks nice and the borders look smooth when the image is rotated.

Here's the code:

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.SwingUtilities;

public class RotatedPlayingCards
{
    public static void main(String[] args)
    {
        try
        {
            List<BufferedImage> images = loadTestImages();
            SwingUtilities.invokeLater(() -> createAndShowGui(images));
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }

    }

    private static void createAndShowGui(List<BufferedImage> images)
    {
        JFrame f = new JFrame();
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        RotatedPlayingCardsPanel cardsPanel = 
            new RotatedPlayingCardsPanel(images);

        JSlider angleDegSlider = new JSlider(0, 20, 10);
        angleDegSlider.addChangeListener(e -> {
            double rotationAngleRad = Math.toRadians(angleDegSlider.getValue());
            cardsPanel.setRotationAngleRad(rotationAngleRad);
        });
        JPanel controlPanel = new JPanel();
        controlPanel.add(angleDegSlider);
        f.getContentPane().add(controlPanel, BorderLayout.NORTH);

        f.getContentPane().add(cardsPanel, BorderLayout.CENTER);
        f.setSize(500,500);
        f.setLocationRelativeTo(null);
        f.setVisible(true);
    }

    private static List<BufferedImage> loadTestImages() throws IOException
    {
        String basePath = "https://upload.wikimedia.org/wikipedia/commons/thumb/";
        List<String> subPaths = Arrays.asList(
          "3/36/Playing_card_club_A.svg/480px-Playing_card_club_A.svg.png",
          "2/20/Playing_card_diamond_4.svg/480px-Playing_card_diamond_4.svg.png",
          "9/94/Playing_card_heart_7.svg/480px-Playing_card_heart_7.svg.png",
          "2/21/Playing_card_spade_8.svg/480px-Playing_card_spade_8.svg.png",
          "b/bd/Playing_card_spade_J.svg/480px-Playing_card_spade_J.svg.png",
          "0/0b/Playing_card_diamond_Q.svg/480px-Playing_card_diamond_Q.svg.png",
          "2/25/Playing_card_spade_A.svg/480px-Playing_card_spade_A.svg.png"
        );
        List<BufferedImage> result = new ArrayList<BufferedImage>();
        for (String subPath : subPaths)
        {
            String path = basePath + subPath;
            System.out.println("Loading "+path);
            BufferedImage image = ImageIO.read(new URL(path));

            image = scale(image, 0.3);
            image = addBorder(image);
            result.add(image);
        }
        return result;
    }

    // Scale the given image by the given factor
    private static BufferedImage scale(
        BufferedImage image, double factor) 
    {
        int w = (int)(image.getWidth() * factor);
        int h = (int)(image.getHeight() * factor);
        BufferedImage scaledImage = new BufferedImage(w, h, image.getType());
        Graphics2D g = scaledImage.createGraphics();
        g.setRenderingHint(
            RenderingHints.KEY_INTERPOLATION,
            RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(image, 0, 0, w, h, null);
        g.dispose();
        return scaledImage;
    }

    // Add a 1-pixel transparent border to the given image, to avoid
    // aliasing artifacts when the image is rotated
    private static BufferedImage addBorder(
        BufferedImage image) 
    {
        int w = image.getWidth();
        int h = image.getHeight();
        BufferedImage result = new BufferedImage(w + 2, h + 2, image.getType());
        Graphics2D g = result.createGraphics();
        g.setColor(new Color(0,0,0,0));
        g.fillRect(0, 0, w + 2, h + 2);
        g.drawImage(image, 1, 1, w, h, null);
        g.dispose();
        return result;
    }    

}

class RotatedPlayingCardsPanel extends JPanel
{
    private List<BufferedImage> images;
    private double rotationAngleRad;

    public RotatedPlayingCardsPanel(List<BufferedImage> images)
    {
        this.images = images;
        this.rotationAngleRad = Math.toRadians(10);
    }

    public void setRotationAngleRad(double rotationAngleRad)
    {
        this.rotationAngleRad = rotationAngleRad;
        repaint();
    }

    @Override
    protected void paintComponent(Graphics gr)
    {
        super.paintComponent(gr);
        Graphics2D g = (Graphics2D)gr;
        g.setRenderingHint(
            RenderingHints.KEY_ANTIALIASING,
            RenderingHints.VALUE_ANTIALIAS_ON);
        g.setRenderingHint(
            RenderingHints.KEY_INTERPOLATION,
            RenderingHints.VALUE_INTERPOLATION_BILINEAR);

        g.translate(200, 100);
        RotatedPlayingCardsPainter.drawImages(
            g, images, rotationAngleRad);
    }
}


class RotatedPlayingCardsPainter
{
    public static BufferedImage createImage(
        List<? extends BufferedImage> images, double rotationAngleRad)
    {
        Rectangle2D bounds = computeBounds(images, rotationAngleRad);
        BufferedImage image = new BufferedImage(
            (int)bounds.getWidth(), (int)bounds.getHeight(), 
            BufferedImage.TYPE_INT_ARGB);
        Graphics2D graphics = image.createGraphics();
        graphics.setRenderingHint(
            RenderingHints.KEY_ANTIALIASING,
            RenderingHints.VALUE_ANTIALIAS_ON);
        graphics.setRenderingHint(
            RenderingHints.KEY_INTERPOLATION,
            RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        graphics.translate(-bounds.getX(), -bounds.getY());
        drawImages(graphics, images, rotationAngleRad);
        graphics.dispose();
        return image;
    }

    public static Rectangle2D computeBounds(
        List<? extends BufferedImage> images, double rotationAngleRad)
    {
        Rectangle2D totalBounds = null;
        for (int i=0; i<images.size(); i++)
        {
            BufferedImage image = images.get(i);
            AffineTransform transform = createTransform(
                i, images.size(), image.getWidth(), image.getHeight(), 
                rotationAngleRad);
            Rectangle2D imageBounds = new Rectangle2D.Double(0.0, 0.0, 
                image.getWidth(), image.getHeight());
            Rectangle2D transformedBounds = 
                transform.createTransformedShape(imageBounds).getBounds();
            if (totalBounds == null)
            {
                totalBounds = transformedBounds;
            }
            else
            {
                Rectangle.union(transformedBounds, totalBounds, totalBounds);
            }
        }
        return totalBounds;
    }

    public static void drawImages(Graphics2D g, 
        List<? extends BufferedImage> images, double rotationAngleRad)
    {
        for (int i=0; i<images.size(); i++)
        {
            AffineTransform oldAt = g.getTransform();
            BufferedImage image = images.get(i);
            AffineTransform transform = createTransform(
                i, images.size(), image.getWidth(), image.getHeight(), 
                rotationAngleRad);
            g.transform(transform);
            g.drawImage(image, 0, 0, null);
            g.setTransform(oldAt);
        }
    }

    private static AffineTransform createTransform(
        int index, int total, double width, double height, 
        double rotationAngleRad)
    {
        double startAngleRad = (total - 1) * 0.5 * rotationAngleRad;
        double angleRad = index * rotationAngleRad - startAngleRad;
        AffineTransform transform = new AffineTransform();

        // A magic factor to shift the images slightly, to give 
        // them a more fan-like appearance. Just set it to 0.0
        // or remove it if you don't like it.
        double magicFactor = 0.2;

        double magicOffsetFactor = 
            (1.0 - index) * magicFactor * rotationAngleRad;
        double magicOffsetX = -width * magicOffsetFactor;
        double magicOffsetY = height * magicOffsetFactor;
        transform.translate(magicOffsetX, height + magicOffsetY);
        transform.rotate(angleRad);
        transform.translate(0, -height);
        return transform;
    }

}
Community
  • 1
  • 1
Marco13
  • 53,703
  • 9
  • 80
  • 159
  • Sorry, I can't read your entire answer right now - I'll get to it later. In terms of solving the problem in my own solution, when you say to always use a positive rotation with an `AffineTransformation`, does this mean instead of rotating -20°, I have to rotate +340°? – KOB Apr 29 '17 at 18:01
  • @KOB No. When you print the bounds, as with the first line of code in the answer, you will see some output like `Bounds: java.awt.geom.Rectangle2D$Float[x=-218.15294,y=43.783157,w=718.15295,h=853.2269]`. So when you call `transform.translate(218.53717,0.0);` after creating the `transform` object, you will see that the missing parts become visible (but then, of course, the output image will not be wide enough - you would have to make it 219 pixels wider). I didn't follow your math of computing the image size - this is a bit fiddly in the given case. – Marco13 Apr 29 '17 at 18:19
  • Ok I see, so I need to extend the entire image by 218 pixels on the right side, and then there will be a width of 218 blank pixels in the image to the left of the leftmost card? Any idea where this 218 is coming from? I would like to have this working without hardcoding that number in, and I have calculated what I thought this measure should be, and got 682. – KOB Apr 29 '17 at 19:16
  • I calculated it using Pythagoras Theorem, as the length of the distance that the card extends to the left side from its original position. – KOB Apr 29 '17 at 19:18
  • @KOB Again, I didn't go through your math (e.g. what **is** `ROTATION` actually?). The current width/height that you are computing only seems to be for **one** card that is rotated to the **right**, by 20 degrees, but do not include the bounds for the card that is rotated to the **left**, by **minus** 20 degrees. You have to compute the "bounds" for **all** cards. (This can be done differently than in my `computeBounds` method. You **can** do it manually, with `sin`/`cos`, but your current way *seems* to be wrong, as far as I understood). – Marco13 Apr 29 '17 at 19:35
  • @KOB Another note: Computing the bounds manually usually is not very difficult. But in this case, it becomes a bit fiddly, because you are rotating about the *lower right* point of the card, and then drawing this image at the position `xPos, yPos`, which is no longer really related to the shape of the (now rotated) card image. If you described clearly which physical appearance you want to achieve, maybe one could give more focussed advice of **how** to achieve this. – Marco13 Apr 29 '17 at 19:39
  • @KOB Sorry: You are rotating *some* cards about their *lower right* point, and *some* cards abou their *lower left* point. This makes it particularly hard. – Marco13 Apr 29 '17 at 19:41
  • Marco: dont you have anything else to do in your life than to give long-winded answers to these people? or maybe you are a billionaire – gpasch Apr 29 '17 at 19:44
  • 1
    @gpasch Don't you have anything else to do than asking me this? :-P Seriously: Such Swing/Image/Game stuff is kind of a hobby. I've learned something about the bounds handling of `AffineTransformOp` from answering this question. Building the MCVE with this nifty slider to "unfold" the cards was fun. Yeah, not that "skydiving" or "bungee jumping" kind of fun, but... fun :-) – Marco13 Apr 29 '17 at 19:50
  • Now the answer is "accepted"... but I wonder whether the answer was really satisfactory. However, I assume that you found a solution that worked for you, and the original question was actually already answered with the link to the `AffineTransformOp` docs. – Marco13 Apr 30 '17 at 01:45