Wheel of Fortune in Java






4.83/5 (5 votes)
An animated random string selection wheel class written in Java AWT
Introduction
The idea behind this project was to create a Java class that designs a simple „wheel-of-fortune“ type of control, which is basically a wheel divided into n sections of the same sizes, and has a tick in the middle of the right side (3 o'clock position) which shows the current selection. The idea was that the wheel could be turned by using the mouse, and also spun with an initial speed and deceleration if the mouse button was released during the movement. Once the wheel would stop spinning, the selected string could be read based on what the tick is pointing at.
The wheel could be used for random selections from limited numbers of strings. The limit is due to the fact that the wheel is divided up to as many sections as the size of the string array, so visually it is only possible to fit in so many.
Since I am quite new to Java programming, this project was in a way intended for me to practice a little bit with Java animation using Java AWT.
The Main Class
The reusable classes needed for the Selection Wheel are: "SelectionWheel.java", "Wheel.java" and "Tick.java". I have written and attached to this project another source file, "MainWheel.java", which is just an example of usage of the SelectionWheel
class.
package SelectionWheel;
import javax.swing.*;
import java.io.File;
import java.io.FilenameFilter;
import java.util.*;
public class MainWheel {
public static void main(String[] args) throws Exception {
int width = 1000, height = 1000;
JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
ArrayList<String> list = new ArrayList<String>();
list.add("Avatar");
list.add("The Lord of the Rings: The Return of the King");
list.add("Pirates of the Caribbean: Dead Man's Chest");
list.add("The Dark Knight");
list.add("Harry Potter and the Philosopher's Stone");
list.add("Pirates of the Caribbean: At World's End");
list.add("Harry Potter and the Order of the Phoenix");
list.add("Harry Potter and the Half-Blood Prince");
list.add("The Lord of the Rings: The Two Towers");
list.add("Shrek 2");
list.add("Harry Potter and the Goblet of Fire");
list.add("Spider-Man 3");
list.add("Ice Age: Dawn of the Dinosaurs");
list.add("Harry Potter and the Chamber of Secrets");
list.add("The Lord of the Rings: The Fellowship of the Ring");
list.add("Finding Nemo");
list.add("Star Wars: Episode III – Revenge of the Sith");
list.add("Transformers: Revenge of the Fallen");
list.add("Spider-Man");
list.add("Shrek the Third");
SelectionWheel wheel = new SelectionWheel(list);
wheel.hasBorders(true);
wheel.setBounds(10, 10, 700, 700);
JLabel lbl1 = new JLabel("Selection: ");
JLabel lbl2 = new JLabel("Angle: ");
JLabel lbl3 = new JLabel("Speed: ");
JLabel lblsel = new JLabel("(selection)");
JLabel lblang = new JLabel("(angle)");
JLabel lblsp = new JLabel("(speed)");
lbl1.setBounds(720, 10, 100, 20);
lblsel.setBounds(830, 10, 150, 20);
lbl2.setBounds(720, 30, 100, 20);
lblang.setBounds(830, 30, 150, 20);
lbl3.setBounds(720, 50, 100, 20);
lblsp.setBounds(830, 50, 150, 20);
frame.add(wheel);
frame.add(lbl1);
frame.add(lblsel);
frame.add(lbl2);
frame.add(lblang);
frame.add(lbl3);
frame.add(lblsp);
frame.setSize(width, height);
frame.setLayout(null);
frame.setVisible(true);
lblsel.setText(wheel.getSelectedString());
lblang.setText(Double.toString(wheel.getRotationAngle()));
lblsp.setText(Double.toString(wheel.getSpinSpeed()));
while(true) {
// wait for action
while(true)
{
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(wheel.isSpinning())
break;
}
// while spinning
while(wheel.isSpinning())
{
lblsel.setText(wheel.getSelectedString());
lblang.setText(Double.toString(wheel.getRotationAngle()));
lblsp.setText(Double.toString(wheel.getSpinSpeed()));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lblsp.setText(Double.toString(wheel.getSpinSpeed()));
// show selection
JOptionPane.showMessageDialog(frame, "Selection: " + wheel.getSelectedString());
}
}
}
The SelectionWheel
in the example is initialized with a String ArrayList
containing 20 best grossing movies in 2000s. It is drawn on a JFrame
. There are also several labels – lblsel
shows the current selection, lblang
shows the current rotation angle, and lblsp
shows the current speed of rotation of the wheel.
After the initialization, the code is entering an infinite loop where first a loop is waiting for the wheel to begin spinning, then after the spinning has started, it is refreshing the labels by reading the attributes of the SelectionWheel
, and finally once it has stopped, pops a MessageDialog
showing the finally selected string (the string at which the tick is pointing).
The Classes
There are altogether three classes which are needed for the SelectionWheel
to work.
The first is the SelectionWheel
class, which is basically just a wrapper class which combines the other two classes – Wheel
and Tick
. The Wheel
and Tick
needed to be written separately because the wheel is constantly rotated, while the tick is stationary, so we needed two separate Graphics
objects for drawing them.
All three classes are extensions of JPanel
class.
The Wheel Class
The Wheel
class is where all the animation and calculations happen. My original intention was to just write this class, but, as mentioned previously, the tick needed to be created separately because of the rotation – we need the wheel to rotate, but the tick remains stationary.
The Wheel
class has only one constructor method – it is always initialized with an ArrayList
of String
objects.
public Wheel(ArrayList<String> listOfStrings)
Mouse Listeners
The Wheel
class constructor also creates a MouseListener
– for mousePressed
and mouseReleased
events, and a MouseMotionListener
– for the MouseDragged
event.
The mouse events are tracked so that the animation of the rotation of the wheel can be triggered by using the mouse.
When the mouse is pressed, the rotation stops (if the wheel is moving at the moment). The time and the rotation angle are stored in order to calculate the initial speed and direction of rotation once it is released.
@Override
public void mousePressed(MouseEvent e) {
_mouseDragPosition = new Point2D.Double(e.getX(), e.getY());
// to stop the spinning if the circle is clicked on
double distance = Math.sqrt(Math.pow(_mouseDragPosition.getX() -
_center.getX(),2) + Math.pow(_mouseDragPosition.getY() - _center.getY(),2));
if(distance <= _radius)
{
spinStop();
}
// to measure initial speed
_timeStart = System.currentTimeMillis();
_rotationAngleStart = _rotationAngle;
}
Once the mouse is released, the initial speed is calculated, and the spinning is started by calling the method spinStartAsync
.
@Override
public void mouseReleased(MouseEvent e) {
setCursor(new Cursor(Cursor.DEFAULT_CURSOR));
// to measure initial speed
_timeEnd = System.currentTimeMillis();
_rotationAngleEnd = _rotationAngle;
double initialSpeed = 1000 * (_rotationAngleEnd - _rotationAngleStart) /
(_timeEnd - _timeStart);
initialSpeed = (int)Math.signum(initialSpeed) *
Math.min(Math.abs(initialSpeed), _maxSpinSpeed);
try {
spinStartAsync(Math.abs(initialSpeed),
(int)Math.signum(initialSpeed), _spinDeceleration);
} catch (Exception e1) {
e1.printStackTrace();
}
}
The mouseDragged
event listener is used to rotate the wheel while the mouse is pressed:
addMouseMotionListener(new MouseAdapter() {
@Override
public void mouseDragged(MouseEvent e) {
setCursor(new Cursor(Cursor.HAND_CURSOR));
spinStop();
/*
* Use the equation for angle between two vectors:
* vector 1 between last position of mouse and center of circle
* vector 2 between current position of mouse and center of circle
* ("k" is direction coefficient)
*/
Point2D mousePos = new Point2D.Double(e.getX(), e.getY());
double k1 = (_mouseDragPosition.getY() - _center.getY()) /
(_mouseDragPosition.getX() - _center.getX());
double k2 = (mousePos.getY() - _center.getY()) / (mousePos.getX() - _center.getX());
double _delta = Math.toDegrees(Math.atan((k2-k1)/(1 + k2 * k1)));
if(!Double.isNaN(_delta))
setRotationAngle(getRotationAngle() + _delta);
_mouseDragPosition = mousePos;
}
});
The Animation
After the mouse button is released, the animation is accomplished by repeatedly repainting the JPanel
– while a separate thread is calculating and updating the rotation angle over time; each time the JPanel
is repainted, the wheel is drawn rotated by a new angle. A deceleration is defined in the calculation of the spinning speed, in order to make sure that the wheel eventually stops spinning.
public void spinStartAsync(double speed, int direction, double deceleration)
(The spinning thread and the calculation process are explained in further text.)
However, the only thing handled so far is the calculation of the angle and calling the repaint method; in order to actually paint the wheel the way we want it, we need to override the paintComponent
method.
@Override
public void paintComponent(Graphics g)
{
/*
* Paintcomponent - if the image is null, create it and then draw it
* whilst keeping the current rotation.
* The image can be larger than the displaying area,
* so after it is drawn it needs to be placed properly.
*/
super.paintComponent(g);
if(_image == null) {
_image = drawImage();
_rotationCenter = new Point2D.Double(
this.getWidth() - 2 * BORDER - 2 * _radius + _center.getX(),
this.getHeight() / 2
);
_imagePosition = new Point2D.Double(
(int)(this.getWidth() - 2 * BORDER - 2 * _radius),
(int)(this.getHeight() / 2 - _center.getY())
);
}
Graphics2D gPanel = (Graphics2D) g;
gPanel.setRenderingHint
(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
gPanel.setRenderingHint
(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
gPanel.rotate(Math.toRadians(_rotationAngle),
_rotationCenter.getX(), _rotationCenter.getY());
gPanel.drawImage(_image, (int)_imagePosition.getX(), (int)_imagePosition.getY(), null);
}
For the sake of performance, and since the wheel – once drawn – will not be changed, we are using a BufferedImage
to store the wheel – so the wheel itself is actually drawn only once. The other approach would be to draw the entire wheel – section by section – each time the JPanel
redraws, but this might cause poor performance, depending on the size of the wheel and the number of the sections.
The BufferedImage
is drawn inside a separate method, which is thoroughly explained in further text.
Get / Set Methods
There are a lot of parameters of the wheel that can be affected / changed during runtime.
public void setShape(Shape shape)
The wheel can have different shapes, and this set method is used to change the shape of the wheel. The currently available shapes are:
public static enum Shape {
CIRCLE,
UMBRELLA
}
This list can easily be extended by adding a method for drawing another shape of a section. The circle shape is drawn as arcs (method fillArc
), and the umbrella shape is drawn as triangles (method fillTriangle
).
public double getRotationAngle()
public void setRotationAngle(double rotationAngle)
The rotation angle can be set directly. The repaint
method is called.
public ArrayList<Color> getColorScheme()
public void setColorScheme(ArrayList<Color> colors)
The Wheel
class contains an ArrayList
of Color
objects that are used to draw (fill) the wheel sections. The colors are used in the order of appearance in the ArrayList
. If the ArrayList
was never set, a default list will be used (method getDefaultColorList
).
A color can also be added to the existing list by using method addColor
.
public int getRadius()
Gets the current radius of the wheel. The radius cannot be changed, it is calculated based on the size of the BufferedImage
.
public ArrayList<String> getListOfStrings()
public void setListOfStrings(ArrayList<String> list)
The list of string
s can be changed during runtime. In that case, the existing BufferedImage
is disposed and a new one is drawn.
The number of String
objects in ArrayList
is limited by a variable private final int LIMIT = 100
. If the size of the ArrayList
is above this limit, an Exception will be thrown.
The reason for limiting the number of string
s is limiting the number of sections – because for a large number of sections, the delta angle becomes very very tiny, which is difficult to draw and may cause distortion and miscalculations.
public Font getFont()
public void setFont(Font font)
Get
s/set
s the font of the displayed string
s.
public double getSpinSpeed()
This is the actual spinning speed of the wheel – it cannot be set, it starts with the initial speed and is decreasing by deceleration until a full stop.
The current speed is calculated in a separate thread, a special TimerTask
object which runs the entire time, and based on the change of the rotation angle over a period of time, it is calculating the current speed of rotation. This class is explained in further text.
public double getMaxSpinSpeed()
public void setMaxSpinSpeed(double speed)
The maximum speed limits the initial speed of the rotation. This parameter was introduced in order to avoid too long spinnings. This can be set differently; by allowing greater maximum speeds, we achieve a larger degree of randomness – however, in that case, it would be advisable to also set the deceleration accordingly.
public double getSpinDeceleration()
public void setSpinDeceleration(double deceleration)
Get
s/set
s the spin deceleration. Note that deceleration must be <= 0
, otherwise the result is an Exception
.
public String getSelectedString() {
return _stringList.get((int)Math.floor(_noElem + (_rotationAngle % 360) / _delta) % _noElem);
}
Get
the currently selected string
(the string
from the section to which the tick is pointing).
The idea is to get the number of deltas (section angle) in the current rotationAngle
. This number is added to the size of the string
arraylist, and then MODed by the size of the string arraylist
, in order to avoid negative indices.
The Tick Class
The Tick
class is very simple and short.
Tick
can have width
and height
– these are the attributes. It can have a random polygon shape, which can be set with the method setPolygon
. If the custom polygon is not set, then the triangle is used, which is calculated in method getTriangle()
.
private Polygon getTriangle() {
/*
* Get triangle polygon - default shape of the tick.
*/
Polygon polygon = new Polygon();
polygon.addPoint(0, this.getHeight() / 2);
polygon.addPoint(this.getWidth(),
(int)(this.getHeight() / 2 - this.getWidth() * Math.tan(Math.toRadians(30))));
polygon.addPoint(this.getWidth(),
(int)(this.getHeight() / 2 + this.getWidth() * Math.tan(Math.toRadians(30))));
return polygon;
}
If a custom polygon is used, its size needs to be adjusted to fit the JPanel
, and it needs to be positioned properly, so this method is called:
private void adjustPolygon()
{
/*
* Adjust the size and position of the custom polygon shape of the tick.
*/
int i;
// calculate width/height of the polygon
int xmax = Integer.MIN_VALUE, xmin = Integer.MAX_VALUE;
int ymax = xmax, ymin = xmin;
for(i = 0; i < _polygon.xpoints.length; i++)
{
if(_polygon.xpoints[i]>xmax) xmax = _polygon.xpoints[i];
if(_polygon.xpoints[i]<xmin) xmin = _polygon.xpoints[i];
}
for(i = 0; i < _polygon.ypoints.length; i++)
{
if(_polygon.ypoints[i]>ymax) ymax = _polygon.ypoints[i];
if(_polygon.ypoints[i]<ymin) ymin = _polygon.ypoints[i];
}
int width = xmax - xmin;
// scale polygon
double factor = (double)this.getWidth() / width;
for(i = 0; i < _polygon.xpoints.length; i++)
{
_polygon.xpoints[i] *= factor;
_polygon.ypoints[i] *= factor;
}
// calculate center of polygon
int centerX = 0, centerY = 0;
for(i = 0; i < _polygon.xpoints.length; i++)
{
centerX += _polygon.xpoints[i];
}
centerX /= _polygon.xpoints.length;
for(i = 0; i < _polygon.ypoints.length; i++)
{
centerY += _polygon.ypoints[i];
}
centerY /= _polygon.ypoints.length;
// translate polygon to center of the panel
_polygon.translate(this.getWidth() / 2 - centerX, this.getHeight() / 2 - centerY);
}
The overriden paintComponent
method will create the triangle if the custom polygon is not set, and then call fillPolygon
to draw it.
The SelectionWheel Class
The SelectionWheel
class is a wrapper for other two classes – it joins them together and makes sure that the wheel
and tick
are positioned properly in relation to one another. The positions are adjusted in the overriden setBounds
method.
@Override
public void setBounds(int x, int y, int width, int height) {
/*
* Adjust the bounds of the wheel and tick based on tick width.
*/
super.setBounds(x, y, width, height);
_wheel.setBounds(0, 0, width - _tick.getTickWidth(), height);
_tick.setBounds(width - _tick.getTickWidth(), 0, _tick.getTickWidth(), height);
}
The class initializes a Wheel
and a Tick
object, and contains get
and set
methods that are passing attributes to and from these objects.
Points of Interest
Drawing the Wheel
The Wheel
class contains several attributes that are crucial to the process of drawing, the most important being:
Radius
– the radius of thewheel
(circle); it is set indrawImage()
method and will always be as big as theImage
size allows it, minus the specifiedBORDER
_radius = Math.min(img.getWidth(), img.getHeight()) / 2 - BORDER;
Center
– the center of thewheel
(circle) is always in the middle of theBufferedImage
_center = new Point2D.Double((double)img.getWidth() / 2, (double)img.getHeight() / 2);
stringDistanceFromEdge
– required in positioning the drawnstring
– how far from the edge of thewheel
we want it to be; this is hardcoded to 5% of the radiusdouble stringDistanceFromEdge = 0.05 * _radius;
fontSize
– the optimal size of the font of drawnstring
s; this is calculated in a separate methodcalcFontSize
Calculating Font Size
Calculating the optimal font size was one of the trickiest parts. The idea was to calculate the largest possible font that would still enable all of the string
s to fit into their respective sections, but with alignment to the edge of the wheel, stringDistanceFromEdge
away from it.
The algorithm is as follows:
- First, we find the longest
string
in theArrayList
. - Then, we set it to maximum allowed font size (
final int MAXFONTSIZE
). - Then we adjust the font size to the maximum possible height of the
String
. For this, we usejava.awt.FontMetrics
and a couple of basic trigonometric rules, such as Pythagoras theorem and triangle similarity. - Then, the
width
is adjusted by trial-and-error; if thestring
is wider than the section, the font size is reduced, and if thestring
is narrower than the section, the font size is increased – point by point – and then width is measured usingFontMetrics
in each iteration – until thestring
fits the section.
I tried to solve the font size problem in many different ways (i.e., linear proportionality of font size and string
height, but unfortunately, this rule did not provide the best results). In the end, I used this convenient solution.
private int calcFontSize(Graphics g, double stringDistanceFromEdge, int maxStringWidth) {
/*
* Calculates the optimal font size for the strings inside the sections.
* The strings need to be positioned next to the broader end of the section.
* The optimal size will depend on the longest string length
* and maximum height of the section
* in the left border of the rectangle surrounding the string.
*/
// Find the longest string
String tmpString = "";
for(int i = _noElem - 1; i >= 0; i--) {
if(_stringList.get(i).length() > tmpString.length())
tmpString = _stringList.get(i);
}
// Set it to max font size and calculate rectangle
int fontSize = MAXFONTSIZE;
g.setFont(new Font(_font.getFamily(), _font.getStyle(), fontSize));
FontMetrics fontMetrics = g.getFontMetrics();
Rectangle2D stringBounds = fontMetrics.getStringBounds(tmpString, g);
// Adjust string height / font size
int maxHeight = (int)Math.floor
(2 * stringDistanceFromEdge * Math.sin(Math.toRadians(_delta / 2)));
if(stringBounds.getHeight() > maxHeight) {
fontSize = (int)Math.floor(fontSize * maxHeight / stringBounds.getHeight());
g.setFont(new Font(_font.getFamily(), _font.getStyle(), fontSize));
fontMetrics = g.getFontMetrics();
stringBounds = fontMetrics.getStringBounds(tmpString, g);
}
// Adjust string width
// If the string is too narrow, increase font until it fits
double K = stringBounds.getWidth() / stringBounds.getHeight();
maxHeight = (int)Math.floor(2 * (_radius - stringDistanceFromEdge) *
Math.tan(Math.toRadians(_delta / 2)) / (1 + 2 * K * Math.tan(Math.toRadians(_delta / 2))));
while(stringBounds.getWidth() < maxStringWidth) {
g.setFont(new Font(_font.getFamily(), _font.getStyle(), ++fontSize));
fontMetrics = g.getFontMetrics();
stringBounds = fontMetrics.getStringBounds(tmpString, g);
}
// If the string is too wide, decrease font until it fits
while(stringBounds.getWidth() > maxStringWidth) {
g.setFont(new Font(_font.getFamily(), _font.getStyle(), --fontSize));
fontMetrics = g.getFontMetrics();
stringBounds = fontMetrics.getStringBounds(tmpString, g);
}
return Math.min(fontSize, MAXFONTSIZE);
}
"Zooming"
There is, of course, the eventuality that a string
is so long that the resulting font size would be unreadable. So I introduced a variable MINFONTSIZE
which is limiting the font size to a minimum value. The first idea was to just not draw the string
s if the font size is too small, but then I thought of a better solution – to "zoom" in the wheel
, so that the font can be larger. So, if the font size is too small, the wheel
is proportionally enlarged in order that the minimum font size is satisfied. This is happening inside the drawImage
method:
// Adjust the parameters (for "zoom in") - if the font size is too small
if(fontSize < MINFONTSIZE) {
_zoomFactor = (double)MINFONTSIZE / fontSize;
width += (int) 2 * ((_zoomFactor * _radius) - _radius);
height += (int) 2 * ((_zoomFactor * _radius) - _radius);
_radius = (int)(_zoomFactor * _radius);
img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
g2d = (Graphics2D) img.getGraphics();
maxStringWidth = (int)(_radius - 2 * stringDistanceFromEdge);
fontSize = calcFontSize(g2d, stringDistanceFromEdge, maxStringWidth);
}
Now, the wheel
image is larger than the surrounding container (JPanel
), so it needs to be properly placed. Also, a new rotation center needs to be calculated. This is a part of the paintComponent
overriden method, and is only calculated once after the wheel
image is drawn:
if(_image == null) {
_image = drawImage();
_rotationCenter = new Point2D.Double(
this.getWidth() - _image.getWidth(null) + _center.getX(),
this.getHeight() / 2
);
_imagePosition = new Point2D.Double(
(int)(this.getWidth() - _image.getWidth(null)),
(int)(this.getHeight() / 2 - _center.getY())
);
}
This is how it looks like:
Drawing in a Loop
Each section of the wheel is drawn in a separate iteration of the loop. The simplest way of drawing the wheel was to just turn it around bit by bit and draw the necessary elements.
The loop is going through as many iterations as there are string
s in the ArrayList
(which is equal to the number of sections of the wheel
).
First, the line (section border) from the centre of the wheel
to the 3 o'clock mark is drawn.
// Draw section border
if(hasBorders) {
g2d.setColor(Color.BLACK);
g2d.drawLine((int)_center.getX(), (int)_center.getY(),
(int)_center.getX() + _radius, (int)_center.getY());
}
(The border can be switched on and of with hasBorders
variable.)
Then, the section above it is filled with color. The angle of the section is 360 / (number of sections). The color is chosen from the _colors ArrayList
variable.
// Fill section depending on the chosen shape
g2d.setColor(_colors.get(_colorCounter++ % _colors.size()));
if(_shape == Shape.UMBRELLA)
fillTriangle(g2d);
else //if(_shape == Shape.CIRCLE)
fillArc(g2d);
Depending on the chosen Shape
of the wheel, the section is either drawn as an arc or as a triangle.
private void fillArc(Graphics g2d) {
g2d.fillArc((int)_center.getX() - _radius, (int)_center.getY() -
_radius, 2 * _radius, 2 * _radius, 0, (int)- Math.ceil(_delta)); // use ceil
// because of decimal part (would be left empty)
if(hasBorders) {
g2d.setColor(Color.black);
g2d.drawArc((int)_center.getX() - _radius, (int)_center.getY() -
_radius, 2 * _radius, 2 * _radius, 0, (int)- Math.ceil(_delta));
}
}
private void fillTriangle(Graphics2D g2d) {
/*
* Method that draws section as a triangle (in case Shape=UMBRELLA was chosen)
*/
int[] xpoints = new int[3];
xpoints[0] = (int)_center.getX();
xpoints[1] = (int)_center.getX() + _radius;
int dx = (int) (2 * _radius * Math.pow(Math.sin(Math.toRadians(_delta / 2)), 2));
xpoints[2] = xpoints[1] - dx;
int[] ypoints = new int[3];
ypoints[0] = (int)_center.getY();
ypoints[1] = (int)_center.getY();
int dy = (int) (2 * _radius * Math.sin(Math.toRadians
(_delta / 2)) * Math.cos(Math.toRadians(_delta / 2)));
ypoints[2] = ypoints[1] + dy;
g2d.fillPolygon(xpoints, ypoints, 3);
if(hasBorders) {
g2d.setColor(Color.black);
g2d.drawLine(xpoints[1], ypoints[1], xpoints[2], ypoints[2]);
}
}
Next, the string
is drawn. The wheel
is rotated first for half a section angle so that the string
would be drawn across the middle, and after drawing the string
, it is rotated for another half of the section angle.
// Draw string - rotate half delta, then draw then rotate the other half
// (to have the string in the middle)
g2d.rotate(Math.toRadians(_delta / 2), _center.getX(), _center.getY());
g2d.setColor(Color.BLACK);
fontMetrics = g2d.getFontMetrics();
stringWidth = fontMetrics.stringWidth(_stringList.get(i));
g2d.drawString(_stringList.get(i), (int)(_center.getX() +
maxStringWidth - stringWidth + stringDistanceFromEdge),
(int)(_center.getY() + (double)fontMetrics.getHeight() / 2 -
fontMetrics.getMaxDescent()));
g2d.rotate(Math.toRadians(_delta / 2), _center.getX(), _center.getY());
Rendering Hints
Always be sure to use rendering hints when drawing in Java, otherwise the image can become very grainy. These are the hints I used:
// Set rendering hints
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
Wheel Rotation
The rotation itself is happening in a special class SpinRunnable
which implements the Runnable
interface:
private class SpinRunnable implements Runnable {
/*
* Runnable class that handles the spinning of the wheel.
* It sets the rotation angle by calculating the speed through time based on deceleration.
* Each setRotationAngle call will cause the wheel to be redrawn.
*/
private double spinSpeed;
private int spinDirection;
private double spinDeceleration;
public SpinRunnable(double speed, int direction, double deceleration) {
this.spinSpeed = speed;
this.spinDirection = direction;
this.spinDeceleration = deceleration;
}
public void run()
{
_spinOnOff = true;
int sleepTime = 1000 / _refreshRate;
double delta;
while(_spinOnOff && spinSpeed > 0)
{
delta = spinDirection * (spinSpeed / _refreshRate);
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
setRotationAngle(getRotationAngle() + delta);
spinSpeed += spinDeceleration / _refreshRate;
}
_spinOnOff = false;
}
}
The spinning is accomplished quite simply – by decreasing the speed by the deceleration factor over time, and calculating and setting rotation angle until the speed is 0 or until the spinning is turned off (_spinOnOff == false
).
An instance of this class is initiated and its run method started by spinStartAsync
method. The method is starting a new Thread
, so that the spinning wouldn't block the rest of the code.
public void spinStartAsync(double speed, int direction, double deceleration) throws Exception
{
/*
* Method that starts the spinning thread.
* Parameters:
* speed => degrees per second
* direction => "< 0" = clockwise , "> 0" = counter-clockwise, "=0" = stand still
* deceleration => "< 0" = degrees per second per second reducing speed,
* "= 0" = perpetual spin, "> 0" = throw exception
*/
if(deceleration > 0)
throw new Exception("Illegal parameter value: acceleration must be < 0");
SpinRunnable spinRunnable = new SpinRunnable(speed, direction, deceleration);
Thread t = new Thread(spinRunnable);
t.start();
}
Tracking the Speed of Rotation
To track the speed of rotation, there is also a separate class which extends the TimerTask
class.
private class speedTimerTask extends TimerTask {
/*
* TimerTask class that monitors and refreshes the _spinSpeed
* The speed is calculated as a difference of two rotation angles over a period of time.
* We add the 360 to the "now" angle and then MOD it by 360
* to avoid miscalculation when passing the full circle.
*/
@Override
public void run() {
double prevAngle, nowAngle;
long sleepTime = 100;
while(true) {
prevAngle = getRotationAngle();
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
nowAngle = getRotationAngle();
nowAngle = (nowAngle + Math.signum(nowAngle) * 360) % 360;
_spinSpeed = Math.abs(nowAngle - prevAngle) * (1000 / sleepTime);
}
}
}
This class contains the run
method which is also run in a separate thread – it runs in an infinite loop where, in each iteration, it calculates the angle change over time, and based on that, sets the _spinSpeed
variable.
History
- 4th July, 2020: Initial version