1

I'm building a game in Java and I need to synchronize the animation executions.
Currently they will occur simultaneously, while accessing the same data.
I'd prefer that the animations stack up, and execute one at a time.

In this game, when you select a block, it will rotate 90 degrees either left or right, randomly.

I'm using the following code.

static class GUI extends JFrame {
    JPanel panel = new JPanel(new GridLayout(10, 10));

    static class Box extends JLabel {...}

    GUI() {
        for (int i = 0; i < 100; i++) panel.add(new Box());
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setResizable(false);
        add(panel);
        pack();
        setVisible(true);
    }
}

And, here is the Box class.
Variables bg and c are background and color, a is angle, and d is direction where true is clockwise.

static class Box extends JLabel {
    static Random r = new Random();
    int bg, c = r.nextInt(bg = 0xffffff);
    int a0, a = 90 * r.nextInt(1, 4);
    boolean d;

    Box() {
        setPreferredSize(new Dimension(100, 100));
        setBackground(bg());
        setBorder(BorderFactory.createLineBorder(c(), 2, true));
        setOpaque(true);
        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                super.mouseClicked(e);
                EventQueue.invokeLater(() -> {
                    d = r.nextInt(2) == 0;
                    bg -= 0x0f0f0f;
                    animate();
                    setBackground(bg());
                });
            }
        });
    }

    void animate() {
        a0 = a;
        new Timer(10, e -> {
            a += d ? -5 : 5;
            if (Math.abs(a - a0) == 90) {
                ((Timer) e.getSource()).stop();
                if (a == 360) a = 0;
            }
            repaint();
        }).start();
    }

    Color c() { return new Color(c); }
    Color bg() { return new Color(bg); }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2 = (Graphics2D) g;
        g2.setColor(c());
        g2.setStroke(new BasicStroke(10));
        g2.rotate(Math.toRadians(a), 50, 50);
        g2.draw(new Line2D.Double(50, 50, 50, 0));
        g2.setBackground(bg());
    }
}

When I double-click a JLabel it rotates indefinitely.  How do I synchronize this?
A single click works correctly.

Essentially, I want each mouse click to spawn a new Timer, that will not begin until the previous has finished.

On a final note, should I be using the volatile keyword for a0 and a?
And, if anyone has any recommendations on my code, feel free.


Edit

I believe I found a solution.

I've created a Box inner-class, Animation, which implements Runnable.
In the run method I make the adjustments to Box, and start the Timer object.

The enclosing Box class, now has a List queue, which is appended to for each mouse-click.
When the Animation object finishes, it will remove itself from queue, and start the next, if any.

For a0 and a I am using the AtomicInteger class.
This has significantly improved the performance of the rotation.

AtomicInteger a0 = new AtomicInteger();
AtomicInteger a = new AtomicInteger(90 * r.nextInt(1, 4));
class Animation implements Runnable {
    @Override
    public void run() {
        d = r.nextInt(2) == 0;
        bg -= 0x0f0f0f;
        setBackground(bg());
        setText(String.valueOf(s));
        a0.set(a.get());
        new Timer(10, l -> {
            a.set(a.get() + (d ? -5 : 5));
            repaint();
            if (Math.abs(a.get() - a0.get()) == 90) {
                ((Timer) l.getSource()).stop();
                if (a.get() == 360) a.set(0);
                queue.remove(0);
                if (queue.size() != 0) queue.get(0).run();
            }
        }).start();
    }
}

Additionally, I created a method rotate, to invoke from the mouseClicked method.

void rotate() {
    queue.add(new Animation());
    if (queue.size() == 1) queue.get(0).run();
}

Here is the complete code for Box, re-factored.
I've additionally added text to the box, to count the clicks.

static class Box extends JLabel {
    static Random r = new Random();
    int bg, c = r.nextInt(bg = 0xffffff), s = 0;
    AtomicInteger a0 = new AtomicInteger();
    AtomicInteger a = new AtomicInteger(90 * r.nextInt(1, 4));
    boolean d;
    List<Animation> queue = new ArrayList<>();

    Box() {
        setPreferredSize(new Dimension(100, 100));
        setBackground(bg());
        setBorder(BorderFactory.createLineBorder(c(), 2, true));
        setOpaque(true);
        setFont(new Font("Mono", Font.PLAIN, 100));
        setForeground(c());
        setText(String.valueOf(s));
        setHorizontalAlignment(SwingConstants.CENTER);
        setVerticalTextPosition(SwingConstants.CENTER);
        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                super.mouseClicked(e);
                s++;
                rotate();
            }
        });
    }

    void rotate() {
        queue.add(new Animation());
        if (queue.size() == 1) queue.get(0).run();
    }

    Color c() { return new Color(c); }
    Color bg() { return new Color(bg); }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2 = (Graphics2D) g.create();
        g2.setColor(c());
        g2.setStroke(new BasicStroke(10));
        g2.rotate(Math.toRadians(a.get()), 50, 50);
        g2.draw(new Line2D.Double(50, 50, 50, 0));
        g2.setBackground(bg());
        g2.dispose();
    }

    class Animation implements Runnable {...}
}

Reilas
  • 3,297
  • 2
  • 4
  • 17
  • 1
    "I'm unsure how to synchronize the animations." The common way is to have one and only one animation loop. You create a logical model of your game using plain Java getter/setter classes. The animation loop moves the logical pieces, and then you draw the state of the logical model. – Gilbert Le Blanc Aug 30 '23 at 10:42
  • 2
    "*Essentially, I want each mouse click to spawn a new Timer, that will not begin until the previous has finished."* - No, you shouldn't. This will have degrading effect on your apps performance. Instead, you want a single `Timer` which can trigger "ticks" which your "animatable" objects can then respond to – MadProgrammer Aug 30 '23 at 10:43
  • You should also maintain some kind of "animating" state for your object, so if it's clicked again, while animating, it won't start a new animation cycle – MadProgrammer Aug 30 '23 at 10:45
  • 1
    For [example](https://stackoverflow.com/questions/23209060/how-to-animate-from-one-x-y-coordinate-to-another-java-processing/23210015#23210015) and [example](https://stackoverflow.com/questions/70525750/rotate-image-in-grid-in-java/70526023#70526023) – MadProgrammer Aug 30 '23 at 10:54
  • *"that will not begin until the previous has finished."* - Sounds more like you want some of stack, so the "animation loop" would pop of the entity to be animated, animate it, the pop then next entity of the loop. I would consider having the loop stop when there are no more entities in the stack and start again when you add more – MadProgrammer Aug 30 '23 at 10:56
  • @GilbertLeBlanc, great recommendation, thanks. – Reilas Aug 30 '23 at 11:20
  • @MadProgrammer, both of those links are excellent examples, thanks. – Reilas Aug 30 '23 at 11:20
  • @MadProgrammer, I guess you're saying just use a _Deque_ of _Timer_ values, and some type of mechanism to run each, one at a time. – Reilas Aug 30 '23 at 11:23
  • 1
    No, I'm saying "deque" some kind of state and use a single `Timer` to animate that state – MadProgrammer Aug 30 '23 at 11:27
  • Again, more `Timer`s WILL degrade the performance of your app - you shouldn't me using multiple `Timer`s. You should also decouple the state, so the component becomes responsible for displaying the state and is otherwise disconnected from it – MadProgrammer Aug 30 '23 at 22:15

2 Answers2

4

Essentially, I want each mouse click to spawn a new Timer,

No, I wouldn't do that, more Timer's isn't going to help, it's going to have a degrading effect on performance. Remember, each Timer will still schedule it's callbacks on the Event Dispatching Thread.

that will not begin until the previous has finished.

Sounds more like you want some kind of "stack" where a centralised loop can pop of the next task and animate it.

Cavets

First things first. Animation is hard, good animation is very hard. There's a lot that goes into making animation "look" good.

Centralised engine

Typically, you want to start with a concept of a "main" or "animation" loop. This will repeat, at as regular an interval as it can and will "tick" you animations.

For this, I've started with a Swing Timer. It's simple and safe to use. When there are no more things to animate, it will stop, so it's not wasting CPU cycles doing nothing.

public class AnimationEngine {
    private Timer timer;

    private List<Animatable> animatables = new ArrayList<>(8);
    private Animatable current;

    public AnimationEngine() {
        this.timer = new Timer(5, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                didTick();
            }
        });
    }

    public void add(Animatable animatable) {
        animatables.add(animatable);
        start();
    }

    public void remove(Animatable animatable) {
        animatables.remove(animatable);
        if (current == animatable) {
            current = null;
        }
        if (animatables.isEmpty()) {
            stop();
        }
    }

    public void start() {
        if (timer.isRunning()) {
            return;
        }
        timer.start();
    }

    public void stop() {
        timer.stop();
    }

    protected void didTick() {
        if (current == null) {
            if (animatables.isEmpty()) {
                stop();
                return;
            }
            current = animatables.remove(0);
        }
        if (current.tick(this)) {
            remove(current);
        }
    }
}

Something to animate

This is probably a little bit of overkill for what you need, but the following describes something which can be animated.

public interface Animatable {
    public double getProgress();
    public Duration getDuration();
    public boolean tick(AnimationEngine engine);
}

This is "duration" based, meaning that it will animate some change over a specified period of time. Generally speaking, a time based animation will give better results over a linear delta based animation. It also makes it much easier to change the speed of the animation.

I then created a RotationAnimation, which just animates the rotation of a Box from one angle to other (over a period of time)

public class RotationAnimation implements Animatable {

    private float from;
    private float to;
    private Box box;
    private Duration duration;

    private Instant startedAt;

    public RotationAnimation(Box box, float from, float to, Duration duration) {
        this.from = from;
        this.to = to;
        this.box = box;
        this.duration = duration;
    }

    public Box getBox() {
        return box;
    }

    public float getFrom() {
        return from;
    }

    public float getTo() {
        return to;
    }

    protected float getDistance() {
        return getTo() - getFrom();
    }

    protected float valueAt(double progress) {
        float distance = getDistance();
        float value = distance * (float) progress;
        value += getFrom();
        return value;
    }

    @Override
    public double getProgress() {
        if (startedAt == null) {
            startedAt = Instant.now();
        }
        Duration duration = getDuration();
        Duration runningTime = Duration.between(startedAt, Instant.now());
        double progress = (runningTime.toMillis() / (double) duration.toMillis());

        return Math.min(1.0, Math.max(0.0, progress));
    }

    @Override
    public Duration getDuration() {
        return duration;
    }

    @Override
    public boolean tick(AnimationEngine engine) {
        boolean didComplete = false;
        double progress = getProgress();
        if (progress >= 1.0f) {
            didComplete = true;
            progress = 1.0f;
        }
        float angle = valueAt(progress);
        box.setAngle(angle);
        box.repaint();
        return didComplete;
    }

}

Relatively speaking, this is pretty simple. On each tick it determines the amount of time it's been running, converts this value to a normalised progress (0-1) and calculates the resulting angle to be applied.

Runnable example...

enter image description here

import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.Timer;
import javax.swing.border.EmptyBorder;

public class Main {
    public static void main(String[] args) {
        new Main();
    }

    public Main() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new JFrame();
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private AnimationEngine engine = new AnimationEngine();

        public TestPane() {
            setBorder(new EmptyBorder(32, 32, 32, 32));
            setLayout(new GridLayout(10, 10));

            for (int index = 0; index < 100; index++) {
                Box box = new Box();
                add(box);
                box.addMouseListener(new MouseAdapter() {
                    @Override
                    public void mouseClicked(MouseEvent e) {
                        float startAngle = box.getAngle();
                        float endAngle = startAngle + 90;
                        engine.add(new RotationAnimation(box, startAngle, endAngle, Duration.ofSeconds(1)));
                    }
                });
            }
        }
    }

    public class Box extends JPanel {
        public static final int BOX_WIDTH = 50;
        public static final int BOX_HEIGHT = 50;

        protected static final List<Color> COLORS = new ArrayList<>(Arrays.asList(new Color[] {
            Color.BLACK, Color.BLUE, Color.CYAN, Color.DARK_GRAY, Color.GRAY, Color.GREEN,
            Color.LIGHT_GRAY, Color.MAGENTA, Color.PINK, Color.RED
        }));

        private float angle = 0;

        private Map<RenderingHints.Key, Object> renderingHints = new HashMap<>();

        public Box() {
            Collections.shuffle(COLORS);
            setForeground(COLORS.get(0));
            renderingHints.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        }

        public void setAngle(float angle) {
            this.angle = angle;
        }

        public float getAngle() {
            return angle;
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(BOX_WIDTH, BOX_HEIGHT);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();
            g2d.setRenderingHints(renderingHints);
            g2d.rotate(Math.toRadians(getAngle()), getWidth() / 2, getHeight() / 2);
            g2d.setColor(getForeground());
            int x = (getWidth() - BOX_WIDTH) / 2;
            int y = (getHeight() - BOX_HEIGHT) / 2;
            g2d.drawRect(x, y, BOX_WIDTH - 1, BOX_HEIGHT - 1);
            g2d.drawLine(x + BOX_WIDTH / 2, y + BOX_HEIGHT / 2, x + BOX_WIDTH / 2, y + BOX_HEIGHT);
            g2d.dispose();
        }
    }

    public interface Animatable {
        public double getProgress();

        public Duration getDuration();

        public boolean tick(AnimationEngine engine);
    }

    public class AnimationEngine {
        private Timer timer;

        private List<Animatable> animatables = new ArrayList<>(8);
        private Animatable current;

        public AnimationEngine() {
            this.timer = new Timer(5, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    didTick();
                }
            });
        }

        public void add(Animatable animatable) {
            animatables.add(animatable);
            start();
        }

        public void remove(Animatable animatable) {
            animatables.remove(animatable);
            if (current == animatable) {
                current = null;
            }
            if (animatables.isEmpty()) {
                stop();
            }
        }

        public void start() {
            if (timer.isRunning()) {
                return;
            }
            timer.start();
        }

        public void stop() {
            timer.stop();
        }

        protected void didTick() {
            if (current == null) {
                if (animatables.isEmpty()) {
                    stop();
                    return;
                }
                current = animatables.remove(0);
            }
            if (current.tick(this)) {
                remove(current);
            }
        }
    }

    public class RotationAnimation implements Animatable {

        private float from;
        private float to;
        private Box box;
        private Duration duration;

        private Instant startedAt;

        public RotationAnimation(Box box, float from, float to, Duration duration) {
            this.from = from;
            this.to = to;
            this.box = box;
            this.duration = duration;
        }

        public Box getBox() {
            return box;
        }

        public float getFrom() {
            return from;
        }

        public float getTo() {
            return to;
        }

        protected float getDistance() {
            return getTo() - getFrom();
        }

        protected float valueAt(double progress) {
            float distance = getDistance();
            float value = distance * (float) progress;
            value += getFrom();
            return value;
        }

        @Override
        public double getProgress() {
            if (startedAt == null) {
                startedAt = Instant.now();
            }
            Duration duration = getDuration();
            Duration runningTime = Duration.between(startedAt, Instant.now());
            double progress = (runningTime.toMillis() / (double) duration.toMillis());

            return Math.min(1.0, Math.max(0.0, progress));
        }

        @Override
        public Duration getDuration() {
            return duration;
        }

        @Override
        public boolean tick(AnimationEngine engine) {
            boolean didComplete = false;
            double progress = getProgress();
            if (progress >= 1.0f) {
                didComplete = true;
                progress = 1.0f;
            }
            float angle = valueAt(progress);
            box.setAngle(angle);
            box.repaint();
            return didComplete;
        }

    }
}

I've not even started talking about things like "easement", which could make the animation look "more natural".

if you're interested, you could have a look at How can I implement easing functions with a thread (which I also implemented here)

I need. In you're example here, it only allows you to select one box at a time. I did not mean that, by "synchronization". I meant, prevent them from overlapping, and using the same resource simultaneously

This is relatively simple to fix, simply modify the didTick method of the AnimationEngine to loop through the List and call each Animatables tick method, for example...

protected void didTick() {
    List<Animatable> copy = new ArrayList<>(animatables);
    for (Animatable animated : copy) {
        if (animated.tick(this)) {
            animatables.remove(animated);
        }
    }
    if (animatables.isEmpty()) {
        stop();
    }
}

The mouse-click is part of the score, and the player may want to quickly double-click a box, or triple-click. They'll expect to see an animation for each click

This is a more complicated, but could be accomplished through a number of different methods.

You could "interrupt" the current animation, take the Boxs current angle, project the new angle and start again. This, however, is going to make the animation look ... well, weird. Since the time doesn't change, the speed at which the box moves will change, so click it enough times and you can have a very fast spin.

Alternatively, you could build a "chain" of animations. This would basically be a Animatable container which contains a List of animations to be played. It would simply play through each animation in sequence until it has no more animations.

For example...

public class ChainedRotationAnimation implements Animatable {
    private List<RotationAnimation> animations = new ArrayList<>(8);
    private RotationAnimation active;
    
    private Box box;

    public ChainedRotationAnimation(Box box) {
        this.box = box;
    }

    public Box getBox() {
        return box;
    }
    
    public Float getTargetAngle() {
        // If nothing is avaliable, return `null`
        if (animations.isEmpty() && active == null) {
            return null;
        }
        // If no other animations are avaliable, use the active animation
        // instead
        if (animations.isEmpty() && active != null) {
            return active.getTo();
        }
        // Otherwise, find the last animation and use it's target angle
        RotationAnimation last = animations.get(animations.size() - 1);
        return last.getTo();
    }

    public void add(RotationAnimation animatable) {
        animations.add(animatable);
    }

    public void remove(RotationAnimation animatable) {
        animations.remove(animatable);
        if (active == animatable) {
            active = null;
        }
    }

    // I would remove these properties fom the `Animatable` class
    // and instead move them to a `DurationAnimatable` class
    @Override
    public double getProgress() {
        return 0;
    }

    @Override
    public Duration getDuration() {
        return Duration.ZERO;
    }

    @Override
    public boolean tick(AnimationEngine engine) {
        if (active == null) {
            if (animations.isEmpty()) {
                return true;
            }
            active = animations.remove(0);
        }
        if (active.tick(engine)) {
            active = null;
            // Save us a whole tick
            return animations.isEmpty();
        }
        return false;
    }

}

Then we'd need to update the Box mouse click handler...

box.addMouseListener(new MouseAdapter() {
    @Override
    public void mouseClicked(MouseEvent e) {
        // Get all the current animations...
        List<Animatable> animations = engine.getAnimations();
        boolean exists = false;
        for (Animatable animatable : animations) {
            // Ignore anything which isn't a chained rotation
            if (!(animatable instanceof ChainedRotationAnimation)) {
                continue;
            }
            // Ignore the animation if it's not for our Box
            ChainedRotationAnimation chained = (ChainedRotationAnimation) animatable;
            if (chained.getBox() != box) {
                continue;
            }
            exists = true;
            // Determine the angle from which the box will start
            // Either this is the current angle if it's not been
            // animated or the target angle for the last animation
            // in the chain
            float startAngle = box.getAngle();
            if (chained.getTargetAngle() != null) {
                startAngle = chained.getTargetAngle();
            }
            float endAngle = startAngle + 90;
            chained.add(new RotationAnimation(box, startAngle, endAngle, Duration.ofMillis(250)));
        }
        // If no chained animation exists, we need to create one
        if (exists) {
            return;
        }               
        float startAngle = box.getAngle();
        float endAngle = startAngle + 90;
        ChainedRotationAnimation chained = new ChainedRotationAnimation(box);
        chained.add(new RotationAnimation(box, startAngle, endAngle, Duration.ofMillis(250)));
        engine.add(chained);
    }
});

This is just an example and the concepts are loosely based on my personal Super Simple Swing Animation Framework library

MadProgrammer
  • 343,457
  • 22
  • 230
  • 366
  • I appreciate the solution. The concept of the _Animatable_ object is a good idea. I'm going to try and incorporate this, I believe this provides the scalability I need. In you're example here, it only allows you to select one box at a time. I did not mean that, by "_synchronization_". I meant, prevent them from overlapping, and using the same resource simultaneously. The mouse-click is part of the score, and the player may want to quickly double-click a box, or triple-click. They'll expect to see an animation for each click. – Reilas Aug 30 '23 at 15:26
  • Oh, easy then, instead of pulling `current` from the `List` on each `tick`, simply iterate through the list, removing each animatable when it returns `true` – MadProgrammer Aug 30 '23 at 20:53
  • Yes, I've updated my question, towards the bottom. – Reilas Aug 30 '23 at 22:04
1

I was able to derive a solution.

I've created a Box inner-class, Animation, which extends the Thread class.
In the run method I make the adjustments to Box atomically, and start the Timer object.

Every time the player clicks a Box, the animation queue is incremented.
And, when queue is 1, the Animation thread is started.
Subsequently, the Timer routine will reset delta when queue is > 1; continuing the rotation.
This reduces the object creation significantly.

Additionally, mechanizing the objects in this manner is objectively more resourceful than rigging a poll.

Since I'm resolving angle, delta, and animator from the Animation runnable, I've converted them to AtomicInteger and AtomicReference objects.

And, I've removed the Math#abs call by adjusting the math.
The delta variable now carries an accumulating value, 0 through 90.

class Animation extends Thread {
    @Override
    public void run() {
        super.run();
        delta.set(0);
        animator.set(
            new Timer(1, l -> {
                angle.set(angle.get() + 2);
                repaint();
                if (delta.addAndGet(2) == 90) {
                    if (angle.get() == 360) angle.set(0);
                    if (queue.get() > 1) delta.set(0);
                    else ((Timer) l.getSource()).stop();
                    queue.set(queue.get() - 1);
                }
            }));
        animator.get().start();
    }
}

Additionally, I've been able to increase the render performance by re-factoring the Box#paintComponent method.

I've added the Graphics#dispose invocation, replaced the Math#toRadians call, and removed the Graphics2D and BasicStroke objects.
Additionally, c is now a Color object, color, so the c method is removed.

BasicStroke stroke 
    = new BasicStroke(((w + h) / 2f) / 3f, CAP_SQUARE, JOIN_ROUND);

@Override
protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    ((Graphics2D) g).rotate(0.017453292519943295 * angle.get(), wm, hm);
    g.setColor(color);
    ((Graphics2D) g).setStroke(stroke);
    ((Graphics2D) g).draw(path);
    g.dispose();
}

I've also been updating the game, so there are other adjustments.
Here is the full re-factor.

import static java.awt.BasicStroke.CAP_SQUARE;
import static java.awt.BasicStroke.JOIN_ROUND;
import static java.awt.geom.Path2D.WIND_EVEN_ODD;

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.GeneralPath;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
public class X {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(GUI::new);
    }

    static int seed;
    final static Random r
        = new Random() {
            { setSeed(seed = nextInt(0x111111, 0xffffff + 1)); }
        };

    static int n = 10, m = 10, dim = n * m;
    static int w = 100, wm = (int) (w / 2d);
    static int h = 100, hm = (int) (h / 2d);

    static class GUI extends JFrame {
        static JPanel top = new JPanel(new GridLayout(1, 1));
        static JPanel bottom = new JPanel(new GridLayout(n, m));

        GUI() {
            setLayout(new BorderLayout());
            JLabel label = new JLabel();
            label.setFont(new Font(Font.SERIF, Font.PLAIN, (int) ((w + h) / 2d)));
            label.setForeground(new Color(seed));
            label.setText("0x%x".formatted(seed));
            top.add(label);
            add(top, BorderLayout.PAGE_START);
            for (int i = 1; i <= dim; i++) bottom.add(new Box());
            add(bottom, BorderLayout.CENTER);
            setDefaultCloseOperation(EXIT_ON_CLOSE);
            setResizable(false);
            pack();
            setVisible(true);
        }

        static class Box extends JLabel {
            Color bg, color;
            AtomicInteger angle, delta, queue;
            Animation animation;
            AtomicReference<Timer> animator = new AtomicReference<>();
            GeneralPath path;
            BasicStroke stroke = new BasicStroke(((w + h) / 2f) / 3f, CAP_SQUARE, JOIN_ROUND);

            Box() {
                bg = new Color(r.nextInt(0xffffff + 1));
                color = new Color(r.nextInt(0xffffff + 1));
                angle = new AtomicInteger(90 * r.nextInt(1, 4));
                delta = new AtomicInteger();
                queue = new AtomicInteger();
                path = switch (r.nextInt(1, 14)) {
                    case 1, 2, 3, 4 -> Paths.L;
                    case 5, 6, 7, 8 -> Paths.I;
                    case 9, 10, 11, 12 -> Paths.T;
                    default -> Paths.X;
                };
                init();
                setVisible(true);
            }

            void init() {
                setPreferredSize(new Dimension(w, h));
                setOpaque(true);
                setBackground(bg);
                addMouseListener(new MouseAdapter() {
                    @Override
                    public void mouseClicked(MouseEvent e) {
                        super.mouseClicked(e);
                        queue.set(queue.get() + 1);
                        if (queue.get() == 1) {
                            animation = new Animation();
                            animation.setPriority(10);
                            animation.start();
                        }
                    }
                });
            }

            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g);
                ((Graphics2D) g).rotate(0.017453292519943295 * angle.get(), wm, hm);
                g.setColor(color);
                ((Graphics2D) g).setStroke(stroke);
                ((Graphics2D) g).draw(path);
                g.dispose();
            }

            class Animation extends Thread {
                @Override
                public void run() {
                    super.run();
                    delta.set(0);
                    animator.set(
                        new Timer(1, l -> {
                            angle.set(angle.get() + 2);
                            repaint();
                            if (delta.addAndGet(2) == 90) {
                                if (angle.get() == 360) angle.set(0);
                                if (queue.get() > 1) delta.set(0);
                                else ((Timer) l.getSource()).stop();
                                queue.set(queue.get() - 1);
                            }
                        }));
                    animator.get().start();
                }
            }
        }

        static class Paths {
            static GeneralPath X = new GeneralPath(WIND_EVEN_ODD, 4);
            static GeneralPath T = new GeneralPath(WIND_EVEN_ODD, 4);
            static GeneralPath L = new GeneralPath(WIND_EVEN_ODD, 3);
            static GeneralPath I = new GeneralPath(WIND_EVEN_ODD, 2);

            static {
                X.moveTo(0, hm);
                X.lineTo(w, hm);
                X.moveTo(wm, 0);
                X.lineTo(wm, h);
                T.moveTo(0, hm);
                T.lineTo(w, hm);
                T.moveTo(wm, hm);
                T.lineTo(wm, 0);
                L.moveTo(wm, 0);
                L.lineTo(wm, hm);
                L.lineTo(w, hm);
                I.moveTo(0, hm);
                I.lineTo(w, hm);
            }
        }
    }
}
Reilas
  • 3,297
  • 2
  • 4
  • 17