第8章
用Java 2-D
来绘制图形、文字和图像(
第二部分)
8
.1
享受几何学的乐趣
8.1.1
冲突检测
冲突检测无疑是当前很多游戏中一个重要的部分。无论玩家是在用激光和导弹轰击舰艇,还是爬梯,或者是组装魔方块,这些都需要检测物体之间的冲突。我们将从如何使用边框盒子来简单快速地判断两个给定物体是否冲突来开始讨论。
1.
边界盒
需要检测画面中的一个移动物体是否和另一个碰撞,通常可以根据常识来判断。比如说,在现实世界中,我们都知道两个物体不可能同时在同一个空间存在(除掉它们具有不同的量子数的情况)。所以,如果画面中到处都有物体在移动,应该检测物体,防止它和别的物体冲突而违反这个规则。只有冲突被检测出来,我们才可以采取适当的措施。例如,如果一个物体跑进了一堵墙里,则可能想使这个正在移动的物体停下来或者让它反弹;一枚导弹和一艘船的冲突可能导致导弹爆炸,船被损坏。这些都取决于情景。
至少对于二维空间而言,可以使用边界盒子来检测物体间的冲突。边界盒只是紧紧围住图形或者图像的一个矩形,使用它可以极大地简化冲突检测所涉及的计算。
下面的applet名为CollisionTest,使用intersects方法来检测Shape对象间的冲突。这个applet还包含了一个指向被选择物体的引用,称为pick,这个物体可以用鼠标选中并拖动与其他物体产生冲突。为了显示冲突,和pick冲突的物体采用了红色高亮显示。为了简单起见,CollisionTest applet对位置和尺寸采用了硬编码而不是实例建模。
import java.applet.*;
import java.awt.*;
import java.awt.geom.*;
import java.awt.event.*;
import java.util.*;
public class CollisionTest extends Applet implements MouseListener,MouseMotionListener{
//画面中所包含的矩形的数量
private final int NUM_RECTS=10;
//矩形队列
private LinkedList rectangles;
//当前所选择的矩形
private Rectangle2D pick;
//用来显示半透明矩形的AlphaCompistie
private AlphaComposite alpha;
public void init(){
rectangles=new LinkedList();
pick=null;
//用50%的透明度来创建一个AlphaComposite
alpha=AlphaComposite.getInstance(AlphaComposite.SRC_OVER,0.5f);
//在随机位置创建NUM_RECTS个矩形并把它们添加到队列中
Random r=new Random();
int width=(int)getSize().getWidth();
int height=(int)getSize().getHeight();
for(int i=0;i<NUM_RECTS;i++){
rectangles.add(new Rectangle2D.Double(
(double)(Math.abs(r.nextInt())%width),
(double)(Math.abs(r.nextInt())%height),
(double)(20+Math.abs(r.nextInt())P),
(double)(20+Math.abs(r.nextInt())P)));
}
//不要忘记注册applet,让它监听鼠标事件
addMouseListener(this);
addMouseMotionListener(this);
}
public void paint(Graphics g){
Graphics2D g2d=(Graphics2D)g;
//告诉Graphics2D容器使用透明度
g2d.setComposite(alpha);
//绘制矩形
g2d.setPaint(Color.BLACK);
for(int i=0;i<NUM_RECTS;i++){
g2d.draw((Rectangle2D.Double)rectangles.get(i));
}
//如果所选取的是一个有效的矩形,则对它进行冲突检测
if(pick!=null){
Rectangle2D rect;
g2d.setPaint(Color.red.darker());
for(int i=0;i<NUM_RECTS;i++){
//得到队列中的第i个矩形
rect=(Rectangle2D)rectangles.get(i);
//检测重叠,注意不能检测被选取的矩形自身
if(pick!=rect&&pick.intersects(rect)){
g2d.fill(rect);
}
}
//填充所选取的矩形
g2d.setPaint(Color.blue.brighter());
g2d.fill(pick);
}
}
//从MouseListene接口继承的方法
public void mouseClicked(MouseEvent e){}
public void mouseEntered(MouseEvent e){}
public void mouseExited(MouseEvent e){}
public void mousePressed(MouseEvent e){
//尝试选取一个矩形
if(pick==null){
Rectangle2D rect;
for(int i=0;i<NUM_RECTS;i++){
rect=(Rectangle2D)rectangles.get(i);
//如果矩形包含鼠标位置,则选取它
if(rect.contains(e.getPoint())){
pick=rect;
return;
}
}
}
}
public void mouseReleased(MouseEvent e){
//释放所选取的矩形并重绘画面
pick=null;
repaint();
}
//从MouseMotionListener接口继承来的方法
public void mouseDragged(MouseEvent e){
//如果我们已经选取了一个矩形,则把它的位置设置为当前鼠标位置并重绘
if(pick!=null){
pick.setRect(e.getX(),e.getY(),pick.getWidth(),pick.getHeight());
repaint();
}
}
public void mouseMoved(MouseEvent e){}
}
默然:这个程序很有趣,只是会不停的闪烁。不知道怎么样才能让它不闪呢?
上面的applet只需要在任何矩形上按下鼠标,然后把它沿着画面拖动,一定要注意intersects和contains方法是如何用来检测冲突和内含的。
出于演示目的,CollisionTest applet只用了矩形来检测冲突。对于非矩形形状,可以调用getBouns2D方法来产生一个完全包含该形状的Rectangle2D对象。对于复杂的图形,边界矩形可能会产生一些问题。设想一下钻石形的左下角和另一个图形的右上角飞快碰撞,实际的几何图形彼此之间毫无接触,即使如此,这种情况仍然会报告一个冲突。必须有一种方法来改进边界盒技术使得冲突检测更加严密。
2.
改进冲突检测
上面这个问题的一个简单的解决方案涉及缩小边界盒。把边界盒放在图形主体的中间是一个好主意。盒子的尺寸应该足够大以容纳图形的重要区域,但是不要太大以免检测到不正确的冲突。
下面的代码段把一个边界盒缩小为原尺寸的75%。
//假设poly指向一个有效的Polygon或者Shape对象
Rectangle2D bounds=poly.getBounds2D();
//把边界矩形缩小为75%
bounds.setRect(bounds.getX()+0.125*bounds.getWidth(),
bounds.getY()+0.125*bounds.getHeight(),
0.75*bounds.getWidth(),
0.75*bounds.getHeight());
注意,虽然我们解决了一个问题,却制造了另一个问题。虽然更紧的边界盒解决了“非冲突”问题,但是也可能几何图形逻辑上已经冲突而盒子却没有重叠。所以,和大多数事物一样,有一个平衡问题。这里,这个平衡处于过早和过迟检测到冲突之间。物体的速度效果经常使得眼睛注意不到这种负面效应,所以,建议尽可能地使用紧缩的边界矩形。
3.
边界图像
毫无疑问,在游戏中图像的使用会占图形内存的绝大部分。因此,讨论一下图像间冲突检测和图形(shape)间冲突检测的差异是值得的。在这一节中,我们看看如何把图像和边界盒信息封装到一个类中来检测同一个画面中图像之间的冲突。
Image和Shape之间的差别,可以做以下概括:
q
Shape对象可以包含屏幕位置相关的信息,而Image不可以。
q
图像(image)本身总是矩形的,而几何图形可以是任何形状的(包括矩形)。
q
Image对象知道它们自己的宽度和高度,Shape对象必须通过getBounds2D方法获得这些信息(当然,除了Shape本身是一个Rectangle2D对象)。
q
image可以包含透明的区域,这些要求它们的边界调整大小。
Image对象最重要的一个属性是它只包含自己的宽度和高度,不包含它相对于屏幕的位置。记住,图像的屏幕位置总是被传给Graphics2D容器的Affinetransform对象指定。因此,需要提供一种将Image和Rectangle2D边界盒子关联起来的方法。
解决方法之一是创建一个容器类,它包含一个Image对象和它的关联Rectangle2D边界盒。使用边界盒可以定义图像在画面中的逻辑位置,然后可以使用边界盒的x和y成员在Graphics2D容器中创建一个供绘画用的AffineTransform对象。一个简单的BoundedImage类可能和下面的代码类似。
import java.applet.*;
import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.awt.event.*;
import java.util.*;
class BoundedImage extends Object{
//容器的Image和边框
private Image image;
private Rectangle2D bounds;
public BoundedImage(Image img,ImageObserver obs){
image=img;
//把边框位置设为(0,0),宽和高和图像相同
bounds=new Rectangle2D.Double(0,0,image.getWidth(obs),image.getHeight(obs));
}
public Rectangle2D getBounds2D(){
return bounds;
}
public Image getImage(){
return image;
}
public AffineTransform getTransform(){
return AffineTransform.getTranslateInstance(bounds.getX(),bounds.getY());
}
public void moveTo(Point p){
bounds.setRect((double)p.x,(double)p.y,bounds.getWidth(),bounds.getHeight());
}
}
注意,BoundedImage类的构造函数以一个Image对象和一个ImageObserver对象作为参数,这个ImageObserver对象通常是主Applet类的一个引用。ImageObserver用来正确地报告Image的宽和高,这样才能正确设置边界矩形的尺寸。
接下来的两个方法是获取数据和图像边界的基本访问方法。getTransform方法产生一个基于边界矩形位置的AffineTransform,随后Graphics2D容器可以在绘制图像时使用这个变换。最后一个方法moveTo让边界矩形的位置和一个的Point对象相同,同时边界矩形的宽和高不变。我们可以使用这个方法,用鼠标使物体在画面中到处移动。
下面的BoundedImageTest applet和CollisionTest applet相似,不同的是它使用BoundedImage类而不是形状。这里把它设计得和CollisionTest相似是为了使得使用图像的不同之处更加突出。浏览一下下面的代码清单,然后尝试自己修改CollisionTest,看看自己是否可以完成变更。
import java.applet.*;
import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.awt.event.*;
import java.util.*;
class BoundedImage extends Object{
//容器的Image和边框
private Image image;
private Rectangle2D bounds;
public BoundedImage(Image img,ImageObserver obs){
image=img;
//把边框位置设为(0,0),宽和高和图像相同
bounds=new Rectangle2D.Double(0,0,image.getWidth(obs),image.getHeight(obs));
}
public Rectangle2D getBounds2D(){
return bounds;
}
public Image getImage(){
return image;
}
public AffineTransform getTransform(){
return AffineTransform.getTranslateInstance(bounds.getX(),bounds.getY());
}
public void moveTo(Point p){
bounds.setRect((double)p.x,(double)p.y,bounds.getWidth(),bounds.getHeight());
}
}
public class BoundedImageTest extends Applet implements MouseListener,MouseMotionListener{
//为了方便编辑,对图像文件名做全局引用
private final String FILENAME="simon.gif";
//画面所包含的图像数量
private final int NUM_IMAGES=3;
//BoundedImages队列
private LinkedList images;
//当前所选择的BoundedImage
private BoundedImage pick;
//高亮显示冲突用的AlphaCompicite
private AlphaComposite alpha;
public void init(){
images=new LinkedList();
pick=null;
//在随机位置创建NUM_IMAGES个图像
//并把它们添加到队列中
Random r=new Random();
int width=(int)getSize().getWidth();
int height=(int)getSize().getHeight();
//创建一个MediaTracker对象,这样图像可以先全部加载进来
//然后再传给BoundedImage类
MediaTracker mt=new MediaTracker(this);
BoundedImage bi;//要添加到队列中的BoundedImage
Image img;//一个图像
for(int i=0;i<NUM_IMAGES;i++){
img=getImage(getCodeBase(),FILENAME);
mt.addImage(img,i);
try{
mt.waitForID(i);
}catch(InterruptedException e){
//Nothing
}
bi=new BoundedImage(img,this);
bi.moveTo(new Point(Math.abs(r.nextInt())%width,Math.abs(r.nextInt())%height));
images.add(bi);
}
//用40%的透明度创建一个AlphaCompostie
alpha=AlphaComposite.getInstance(AlphaComposite.SRC_OVER,0.4f);
//不要忘记注册applet来监听鼠标事件
addMouseListener(this);
addMouseMotionListener(this);
}
public void paint(Graphics g){
Graphics2D g2d=(Graphics2D)g;
//绘制图像
BoundedImage bi;
for(int i=0;i<NUM_IMAGES;i++){
bi=(BoundedImage)images.get(i);
g2d.drawImage(bi.getImage(),bi.getTransform(),this);
}
//如果pick指向有效图像,则检测冲突
if(pick!=null){
for(int i=0;i<NUM_IMAGES;i++){
//得到队列中的第i个图像
bi=(BoundedImage)images.get(i);
//检测交叉部分
if(imageCollision(pick,bi)){
//填充边界矩形来高亮显示冲突
g2d.setComposite(alpha);
g2d.setPaint(Color.RED.darker());
g2d.fill(bi.getBounds2D());
}
}
//绘制并填充pick矩形
g2d.setPaint(Color.blue.brighter());
g2d.setComposite(alpha);
g2d.drawImage(pick.getImage(),pick.getTransform(),this);
g2d.fill(pick.getBounds2D());
}
}
//检测两个BoundedImage是否交叉(冲突),这个方法在i1和i2指向同一个对象时
//返回false
private boolean imageCollision(BoundedImage i1,BoundedImage i2){
return (i1==i2)?false:i1.getBounds2D().intersects(i2.getBounds2D());
}
//从MouseListener接口继承来的方法
public void mouseClicked(MouseEvent e){}
public void mouseEntered(MouseEvent e){}
public void mouseExited(MouseEvent e){}
public void mousePressed(MouseEvent e){
//选取一个图像
if(pick==null){
BoundedImage bi;
for(int i=0;i<NUM_IMAGES;i++){
bi=(BoundedImage)images.get(i);
//如果BoundedImage包含鼠标位置,则选取它
if(bi.getBounds2D().contains(e.getPoint())){
pick=bi;
return;
}
}
}
}
public void mouseReleased(MouseEvent e){
//释放放选取的图像并重绘画面
pick=null;
repaint();
}
//从mouseMotionListener接口继承来的方法
public void mouseDragged(MouseEvent e){
//如果已经选取了一个图像,则把它的位置设为鼠标位置并重绘
if(pick!=null){
pick.moveTo(e.getPoint());
repaint();
}
}
public void mouseMoved(MouseEvent e){}
}
希望大家能够自己轻而易举地修改CollisionTest applet,如果有困难,试试专注于上面这个问题涉及的主要概念。如果MediaTracker类的使用看起来令人迷惑,可以直接复制它,或者跳到第9章。另外,也可以把全局的FILENAME String对象修改为任何东西。
4.
检测大队列的对象
如果游戏中只有一个活动的物体,它可能和一个或多个静态物体碰撞,那么,上面的线性模型是很好的。然而,如果所有的物体都在运行,就象在一个小行星带中,情况会如何呢?这里,任何一颗小行星都需要进行检测,它们都可能和任何物体发生逻辑冲突。
下面的算法可以用来比较
//假设list指向一个有效的可能冲突的物体的数组
for(int i=0;i<list.length;i++){
for(int j=0;j<list.length;j++){
//检测冲突,不对物体检测其本身
if(i!=j&&list[i].collidesWith(list[j])){
//处理冲突
}
}
}
上面的算法不仅会检测所有的冲突,而且会检测两次。如果元素i和元素j冲突,那么元素j也会和元素i冲突,所以计算两次。导致:
q
浪费了处理器时间。
q
并发的冲突会调用两次。
避免上述问题的一个简单而有效的方法是这样的:
//假设list指向一个有效的可能冲突的物体的数组
for(int i=0;i<list.length-1;i++){
for(int j=i+1;j<list.length;j++){
//检测冲突,不对物体检测其本身
if(list[i].collidesWith(list[j])){
//处理冲突
}
}
}
只修改了简单几行代码,我们就已经解决了双倍冲突的问题,并且减少了一半以上的处理数目。而且,由于被保证一个物体不会对它自己检测,所以不需要让两个循环变量相等。
在接下来的几节中,我们将学习进一步减少所需测试的冲突数目的方法,还会学习如何在两个物体冲突时执行更复杂的动作。
8.1.2
叠加几何
Java 2-D的另一个有趣的领域是叠加几何,也被称为构建区域几何。叠加几何允许使用布尔操作来把多个形状合并为一个更复杂的形状。
Java 2-D提供了一个实现了Shape接口的Area类,一个Area定义任意一个像多边形的形状对象。
除了Shape接口相关的通用方法外,Area类学定义了4个合并形状的操作。如下:
q
并(Union)
q
差(Subtraction)
q
交(Intersection)
q
异或(Exclusive,或者叫XOR)
每一个Area操作工作的原则是两个形状的每一个部分要么叠加要么不叠加,所作的比较是纯粹的二元运算。
一般而言,创建一个叠加形状所需要的步骤是:
1.创建一个或者多个Shape对象。
2.创建一个Area对象,在创建时设定它的形状。
3.应用上面4种操作中的一个或者多个为创建一个新形状
AreaTest applet创建了两个圆,新创建的形状和每一个操作的名字一起画在屏幕上。
import java.applet.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
public class AreaTest extends Applet{
//一个Area对象数组和一个对应的几何描述String数组
private Area[] shapes;
private String[] ops;
public void init(){
//创建4个Area对象和对应的String
shapes=new Area[4];
ops=new String[4];
//创建两个在原点附近重叠的圆
Ellipse2D e1=new Ellipse2D.Double(-0.125,0.0,0.5,0.5);
Ellipse2D e2=new Ellipse2D.Double(0.125,0.0,0.5,0.5);
//创建两个形状的并集
shapes[0]=new Area(e1);
shapes[0].add(new Area(e2));
ops[0]="Unoin";
//从e1中减去e2
shapes[1]=new Area(e1);
shapes[1].subtract(new Area(e2));
ops[1]="Subtraction";
//创建两个形状之间的补集
shapes[2]=new Area(e1);
shapes[2].intersect(new Area(e2));
ops[2]="Intersection";
//在两个形状间使用异或(Exclusive OR)操作
shapes[3]=new Area(e1);
shapes[3].exclusiveOr(new Area(e2));
ops[3]="XOR";
}
public void paint(Graphics g){
//把传入的Graphics容器转换为一个可用的Graphics2D对象
Graphics2D g2d=(Graphics2D)g;
//创建一个笔画来描述形状的外形
g2d.setStroke(new BasicStroke(2.0f/100.0f));
//用来产生随机颜色的Random对象
Random r=new Random();
//绘制形状和操作描述
for(int i=0;i<4;i++){
g2d.setTransform(new AffineTransform());
g2d.translate(50+(i*100),40);
g2d.drawString(ops[i],0,70);
g2d.scale(100,100);
g2d.setPaint(new Color(r.nextInt()));
g2d.fill(shapes[i]);
g2d.setPaint(Color.BLACK);
g2d.draw(shapes[i]);
}
}
}
下面是Area操作和对应的布尔运算的相似之处。
q
Unoin操作和布尔运算的OR操作类似
q
Subtract操作和布尔运算的NOT操作类似
q
Intersection操作和布尔运算的AND操作类似
q
XOR操作和布尔运算的XOR操作类似
记住,构造区域几何学不是仅在圆上起作用,可以将三角形和圆组合,也可以将圆和多边形组合,或者是其他任何想象得出来的形状。
8.1.3
区域剪裁
Java 2-D API提供的另外一个有趣的几何操作被称为区域剪裁。区域剪裁允许指定一个任意的Shape对象来限定屏幕绘画区域,其中的这个Shape对象称为clip。如果把Shape传给Graphics2D类的setClip方法,剪裁图形会直接定义剪裁区域。注意,所产生的剪裁区域的最终屏幕坐标根据Graphics2D容器的当前变换决定。
ClipTest允许用户用一个Choice对象从3个绘制例子中选择。第一个选项不用剪裁技巧绘制了一个图像,第二个选项只使用Graphics2D的draw方法绘制了Shape对象,第三个选项定义Shape对象为剪裁区域,并在这个区域之中绘制图像。
import java.applet.*;
import java.awt.*;
import java.awt.geom.*;
import java.awt.event.*;
public class ClipTest extends Applet implements ItemListener{
//要绘制的Image和剪裁区域
private Image image;
private Polygon clip;
//显示Image和剪裁区域的Choice
private Choice dropBox;
//下拉列表框中的索引
private final int SHOW_IMAGE_ONLY=0;
private final int SHOW_CLIP_ONLY=1;
private final int SHOW_CLIPPED_IMAGE=2;
public void init(){
//从文件中加载图像
MediaTracker mt=new MediaTracker(this);
image=getImage(getCodeBase(),"simon.gif");
mt.addImage(image,0);
try{
mt.waitForID(0);
}catch(InterruptedException e){
//Nothing
}
//相对参照点创建一个八边形的剪裁区域
//参照点可以位于图像中心
clip=new Polygon();
int anchor=image.getWidth(this)/2;
for(int i=0;i<8;i++){
clip.addPoint(anchor+(int)(anchor*Math.cos(Math.toRadians(i*45))),
anchor+(int)(anchor*Math.sin(Math.toRadians(i*45))));
}
//把下拉列表框添加到容器的底部,和3个绘制选项在一起
setLayout(new BorderLayout());
dropBox=new Choice();
dropBox.add("显示图像");
dropBox.add("显示剪裁区域");
dropBox.add("显示剪裁图像");
dropBox.addItemListener(this);
add(dropBox,BorderLayout.SOUTH);
}
public void paint(Graphics g){
//把传进来的Graphics容器转化为一个可用的Graphics2D对象
Graphics2D g2d=(Graphics2D)g;
//把变换设置为恒等变换
final AffineTransform at=new AffineTransform();
//根据当前的选择来绘制
switch(dropBox.getSelectedIndex()){
case SHOW_IMAGE_ONLY:
g2d.drawImage(image,at,this);
break;
case SHOW_CLIP_ONLY:
g2d.setTransform(at);
g2d.draw(clip);
break;
case SHOW_CLIPPED_IMAGE:
g2d.setClip(clip);
g2d.drawImage(image,at,this);
break;
default:
//无效索引
System.out.println("无效选择:"+dropBox.getSelectedIndex());
break;
}
}
public void itemStateChanged(ItemEvent e){
//下拉列表框已经改变,重绘画面
repaint();
}
}
注意,当运行ClipTest applet的时候,被剪裁的图像并没有被变换,或者被处理来适合剪裁区域,剪裁区域只是定义了可以用来绘制的实际区域。要让使用setClip定义的剪裁区域失效,只需要再次调用这个方法,如下:
//假设g2d指向一个有效的Graphics2D对象
g2d.setClip(null);//删除所设的任何剪裁区域
默然:其实我觉得好象不一定需要重设setClip方法(从ClipTest applet中就可以看出来),只需要重新drawXXX,Clip就会失效似的。当然,也可能是因为ClipTest的repaint方法被多次调用,而每次调用调用repaint方法之后,g2d都被重置过了吧?
我们也可以把剪裁区域的概念延伸到文本的绘制中。回想一下,可以使用FontRenderContext和TextLayout类来提取给定的字体的度量,还可以使用这两个类来产生一个表示一串文本轮廓的Shape对象。下面的代码段显示了在文本串边界中绘制所需的基本步骤:
//此处设置字体,文本串,FontRenderContext和TextLayout…
//假设layout指向一个有效的TextLayout对象
Shape outline=layout.getOutline(null);
//此处设置paint,变换和其他的Graphics2D属性
//设置剪裁区域并绘制一个Image
g2d.setClip(outline);
g2d.drawImage(myImage,new AffineTransform(),this);
我们应该可以填充所缺的代码,如果需要复习一下Font,FontRenderContext或者TextLayout对象的创建,可以参看7.8.3节。
注意:记住,如果决定使用实例建模来创建剪裁形状,屏幕坐标
(0,0)
会在变换前定义的几何形状的死点
(
默然:
?
!这句话太怪啦!什么意思呀
?
!
)
。然后,
(0,0)
会参照没有变换的图像的左上角,不是图像的中心,所以,如果用
(50,50)
缩放形状,并把图像平移到
(100,200),
它们可能并不按照规定的路线进行,很可能需要对图形和图像的基准点定义为同一个点。本章的一个习题要求做的就是这个。