第二章 2D图形和动画
全屏幕图形
在编程之前,我们先来看看硬件是怎样工作的。有两个显示硬件:显示卡和监视器。
显示卡在显卡内存中存储屏幕上显示的东西,它还有几个功能用来修改显示内容。显示卡还在后台把内存中的内容送到监视器。
监视器只是显示显示卡让它显示的信息。
屏幕布局
监视器被分成大小相等的微小像素。像素是监视器显示的光点。组成屏幕的水平和垂直像素数目称为屏幕分辨率。
屏幕的原点在屏幕的左上角,如图2.1所示。
图2.1 800X600屏幕
可用的分辨率依赖于显示卡和监视器的能力。典型的分辨率有640x480,800x600,1024x768,和1280x1024。
典型的监视器和电视机的显示比都是4:3。即显示高度是宽度的四分之三。一些新式监视器有更大的宽度,显示比是3:2或16:10。宽屏幕电影的显示比是16:9
。
老式大CRT(阴极射线管)监视器能够清楚地显示各种分辨率,因为CRT是用电子束来画像素的。膝上电脑所用的LCD(液晶显示)监视器和新式电脑系统的情况就不同了。因为LCD上的每一个像素是用晶体管激发的,所以它有自身的分辨率。其它分辨率在LCD上显示时会出现斑点或不清晰。因此,最好让你的游戏能在两个或三个不同的分辨率下运行,以便玩家可以选择一个最适合自己系统地分辨率。
像素颜色和位深度
孩童时你或许学习过红、黄、蓝三原色。你或许也知道“黄加蓝是绿”。这个思想就是,当你在画画时,你可以用这三种颜色结合产生你要的任何颜色。这就是所谓的减色模型 (实际上,这不是一个精密的颜色模型---现代打印机使用了更复杂的颜色模型,使用了蓝绿色、红紫色、黄色和黑色)。
计算机监视器和电视机的原理与此类似。监视器使用红、蓝、绿的结合生成各种颜色。代替像画笔或墨水这样的物理媒介,监视器发射光,因此,RGB颜色模型是增色模型,增加所有的颜色产生白色。
监视器能够显示的颜色数量依赖于显示模式的位深度。常用的位深度有8位、15位、16位24位和32位。
l 8位颜色有28 = 256种颜色。在调色板上一次只能显示256种颜色,不幸的是,目前在Java中无法改变调色板,也没有指定这256种颜色是那些颜色。Java运行环境可以使用一个web安全的调色板,这个调色板有216种颜色,红、绿、蓝每种颜色可以有6种可能的取值(6x6x6=216)。
l 15位颜色有215 = 32,768种颜色,红、绿、蓝每种颜色有5位可能的取值。
l 16位颜色有216 = 65,536种颜色, 红、蓝颜色有5位可能的取值,绿色有6位可能的取值。
l 24位颜色有224 = 16,777,216种颜色,红、绿、蓝每种颜色有8位可能的取值。
l 32位颜色与24位一样
现代大多数显示卡支持8位、16位和32位模式。因为人的眼睛可以看到1千万种颜色,所以,24位颜色是理想的。由于传送的数据较少,16位颜色比24位颜色要快,但是颜色质量不够精密。
刷新率
虽然监视器看起来一直在显示图像,实际上几毫秒以后像素就会消失。为了弥补这个情况,监视器不断地刷新显示,刷新的速度称为刷新率,刷新率用Hz来度量。适合人眼的刷新率在75Hz和85Hz之间。
将显示切换到全屏幕方式
现在,我们已经了解了分辨率、位深度和刷新率,让我们来写一些代码。将显示切换到全屏幕方式需要几个对象:
l Window对象是屏幕显示的抽象,可以把它看作画布。下面的例子实际上使用的是JFrame,它是Window类的子类,也可以用于window应用程序。
l DisplayMode对象指定切换后的分辨率、位深度和刷新率。
l GraphicsDevice对象使你可以改变显示模式,检查显示属性。可以把它看作与显示卡的接口。从GraphicsEnvironment对象获得GraphicsDevice对象。
下面是如何将显示切换到全屏幕的例子:
JFrame window = new JFrame();
DisplayMode displayMode = new DisplayMode(800, 600, 16, 75);
// get the GraphicsDevice
GraphicsEnvironment environment =
GraphicsEnvironment.getLocalGraphicsEnvironment();
GraphicsDevice device = environment.getDefaultScreenDevice();
// use the JFrame as the full screen window
device.setFullScreenWindow(window);
// change the display mode
device.setDisplayMode(displayMode);
之后,要将显示模式切换到之前的显示模式,用如下语句:
device.setFullScreenWindow(null);
注意到这句代码是不完全的。有些系统不允许你改变显示模式,在这些系统上,setDisplayMode()会抛出异常IllegalArgumentException。
还有一点,JFrame缺省地即使在全屏幕模式仍会显示边界和标题,我们将创建一个wrapper类SimpleScreenManager来处理这些问题。
SimpleScreenManager类如列表2.1所示,是将显示改变到全屏幕模式的简单接口。SimpleScreenManager捕获异常,通过调用setUndecorated(true)移出JFrame的边界和标题。当恢复屏幕后,也要对JFrame进行处理。
列表2.1SimpleScreenManager.java
import java.awt.*;
import javax.swing.JFrame;
/**
The SimpleScreenManager class manages initializing and
displaying full screen graphics modes.
*/
public class SimpleScreenManager {
private GraphicsDevice device;
/**
Creates a new SimpleScreenManager object.
*/
public SimpleScreenManager() {
GraphicsEnvironment environment =
GraphicsEnvironment.getLocalGraphicsEnvironment();
device = environment.getDefaultScreenDevice();
}
/**
Enters full screen mode and changes the display mode.
*/
public void setFullScreen(DisplayMode displayMode,
JFrame window)
{
window.setUndecorated(true);
window.setResizable(false);
device.setFullScreenWindow(window);
if (displayMode != null &&
device.isDisplayChangeSupported())
{
try {
device.setDisplayMode(displayMode);
}
catch (IllegalArgumentException ex) {
// ignore - illegal mode for this device
}
}
}
/**
Returns the window currently used in full screen mode.
*/
public Window getFullScreenWindow() {
return device.getFullScreenWindow();
}
/**
Restores the screen's display mode.
*/
public void restoreScreen() {
Window window = device.getFullScreenWindow();
if (window != null) {
window.dispose();
}
device.setFullScreenWindow(null);
}
}
现在,我们可以使用SimpleScreenManager了。列表2.2中的FullScreenTest类测试SimpleScreenManager类的方法。它把显示改变到全屏幕模式,显示“Hello World!”信息,等待5秒后退出。
FullScreenTest在800X600的分辨率,16位颜色显示模式运行。如果你想以不同的分辨率运行,只要在命令行指定不同的显示模式。程序将传递给它的三个参数分别看作显示模式的宽、高和位深度。例如,输入如下的命令:
Java FullScreenTest 1024 768 32
注意到代码不允许你设置无效的模式。如果没有你所要求的显示模式或者系统不支持全屏幕模式,就抛出IllegalArgumentException异常,例子就用当前显示模式模拟全屏幕模式。
列表2.2 FullScreenTest.java
import java.awt.*;
import javax.swing.JFrame;
public class FullScreenTest extends JFrame {
public static void main(String[] args) {
DisplayMode displayMode;
if (args.length == 3) {
displayMode = new DisplayMode(
Integer.parseInt(args[0]),
Integer.parseInt(args[1]),
Integer.parseInt(args[2]),
DisplayMode.REFRESH_RATE_UNKNOWN);
}
else {
displayMode = new DisplayMode(800, 600, 16,
DisplayMode.REFRESH_RATE_UNKNOWN);
}
FullScreenTest test = new FullScreenTest();
test.run(displayMode);
}
private static final long DEMO_TIME = 5000;
public void run(DisplayMode displayMode) {
setBackground(Color.blue);
setForeground(Color.white);
setFont(new Font("Dialog", Font.PLAIN, 24));
SimpleScreenManager screen = new SimpleScreenManager();
try {
screen.setFullScreen(displayMode, this);
try {
Thread.sleep(DEMO_TIME);
}
catch (InterruptedException ex) { }
}
finally {
screen.restoreScreen();
}
}
public void paint(Graphics g) {
g.drawString("Hello World!", 20, 50);
}
}
在FullScreenTest中要注意的一点是在run()方法中try/finally代码块的使用。在finally块中恢复屏幕保证了总能恢复屏幕,即使在try块抛出了异常。
此外,你或许注意到,在FullScreenTest中paint()方法用到了Graphics对象。Graphics对象提供了画文本、直线、矩形、椭圆、多边形、图像等等功能。大多数方法都是不言自明的,详细信息请看Java API规范。
Paint()方法是如何调用的呢?注意到,FullScreenTest是JFrame,当显示JFrame时,AWT调用它的paint()方法。
如果你想强制AWT调用paint()方法,就调用repaint()。这就告诉AWT调用paint()方法。AWT在不同的线程中发送paint事件,因此,如果你想发送repaint事件,然后等待paint完成,利用下面的代码:
public class MyComponent extends SomeComponent {
...
public synchronized void repaintAndWait() {
repaint();
try {
wait();
}
catch (InterruptedException ex) { }
}
public synchronized void paint(Graphics g) {
// do painting here
...
// notify that we're done painting
notifyAll();
}
}
Anti-Aliasing
你或许注意到FullScreenTest中的“Hello World”文本的边缘有锯齿。这是因为文本不是Anti-Aliasing。听起来有点奇怪,但是anti-liasing通过模糊边缘使得文本的颜色与背景色混合而使文本看起来更平滑。比较图2.2和图2.3.
图2.2
图 2.3
为了使文本anti-aliased,在画文本之前设置rendering hint。Rendering hint只是Graphics2D类才有,Graphics2D是Graphics的子类。
为了向后兼容,paint()方法以Graphics对象作为参数。然而,从Java SDK 1.2开始,传递给方法的实际上是Graphics2D对象。下面是paint()方法的代码:
public void paint(Graphics g) {
if (g instanceof Graphics2D) {
Graphics2D g2 = (Graphics2D)g;
g2.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
}
g.drawString("Hello World!", 20, 50);
}
还有其它的rendering hint,详细说明请看RenderingHints类的文档。
使用什幺显示模式
有许多显示模式,你的游戏应该使用哪个显示模式呢?
确保你的游戏至少可以运行在两种分辨率,允许玩家改变显示模式,以便玩家可以选择他最喜欢的显示模式。
如果可能,开始时在当前分辨率下运行游戏。在LCD上,当前分辨率很可能就是它自身的分辨率,如果你使用当前分辨率,游戏的显示就会很好。现代游戏一般使用16位或24位或32位颜色。16位颜色更快一点,如果需要更好的颜色,就使用24位或32位颜色。
对于人眼,75Hz到85Hz的刷新率是适合的。
图像
在屏幕上画文本是好玩的,但是,你或许想要在你的游戏中有一些图像,不是吗?在向屏幕画图像之前,让我们学习一些图像的基础知识:transparency类型和文件格式。
透明度Transparency
假定你想显示一个简单图像,如图2.4所示
图 2.4
图 2.4中的图像白色背景上的人物,而且是图像的背景部分,它也是画上去的吗?这依赖于图像的透明度。你可以使用图像的三种透明度:不透明(opaque),透明(transparent),和半透明(translucent)。
l 不透明,图像中的每个像素都是可见的。
l 透明,图像中的每个像素要幺是完全可见的要幺是完全不可见的。在图像中,白色背景是透明的,因此,画了白色背景后,图像后面的东西可以透过白色背景显示出来。
l 半透明,像素是部分透明的。比如创建部分可见的鬼怪。透明度只可用在图像的边缘,创建anti-aliased图像。
在人物图像中,你或许想要使白色背景是透明的,
文件格式
有两个基本的图像格式:光栅和矢量。光栅图像格式用指定位深度的像素显示图像。矢量图像格式用几何的方式描述图像,改变图像的大小不会影响图像的质量。
Java API本身不支持矢量格式,因此,我们关注的是光栅图像。如果你对矢量图像感兴趣,请看Apache的Batik(Scalable Vector Graphics实现),网址是http://xml.apache.org/batik/。
Java运行环境本身有三个不同的光栅格式,你可以毫不费力地读取这些格式的图像。这三种格式是GIF、PNG和JPEG:
l GIF,GIF图像可以是不透明的,也可以是透明的,有8位颜色或更少颜色。虽然GIF对颜色不多的图像有很高的压缩比,但是PNG的功能超过了GIF,因此,我们没有理由还使用GIF。
l PNG,PNG图像可以有任何透明度:不透明、透明和半透明。PNG图像可以有任何位深度,多达24位颜色。8位PNG图像的压缩比与GIF图像差不多。
l JPEG,JPEG图像只能是不透明的24位图像。JPEG有很高的压缩比,但是压缩后图像会失真。
这些图像文件格式可以用常见的绘图软件创建,例如Adobe Photoshop(www.adobe.com), Jasc Paint Shop Pro (www.jasc.com), 和 GIMP (www.gimp.org)。
读取图像
如何显示GIF、PNG或JPEG文件的图像呢?这可以用Toolkit的getImage()方法:这个方法解析图像文件,返回Image对象。下面是一个例子:
Toolkit toolkit = Toolkit.getDefaultToolkit();
Image image = toolkit.getImage(fileName);
这段代码非常清楚,但是它实际上并不装载图像。图像的装载是在另一个线程中完成的。如果你在图像装载完成前就显示图像,图像就会显示不全,或者不能显示。
你可以使用MediaTracker对象监视图像的装载,等待装载完成。但是,还有一个更简单的方法。ImageIcon类利用MediaTracker装载图像,javax.swing包中的ImageIcon类利用Toolkit装载图像,一直等到装载完成才返回。例如:
ImageIcon icon = new ImageIcon(fileName);
Image image = icon.getImage();
现在,你可以装载图像了,你可以试一试了。列表2.3中的ImageTest类类似于FullScreenTest类,都使用了SimpleScreenManager。ImageTest画一个JPEG背景图,4个PNG前景图,等待10秒,然后退出。
背景用JPEG文件是因为JPEG是照片级的,JPEG比PNG压缩比更高。
所显示的PNG图像是不透明的、透明的和半透明的。一个半透明的图像是完全半透明的,另一个则只是在边缘是半透明的,这是为了使图像anti-aliased。图2.5是ImageTest的显示结果。
图 2.5
如果你想在不同的显示模式运行ImageTest,只要像运行FullScreenTest一样,在命令行指定显示模式就行了。
列表2.3 ImageTest.java
import java.awt.*;
import javax.swing.ImageIcon;
import javax.swing.JFrame;
public class ImageTest extends JFrame {
public static void main(String[] args) {
DisplayMode displayMode;
if (args.length == 3) {
displayMode = new DisplayMode(
Integer.parseInt(args[0]),
Integer.parseInt(args[1]),
Integer.parseInt(args[2]),
DisplayMode.REFRESH_RATE_UNKNOWN);
}
else {
displayMode = new DisplayMode(800, 600, 16,
DisplayMode.REFRESH_RATE_UNKNOWN);
}
ImageTest test = new ImageTest();
test.run(displayMode);
}
private static final int FONT_SIZE = 24;
private static final long DEMO_TIME = 10000;
private SimpleScreenManager screen;
private Image bgImage;
private Image opaqueImage;
private Image transparentImage;
private Image translucentImage;
private Image antiAliasedImage;
private boolean imagesLoaded;
public void run(DisplayMode displayMode) {
setBackground(Color.blue);
setForeground(Color.white);
setFont(new Font("Dialog", Font.PLAIN, FONT_SIZE));
imagesLoaded = false;
screen = new SimpleScreenManager();
try {
screen.setFullScreen(displayMode, this);
loadImages();
try {
Thread.sleep(DEMO_TIME);
}
catch (InterruptedException ex) { }
}
finally {
screen.restoreScreen();
}
}
public void loadImages() {
bgImage = loadImage("images/background.jpg");
opaqueImage = loadImage("images/opaque.png");
transparentImage = loadImage("images/transparent.png");
translucentImage = loadImage("images/translucent.png");
antiAliasedImage = loadImage("images/antialiased.png");
imagesLoaded = true;
// signal to AWT to repaint this window
repaint();
}
private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void paint(Graphics g) {
// set text anti-aliasing
if (g instanceof Graphics2D) {
Graphics2D g2 = (Graphics2D)g;
g2.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
}
// draw images
if (imagesLoaded) {
g.drawImage(bgImage, 0, 0, null);
drawImage(g, opaqueImage, 0, 0, "Opaque");
drawImage(g, transparentImage, 320, 0, "Transparent");
drawImage(g, translucentImage, 0, 300, "Translucent");
drawImage(g, antiAliasedImage, 320, 300,
"Translucent (Anti-Aliased)");
}
else {
g.drawString("Loading Images...", 5, FONT_SIZE);
}
}
public void drawImage(Graphics g, Image image, int x, int y,
String caption)
{
g.drawImage(image, x, y, null);
g.drawString(caption, x + 5, y + FONT_SIZE +
image.getHeight(null));
}
}
在图像装载以前,ImageTest类显示“Loading images”信息。在图像装载以后,调用repaint()给AWT发送信号,让它重画屏幕。这时就画背景和4个PNG图像。
硬件加速图像
硬件加速图像是这样的图像,它们存储在显存中而不是系统内存中。硬件加速的图像比不是硬件加速的图像可以更快地拷贝到屏幕。
Java试着对用Toolkit的getImage()方法装载的任何图像进行硬件加速。因为硬件加速是自动进行的,你通常不必操心对图像进行硬件加速。
然而,有几个因素会影响图像的硬件加速:
l 如果你经常改变图像的内容,图像就不会被加速。
l 对于Java SDK 1.4.1来说,半透明的图像不能被加速,只有不透明的或透明的图像才会被加速。因此,本书很少用到半透明的图像。
l 不是每个系统都有图像加速功能。
如果你想在支持图像加速的系统上对图像强制加速,你可以创建VolatileImage。VolatileImage是存储在显存中的图像。
用组件的createVolatileImage(int w,int h)方法或GraphicsConfiguration的createCompatibleVolatileImage(int w,int h)方法创建VolatileImage。不幸的是,VolatileImage只能是不透明的。
VolateImage在任何时候都有可能丢失内容。例如,屏保开始时或显示模式改变时,VolatileImage或许会从显存中消失,你不得不重画。
你可以用validate()和contentsLost()方法检查VolatileImage是否丢失内容。Validate()方法确保图像与当前显示模式兼容,contentsLost()方法返回上次调用validate()后图像内容是否有丢失的信息。下面的例子说明了如何检查和恢复VolatileImage:
// create the image
VolatileImage image = createVolatileImage(w, h);
...
// draw the image
do {
int valid = image.validate(getGraphicsConfiguration());
if (valid == VolatileImage.IMAGE_INCOMPATIBLE) {
// image isn't compatible with this display; re-create it
image = createVolatileImage(w, h);
}
else if (valid == VolatileImage.IMAGE_RESTORED) {
// restore the image
Graphics2D g = image.createGraphics();
myDrawMethod(g);
g.dispose();
}
else {
// draw the image on the screen
Graphics g = screen.getDrawGraphics();
g.drawImage(image, 0, 0, null);
g.dispose();
}
}
while (image.contentsLost());
这段代码主要是一个循环,直到图像成功地显示在屏幕上。
图像画法Benchmark
不透明和透明图像都可以硬件加速,但是,加速的程度如何呢?
为此,我们修改ImageTest类,创建ImageSpeedTest类,如列表2.4所示。ImageSpeedTest类在指定的时间内重复画ImageTest中所用的4个图像,然后输出每秒钟画了多少图像。
注意
你不要再这个paint()方法上花太多的时间。在这个简单例子中,不会有什幺问题。但是,在真实的游戏中就不同了。AWT事件分配线程调用paint()方法,但是,AWT事件分配线程还处理键盘、鼠标和许多其它的事件(下一章我们会讲到),因此,这样做会阻塞AWT线程。我们将在下一节介绍更好的方法。
列表2.4 ImageSpeedTest.java
import java.awt.*;
import javax.swing.ImageIcon;
import javax.swing.JFrame;
public class ImageSpeedTest extends JFrame {
public static void main(String args[]) {
DisplayMode displayMode;
if (args.length == 3) {
displayMode = new DisplayMode(
Integer.parseInt(args[0]),
Integer.parseInt(args[1]),
Integer.parseInt(args[2]),
DisplayMode.REFRESH_RATE_UNKNOWN);
}
else {
displayMode = new DisplayMode(800, 600, 16,
DisplayMode.REFRESH_RATE_UNKNOWN);
}
ImageSpeedTest test = new ImageSpeedTest();
test.run(displayMode);
}
private static final int FONT_SIZE = 24;
private static final long TIME_PER_IMAGE = 1500;
private SimpleScreenManager screen;
private Image bgImage;
private Image opaqueImage;
private Image transparentImage;
private Image translucentImage;
private Image antiAliasedImage;
private boolean imagesLoaded;
public void run(DisplayMode displayMode) {
setBackground(Color.blue);
setForeground(Color.white);
setFont(new Font("Dialog", Font.PLAIN, FONT_SIZE));
imagesLoaded = false;
screen = new SimpleScreenManager();
try {
screen.setFullScreen(displayMode, this);
synchronized (this) {
loadImages();
// wait for test to complete
try {
wait();
}
catch (InterruptedException ex) { }
}
}
finally {
screen.restoreScreen();
}
}
public void loadImages() {
bgImage = loadImage("images/background.jpg");
opaqueImage = loadImage("images/opaque.png");
transparentImage = loadImage("images/transparent.png");
translucentImage = loadImage("images/translucent.png");
antiAliasedImage = loadImage("images/antialiased.png");
imagesLoaded = true;
// signal to AWT to repaint this window
repaint();
}
private final Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void paint(Graphics g) {
// set text anti-aliasing
if (g instanceof Graphics2D) {
Graphics2D g2 = (Graphics2D)g;
g2.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
}
// draw images
if (imagesLoaded) {
drawImage(g, opaqueImage, "Opaque");
drawImage(g, transparentImage, "Transparent");
drawImage(g, translucentImage, "Translucent");
drawImage(g, antiAliasedImage,
"Translucent (Anti-Aliased)");
// notify that the test is complete
synchronized (this) {
notify();
}
}
else {
g.drawString("Loading Images...", 5, FONT_SIZE);
}
}
public void drawImage(Graphics g, Image image, String name) {
int width = screen.getFullScreenWindow().getWidth() -
image.getWidth(null);
int height = screen.getFullScreenWindow().getHeight() -
image.getHeight(null);
int numImages = 0;
g.drawImage(bgImage, 0, 0, null);
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime
< TIME_PER_IMAGE)
{
int x = Math.round((float)Math.random() * width);
int y = Math.round((float)Math.random() * height);
g.drawImage(image, x, y, null);
numImages++;
}
long time = System.currentTimeMillis() - startTime;
float speed = numImages * 1000f / time;
System.out.println(name + ": " + speed + " images/sec");
}
}
不要以为这是最后的图像画法benchmark。要记住的是,你在一台机器上只测试了4个图像。测试结果与计算机的显卡、处理器速度、显示模式和你的计算机是否真的感觉画图很快有关。
为了说明这一点,下面给出在600MHz Athlon,GeForce-256显卡,800x600分辨率,16位颜色系统上的测试结果:
Opaque: 5550.599 images/sec
Transparent: 5478.6953 images/sec
Translucent: 85.2197 images/sec
Translucent (Anti-Aliased): 113.18243 images/sec
正如你所见到的,在这样的电脑上,半透明图像比硬件加速的不透明图像和透明图像慢的多。Anti-aliased图像比完全半透明图像稍快的原因或许是有更多的固定像素,因此,在画图时需要较少的混合。
在这个测试中,透明图像几乎和不透明图像一样快。要注意的是,一些老显卡可能没有能力画硬件加速的透明图像,不得不求助于与半透明图像所用的同样慢的方法。
我们已经知道了图像的硬件加速和画法的benchmark。下面我们转到游戏中用到的内容:动画。
动画
我们将要讨论的第一种动画是卡通类动画。这种动画是一系列的图像,一个接一个显示。这就是卡通动画的工作原理。
动画中的图像称为帧。每一帧显示一段时间,但是不必都显示同等长度的时间。例如,第一帧可以显示200毫秒,第二帧显示75毫秒,等等。如图2.7所示。
图 2.7
现在,我们将考虑动画的概念,并用代码来实现动画。在我们的实现中,同一个图像可以使用多次。还有就是,这个动画一直循环,而不是只播放一次。
列表 2.5中的动画类有三个重要的方法:addFrame()、update()、和getImage()。AddFrame()方法向动画中增加图像,并指定了图像的显示时间(毫秒)。Update()方法告诉动画已经过了多长时间。最后,getImage()得到过了多长时间应该显示的图像。
列表2.5 Animation.java
import java.awt.Image;
import java.util.ArrayList;
/**
The Animation class manages a series of images (frames) and
the amount of time to display each frame.
*/
public class Animation {
private ArrayList frames;
private int currFrameIndex;
private long animTime;
private long totalDuration;
/**
Creates a new, empty Animation.
*/
public Animation() {
frames = new ArrayList();
totalDuration = 0;
start();
}
/**
Adds an image to the animation with the specified
duration (time to display the image).
*/
public synchronized void addFrame(Image image,
long duration)
{
totalDuration += duration;
frames.add(new AnimFrame(image, totalDuration));
}
/**
Starts this animation over from the beginning.
*/
public synchronized void start() {
animTime = 0;
currFrameIndex = 0;
}
/**
Updates this animation's current image (frame), if
necessary.
*/
public synchronized void update(long elapsedTime) {
if (frames.size() > 1) {
animTime += elapsedTime;
if (animTime >= totalDuration) {
animTime = animTime % totalDuration;
currFrameIndex = 0;
}
while (animTime > getFrame(currFrameIndex).endTime) {
currFrameIndex++;
}
}
}
/**
Gets this Animation's current image. Returns null if this
animation has no images.
*/
public synchronized Image getImage() {
if (frames.size() == 0) {
return null;
}
else {
return getFrame(currFrameIndex).image;
}
}
private AnimFrame getFrame(int i) {
return (AnimFrame)frames.get(i);
}
private class AnimFrame {
Image image;
long endTime;
public AnimFrame(Image image, long endTime) {
this.image = image;
this.endTime = endTime;
}
}
}
在animation类中,你可能注意到了下面一行代码中的求余符号%
animTime = animTime % totalDuration;
求余符号是一个运算符,它返回两个整数相除的余数。例如,10%3等于1---10除以3得3,余数是1。这里用来确保动画完成后能够重新开始,以便动画能够循环。
Animation的代码是简单明了的。内部类AnimFrame包含了一个图像和要显示的时间。大部分工作是在Animation的update()方法中完成的,update()方法根据过了多少时间选择正确的AnimFrame。
Active Rendering
为了实现动画,需要用一个有效的方法不断更新屏幕。之前,我们用的是paint()方法。你可以调用repaint()方法,告诉AWT事件分配线程重画屏幕,但是由于AWT线程可能正在忙于做其它的事情,这就会引起延迟。
另一个方法是用active rendering。Active Rendering就是在主线程中直接画在屏幕上。这样,你就可以控制何时画屏幕,简化了一点代码。
要使用active rendering方法,就要使用组件的getGraphics()方法获得屏幕的图形上下文:
Graphics g = screen.getFullScreenWindow().getGraphics();
draw(g);
g.dispose();
很简单,不是吗?就像这个例子一样,画完后不要忘记释放Graphics对象。这将清除垃圾收集器暂时不会处理的资源。
动画循环
现在,你将在一个循环中用active rendering不断地画。这个循环称为动画循环。动画循环的步骤如下:
1. 更新动画
2. 画屏幕
3. 睡眠短暂时间
4. 循环
动画循环的代码如下所示:
while (true) {
// update any animations
updateAnimations();
// draw to screen
Graphics g = screen.getFullScreenWindow().getGraphics();
draw(g);
g.dispose();
// take a nap
try {
Thread.sleep(20);
}
catch (InterruptedException ex) { }
}
显然,在真实的情况里,动画循环不应该永远循环。在我们的例子中,我们将让动画循环在几秒钟后终止。
现在就可以试一试动画了。列表2.6中的AnimationTest是一个简单的例子。
在AnimationTest1中,每次画图像时都要更新整个屏幕。你也可以只更新屏幕的改变了的部分。对于背景是静态(例如,Pac-Man游戏中的迷宫)的游戏,这个方法会很好。但是,现代游戏都有动态的背景或者是几个事情同时发生。但是,当你使用page flipping时(我们在后面会讨论到),只画改变了的部分就不行了。总之,你只管继续,在这些例子中更新整个屏幕。
列表2.6 AnimationTest1.java
import java.awt.*;
import javax.swing.ImageIcon;
import javax.swing.JFrame;
public class AnimationTest1 {
public static void main(String args[]) {
DisplayMode displayMode;
if (args.length == 3) {
displayMode = new DisplayMode(
Integer.parseInt(args[0]),
Integer.parseInt(args[1]),
Integer.parseInt(args[2]),
DisplayMode.REFRESH_RATE_UNKNOWN);
}
else {
displayMode = new DisplayMode(800, 600, 16,
DisplayMode.REFRESH_RATE_UNKNOWN);
}
AnimationTest1 test = new AnimationTest1();
test.run(displayMode);
}
private static final long DEMO_TIME = 5000;
private SimpleScreenManager screen;
private Image bgImage;
private Animation anim;
public void loadImages() {
// load images
bgImage = loadImage("images/background.jpg");
Image player1 = loadImage("images/player1.png");
Image player2 = loadImage("images/player2.png");
Image player3 = loadImage("images/player3.png");
// create animation
anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
}
private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void run(DisplayMode displayMode) {
screen = new SimpleScreenManager();
try {
screen.setFullScreen(displayMode, new JFrame());
loadImages();
animationLoop();
}
finally {
screen.restoreScreen();
}
}
public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime =
System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update animation
anim.update(elapsedTime);
// draw to screen
Graphics g =
screen.getFullScreenWindow().getGraphics();
draw(g);
g.dispose();
// take a nap
try {
Thread.sleep(20);
}
catch (InterruptedException ex) { }
}
}
public void draw(Graphics g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
// draw image
g.drawImage(anim.getImage(), 0, 0, null);
}
}
消除闪动和破裂
当你运行AnimationTest1时,你或许会注意到一个严重的问题:动画人物会闪动。这是很烦人的。为什幺会发生这种情况呢,如何消除这个现象呢?
这个现象会发生是因为你在不断地画屏幕,如图2.8所示。这就是说,你擦除背景中的人物,然后重画人物,因此,有时会有短暂的时候,你看到的是没有人物的背景。这个时间是那幺地短,就好象人物在闪动一样。
图2.8
如何消除闪动?答案是使用double buffering。
Double buffering
Buffer是用来画图的内存块。使用double buffering时,不是直接画到屏幕上,而是先画到备用buffer,然后将整个buffer拷贝到屏幕,如图2.9所示。这样,整个屏幕就一次被更新,玩家就只能看到应该看到的内容。
图2.9
备用buffer只能是通常的Java图像。可以用组件的createImage
(int w,int h)方法创建备用buffer。例如,如果你想对没有使用active rendering的applet使用double buffer,你可以重载update()方法以便使用double buffer,调用带有double buffer的图形上下文的paint()方法。
private Image doubleBuffer;
...
public void update(Graphics g) {
Dimension size = getSize();
if (doubleBuffer == null ||
doubleBuffer.getWidth(this) != size.width ||
doubleBuffer.getHeight(this) != size.height)
{
doubleBuffer = createImage(size.width, size.height);
}
if (doubleBuffer != null) {
// paint to double buffer
Graphics g2 = doubleBuffer.getGraphics();
paint(g2);
g2.dispose();
// copy double buffer to screen
g.drawImage(doubleBuffer, 0, 0, null);
}
else {
// couldn't create double buffer, just paint to screen
paint(g);
}
}
public void paint(Graphics g) {
// do drawing here
...
}
Page Flipping
使用double buffer的一个缺点是,将备用buffer拷贝到屏幕上需要一定时间。800x600的分辨率,16位颜色的图像需要800x600x2字节,即938KB。对于每秒30帧来说要显示大约1M的内容会感到有点慢。虽然对于大多数游戏来说,拷贝这幺多内存是足够快的,但是,如果根本就不用拷贝buffer,让备用buffer成为显示buffer不是更好吗?
这个技术成为page flipping。利用page flipping,你可以使用两个buffer,一个作为备用buffer,另一个作为显示buffer,如图2.10所示。
图2.10
显示指针指向要显示的buffer。在最现代的系统上,显示指针是可以改变的。当你画完备用buffer时,显示指针就从当前显示buffer切换到备用buffer,如图2.11所示。显示指针改变后,显示buffer就立即成为备用buffer,反之亦然。
图2.11
当然,改变指针比拷贝一大块内存要快的多。因此,就有了比double buffering更好的性能。
监视器刷新和破裂
要记住的是,你的监视器有刷新率。这个刷新率通常是75Hz左右,这意味着监视器每秒钟刷新75次。但是当page flipping时,或者监视器刷新时正在拷贝buffer,会发生什幺情况呢?是的,你猜对了。前一个buffer的部分内容和后一个buffer的部分内容会同时显示。这种现象类似于闪动,称为破裂(见图2.12)。这种现象发生的是那样地快,很少会被注意到。当被注意到时,就好象屏幕破了一样。
图2.12
为了消除这种现象,应当在适当的时候,就在监视器将要刷新之前进行page flip。这听起来很复杂,但不用担心。Java运行环境完成这个任务,你只要使用BufferStrategy类。
BufferStrategy类
Double buffering、page flipping和等待屏幕刷新都由BufferStrategy类处理。BufferStrategy根据系统的能力选择最好的buffering方法。首先,它会尝试page flipping。如果不行,它就尝试double buffering。它会在进行page flip之前等待监视器刷新完成。总之,Java会为你做好一切,你完全不必操心。
等待监视器刷新的一个缺点是限制了游戏每秒能显示的帧数。如果设置监视器的刷新率为75Hz,游戏每秒最多能显示75帧。这就是说,你不能使用游戏的帧速率作为benchmark来测试系统的运行速度。
当然,你的游戏是否以每秒200帧运行是没有关系的。你所看到的仍然取决于监视器的性能。不管你的游戏运行多幺快,在75Hz的监视器上,你仍然看到的是每秒75帧。
Canvas和window对象都有BufferStrategy。根据你想要使用的buffer数目,用createBufferStrategy()方法创建BufferStrategy。对于double buffering和page flipping,至少需要两个buffer。例如:
frame.createBufferStrategy(2);
创建了BufferStrategy以后,调用getBufferStrategy()方法来引用它,用getDrawGraphics()方法获得要画的buffer的图形上下文。画完后,调用show()方法通过page flip或将所画的buffer拷贝到显示buffer来显示所画的buffer。如下面的例子所示:
BufferStrategy strategy = frame.getBufferStrategy();
Graphics g = strategy.getDrawGraphics();
draw(g);
g.dispose();
strategy.show();
创建Screen Manager
现在,让我们用上面介绍的新特性修改SimpleScreenManager。下面是要增加的:
l 通过创建BufferStrategy得到的Double buffering和page flipping
l getGraphics(),得到要显示的图形上下文
l update(),更新显示
l getCompatibleDisplayMode(),获得兼容的显示模式列表
l getCurrentDisplayMode(),获得当前显示模式
l findFirstCompatibleMode(),从显示模式列表中获得第一个兼容的显示模式
列表2.7 ScreenManager.java
import java.awt.*;
import java.awt.image.BufferStrategy;
import javax.swing.JFrame;
/**
The ScreenManager class manages initializing and displaying
full screen graphics modes.
*/
public class ScreenManager {
private GraphicsDevice device;
/**
Creates a new ScreenManager object.
*/
public ScreenManager() {
GraphicsEnvironment environment =
GraphicsEnvironment.getLocalGraphicsEnvironment();
device = environment.getDefaultScreenDevice();
}
/**
Returns a list of compatible display modes for the
default device on the system.
*/
public DisplayMode[] getCompatibleDisplayModes() {
return device.getDisplayModes();
}
/**
Returns the first compatible mode in a list of modes.
Returns null if no modes are compatible.
*/
public DisplayMode findFirstCompatibleMode(
DisplayMode modes[])
{
DisplayMode goodModes[] = device.getDisplayModes();
for (int i = 0; i < modes.length; i++) {
for (int j = 0; j < goodModes.length; j++) {
if (displayModesMatch(modes[i], goodModes[j])) {
return modes[i];
}
}
}
return null;
}
/**
Returns the current display mode.
*/
public DisplayMode getCurrentDisplayMode() {
return device.getDisplayMode();
}
/**
Determines if two display modes "match". Two display
modes match if they have the same resolution, bit depth,
and refresh rate. The bit depth is ignored if one of the
modes has a bit depth of DisplayMode.BIT_DEPTH_MULTI.
Likewise, the refresh rate is ignored if one of the
modes has a refresh rate of
DisplayMode.REFRESH_RATE_UNKNOWN.
*/
public boolean displayModesMatch(DisplayMode mode1,
DisplayMode mode2)
{
if (mode1.getWidth() != mode2.getWidth() ||
mode1.getHeight() != mode2.getHeight())
{
return false;
}
if (mode1.getBitDepth() != DisplayMode.BIT_DEPTH_MULTI &&
mode2.getBitDepth() != DisplayMode.BIT_DEPTH_MULTI &&
mode1.getBitDepth() != mode2.getBitDepth())
{
return false;
}
if (mode1.getRefreshRate() !=
DisplayMode.REFRESH_RATE_UNKNOWN &&
mode2.getRefreshRate() !=
DisplayMode.REFRESH_RATE_UNKNOWN &&
mode1.getRefreshRate() != mode2.getRefreshRate())
{
return false;
}
return true;
}
/**
Enters full screen mode and changes the display mode.
If the specified display mode is null or not compatible
with this device, or if the display mode cannot be
changed on this system, the current display mode is used.
<p>
The display uses a BufferStrategy with 2 buffers.
*/
public void setFullScreen(DisplayMode displayMode) {
JFrame frame = new JFrame();
frame.setUndecorated(true);
frame.setIgnoreRepaint(true);
frame.setResizable(false);
device.setFullScreenWindow(frame);
if (displayMode != null &&
device.isDisplayChangeSupported())
{
try {
device.setDisplayMode(displayMode);
}
catch (IllegalArgumentException ex) { }
}
frame.createBufferStrategy(2);
}
/**
Gets the graphics context for the display. The
ScreenManager uses double buffering, so applications must
call update() to show any graphics drawn.
<p>
The application must dispose of the graphics object.
*/
public Graphics2D getGraphics() {
Window window = device.getFullScreenWindow();
if (window != null) {
BufferStrategy strategy = window.getBufferStrategy();
return (Graphics2D)strategy.getDrawGraphics();
}
else {
return null;
}
}
/**
Updates the display.
*/
public void update() {
Window window = device.getFullScreenWindow();
if (window != null) {
BufferStrategy strategy = window.getBufferStrategy();
if (!strategy.contentsLost()) {
strategy.show();
}
}
// Sync the display on some systems.
// (on Linux, this fixes event queue problems)
Toolkit.getDefaultToolkit().sync();
}
/**
Returns the window currently used in full screen mode.
Returns null if the device is not in full screen mode.
*/
public Window getFullScreenWindow() {
return device.getFullScreenWindow();
}
/**
Returns the width of the window currently used in full
screen mode. Returns 0 if the device is not in full
screen mode.
*/
public int getWidth() {
Window window = device.getFullScreenWindow();
if (window != null) {
return window.getWidth();
}
else {
return 0;
}
}
/**
Returns the height of the window currently used in full
screen mode. Returns 0 if the device is not in full
screen mode.
*/
public int getHeight() {
Window window = device.getFullScreenWindow();
if (window != null) {
return window.getHeight();
}
else {
return 0;
}
}
/**
Restores the screen's display mode.
*/
public void restoreScreen() {
Window window = device.getFullScreenWindow();
if (window != null) {
window.dispose();
}
device.setFullScreenWindow(null);
}
/**
Creates an image compatible with the current display.
*/
public BufferedImage createCompatibleImage(int w, int h,
int transparency)
{
Window window = device.getFullScreenWindow();
if (window != null) {
GraphicsConfiguration gc =
window.getGraphicsConfiguration();
return gc.createCompatibleImage(w, h, transparency);
}
return null;
}
}
在ScreenManager中,你会注意到update()方法中的一行,如下:
Toolkit.getDefaultToolkit().sync();
这个方法确保显示与windows系统同步。在许多系统上,这个方法什幺也不做,但是,在linux上,调用这个方法解决了AWT事件队列的问题。如果不调用这个方法,某些linux系统就会出现鼠标和键盘输入事件延迟。
ScreenManager的两个新方法是displayModesMatch()和createCompatibleImage()。
DisplayModesMatch()方法检查两个显示模式对象是否相配。如果分辨率、位深度和刷新率都相同,这样的显示模式就是相配的。如果其中一个显示模式对象中没有指定位深度和刷新率,则可以忽略位深度和刷新率。
CreateCompatibleImage()创建一个显示兼容的图像。这样的图像与显示有相同的位深度和颜色模式。所创建的图像类是BufferedImage,这是一个存储在系统内存中的未加速图像。因为createImage()方法只能创建不透明图像,要创建透明或半透明图像就要用createCompatiableImage()方法。
现在,我们要修改AnimationTest1以便使用新的改进的ScreenManager,在列表2.8中创建AnimationTest2类。万岁,再也没有闪动了!
列表2.8 AnimationTest2.java
import java.awt.*;
import javax.swing.ImageIcon;
public class AnimationTest2 {
public static void main(String args[]) {
AnimationTest2 test = new AnimationTest2();
test.run();
}
private static final DisplayMode POSSIBLE_MODES[] = {
new DisplayMode(800, 600, 32, 0),
new DisplayMode(800, 600, 24, 0),
new DisplayMode(800, 600, 16, 0),
new DisplayMode(640, 480, 32, 0),
new DisplayMode(640, 480, 24, 0),
new DisplayMode(640, 480, 16, 0)
};
private static final long DEMO_TIME = 10000;
private ScreenManager screen;
private Image bgImage;
private Animation anim;
public void loadImages() {
// load images
bgImage = loadImage("images/background.jpg");
Image player1 = loadImage("images/player1.png");
Image player2 = loadImage("images/player2.png");
Image player3 = loadImage("images/player3.png");
// create animation
anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
}
private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void run() {
screen = new ScreenManager();
try {
DisplayMode displayMode =
screen.findFirstCompatibleMode(POSSIBLE_MODES);
screen.setFullScreen(displayMode);
loadImages();
animationLoop();
}
finally {
screen.restoreScreen();
}
}
public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime =
System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update animation
anim.update(elapsedTime);
// draw and update screen
Graphics2D g = screen.getGraphics();
draw(g);
g.dispose();
screen.update();
// take a nap
try {
Thread.sleep(20);
}
catch (InterruptedException ex) { }
}
}
public void draw(Graphics g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
// draw image
g.drawImage(anim.getImage(), 0, 0, null);
}
}
从AnimationTest1到AnimationTest2改变的不多。一个改变是AnimationTest2
如何选择显示模式。代替使用缺省的显示模式或从命令行获得显示模式,AnimationTest2为ScreenManager提供了一组可用的显示模式,ScreenManager在这一组模式中选择第一个兼容的模式。
ScreenManager还创建了自己的JFrame对象作为全屏幕窗口,因此,AnimationTest2就不用创建JFrame了。
精灵
动画现在可以平滑地运行了,但是,它只能在屏幕的一个地方,这一点令人扫兴。让我们通过创建精灵让它动起来。
精灵是可以在屏幕上能够独立地运动的图像。它可以同时自动和行走。
除了动画以外,精灵还有两点:位置和速度。如果在学校老师教速度时你在睡觉,我们这里就重复一下。速度既有大小(例如每小时55英里)又有方向(例如向北)。我们将速度分为水平和垂直。用每毫秒像素代替每小时英里或每秒米。
你或许会问,“为什幺使用速度?为什幺不是仅仅将每一帧精灵的位置改变一点?”。如果这样,精灵在不同速度的机器上就会以不同的速度运动。
较快的帧速率意味着精灵运动得较快。让精灵实时运动使得不论帧之间的时间长短,精灵都会以一致的步调运动。
至于动画,精灵根据上次被画后所经过的毫秒数更新。你会说,“嗨,精灵,已经过了50毫秒了”,精灵就会更新它的位置(根据速度)和动画。
列表2.9中的Sprite类有一个动画、一个位置和一个速度。
你可以让sprite的位置是一个整数,但是,如果sprite运动很慢会怎幺样呢?例如,设想一下,每调用一次update()方法,精灵移动一个像素的十分之一。这就是说,10次调用update()有9次看不到精灵移动。如果精灵的位置是整数,由于每次的结果四舍五入,精灵就不会移动。
如果精灵的位置是浮点数,精灵位置的增加包含了这些不可见的移动,精灵会如所期望的,每十分之一次调用update()就移动一个像素。由于这个原因,精灵的位置应该是浮点数。用Math.round()获得精灵的像素位置。
列表2.9 Sprite.java
import java.awt.Image;
public class Sprite {
private Animation anim;
// position (pixels)
private float x;
private float y;
// velocity (pixels per millisecond)
private float dx;
private float dy;
/**
Creates a new Sprite object with the specified Animation.
*/
public Sprite(Animation anim) {
this.anim = anim;
}
/**
Updates this Sprite's Animation and its position based
on the velocity.
*/
public void update(long elapsedTime) {
x += dx * elapsedTime;
y += dy * elapsedTime;
anim.update(elapsedTime);
}
/**
Gets this Sprite's current x position.
*/
public float getX() {
return x;
}
/**
Gets this Sprite's current y position.
*/
public float getY() {
return y;
}
/**
Sets this Sprite's current x position.
*/
public void setX(float x) {
this.x = x;
}
/**
Sets this Sprite's current y position.
*/
public void setY(float y) {
this.y = y;
}
/**
Gets this Sprite's width, based on the size of the
current image.
*/
public int getWidth() {
return anim.getImage().getWidth(null);
}
/**
Gets this Sprite's height, based on the size of the
current image.
*/
public int getHeight() {
return anim.getImage().getHeight(null);
}
/**
Gets the horizontal velocity of this Sprite in pixels
per millisecond.
*/
public float getVelocityX() {
return dx;
}
/**
Gets the vertical velocity of this Sprite in pixels
per millisecond.
*/
public float getVelocityY() {
return dy;
}
/**
Sets the horizontal velocity of this Sprite in pixels
per millisecond.
*/
public void setVelocityX(float dx) {
this.dx = dx;
}
/**
Sets the vertical velocity of this Sprite in pixels
per millisecond.
*/
public void setVelocityY(float dy) {
this.dy = dy;
}
/**
Gets this Sprite's current image.
*/
public Image getImage() {
return anim.getImage();
}
}
Sprite类是很简单的。大部分是get和set方法。所有工作都是在update()方法中完成的,update()根据精灵的速度和已过的时间更新精灵的位置。
现在,让我们好好玩一下。使用sprite类使人物会动,并在屏幕上弹跳。列表2.10中的SpriteTest1实现了这些。每当精灵碰到了屏幕的边界,就改变速度以便反映弹跳。
列表 2.10 SpriteTest1.java
import java.awt.*;
import javax.swing.ImageIcon;
public class SpriteTest1 {
public static void main(String args[]) {
SpriteTest1 test = new SpriteTest1();
test.run();
}
private static final DisplayMode POSSIBLE_MODES[] = {
new DisplayMode(800, 600, 32, 0),
new DisplayMode(800, 600, 24, 0),
new DisplayMode(800, 600, 16, 0),
new DisplayMode(640, 480, 32, 0),
new DisplayMode(640, 480, 24, 0),
new DisplayMode(640, 480, 16, 0)
};
private static final long DEMO_TIME = 10000;
private ScreenManager screen;
private Image bgImage;
private Sprite sprite;
public void loadImages() {
// load images
bgImage = loadImage("images/background.jpg");
Image player1 = loadImage("images/player1.png");
Image player2 = loadImage("images/player2.png");
Image player3 = loadImage("images/player3.png");
// create sprite
Animation anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
sprite = new Sprite(anim);
// start the sprite off moving down and to the right
sprite.setVelocityX(0.2f);
sprite.setVelocityY(0.2f);
}
private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void run() {
screen = new ScreenManager();
try {
DisplayMode displayMode =
screen.findFirstCompatibleMode(POSSIBLE_MODES);
screen.setFullScreen(displayMode);
loadImages();
animationLoop();
}
finally {
screen.restoreScreen();
}
}
public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime =
System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update the sprites
update(elapsedTime);
// draw and update the screen
Graphics2D g = screen.getGraphics();
draw(g);
g.dispose();
screen.update();
// take a nap
try {
Thread.sleep(20);
}
catch (InterruptedException ex) { }
}
}
public void update(long elapsedTime) {
// check sprite bounds
if (sprite.getX() < 0) {
sprite.setVelocityX(Math.abs(sprite.getVelocityX()));
}
else if (sprite.getX() + sprite.getWidth() >=
screen.getWidth())
{
sprite.setVelocityX(-Math.abs(sprite.getVelocityX()));
}
if (sprite.getY() < 0) {
sprite.setVelocityY(Math.abs(sprite.getVelocityY()));
}
else if (sprite.getY() + sprite.getHeight() >=
screen.getHeight())
{
sprite.setVelocityY(-Math.abs(sprite.getVelocityY()));
}
// update sprite
sprite.update(elapsedTime);
}
public void draw(Graphics g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
// draw sprite
g.drawImage(sprite.getImage(),
Math.round(sprite.getX()),
Math.round(sprite.getY()),
null);
}
}
因为Sprite对象处理自己的移动,SpriteTest1类中就没有多少要做的。最新的东西就是update()方法,这个方法让精灵碰到屏幕边界时弹跳。如果精灵碰到了屏幕的左或右边界,就改变水平方向的速度。如果精灵碰到了屏幕的上或下边界,就改变垂直方向的速度。
简单效果
你可以想的到,现在增加多个精灵是很容易的。只要创建多个Sprite对象,确保更新和画了每个对象,例如:
for (int i=0; i<sprites.length; i++) {
sprites[i].update(elapsedTime);
g.drawImage(sprites[i].getImage(),
Math.round(sprite[i].getX()),
Math.round(sprite[i].getY()),
null);
}
要注意的一点是,sprite更新自己的动画,因此,它们不能共享同一个Animation对象;Animation对象就会得到太多次更新。如果你使用了许多一样的animation,你可以在animation中增加clone()方法,使得它的复制更容易。
图像变换
一个很酷的效果是旋转和放大图像。这称为图像变换。图像变换可以使你对图像进行转换、翻转、放大、修剪和旋转,甚至在移动的过程中。
AffineTransform对象描述了变换,AffineTransform类将变换数据存储在2D 3x3矩阵中,没有必要了解矩阵是怎样与图形一起工作的。这个类提供了几个简单的方法,例如,rotate()、scale()、和translate(),为你做计算工作。
Graphics2D类是变换实际发生的地方。在Graphics2D中有一个特别的drawImage()方法,它以AffineTranform作为参数,下面是一个例子,画一个放大了两倍的图像。
AffineTransform transform = new AffineTransform();
transform.scale(2,2);
transform.translate(100,100);
g.drawImage(image, transform, null);
注意到transform操作是基于一个原点的,这个原点是图像的左上角。这就是说,你需要转换图像才能得到你想要的结果。
现在,我们在SpriteTest1中增加两个精灵和一些图像变换。
人物通常是面向右的,我们想要让人物向左移动时面向左。我们使用变换创建图像的镜像,代替装载面向左的不同图像。为此,将图像的宽度放大-1倍,然后对结果转换以便它在正确的位置上。
transform.scale(-1, 1);
transform.translate(-sprite.getWidth(), 0);
为了更好玩,列表2.11中的SpriteTest2还有淡入淡出效果。当程序运行开始时,图像从黑色淡入。相反地,当程序退出时,图像淡出。这个效果是用Graphics对象的fillRect()方法实现的。
SpriteTest2的运行结果如图2.13所示。
列表2.11 SpriteTest2.java
import java.awt.*;
import java.awt.geom.AffineTransform;
import javax.swing.ImageIcon;
public class SpriteTest2 {
public static void main(String args[]) {
SpriteTest2 test = new SpriteTest2();
test.run();
}
private static final DisplayMode POSSIBLE_MODES[] = {
new DisplayMode(800, 600, 32, 0),
new DisplayMode(800, 600, 24, 0),
new DisplayMode(800, 600, 16, 0),
new DisplayMode(640, 480, 32, 0),
new DisplayMode(640, 480, 24, 0),
new DisplayMode(640, 480, 16, 0)
};
private static final long DEMO_TIME = 10000;
private static final long FADE_TIME = 1000;
private static final int NUM_SPRITES = 3;
private ScreenManager screen;
private Image bgImage;
private Sprite sprites[];
public void loadImages() {
// load images
bgImage = loadImage("images/background.jpg");
Image player1 = loadImage("images/player1.png");
Image player2 = loadImage("images/player2.png");
Image player3 = loadImage("images/player3.png");
// create and init sprites
sprites = new Sprite[NUM_SPRITES];
for (int i = 0; i < NUM_SPRITES; i++) {
Animation anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
sprites[i] = new Sprite(anim);
// select random starting location
sprites[i].setX((float)Math.random() *
(screen.getWidth() - sprites[i].getWidth()));
sprites[i].setY((float)Math.random() *
(screen.getHeight() - sprites[i].getHeight()));
// select random velocity
sprites[i].setVelocityX((float)Math.random() - 0.5f);
sprites[i].setVelocityY((float)Math.random() - 0.5f);
}
}
private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}
public void run() {
screen = new ScreenManager();
try {
DisplayMode displayMode =
screen.findFirstCompatibleMode(POSSIBLE_MODES);
screen.setFullScreen(displayMode);
loadImages();
animationLoop();
}
finally {
screen.restoreScreen();
}
}
public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime =
System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update the sprites
update(elapsedTime);
// draw and update screen
Graphics2D g = screen.getGraphics();
draw(g);
drawFade(g, currTime - startTime);
g.dispose();
screen.update();
// take a nap
try {
Thread.sleep(20);
}
catch (InterruptedException ex) { }
}
}
public void drawFade(Graphics2D g, long currTime) {
long time = 0;
if (currTime <= FADE_TIME) {
time = FADE_TIME - currTime;
}
else if (currTime > DEMO_TIME - FADE_TIME) {
time = FADE_TIME - DEMO_TIME + currTime;
}
else {
return;
}
byte numBars = 8;
int barHeight = screen.getHeight() / numBars;
int blackHeight = (int)(time * barHeight / FADE_TIME);
g.setColor(Color.black);
for (int i = 0; i < numBars; i++) {
int y = i * barHeight + (barHeight - blackHeight) / 2;
g.fillRect(0, y, screen.getWidth(), blackHeight);
}
}
public void update(long elapsedTime) {
for (int i = 0; i < NUM_SPRITES; i++) {
Sprite s = sprites[i];
// check sprite bounds
if (s.getX() < 0.) {
s.setVelocityX(Math.abs(s.getVelocityX()));
}
else if (s.getX() + s.getWidth() >=
screen.getWidth())
{
s.setVelocityX(-Math.abs(s.getVelocityX()));
}
if (s.getY() < 0) {
s.setVelocityY(Math.abs(s.getVelocityY()));
}
else if (s.getY() + s.getHeight() >=
screen.getHeight())
{
s.setVelocityY(-Math.abs(s.getVelocityY()));
}
// update sprite
s.update(elapsedTime);
}
}
public void draw(Graphics2D g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
AffineTransform transform = new AffineTransform();
for (int i = 0; i < NUM_SPRITES; i++) {
Sprite sprite = sprites[i];
// translate the sprite
transform.setToTranslation(sprite.getX(),
sprite.getY());
// if the sprite is moving left, flip the image
if (sprite.getVelocityX() < 0) {
transform.scale(-1, 1);
transform.translate(-sprite.getWidth(), 0);
}
// draw it
g.drawImage(sprite.getImage(), transform, null);
}
}
}
图 2.13
在SpriteTest2中,Sprite图像被翻转了。这只有一个问题:图像变换没有利用硬件加速。即使像图像转换这样简单的变换,图像也没有加速。因此,要尽量少用图像变换。
关于这个实例,你应当会注意到两点。
第二章 人机交互和用户接口
AWT事件模型
正如前面提到的,AWT有自己的事件分配线程。这个线程分配来自于操作系统的各种事件,例如,点击鼠标、按下键盘键。
AWT是在哪里分配这些事件的呢?当某个组件发生一个事件时,AWT检查是否有该事件的listener。Listener是一个对象,它接受来自另一个对象的事件。这里,事件来自于AWT事件分配线程。
不同的事件有不同的listener。例如,键盘输入事件有KeyListener接口。
下面的例子说明了键盘按键的事件模型:
1. 用户按下一个键
2. 操作系统向Java运行时发送键盘事件
3. Java运行时将收到的事件放入AWT事件队列
4. AWT事件分配线程将事件分配给KeyListener
5. KeyListener收到键盘事件,完成键盘事件所要求的工作
所有Listener都是接口,因此任何对象都可以通过实现listener接口成为listener。还要注意到,同一个类型的事件可以有几个listener。例如,几个对象都在侦听鼠标事件。这个特点是有用的,但是你不必在你的代码中处理同类型事件多个listener的情况。
有一个方法可以捕获所有AWT事件。虽然这样做对实际的游戏没有用,但是,这对调试代码或弄清分配了什幺事件是有帮助的。下面的代码通过创建AWTEventListener捕获所有事件,并将事件输出到控制台:
Toolkit.getDefaultToolkit().addAWTEventListener(
new AWTEventListener() {
public void eventDispatched(AWTEvent event) {
System.out.println(event);
}
}, -1);
要记住的是,不要在交付的游戏代码中使用上面的代码段;只在测试时使用这样的代码。
键盘输入
游戏中会用到许多键,例如,箭头键控制运动方向,Ctrl键发射武器。我们确实不打算处理文本输入这样的事件---这留给本章后面讨论的Swing组件处理。
要捕获键盘事件需要做两件事情:创建KeyListener和注册listener以便接收事件。要注册listener,只要调用接收键盘事件的组件的addKeyListener()方法。对游戏来说,这个组件就是全屏幕窗口:
Window window = screen.getFullScreenWindow();
window.addKeyListener(keyListener);
要创建KeyListener,只要创建一个实现了KeyListener接口的对象。KeyListener接口有三个方法:keyPressed()、keyReleased()和keyTyped()。接收“Typed”事件对游戏几乎没有什幺用处,因此,我们只讨论按下键和释放键事件。
这三个方法都以KeyEvent作为参数。KeyEvent对象使你可以检查按下或释放了什幺键,得到的是虚拟键码。虚拟键码是Java中与键盘键对应的代码,但是,它不同于字符。例如,虽然Q和q是不同的字符,但是,它们有相同的虚拟键码。
所有虚拟键码都以VK_xxx的形式定义在KeyEvent中。例如,Q键的键码是KeyEvent.VK_Q。大部分键码都是可想而知的(例如,VK_ENTER或VK_1),完整的虚拟键码可在Java API文档的KeyEvent类中查找到。
现在,让我们试一试。代码3.2中的KeyTest类是KeyListener接口的一个实现。它将按下的键和释放的键显示在屏幕上。按ESC键退出程序。
代码3.2 KeyTest.java
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.util.LinkedList;
import com.brackeen.javagamebook.graphics.*;
import com.brackeen.javagamebook.test.GameCore;
/**
A simple keyboard test. Displays keys pressed and released to
the screen. Useful for debugging key input, too.
*/
public class KeyTest extends GameCore implements KeyListener {
public static void main(String[] args) {
new KeyTest().run();
}
private LinkedList messages = new LinkedList();
public void init() {
super.init();
Window window = screen.getFullScreenWindow();
// allow input of the TAB key and other keys normally
// used for focus traversal
window.setFocusTraversalKeysEnabled(false);
// register this object as a key listener for the window
window.addKeyListener(this);
addMessage("KeyInputTest. Press Escape to exit");
}
// a method from the KeyListener interface
public void keyPressed(KeyEvent e) {
int keyCode = e.getKeyCode();
// exit the program
if (keyCode == KeyEvent.VK_ESCAPE) {
stop();
}
else {
addMessage("Pressed: " +
KeyEvent.getKeyText(keyCode));
// make sure the key isn't processed for anything else
e.consume();
}
}
// a method from the KeyListener interface
public void keyReleased(KeyEvent e) {
int keyCode = e.getKeyCode();
addMessage("Released: " + KeyEvent.getKeyText(keyCode));
// make sure the key isn't processed for anything else
e.consume();
}
// a method from the KeyListener interface
public void keyTyped(KeyEvent e) {
// this is called after the key is released - ignore it
// make sure the key isn't processed for anything else
e.consume();
}
/**
Add a message to the list of messages.
*/
public synchronized void addMessage(String message) {
messages.add(message);
if (messages.size() >= screen.getHeight() / FONT_SIZE) {
messages.remove(0);
}
}
/**
Draw the list of messages
*/
public synchronized void draw(Graphics2D g) {
Window window = screen.getFullScreenWindow();
g.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// draw background
g.setColor(window.getBackground());
g.fillRect(0, 0, screen.getWidth(), screen.getHeight());
// draw messages
g.setColor(window.getForeground());
int y = FONT_SIZE;
for (int i=0; i<messages.size(); i++) {
g.drawString((String)messages.get(i), 5, y);
y+=FONT_SIZE;
}
}
}
你应当注意到了两点。第一,init()方法用到了下面一行代码:
window.setFocusTraversalKeysEnabled(false);
这行代码屏蔽了focus traversal 键。focus traversal 键是按下后改变键盘焦点的键。例如,在Web页面上,按Tab键可在表单的元素之间切换。Tab键事件被AWT的focus traversal代码掩盖,但是,这里我们想要接收到Tab键事件。调用这个方法就可以了。
如果你想知道有哪些focus traversal 键,就调用getFocusTraversalKeys()方法。
Tab键不是唯一会引起奇怪现象的键。Alt键也会引起问题。在大多数系统上,Alt键用于激活记忆键,所谓记忆键就是特定的用户接口的快捷键。例如Alt+F激活大多数带有菜单条应用的文件菜单。AWT会认为在Alt键后按下的键是记忆的而忽略了这个键。为了避免这种情况,在KeyTest中用下面的代码防止KeyListener中的KeyEvent按照缺省的方式处理:
e.consume();
这确保没有其它对象处理Alt键,因此,Alt键就象其它键一样处理。
KeyTest还有一个作用,就是用来测试不同系统上键的反应。“哇!你是说在不同的系统上键输入有可能不同。”是的,确实如此。
让我们以重复按键为例。当用户按住一个键时,操作系统发出多个事件。例如,在文本编辑器中,当你按住Q键,就一直输入Q。在有些系统上(例如Linux)这样的操作会产生按下键和释放键事件。在另一些系统上(例如Windows)只产生按下键事件,用户释放了键后才产生释放键事件。
还有一些其它的细小差别,例如,不同版本的Java虚拟机的键事件也会有点不同。
幸运的是差别不大。大多数时候你不需管它。
鼠标输入
键盘只不过是排列在一起的一组键,但是,鼠标就复杂多了。鼠标不仅有键(有一键、两键、三键或更多键鼠标),还可以移动,可能还有滚轮。
也就是说,你可能收到三种类型的鼠标事件:
l 点击鼠标按键
l 滑动鼠标
l 转动鼠标滚轮
点击鼠标按键与按下键盘键相同,但是没有重复键。鼠标的位置用屏幕的x、y座标表示。鼠标的滚轮事件给出了滚轮转动了多少。
每一种鼠标事件都有自己的listener:MouseListener、MouseMotionListener和MouseWheelListener。它们都以MouseEvent作为参数。
象KeyListener一样,MouseListener接口的方法可以检测鼠标键的按下、释放和点击(按下,接着释放)。我们在游戏中不考虑点击,就象我们不考虑KeyTyped事件一样,只考虑按下和释放。可以调用MouseEvent的getButton()方法获得那个键被按下了或释放了。
MouseListener接口的方法还可以检测到鼠标进入或退出组件。因为我们所用的组件覆盖了整个屏幕,我们也不考虑这样的方法。
对于鼠标的移动,我们可以用MouseMotionListener接口检测到两种移动:通常的移动和拖动。当用户按住一个鼠标键同时移动鼠标时,就发生拖动事件。这两种移动都可以用MouseEvent的getX()和getY()方法获得当前的位置。
MouseWheelListener使用了MouseEvent的子类,MouseWheelEvent。它的getWheelRotation()方法检测鼠标滚轮转动了多少。负值表示向上转动,正值表示向下转动。
好了,已经介绍了鼠标输入基础。让我们编写一个程序试一试。
代码3.3中的MouseTest在鼠标所在的位置上画出“Hello World”。当点击鼠标时,画出最后10个鼠标的位置做成轨迹,鼠标改变到“轨迹模式”。转动鼠标的滚轮会改变文本的颜色。如前所述,按ESC键退出程序。
代码3.3 MouseTest.java
import java.awt.*;
import java.awt.event.*;
import java.util.LinkedList;
import com.brackeen.javagamebook.graphics.*;
import com.brackeen.javagamebook.test.GameCore;
/**
A simple mouse test. Draws a "Hello World!" message at
the location of the cursor. Click to change to "trail mode"
to draw several messages. Use the mouse wheel (if available)
to change colors.
*/
public class MouseTest extends GameCore implements KeyListener,
MouseMotionListener, MouseListener, MouseWheelListener
{
public static void main(String[] args) {
new MouseTest().run();
}
private static final int TRAIL_SIZE = 10;
private static final Color[] COLORS = {
Color.white, Color.black, Color.yellow, Color.magenta
};
private LinkedList trailList;
private boolean trailMode;
private int colorIndex;
public void init() {
super.init();
trailList = new LinkedList();
Window window = screen.getFullScreenWindow();
window.addMouseListener(this);
window.addMouseMotionListener(this);
window.addMouseWheelListener(this);
window.addKeyListener(this);
}
public synchronized void draw(Graphics2D g) {
int count = trailList.size();
if (count > 1 && !trailMode) {
count = 1;
}
Window window = screen.getFullScreenWindow();
// draw background
g.setColor(window.getBackground());
g.fillRect(0, 0, screen.getWidth(), screen.getHeight());
// draw instructions
g.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setColor(window.getForeground());
g.drawString("MouseTest. Press Escape to exit.", 5,
FONT_SIZE);
// draw mouse trail
for (int i=0; i<count; i++) {
Point p = (Point)trailList.get(i);
g.drawString("Hello World!", p.x, p.y);
}
}
// from the MouseListener interface
public void mousePressed(MouseEvent e) {
trailMode = !trailMode;
}
// from the MouseListener interface
public void mouseReleased(MouseEvent e) {
// do nothing
}
// from the MouseListener interface
public void mouseClicked(MouseEvent e) {
// called after mouse is released - ignore it
}
// from the MouseListener interface
public void mouseEntered(MouseEvent e) {
mouseMoved(e);
}
// from the MouseListener interface
public void mouseExited(MouseEvent e) {
mouseMoved(e);
}
// from the MouseMotionListener interface
public void mouseDragged(MouseEvent e) {
mouseMoved(e);
}
// from the MouseMotionListener interface
public synchronized void mouseMoved(MouseEvent e) {
Point p = new Point(e.getX(), e.getY());
trailList.addFirst(p);
while (trailList.size() > TRAIL_SIZE) {
trailList.removeLast();
}
}
// from the MouseWheelListener interface
public void mouseWheelMoved(MouseWheelEvent e) {
colorIndex = (colorIndex + e.getWheelRotation()) %
COLORS.length;
if (colorIndex < 0) {
colorIndex+=COLORS.length;
}
Window window = screen.getFullScreenWindow();
window.setForeground(COLORS[colorIndex]);
}
// from the KeyListener interface
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
// exit the program
stop();
}
}
// from the KeyListener interface
public void keyReleased(KeyEvent e) {
// do nothing
}
// from the KeyListener interface
public void keyTyped(KeyEvent e) {
// do nothing
}
}
MouseTest的代码简单易懂,没有什幺需要解释的。当鼠标移动时,就向trailList中增加一个新的Point对象。Point对象含有x和y的值。TrailList中最多可有10个Point。如果开启了轨迹模式,draw()方法就在trailList的每个Point的位置画出“Hello World”。否则,“Hello World”就只画在第一个Point的位置。点击鼠标切换轨迹模式。