3.创建可滚动的场景
下面将探讨管理动态Java场景的另一种方法。可滚动的场景。可滚动的场景是指同样的景物或者装饰在它们跨越屏幕时不断循环,这让用户觉得游戏的规模是巨大的,而事实上只是同样的几幅画在重复使用。卡通动画一直在使用这种技术,这样做节约了时间也节约了资源。
在实现可滚动场景时,通常把这个工作分为两个部分是比较方便的:在物体离开场景时把它包装起来,离开的部位进行重绘物体让它们看起连续。第一个部分是很清楚的,一旦一个可滚动的物体离开场景,它应该毫无痕迹地被场景所替换;第二个部分也很简单,一旦一个可滚动物体开始离开场景边界,它很可能在窗口的另一端留下一个间隙,可以通过重复平铺图片直到填满间隙来解决这个问题。
下面的SpaceScene类展示了一种使用场景管理器的方法。
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
//基础的可以滚动的场景
public class SpaceScene extends Scene{
//场景中的物体
protected StaticActor[] scenery;
public SpaceScene(Rectangle2D view){
super(view,view);
scenery=null;
}
public void setScenery(StaticActor[] v){
scenery=v;
}
//移动场景中的物体
public void update(){
if(scenery==null)return;
for(int i=0;i<scenery.length;i++){
if(scenery[i]!=null){
//在物体离开场景后让它再次转回来
if(scenery[i].getX()<=-scenery[i].getWidth()){
//为下一帧准备scenery
scenery[i].setX(scenery[i].getX()+
scenery[i].getWidth());
}
scenery[i].update();
}
}
}
public void paint(Graphics2D g2d){
if(scenery==null)return;
for(int i=0;i<scenery.length;i++){
if(scenery[i]!=null){
scenery[i].paint(g2d);
//如果物体不是完全在边界内则将它添加到自身上
if(scenery[i].getX()+scenery[i].getWidth()<
bounds.getWidth()){
scenery[i].paint(g2d,scenery[i].getX()+
scenery[i].getWidth(),scenery[i].getY());
}
}
}
}
}//SpaceScene
注意,这个类的重用性不是很好,因为场景内的物体可以以很多不同的方式滑动。实际上它们可以朝任何方向滑动,并且物体重叠的规律也不相同,如果你对自己创建更复杂的滚动场景感兴趣,可以尝试写自己的可重用的滚动场景管理器。
上面的代码对于与applet窗体一样宽或者更宽的物体时很完美,而对于没有窗体宽的物体则需要多次重绘来填满间隙。
还要注意,真实的程序可能需要单独的列表来区分需要滚动的物体和不需要滚动的物体。可以修改ScrollableScene类来维护不可以滚动的物体,比如船只,导弹和行星等。可以让游戏中的物体自己平铺和滚动,这个任务也可以转移给它们自己的角色组对象。试使用不同的方式来组织场景看哪种方式最适合。
下面用一个简单的例子来结束这一节。下面的applet和SceneScrollTest,创建了两个背景图像,速度各不相同。物体被传给了场景管理器,所有的更新和绘制都由场景管理器来完成。
import java.applet.*;
import java.awt.*;
import java.awt.image.*;
import java.awt.geom.*;
import java.util.*;
public class SceneScrollTest extends Applet implements Runnable{
//动画线程
private Thread animation;
//屏外绘制图像
private VolatileGraphics offscreen;
//一个放置可滚动物体的场景
private SpaceScene scene;
public void init(){
//用整个applet窗体的大小创建场景
scene=new SpaceScene(getBounds());
//"背景"和"前景"场景图像
StaticActor[] actors=new StaticActor[2];
StaticActorGroup group;
//创建前景
group=new StaticActorGroup("mountain.gif");
group.init(this);
actors[1]=new StaticActor(group);
actors[1].setPos(0.0,(double)getSize().height-actors[1].getHeight());
actors[1].setVel(-3,0);
//创建背景
group=new StaticActorGroup("haze.gif");
group.init(this);
actors[0]=new StaticActor(group);
actors[0].setPos(0.0,(double)getSize().height-actors[1].getHeight());
actors[0].setVel(-1,0);
//设置scenery
scene.setScenery(actors);
offscreen=new VolatileGraphics(this);
animation=new Thread(this);
AnimationStrip.observer=this;
}//init
public void start(){
//启动动画线程
animation.start();
}
public void stop(){
animation=null;
}
//执行一个标准绘制循环
public void run(){
Thread t=Thread.currentThread();
while(t==animation){
repaint();
try{
Thread.sleep(10);
}catch(InterruptedException e){
break;
}
}
}//run
//让场景执行它的update方法
public void update(Graphics g){
scene.update();
paint(g);
}
//绘制场景
public void paint(Graphics g){
Graphics2D bg=(Graphics2D)offscreen.getValidGraphics();
bg.setPaint(Color.black);
bg.fill(getBounds());
scene.paint(bg);
g.drawImage(offscreen.getBuffer(),0,0,this);
}
}//SceneScrollTest
在运行SceneScrolltest applet时找不到一幅图像结束另一幅图像开始的地方。让滚动的场景运转起来的关键是让图像在计划重叠的任何一条边上都要没有痕迹,很多图像处理程序中的剪切滤镜可以让我们简单快速地完成这个工作。平铺图像对于TexturePaint对象是很合适的,对于需要创建图像模式的场合,平铺图像也是合适的。
此外,这个applet还涉及了另外一个类:StaticActorGroup。我也给出它的源代码:
import java.applet.*;
import java.awt.Image;
public class StaticActorGroup extends ActorGroup2D
{
private String filename;
protected StaticActorGroup()
{
filename = null;
}
public StaticActorGroup(String fn)
{
filename = fn;
animations = new AnimationStrip[1];
}
public void init(Applet a)
{
animations[0] = new AnimationStrip();
Image image = a.getImage(a.getCodeBase(), filename);
while(image.getWidth(a) <= 0);
animations[0].addFrame(image);
animations[0].setAnimator(new Animator.Single());
}
} // StaticActorGroup
还有就是VolatileGraphics类:
import java.applet.*;
import java.awt.*;
import java.awt.image.*;
public class VolatileGraphics extends BufferedGraphics
{
public VolatileGraphics(Component c)
{
super(c);
createBuffer();
}
protected void createBuffer()
{
Dimension size = parent.getSize();
buffer = parent.createVolatileImage(size.width, size.height);
}
protected boolean isValid()
{
if(! super.isValid()) return false;
if(((VolatileImage)buffer).validate(parent.getGraphicsConfiguration()) ==
VolatileImage.IMAGE_INCOMPATIBLE)
{
return false;
}
return true;
}
} // VolatileGraphics
让滚动的场景运转起来的关键是让图像在计划重叠的任何一条边上都要没有痕迹,很多图像处理程序中的剪切滤镜可以让我们简单快速地完成这个工作。平铺图像对于TexturePaint对象是很合适的。
下面看另一个小巧的例子,这个例子使用等视角图像来创建一个等视角平铺的行走者。
4.创建一个等视角平铺的行走者
如果你喜欢Blizzard公司的非常流行的星际争霸这样的基于等视角平铺物的游戏的爱好者,那么你可能会对在游戏中实现等视角平铺的场景很感兴趣。在Java中很容易实现等视角场景,特别是使用一个场景管理系统时。
可以想象的等视角场景的最简单的例子应该包含一个可以在平铺场景中移动的角色。
由于窗口是固定的,所以很难让角色从一个地方移动到另一个地方,因此,必须找到一种方法,使这个角色虽然实际不动但是看起来却像在动。平铺行走者的巧妙在于让背景相对于参考点(默然:这个参考点就是实际不动的这个角色)运动,而不是让角色在场景中移动,虽然这和真实世界中物体的运动方式不一致,但是必须记住不是在处理真实世界。只要能解决问题而且还有些条理性,就可以使用这些技巧。
让我们继续编写等视角场景,下面的类IsoScene扩展了Scene类并提供了一个Vector对象来容纳滚动物体以及一个单独的作为参考的角色的引用,它还负责接收击键事件并恰当地更新场景。
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
import java.awt.event.*;
public class IsoScene extends Scene implements KeyListener{
//背景Actor2D对象数组
protected Vector actors;
//画面旋转的参照物
protected Actor2D anchor;
//参照物当前位置
protected Vector2D anchorPos;
public IsoScene(Rectangle2D v){
super(v,v);
actors=new Vector();
anchor=null;
anchorPos=null;
}
public void add(Actor2D a,boolean isAnchor){
//只有在Actor不是参照物的情况下才把它加入到数组中
//参照物将另外处理
if(!isAnchor){
actors.add(a);
}else{
//设置参考物和它的位置
anchor=a;
anchorPos=new Vector2D.Double(anchor.getX(),anchor.getY());
}
}
public void update(){
//更新参照物
anchor.update();
//根据参照物的位置更新画面剩余部分
//只有参照物在移动的情况下才需要做更新
if(!anchor.getVel().equals(Vector2D.ZERO_VECTOR)){
double x=0.0;
double y=0.0;
x=-anchor.getVel().getX();
y=-anchor.getVel().getY();
for(Object e:actors){
((Actor2D)e).moveBy(x,y);
}
}
for(Object e:actors){
((Actor2D)e).update();
}
}
public void paint(Graphics2D g2d){
//绘制参照物以外的组件
for(Object e:actors){
((Actor2D)e).paint(g2d);
}
//绘制参照物
anchor.paint(g2d);
}
public void keyTyped(KeyEvent e){
}
public void keyPressed(KeyEvent e){
if(anchor==null)return;
//给参照物一个大小为1/4砖块的恒定速度
switch(e.getKeyCode()){
case KeyEvent.VK_UP:
anchor.setVel(-IsoTileGroup.TILE_WIDTH/4,
-IsoTileGroup.TILE_HEIGHT/4);
break;
case KeyEvent.VK_DOWN:
anchor.setVel(+IsoTileGroup.TILE_WIDTH/4,
+IsoTileGroup.TILE_HEIGHT/4);
break;
case KeyEvent.VK_LEFT:
anchor.setVel(-IsoTileGroup.TILE_WIDTH/4,
+IsoTileGroup.TILE_HEIGHT/4);
break;
case KeyEvent.VK_RIGHT:
anchor.setVel(+IsoTileGroup.TILE_WIDTH/4,
-IsoTileGroup.TILE_HEIGHT/4);
break;
default:break;
}
}
public void keyReleased(KeyEvent e){
if(anchor==null)return;
//只要键被松开,马上停止移动参照物
switch(e.getKeyCode()){
case KeyEvent.VK_UP:
case KeyEvent.VK_DOWN:
case KeyEvent.VK_LEFT:
case KeyEvent.VK_RIGHT:
anchor.setVel(0,0);
break;
default:break;
}
}
}//IsoScene
IsoScene里涉及到了两个类,IsoTile和IsoTileGroup,源代码如下:
import java.awt.*;
public class IsoTile extends StaticActor
{
protected static final java.util.Random random = new java.util.Random();
public IsoTile(ActorGroup2D grp)
{
super(grp);
currAnimation = group.getAnimationStrip(random.nextInt(4));
}
public void update()
{
super.update();
}
} // IsoTile
import java.applet.*;
public class IsoTileGroup extends ActorGroup2D
{
public static final int FRAME_A = 0;
public static final int FRAME_B = 1;
public static final int FRAME_C = 2;
public static final int FRAME_D = 3;
public static final int TILE_WIDTH = 96;
public static final int TILE_HEIGHT = 64;
public IsoTileGroup()
{
super();
animations = new AnimationStrip[4];
}
public void init(Applet a)
{
ImageLoader loader;
loader = new ImageLoader(a, "isotiles.gif", true);
for(int i = 0; i < 4; i++)
{
animations[i] = new AnimationStrip();
animations[i].addFrame(loader.extractCell
(i*TILE_WIDTH, 0, TILE_WIDTH, TILE_HEIGHT)); animations[i].setAnimator(new Animator.Single());
}
}
} // IsoTileGroup
只要有一个方位箭头键按下,就会给小人一个固定的速度,虽然小人实际上并没有移动,但是场景会按与小人等速反向的方式移动方块,场景以砖宽和高四分之一的加速度移动,这样使小人看起来在和砖一起闪。
现在有了一个控制等视角场景的机制,让我们创建一个简单的例子来使用它。下面的IsoTest applet,创建了一系列的等视角的方块和一个等视角的参照角色,并且把它们全部加载到一个IsoScene对象中。正如在前面的例子中看到的,场景负责更新和绘制场景中的物体,applet类只指导通信。虽然经常使用applet本身来捕获键盘事件,这里决定让场景来做这个只是为了让各个部分交互。
这里先发布一下与IsoTest applet有关的两个类的源代码:IsoManGroup
import java.applet.*;
public class IsoManGroup extends ActorGroup2D
{
public static final int WALKING_NORTH = 0;
public static final int WALKING_SOUTH = 1;
public static final int WALKING_EAST = 2;
public static final int WALKING_WEST = 3;
// iso man的大小
public static final int ISO_MAN_SIZE = 64;
public IsoManGroup()
{
super();
animations = new AnimationStrip[1];
}
public void init(Applet a)
{
ImageLoader loader;
int i;
loader = new ImageLoader(a, "isoman.gif", true);
animations[WALKING_NORTH] = new AnimationStrip();
for(i = 0; i < 3; i++)
{
animations[WALKING_NORTH].addFrame(loader.extractCell(
(i*ISO_MAN_SIZE)+1+(i*1), 1, ISO_MAN_SIZE, ISO_MAN_SIZE));
}
animations[WALKING_NORTH].setAnimator(new Animator.Looped());
}
} // IsoManGroup2D
IsoMan类
import java.awt.*;
public class IsoMan extends Actor2D
{
public IsoMan(ActorGroup2D grp)
{
super(grp);
}
public void update()
{
if(currAnimation == null)
{
currAnimation = group.getAnimationStrip(IsoManGroup.WALKING_NORTH);
}
if(frameWidth <= 0)
{
frameWidth = currAnimation.getFrameWidth();
}
if(frameHeight <= 0)
{
frameHeight = currAnimation.getFrameHeight();
}
if(! vel.equals(Vector2D.ZERO_VECTOR))
{
animate();
}
// 确保IsoMan的脚被设置为参考点
xform.setToTranslation(pos.getX()-frameWidth/2, pos.getY()-frameHeight);
updateBounds();
checkBounds();
}
} // IsoMan
IsoTest类
import java.applet.*;
import java.awt.*;
public class IsoTest extends Applet implements Runnable{
//动画线程
private Thread animation;
private VolatileGraphics offscreen;
//一个等大场景
private IsoScene scene;
public void init(){
//初始化场景
scene=new IsoScene(getBounds());
//创建IsoTileGroup
IsoTileGroup tileGroup=new IsoTileGroup();
tileGroup.init(this);
int x=-getSize().width*2-(IsoTileGroup.TILE_WIDTH/2);
int y=-getSize().height*2-(IsoTileGroup.TILE_HEIGHT/2);
int width=getSize().width*4;
int height=getSize().height*4;
//用iso砖块填充场景,注意offset标志是如何让其他的行偏移产生互锁的
IsoTile tile;
boolean offset=false;
while(y<height){
while(x<width){
tile=new IsoTile(tileGroup);
tile.setPos(x,y);
//将砖块作为非参照物添加进来
scene.add(tile,false);
x+=IsoTileGroup.TILE_WIDTH;
}
offset=!offset;
if(offset){
x=-getSize().width*2;
}
else{
x=-getSize().width*2-(IsoTileGroup.TILE_WIDTH/2);
}
y+=IsoTileGroup.TILE_HEIGHT/2;
}
//创建一个IsoManGroup并创建一个IsoMan角色
IsoManGroup group=new IsoManGroup();
group.init(this);
IsoMan isoMan=new IsoMan(group);
isoMan.setPos(getSize().width/2,getSize().height/2);
//将isoman作为参照物添加到场景中
scene.add(isoMan,true);
//注册场景让它接收键盘事件
addKeyListener(scene);
offscreen=new VolatileGraphics(this);
animation=new Thread(this);
AnimationStrip.observer=this;
}//init
public void start(){
//启动动画线程
animation.start();
}
public void stop(){
animation=null;
}
public void run(){
Thread t=Thread.currentThread();
while(t==animation){
repaint();
try{
Thread.sleep(10);
}catch(InterruptedException e){
break;
}
}
}//run
public void update(Graphics g){
//更新场景
scene.update();
paint(g);
}
public void paint(Graphics g){
Graphics2D bg=(Graphics2D)offscreen.getValidGraphics();
bg.setPaint(Color.white);
bg.clearRect(0,0,getSize().width,getSize().height);
//绘制场景
scene.paint(bg);
g.drawImage(offscreen.getBuffer(),0,0,this);
}//paint
}//IsoTest
注意方块是怎样创建的,我们需要让它们一个接着另一个的偏移机制来摆放方块。还要注意方块的图像是从文件中加载的,所以,如果使用的是一个不支持透明图像的图像工具,就可能需要一个确保方块正确的一个叠着另一个的绘制机制。但是由于我们可以使用原本就透明的图像,所以可以以任何顺序绘制平铺图像,因为透明区域不会被绘制。
查看输出的最好方式是自己运行它。这个applet确实不错,所以值得一试。此外,如果有兴趣学习更多关于等视角游戏编程的知识,可以看看Ernest Pazera写的Isometric Game Programming with DirectX7.0。
接下来,我们将看场景管理的最后一个例子,也就是关于曾经提到的重叠场景的例子。
5.创建一个重叠场景
本章的最后一个场景管理系统称之为重叠场景(Wrapped Scene)。重叠场景简单地跟踪离开场景一条边的物体,并在另外一边叠放它,可以把它看作是一个球体转化为图。图只是球体的一个平面映射,虽然图有明显的边界,逻辑上物体还是应该可以从一条边离开而翻转回到另一条边。
自从使用矢量图像的游戏流行开始,翻转场景已经流行了很长时间,像Asteroids和Pac-Man这样的经典游戏都使用了翻转场景。
实现翻转场景应该是很直接的,然而,需要指出一个可能疏忽并给人们带来麻烦的地方。更新场景时,会检查物体是否完全离开场景,那些完全离开场景的物体应该被平移重新进入场景。如果物体没有被平移以致至少有一个像素和场景边界相交,那么场景管理器很可能会继续认为物体还在边界之外并重复不断平移物体,这将导致物体离开场景然后被清除并再一次重显。
下面WrapTest applet的代码允许用户控制一个飞船并让它在边界翻转。由于场景只包含一个物体,这里没有实现一个单独的场景管理器,读者可以自己试着创建一个。
import java.applet.*;
import java.awt.*;
import java.awt.event.*;
public class WrapTest extends Applet implements Runnable,KeyListener{
//动画线程
private Thread animation;
private BufferedGraphics offscreen;
//跟踪飞船的加速
private Vector2D accel;
private final double MAX_ACCEL=5.0f;
//旋转1度
private final double ONE_RADIAN=Math.toRadians(1.0);
//充当回转物的actor
private Actor2D ship;
public void init(){
//创建飞船和它的角色组
StaticActorGroup group=new StaticActorGroup("ship.gif");
group.init(this);
ship=new StaticActor(group);
addKeyListener(this);
accel=new Vector2D.Double();
offscreen=new VolatileGraphics(this);
animation=new Thread(this);
AnimationStrip.observer=this;
}//init
public void start(){
//启动动画线程
animation.start();
}
public void stop(){
animation=null;
}
public void run(){
Thread t=Thread.currentThread();
while(t==animation){
repaint();
try{
Thread.sleep(10);
}catch(InterruptedException e){
break;
}
}
}//run
public void update(Graphics g){
double accelX=accel.getX();
double accelY=accel.getY();
//分别对物体的(x,y)坐标进行加速
//再次检查最大值
if(accelX!=0){
ship.accelerate(accelX,0);
if(ship.getVel().getX()>MAX_ACCEL){
ship.getVel().setX(MAX_ACCEL);
}else if(ship.getVel().getX()<-MAX_ACCEL){
ship.getVel().setX(-MAX_ACCEL);
}
}
if(accelY!=0){
ship.accelerate(0,accelY);
if(ship.getVel().getY()>MAX_ACCEL){
ship.getVel().setX(MAX_ACCEL);
}else if(ship.getVel().getY()<-MAX_ACCEL){
ship.getVel().setY(-MAX_ACCEL);
}
}
//如果不再加速,让飞船慢下来
if(!ship.getVel().equals(Vector2D.ZERO_VECTOR)&&
accelX==0&&accelY==0){
Vector2D drag=ship.getVel();
drag.normalize();
ship.accelerate(-drag.getX(),-drag.getY());
}
//更新飞船的位置,并在需要时回转
ship.update();
int x=(int)ship.getX();
int y=(int)ship.getY();
int h=(int)ship.getHeight();
int w=(int)ship.getWidth();
if(x>getSize().width){
ship.setX(-w+1);
}else if(x<-w){
ship.setX(getSize().width-1);
}
if(y>getSize().height){
ship.setY(-h+1);
}else if(y<-h){
ship.setY(getSize().height-1);
}
//状态栏中显示飞船的状态
showStatus(ship.getPos()+" "+
(int)Math.toDegrees(ship.getRot())+" "+
ship.getVel());
paint(g);
}
public void paint(Graphics g){
Graphics2D bg=(Graphics2D)offscreen.getValidGraphics();
bg.setBackground(Color.black);
bg.clearRect(0,0,getSize().width,getSize().height);
//绘制飞船
ship.paint(bg);
g.drawImage(offscreen.getBuffer(),0,0,this);
}//paint
public void keyTyped(KeyEvent e){
}
//监视键按下事件,并在需要时对飞船进行加速和旋转
public void keyPressed(KeyEvent e){
if(ship==null)return;
switch(e.getKeyCode()){
//根据"前进"方向对飞船加速或者减速
case KeyEvent.VK_UP:
accel.setX(Math.cos(ship.getRot()));
accel.setY(Math.sin(ship.getRot()));
accel.normalize();
break;
&n