// Near Infinity - An Infinity Engine Browser and Editor
// Copyright (C) 2001 - 2005 Jon Olav Hauglid
// See LICENSE.txt for license information
package org.infinity.gui.layeritem;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import javax.swing.SwingConstants;
import javax.swing.SwingWorker;
import javax.swing.Timer;
import org.infinity.gui.RenderCanvas;
import org.infinity.resource.Viewable;
import org.infinity.resource.are.viewer.ViewerConstants;
import org.infinity.resource.graphics.ColorConvert;
/**
* Represents a game resource structure visually as a bitmap animation.
*/
public class AnimatedLayerItem extends AbstractLayerItem
implements LayerItemListener, ActionListener, PropertyChangeListener
{
private static final Color TransparentColor = new Color(0, true);
private final FrameInfo[] frameInfos = {new FrameInfo(), new FrameInfo()};
private BasicAnimationProvider animation;
private boolean isAutoPlay;
private Timer timer;
private Object interpolationType;
private boolean forcedInterpolation;
private double zoomFactor;
private Rectangle frameBounds; // Point(x,y) defines the point of origin for the animation graphics
private RenderCanvas rcCanvas; // Renders both the animation graphics and an optional frame
private SwingWorker<Void, Void> workerAnimate;
/**
* Initialize object with default settings.
*/
public AnimatedLayerItem()
{
this(null, null, null, null);
}
/**
* Initialize object with the specified map location.
* @param location Map location
*/
public AnimatedLayerItem(Point location)
{
this(location, null, null, null);
}
/**
* Initialize object with a specific map location and an associated viewable object.
* @param location Map location
* @param viewable Associated Viewable object
*/
public AnimatedLayerItem(Point location, Viewable viewable)
{
this(location, viewable, null, null);
}
/**
* Initialize object with a specific map location, associated Viewable and an additional text message.
* @param location Map location
* @param viewable Associated Viewable object
* @param msg An arbitrary text message
*/
public AnimatedLayerItem(Point location, Viewable viewable, String msg)
{
this(location, viewable, msg, null);
}
/**
* Initialize object with a specific map location, associated Viewable, an additional text message
* and an array of Frame object containing graphics data and frame centers.
* @param location Map location
* @param viewable Associated Viewable object
* @param msg An arbitrary text message
* @param frames An array of Frame objects defining the animation for this layer item
*/
public AnimatedLayerItem(Point location, Viewable viewable, String msg, BasicAnimationProvider anim)
{
super(location, viewable, msg);
init();
initAnimation(anim);
}
/**
* Returns the currently assigned animation provider.
*/
public BasicAnimationProvider getAnimation()
{
return animation;
}
/**
* Assigns a new animation to the layer item.
*/
public void setAnimation(BasicAnimationProvider anim)
{
initAnimation(anim);
}
/**
* Returns the currently defined frame rate for the animation. The returned value is only an
* approximation of the frame rate defined in {@link #setFrameRate(double)}, as the timer
* resolution is limited to 1 ms.
* @return Frame rate in frames/second.
*/
public double getFrameRate()
{
double delay = (double)timer.getDelay();
if (delay > 0.0) {
return 1000.0 / delay;
} else {
return 0.0;
}
}
/**
* Sets the frame rate of the animation in frames/second.
* @param framesPerSecond The desired frame rate in the range [1.0, 60.0].
*/
public void setFrameRate(double framesPerSecond)
{
if (framesPerSecond < 1.0) framesPerSecond = 1.0; else if (framesPerSecond > 60.0) framesPerSecond = 60.0;
int delay = (int)(1000.0 / framesPerSecond);
timer.setDelay(delay);
}
/**
* Returns the currently used zoom factor for this layer item.
* @return Zoom factor
*/
public double getZoomFactor()
{
return zoomFactor;
}
/**
* Defines a new zoom factor for this layer item.
* @param zoomFactor The new zoom factor.
*/
public void setZoomFactor(double zoomFactor)
{
if (this.zoomFactor != zoomFactor) {
this.zoomFactor = zoomFactor;
updateSize();
updatePosition();
}
}
/**
* Returns the currently used interpolation type.
*/
public Object getInterpolationType()
{
return interpolationType;
}
/**
* Specifies the interpolation type used for scaled items.
* @param type One of the TYPE_xxx constants.
*/
public void setInterpolationType(Object type)
{
if (this.interpolationType != type) {
if (type == ViewerConstants.TYPE_NEAREST_NEIGHBOR ||
type == ViewerConstants.TYPE_BILINEAR ||
type == ViewerConstants.TYPE_BICUBIC) {
this.interpolationType = type;
if (forcedInterpolation) {
rcCanvas.setInterpolationType(interpolationType);
}
updateFrame();
}
}
}
/**
* Returns whether the renderer is forced to use the predefined interpolation type on scaling.
* @return
*/
public boolean isForcedInterpolation()
{
return forcedInterpolation;
}
/**
* Specifies whether the renderer uses the best interpolation type based on the current zoom factor
* or uses a predefined interpolation type only.
* @param set If {@code true}, uses a predefined interpolation type only.
* If {@code false}, chooses an interpolation type automatically.
*/
public void setForcedInterpolation(boolean set)
{
if (forcedInterpolation != set) {
forcedInterpolation = set;
updateFrame();
}
}
/**
* Returns whether the animation will automatically restart after playing the last frame.
* (Note: Merely returns the value provided by the attached BasicAnimationProvider object.)
*/
public boolean isLooping()
{
return animation.isLooping();
}
public boolean isAutoPlay()
{
return isAutoPlay;
}
/**
* Sets whether the layer item will start playing automatically when the item becomes visible.
*/
public void setAutoPlay(boolean set)
{
if (set != isAutoPlay) {
isAutoPlay = set;
if (isAutoPlay() && isVisible()) {
play();
}
}
}
/**
* Returns whether the animation is playing.
*/
public boolean isPlaying()
{
return timer.isRunning();
}
/**
* Starts playback of the animation.
*/
public void play()
{
if (!isPlaying()) {
timer.start();
}
}
/**
* Stops playback of the animation without resetting current frame.
*/
public void pause()
{
if (isPlaying()) {
timer.stop();
}
}
/**
* Stops playback of the animation and sets current frame to 0.
*/
public void stop()
{
if (isPlaying()) {
timer.stop();
animation.resetFrame();
updateDisplay(false);
}
}
/**
* Returns whether a frame is drawn around the item in the specified state.
*/
public boolean isFrameEnabled(ItemState state)
{
switch (state) {
case NORMAL:
return frameInfos[0].isEnabled();
case HIGHLIGHTED:
return frameInfos[0].isEnabled();
default:
return false;
}
}
/**
* Enables/disables the frame around the item in the specified state.
*/
public void setFrameEnabled(ItemState state, boolean enabled)
{
switch (state) {
case NORMAL:
frameInfos[0].setEnabled(enabled);
break;
case HIGHLIGHTED:
frameInfos[1].setEnabled(enabled);
break;
}
updateFrame();
}
/**
* Returns the frame width in pixels in the specified state.
*/
public int getFrameWidth(ItemState state)
{
switch (state) {
case NORMAL:
return (int)frameInfos[0].getStroke().getLineWidth();
case HIGHLIGHTED:
return (int)frameInfos[1].getStroke().getLineWidth();
default:
return 0;
}
}
/**
* Defines the frame width in pixels in the specified state.
*/
public void setFrameWidth(ItemState state, int width)
{
if (width < 1) width = 1;
switch (state) {
case NORMAL:
frameInfos[0].setStroke(new BasicStroke((float)width));
break;
case HIGHLIGHTED:
frameInfos[1].setStroke(new BasicStroke((float)width));
break;
}
updateFrame();
}
/**
* Returns the color used for the frame around the item in the specified state.
*/
public Color getFrameColor(ItemState state)
{
switch (state) {
case NORMAL:
return frameInfos[0].getColor();
case HIGHLIGHTED:
return frameInfos[1].getColor();
default:
return FrameInfo.DefaultColor;
}
}
/**
* Defines the color of the frame around the item in the specified state.
*/
public void setFrameColor(ItemState state, Color color)
{
switch (state) {
case NORMAL:
frameInfos[0].setColor(color);
break;
case HIGHLIGHTED:
frameInfos[1].setColor(color);
break;
}
updateFrame();
}
@Override
public void setVisible(boolean aFlag)
{
if (aFlag != isVisible()) {
if (isAutoPlay()) {
if (aFlag) { play(); } else { pause(); }
}
}
super.setVisible(aFlag);
}
//--------------------- Begin Interface LayerItemListener ---------------------
@Override
public void layerItemChanged(LayerItemEvent event)
{
if (event.getSource() == this) {
updateDisplay(false);
}
}
//--------------------- End Interface LayerItemListener ---------------------
//--------------------- Begin Interface ActionListener ---------------------
@Override
public void actionPerformed(ActionEvent event)
{
if (event.getSource() == timer) {
// advancing frame by one
if (!animation.advanceFrame()) {
if (animation.isLooping()) {
animation.resetFrame();
} else {
stop();
return;
}
}
// Important: making sure that only ONE instance is running at a time to avoid GUI freezes
if (workerAnimate == null) {
workerAnimate = new SwingWorker<Void, Void>() {
@Override
protected Void doInBackground() throws Exception {
updateFrame();
return null;
}
};
workerAnimate.addPropertyChangeListener(this);
workerAnimate.execute();
}
}
}
//--------------------- End Interface ActionListener ---------------------
//--------------------- Begin Interface PropertyChangeListener ---------------------
@Override
public void propertyChange(PropertyChangeEvent event)
{
if (event.getSource() == workerAnimate) {
if ("state".equals(event.getPropertyName()) &&
SwingWorker.StateValue.DONE == event.getNewValue()) {
// Important: making sure that only ONE instance is running at a time to avoid GUI freezes
workerAnimate = null;
}
}
}
//--------------------- End Interface PropertyChangeListener ---------------------
@Override
public void repaint()
{
updateCanvas();
super.repaint();
}
@Override
protected boolean isMouseOver(Point pt)
{
Rectangle r = new Rectangle(getCanvasBounds(true));
r.translate(-r.x, -r.y);
return r.contains(pt);
}
// Calculates a rectangle big enough to fit the current frame image and border into.
// Returns whether the canvas size changed.
private void updateCanvasSize()
{
int strokeWidth = (int)Math.max(getFrameInfo(false).getStroke().getLineWidth(),
getFrameInfo(true).getStroke().getLineWidth());
if (frameBounds == null) {
frameBounds = new Rectangle(-strokeWidth, -strokeWidth, 2*strokeWidth, 2*strokeWidth);
} else {
frameBounds.x = frameBounds.y = -strokeWidth;
frameBounds.width = frameBounds.height = 2*strokeWidth;
}
frameBounds.width += animation.getImage().getWidth(null);
frameBounds.height += animation.getImage().getHeight(null);
if (rcCanvas.getImage() == null ||
rcCanvas.getImage().getWidth(null) != frameBounds.width ||
rcCanvas.getImage().getHeight(null) != frameBounds.height) {
rcCanvas.setImage(ColorConvert.createCompatibleImage(frameBounds.width, frameBounds.height, true));
}
}
private Rectangle getCanvasBounds(boolean scaled)
{
if (frameBounds == null) {
updateCanvasSize();
}
if (scaled) {
return new Rectangle((int)((double)frameBounds.x*zoomFactor),
(int)((double)frameBounds.y*zoomFactor),
(int)((double)frameBounds.width*zoomFactor),
(int)((double)frameBounds.height*zoomFactor));
} else {
return frameBounds;
}
}
// Returns the FrameInfo object of the specified state
private FrameInfo getFrameInfo(boolean highlighted)
{
return highlighted ? frameInfos[1] : frameInfos[0];
}
// First-time initializations
private void init()
{
setLayout(new BorderLayout());
isAutoPlay = false;
zoomFactor = 1.0;
interpolationType = ViewerConstants.TYPE_NEAREST_NEIGHBOR;
forcedInterpolation = false;
if (timer == null) {
timer = new Timer(1000 / 15, this);
}
if (rcCanvas == null) {
rcCanvas = new RenderCanvas();
rcCanvas.setHorizontalAlignment(SwingConstants.CENTER);
rcCanvas.setVerticalAlignment(SwingConstants.CENTER);
rcCanvas.setScalingEnabled(true);
add(rcCanvas, BorderLayout.CENTER);
}
addLayerItemListener(this);
}
// Animation-related initializations (requires this.frame to be initialized)
private void initAnimation(BasicAnimationProvider anim)
{
boolean isPlaying = isPlaying();
stop();
if (anim != null) {
animation = anim;
} else {
if (!(animation != null && animation instanceof DefaultAnimationProvider)) {
this.animation = new DefaultAnimationProvider();
}
}
updateAnimation();
if (isPlaying) {
play();
}
}
// Call whenever the behavior of the current animation changes
private void updateAnimation()
{
boolean isPlaying = isPlaying();
pause();
updateCanvasSize();
if (isPlaying) {
play();
} else {
updateFrame();
}
}
// Updates the display if needed
private void updateDisplay(boolean force)
{
if (!isPlaying() || force) {
repaint();
}
}
// Updates both frame content and position.
private void updateFrame()
{
updateSize();
updatePosition();
repaint();
}
// Draws the current frame onto the canvas
private synchronized void updateCanvas()
{
boolean isHighlighted = (getItemState() == ItemState.HIGHLIGHTED);
Graphics2D g2 = (Graphics2D)rcCanvas.getImage().getGraphics();
try {
g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC));
g2.setColor(TransparentColor);
g2.fillRect(0, 0, rcCanvas.getImage().getWidth(null), rcCanvas.getImage().getHeight(null));
// drawing animation graphics
g2.drawImage(animation.getImage(), -frameBounds.x, -frameBounds.y, null);
// drawing frame
FrameInfo fi = getFrameInfo(isHighlighted);
if (fi.isEnabled()) {
g2.setColor(fi.getColor());
g2.setStroke(fi.getStroke());
int penWidth2 = (int)fi.getStroke().getLineWidth() >>> 1;
int penWidthExtra = (int)fi.getStroke().getLineWidth() & 1;
g2.drawRect(penWidth2, penWidth2,
frameBounds.width - penWidth2 - penWidthExtra - 1,
frameBounds.height - penWidth2 - penWidthExtra- 1);
}
} finally {
g2.dispose();
g2 = null;
}
}
private void updateSize()
{
Rectangle r = getBounds();
Image img = rcCanvas.getImage();
if (img != null) {
r.width = (int)((double)img.getWidth(null)*getZoomFactor());
r.height = (int)((double)img.getHeight(null)*getZoomFactor());
} else {
r.width = r.height = 1;
}
setPreferredSize(r.getSize());
setMinimumSize(r.getSize());
setSize(r.getSize());
if (forcedInterpolation) {
rcCanvas.setInterpolationType(interpolationType);
} else {
rcCanvas.setInterpolationType((zoomFactor < 1.0) ? RenderCanvas.TYPE_BILINEAR : RenderCanvas.TYPE_NEAREST_NEIGHBOR);
}
}
// Updates the component position based on the current frame's center. Takes zoom factor into account.
private void updatePosition()
{
Rectangle bounds = getCanvasBounds(true);
Point curOfs = new Point(-bounds.x, -bounds.y);
// applying animation offsets
curOfs.x -= (int)((double)animation.getLocationOffset().x*getZoomFactor());
curOfs.y -= (int)((double)animation.getLocationOffset().y*getZoomFactor());
if (!getLocationOffset().equals(curOfs)) {
Point distance = new Point(getLocationOffset().x - curOfs.x - 1, getLocationOffset().y - curOfs.y - 1);
setLocationOffset(curOfs);
Point loc = super.getLocation();
setLocation(loc.x + distance.x, loc.y + distance.y);
}
}
//----------------------------- INNER CLASSES -----------------------------
// Stores information about frames around the item
private static class FrameInfo
{
private static Color DefaultColor = new Color(0, true);
private static BasicStroke DefaultStroke = new BasicStroke(1.0f);
private boolean enabled;
private Color color;
private BasicStroke stroke;
public FrameInfo()
{
this(DefaultStroke, DefaultColor, false);
}
public FrameInfo(BasicStroke stroke, Color color, boolean enabled)
{
setStroke(stroke);
setColor(color);
this.enabled = enabled;
}
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled)
{
this.enabled = enabled;
}
public BasicStroke getStroke() { return stroke; }
public void setStroke(BasicStroke stroke)
{
if (stroke != null) {
this.stroke = stroke;
} else {
this.stroke = DefaultStroke;
}
}
public Color getColor() { return color; }
public void setColor(Color color)
{
if (color == null) {
color = DefaultColor;
}
this.color = color;
}
}
// A pseudo animation provider that always returns a transparent image of 16x16 size.
private class DefaultAnimationProvider implements BasicAnimationProvider
{
private final BufferedImage image;
public DefaultAnimationProvider()
{
image = ColorConvert.createCompatibleImage(16, 16, true);
}
@Override
public Image getImage()
{
return image;
}
@Override
public boolean advanceFrame()
{
return false;
}
@Override
public void resetFrame()
{
}
@Override
public boolean isLooping()
{
return false;
}
@Override
public Point getLocationOffset()
{
return new Point();
}
}
}