代码演示环境:
- 软件环境:Windows 10
- 开发工具:Visual Studio Code
- JDK版本:OpenJDK 15
创建基于Title的地图
在2D游戏中,地图是整体结构,或者我们叫做游戏地图(game map),通常是几个屏幕的宽度表示。有些游戏地图是屏幕的20倍;甚至是100位以上,主要特点是跨屏幕之后,让地图滚动显示,这种类型的游戏又叫做2D平台游戏(2D platform game)。所以平台游戏是指玩家从一个平台跑到另外一平台,在其中需要跑、跳等动作,除此之外,还要避开敌人,以及采血、加体力等动作。本章我们介绍怎样创建基本的地图、地图文件、碰撞侦测、加体力、简单的敌人,以及生成背景的视差滚动效果等。
如果在游戏中如果巨幅图片,这种策略不是最好的解决方案,因为这会产生大量的内存消耗,可能会导致不装载图片。另外,巨幅图片不能说明玩家哪个地图可以使用,哪些地图不可以使用。解决这个问题的一般策略是使用基于title的图片。tile-base地图是把地图分解决成表格,每个单元格包含一小块的图片,或者没有图片。如下图示:
基于tile的地力点有点像使用预制块来创建游戏,不是同的就是这些块的颜色不,并且可以无限制使用颜色。Tile地力的包含的引用属于表格的每个单元格(cell)所有,这样,我们只需要一些小图片就可以实现整个tile的画面显示,并且我们可以根据游戏的需求无限制创建背景画面,而不担心内存的约束问题。大多数游戏都使用16或者32位的图片来表示单元格,我们这里使用是64位的图片,如下图所示:
以上就是基于tile的地图,它有九个块效果可以很容易决定哪些是“solid”部分,哪些是”empty”的地图,这样,你可以知道哪部分地图玩家可以跳,哪部分玩家可以穿墙。下面我们首先实现这个Tile地图。
TileMap类
package com.funfree.arklis.engine.tilegame; import java.awt.Image; import java.util.LinkedList; import java.util.Iterator; import com.funfree.arklis.graphic.*; /** 功能:书写一个类用来表示Tile地图 备注:该类包含的数据用来实现地图、包括小怪。每个tile引用一个图片。当然这些图片会被 使用多次。 */ public class TileMap{ private Image[][] tiles; //表示地图 private LinkedList sprites; //表示游戏中的小怪 private Sprite player; //表示玩家 /** 初始化成员变量时指定地图的宽和高 */ public TileMap(int width, int height){ tiles = new Image[width][height]; sprites = new LinkedList(); } /** 返回地图的宽度 */ public int getWidth(){ return tiles.length; } /** 获取地图的高度 */ public int getHeight(){ return tiles[0].length; } /** 根据指定的坐标来获取相应的tile。如果返回null值,那么表示地图越界了。 */ public Image getTile(int x, int y){ if(x < 0 || x >= getWidth() || y < 0 || y >= getHeight()){ //那么回返null值 return null; }else{ //否则返回tile return tiles[x][y]; } } /** 根据指定的坐标和指定的图片来更换tile */ public void setTile(int x, int y, Image tile){ tiles[x][y] = tile; } /** 返回玩家角色 */ public Sprite getPlayer(){ return player; } /** 指定玩家角色 */ public void setPlayer(Sprite player){ this.player = player; } /** 添加小怪地图中 */ public void addSprite(Sprite sprite){ sprites.add(sprite); } /** 删除该地图中的小怪 */ public void removeSprite(Sprite sprite){ sprites.remove(sprite); } /** 迭代出该地图中所有的小怪,除了玩家本身之外。 */ public Iterator getSprites(){ return sprites.iterator(); } }
除了地图,TileMap还包含在游戏中的小怪,小怪可以地图中任何位置,并且没有边界的限制。如下图:
装载Title的地图
下面我们需要有一个地方来保存该地图,然后在恰当的时候实际创建该地图。Tile地图游戏总是有多个级别的地图,该示例也不例外。如果我们可以很轻松的方式来创建多个地图,那么玩家可以在完成一个地图之后,然后开始下一个地图的游戏情节。我们创建地图时呼叫TileMap的addTile()方法和addSprite()方法,该方法的灵活性非常好,但是,这样编辑地图的级别比较困难,并且代码本身也不是很优雅。所以,大多数的tile游戏有自己的地图编辑器来创建地图。这个地图编辑器是可视化添加tile和小怪到游戏中,这样做的方式是非常简捷的方式。一般把地图保存到中介地图文件中,而这个文件是可以让游戏解析的。这样,我们可定义一个基于文本地图文件,这样我们可以编辑地图,因为tile是被定义在一个表格中的,所以文本文件中的每个字符可以表示一个tile或者是一个小怪/玩家,如下图:
其中”#”表示注释,而其它的表示tile的row。该地图是固定的,所以可以我们可让地图变量,并且可能添加更多的line或者让line更长。那么解析地图的步骤有三步:
- 读取每一行,忽略注释行,然后把每行放到一个集合中
- 创建一个TileMap对象,TileMap的宽度就是集合中最长元素的长度值,而高度就是集合中的line的数量
- 解析每一line中的每个字符,根据该字符添加相应的tile或者sprite到地图中去。
完成这个工作是ResourceManager类。需要注意的是:添加sprite到TileMap中去时,开始,我们需要创建不同的Sprite对象,这样,我们可根据这些“主”怪来克隆小怪;第二,每个sprite不需要尺寸与tile的尺寸一样,所以,我们需要保证每个sprite在tile中的中央,这些事件都在addSprite()方法完成。本章以前的Sprite的位置相同的屏幕,但是在本章示例中,sprite的位置是相同到tile地图。我们使用TileMapRender的静态方法titlesToPixels()来转换tile位置到地图的位置。该函数乘以tile的数值,
int pixelSize = numTiles * TITLE_SIZE;
以上公式可以让sprite移动到地图上的任意一个位置,并且不需要调整tile的边界。也就是说,我们有一个灵活的方式来创建地图和解析它们,以及创建一个TileMap对象。在示例中,所有的地图都在map文件夹中(map1.txt和map2.txt)等等。如果我们需要下一个地图,只需要让代码去寻找下一个地图文件即可;如果没有找到,代码回装载第一个地图。也就是说,我们不需要新地图,只需要在这个目录中删除地图文件即可,也不需要告诉游戏有多少个地图存在。
ResourceManager类
package com.funfree.arklis.engine.tilegame; import java.awt.*; import java.awt.geom.AffineTransform; import java.io.*; import java.util.ArrayList; import javax.swing.ImageIcon; import com.funfree.arklis.graphic.*; import com.funfree.arklis.engine.tilegame.sprites.*; /** 功能:书写一个ResourceManager类用来装载和管理tile图片和“主”怪。游戏中的 小怪是从“主”怪克隆而来。 备注:该类有第四章GameCore所有功能。 */ public class ResourceManager { private ArrayList tiles; //保存文字地图的集合 private int currentMap; //当前地图 private GraphicsConfiguration gc; //显示卡 // 用来被克隆的主怪 private Sprite playerSprite; private Sprite musicSprite; private Sprite coinSprite; private Sprite goalSprite; private Sprite grubSprite; private Sprite flySprite; /** 使用指定的显卡来创建资源管理器对象 */ public ResourceManager(GraphicsConfiguration gc) { this.gc = gc; loadTileImages(); loadCreatureSprites(); loadPowerUpSprites(); } /** 从images目录获取图片 */ public Image loadImage(String name) { String filename = "images/" + name; return new ImageIcon(filename).getImage(); } //获取Mirror图片 public Image getMirrorImage(Image image) { return getScaledImage(image, -1, 1); } //获取反转后的图片 public Image getFlippedImage(Image image) { return getScaledImage(image, 1, -1); } /** 完成图片的转换功能 */ private Image getScaledImage(Image image, float x, float y) { // 设置一个图片转换对象 AffineTransform transform = new AffineTransform(); transform.scale(x, y); transform.translate( (x-1) * image.getWidth(null) / 2, (y-1) * image.getHeight(null) / 2); // 创建透明的图片(不同半透明) Image newImage = gc.createCompatibleImage( image.getWidth(null), image.getHeight(null), Transparency.BITMASK); // 绘制透明图片 Graphics2D g = (Graphics2D)newImage.getGraphics(); g.drawImage(image, transform, null); g.dispose(); return newImage; } /** 从maps目录中装载下一下地图 */ public TileMap loadNextMap() { TileMap map = null; while (map == null) { currentMap++; try { map = loadMap( "maps/map" + currentMap + ".txt"); } catch (IOException ex) { if (currentMap == 1) { // 无装载的地图,返回null值! return null; } currentMap = 0; map = null; } } return map; } /** 重新装载maps目录下的地图文本 */ public TileMap reloadMap() { try { return loadMap( "maps/map" + currentMap + ".txt"); } catch (IOException ex) { ex.printStackTrace(); return null; } } /** 完成装载地图的核心方法 */ private TileMap loadMap(String filename)throws IOException{ ArrayList lines = new ArrayList(); int width = 0; int height = 0; // 读取文本文件中的每一行内容到集合中保存 BufferedReader reader = new BufferedReader( new FileReader(filename)); for(;;) { String line = reader.readLine(); // 没有内容可读取了 if (line == null) { reader.close(); break; } // 添加每一行记录,除了注释 if (!line.startsWith("#")) { lines.add(line); width = Math.max(width, line.length()); } } // 解析每一行,以便创建TileEngine对象 height = lines.size(); TileMap newMap = new TileMap(width, height); for (int y = 0; y < height; y++) { //获取集合中的字符串对象 String line = (String)lines.get(y); //把每个字符中的字符取出来 for (int x = 0; x < line.length(); x++) { char ch = line.charAt(x); // 检查字符是否为A,B,C等字符 int tile = ch - 'A'; //如果是字符A if (tile >= 0 && tile < tiles.size()) { //那么根据tile值来创建地图元素--这里地图实现的核心方法 newMap.setTile(x, y, (Image)tiles.get(tile)); } // 如果字符是表示小怪的,比如0, !或者*,那么分别添加主怪到集合中 else if (ch == 'o') { addSprite(newMap, coinSprite, x, y); }else if (ch == '!') { addSprite(newMap, musicSprite, x, y); }else if (ch == '*') { addSprite(newMap, goalSprite, x, y); }else if (ch == '1') { addSprite(newMap, grubSprite, x, y); }else if (ch == '2') { addSprite(newMap, flySprite, x, y); } } } // 添加玩家到地图中去 Sprite player = (Sprite)playerSprite.clone(); player.setX(TileMapRenderer.tilesToPixels(3)); player.setY(0); newMap.setPlayer(player); //返回新的tile地图对象 return newMap; } /** 添加小怪到地图中去,并且是指定是的位置。 */ private void addSprite(TileMap map,Sprite hostSprite, int tileX, int tileY){ if (hostSprite != null) { // 从“主”怪克隆小怪 Sprite sprite = (Sprite)hostSprite.clone(); // 把小怪置中 sprite.setX( TileMapRenderer.tilesToPixels(tileX) + (TileMapRenderer.tilesToPixels(1) - sprite.getWidth()) / 2); // 在底部调试该小怪 sprite.setY( TileMapRenderer.tilesToPixels(tileY + 1) - sprite.getHeight()); // 添加该小怪到地图中去 map.addSprite(sprite); } } // ----------------------------------------------------------- // 实现装载小怪和图片的代码 // ----------------------------------------------------------- public void loadTileImages() { //保存查找A,B,C等字符,这样可以非常方便的删除images目录下的tiles tiles = new ArrayList(); char ch = 'A'; while (true) { String name = "tile_" + ch + ".png"; File file = new File("images/" + name); if (!file.exists()) { break; } tiles.add(loadImage(name)); ch++; } } public void loadCreatureSprites() { //声明一个图片至少保存4个图片的数组 Image[][] images = new Image[4][]; // 装载左边朝向的图片 images[0] = new Image[] { //装载玩家图片 loadImage("player1.png"), loadImage("player2.png"), loadImage("player3.png"), //装载苍蝇图片 loadImage("fly1.png"), loadImage("fly2.png"), loadImage("fly3.png"), //装载蠕虫图片 loadImage("grub1.png"), loadImage("grub2.png"), }; images[1] = new Image[images[0].length]; images[2] = new Image[images[0].length]; images[3] = new Image[images[0].length]; for (int i = 0; i < images[0].length; i++) { // 装载右朝向的图片 images[1][i] = getMirrorImage(images[0][i]); // 装载左朝向“死亡”图片 images[2][i] = getFlippedImage(images[0][i]); // 装载右朝向“死亡”图片 images[3][i] = getFlippedImage(images[1][i]); } // 创建creature动画对象 Animation[] playerAnim = new Animation[4]; Animation[] flyAnim = new Animation[4]; Animation[] grubAnim = new Animation[4]; for (int i = 0; i < 4; i++) { playerAnim[i] = createPlayerAnim( images[i][0], images[i][1], images[i][2]); flyAnim[i] = createFlyAnim( images[i][3], images[i][4], images[i][5]); grubAnim[i] = createGrubAnim( images[i][6], images[i][7]); } // 创建creature小怪(包括玩家) playerSprite = new Player(playerAnim[0], playerAnim[1], playerAnim[2], playerAnim[3]); flySprite = new Fly(flyAnim[0], flyAnim[1], flyAnim[2], flyAnim[3]); grubSprite = new Grub(grubAnim[0], grubAnim[1], grubAnim[2], grubAnim[3]); } //根据指定的图片来创建玩家动画对象 private Animation createPlayerAnim(Image player1,Image player2, Image player3){ 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); return anim; } //根据指定的图片来创建苍蝇动画对象 private Animation createFlyAnim(Image img1, Image img2,Image img3){ Animation anim = new Animation(); anim.addFrame(img1, 50); anim.addFrame(img2, 50); anim.addFrame(img3, 50); anim.addFrame(img2, 50); return anim; } //根据指定的图片来创建蠕虫动画对象 private Animation createGrubAnim(Image img1, Image img2) { Animation anim = new Animation(); anim.addFrame(img1, 250); anim.addFrame(img2, 250); return anim; } private void loadPowerUpSprites() { // 创建“心”怪用来加体力 Animation anim = new Animation(); anim.addFrame(loadImage("heart1.png"), 150); anim.addFrame(loadImage("heart2.png"), 150); anim.addFrame(loadImage("heart3.png"), 150); anim.addFrame(loadImage("heart2.png"), 150); goalSprite = new PowerUp.Goal(anim); // 创建 "星"怪,用来加分 anim = new Animation(); anim.addFrame(loadImage("star1.png"), 100); anim.addFrame(loadImage("star2.png"), 100); anim.addFrame(loadImage("star3.png"), 100); anim.addFrame(loadImage("star4.png"), 100); coinSprite = new PowerUp.Star(anim); // 创建“音乐”怪用来加速玩家 anim = new Animation(); anim.addFrame(loadImage("music1.png"), 150); anim.addFrame(loadImage("music2.png"), 150); anim.addFrame(loadImage("music3.png"), 150); anim.addFrame(loadImage("music2.png"), 150); musicSprite = new PowerUp.Music(anim); } }
绘制Tile地图
我们知道tile地图大于屏幕,所以只有一部分地图同一时间在屏幕上显示。玩家的移动的原理是:地图滚动来保持玩家在屏幕的中央位置如下图所示:
它的计算公式如下:
int offsetX = screenWidth / 2 – Math.round(player.getX()) – TITLE_SIZE;
该公式把屏幕的水平位置赋值给offsetX变量,这个公式不复杂,所以我们需要确保玩家在离开左边边缘到地图右边边缘时,地图滚动必须停止,这样地图的边缘不会被显示在屏幕上。于是,我们需要这样限制:
int mapWidth = titlesToPixels(map.getWidth()); offsetX = Math.min(offsetX, 0); offsetX = Math.max(offsetX, screenWidth - mapWidth); int offsetY = screenHeight – titlesToPixels(map.getHeight());
完整的计算公式如下图所示:
完整的代码参见TileMapRenderer类。
TileMapRenderer类
package com.funfree.arklis.engine.tilegame; import java.awt.*; import java.util.Iterator; import com.funfree.arklis.graphic.*; import com.funfree.arklis.engine.tilegame.sprites.*; /** 功能:该类用来绘制TileMap到屏幕中,是一个核心工具类。它绘制所有tile、小怪(包括玩家),以及可选的 背景图片到玩家的位置。如果背景图片宽度小于tile地图的宽度,那么背景图片会慢慢移动出现,从而产生 视差效果给玩家。有三个静态方法用来转换像素到tile的位置,返回既然。注意:该类使用tile的尺寸是64位 备注:该类是核心类,它完成tile的呈现功能。 */ public class TileMapRenderer { //指定tile的固定大小 private static final int TILE_SIZE = 64; //使用bit为单位表示tile的尺寸 Math.pow(2,TILE_SIZE_BITS) == TILE_SIZE private static final int TILE_SIZE_BITS = 6; private Image background; /** 转换像素的位置为tile的位置 */ public static int pixelsToTiles(float pixels) { return pixelsToTiles(Math.round(pixels)); } /** 转换像素的位置为tile的位置 */ public static int pixelsToTiles(int pixels) { //把正确的值转换成负值像素 return pixels >> TILE_SIZE_BITS; //或者使用floor方法,而不是power函数计算tile的尺寸 //return (int)Math.floor((float)pixels / TILE_SIZE); } /** 转换tile的位置为像素的位置 */ public static int tilesToPixels(int numTiles) { //该方法移位的目标是加速作用,但是实际上对于现代的处理器效果不大 return numTiles << TILE_SIZE_BITS; //或者使用乘法运算,而不使用power方法计算tile的尺寸 //return numTiles * TILE_SIZE; } /** 设置背景图片 */ public void setBackground(Image background) { this.background = background; } /** 绘制指定的TileMap */ public void draw(Graphics2D g, TileMap map,int screenWidth, int screenHeight){ //取得玩家角色 Sprite player = map.getPlayer(); //获取的宽度 int mapWidth = tilesToPixels(map.getWidth()); //根据玩家当前的位置获取地图滚动的位置(屏幕宽度一半减去当前玩家X坐标减去TILE_SIZE) int offsetX = screenWidth / 2 - Math.round(player.getX()) - TILE_SIZE; offsetX = Math.min(offsetX, 0); offsetX = Math.max(offsetX, screenWidth - mapWidth); // 取得y的偏移量,然后绘制所有sprites和tiles int offsetY = screenHeight - tilesToPixels(map.getHeight()); // 绘制黑色背景 if (background == null || screenHeight > background.getHeight(null)){ g.setColor(Color.black); g.fillRect(0, 0, screenWidth, screenHeight); } // 绘制视觉差效果的背景图片 if (background != null) { int x = offsetX * (screenWidth - background.getWidth(null)) / (screenWidth - mapWidth); int y = screenHeight - background.getHeight(null); g.drawImage(background, x, y, null); } // 绘制可视化的tiles int firstTileX = pixelsToTiles(-offsetX); int lastTileX = firstTileX + pixelsToTiles(screenWidth) + 1; for (int y = 0; y < map.getHeight(); y++) { for (int x = firstTileX; x <= lastTileX; x++) { Image image = map.getTile(x, y); if (image != null) { g.drawImage(image, tilesToPixels(x) + offsetX, tilesToPixels(y) + offsetY, null); } } } // 绘制玩家 g.drawImage(player.getImage(), Math.round(player.getX()) + offsetX, Math.round(player.getY()) + offsetY, null); // 绘制小怪 Iterator i = map.getSprites(); while (i.hasNext()) { Sprite sprite = (Sprite)i.next(); int x = Math.round(sprite.getX()) + offsetX; int y = Math.round(sprite.getY()) + offsetY; g.drawImage(sprite.getImage(), x, y, null); // 当sprite对象在屏幕上时需要唤醒creature对象 if (sprite instanceof Creature && x >= 0 && x < screenWidth){ ((Creature)sprite).wakeUp(); } } } }
绘制Sprites
在绘制tile之后,我们需要绘制sprite图形。这里我们分开来绘制sprite对象,它的思路如下:
- 区分sprite与屏幕尺寸的区域,只在屏幕中可视部分绘制sprite对象。当sprite移动时,它们被存贮在确定的区域
- 保存sprite在一个有序的列表中,保存的顺序是sprite的从左到右水平位置。跟踪列表中的第一个可视化的sprite对象,当sprite移动时,确保该列表是被保存过的
- 实现列表中的每个sprite的run方法,检查它们是可视的
前两个思路不用置疑的,因为在地图有许多sprite对象,但是,不是每张地图都有很多sprite对象,所以你可brute-force方法来检查每个sprite是否可视。也就是遍历列表,绘制出每个sprite对象:
Iterator i = map.getSprites(); while(i.hasNext()){ Sprite sprite = (Sprite)i.next(); int x = Math.round(sprite.getX()) + offsetX; int y = Math.round(sprite.getY()) + offsetY; g.drawImage(sprite.getImage(),x,y,null); }
视差滚动
现在我们已经绘制了tile和sprite对象,接下面我们绘制背景。当我们绘制背景时,我们需要怎样把背景合成为地图,实现的策略如下:
- 保持背景不动,所以我们不需要在滚动地图滚动背景图片
- 使用与地图滚动的相同速率来滚动背景地图
- 滚地背景的速率比滚动地图的速率小一些,那么可以让背景出现远去的效果
第三种方式我们叫”parallax scrolling”(视觉差滚动),视差出现的原理是:从不同的视点让对象在不同的位置出现。比如,我们讲过不会使用巨幅图片表示背景,如果我们要创建背景地图是屏幕的两倍,背景从屏幕的第一个屏幕到第二个屏幕
GameManager类
package com.funfree.arklis.engine.tilegame; import java.awt.*; import java.awt.event.*; import java.util.Iterator; import static java.lang.System.*; import javax.sound.midi.Sequence; import javax.sound.midi.Sequencer; import javax.sound.sampled.AudioFormat; import com.funfree.arklis.sounds.*; import javax.swing.border.*; import javax.swing.*; import com.funfree.arklis.engine.tilegame.*; import com.funfree.arklis.engine.*; import com.funfree.arklis.input.*; import com.funfree.arklis.graphic.*; import com.funfree.arklis.engine.tilegame.sprites.*; /** 功能:该类管理游戏中所有方面,包括碰撞侦测等等。 */ public class GameManager extends GameCore { // 示压缩4400Hz、16位、单声道little-endian顺序 private static final AudioFormat PLAYBACK_FORMAT = new AudioFormat(44100, 16, 1, true, false); private static final int DRUM_TRACK = 1; public static final float GRAVITY = 0.002f; private Point pointCache = new Point(); private TileMap map; private MidiPlayer midiPlayer; private SoundManager soundManager; private ResourceManager resourceManager; private Sound prizeSound; private Sound boopSound; private InputManager inputManager; private TileMapRenderer renderer; //玩家的四个游戏行为 private GameAction moveLeft; private GameAction moveRight; private GameAction jump; private GameAction exit; public void init() { super.init(); // 设置输入管理器 initInput(); // 启动资源管理器 resourceManager = new ResourceManager( screen.getFullScreenWindow().getGraphicsConfiguration()); // 装载资源 renderer = new TileMapRenderer(); renderer.setBackground( resourceManager.loadImage("background.jpg")); // 载入第一个地图 map = resourceManager.loadNextMap(); // 载入声音 soundManager = new SoundManager(PLAYBACK_FORMAT); prizeSound = soundManager.getSound("sounds/prize.wav"); boopSound = soundManager.getSound("sounds/boop2.wav"); // 播放声音 midiPlayer = new MidiPlayer(); Sequence sequence = midiPlayer.getSequence("sounds/music.midi"); midiPlayer.play(sequence, true); toggleDrumPlayback(); } /** 放一个接口方法,以便关联类使用,比如InputComponent类使用。 */ public InputManager getInputManager(){ return inputManager; } /** 添加有游戏行为的名称,以便让InputComponent使用这些文字来修改被影射的键 */ private void addActionConfig(JPanel configPanel, GameAction action){ JLabel label = new JLabel(action.getName(), JLabel.RIGHT); com.funfree.arklis.engine.InputComponent input = new com.funfree.arklis.engine.InputComponent(action,this); configPanel.add(label); configPanel.add(input); getList().add(input);//放到集合中保存起来 } /** 关闭所有的被使用的资源 */ public void stop() { super.stop(); midiPlayer.close(); soundManager.close(); } private void initInput() { moveLeft = new GameAction("左移"); moveRight = new GameAction("右移"); jump = new GameAction("跳", GameAction.DETECT_INITIAL_PRESS_ONLY); exit = new GameAction("退出", GameAction.DETECT_INITIAL_PRESS_ONLY); inputManager = new InputManager( screen.getFullScreenWindow()); inputManager.setCursor(InputManager.INVISIBLE_CURSOR); inputManager.mapToKey(moveLeft, KeyEvent.VK_LEFT); inputManager.mapToKey(moveRight, KeyEvent.VK_RIGHT); inputManager.mapToKey(jump, KeyEvent.VK_SPACE); inputManager.mapToKey(exit, KeyEvent.VK_ESCAPE); } /** 检查输入 */ private void checkInput(long elapsedTime) { if (exit.isPressed()) { stop(); } Player player = (Player)map.getPlayer(); if (player.isAlive()) { float velocityX = 0; if (moveLeft.isPressed()) { velocityX -= player.getMaxSpeed(); } if (moveRight.isPressed()) { velocityX += player.getMaxSpeed(); } if (jump.isPressed()) { player.jump(false); } player.setVelocityX(velocityX); } } //这里最关键的部分是使用呈现器TileMapRenderer类来封装所有复杂的呈现过程。 public void draw(Graphics2D g) { renderer.draw(g, map, screen.getWidth(), screen.getHeight()); } /** 获取当前的地图 */ public TileMap getMap() { return map; } /** 打开/关闭背景midi音乐 */ public void toggleDrumPlayback() { Sequencer sequencer = midiPlayer.getSequencer(); if (sequencer != null) { sequencer.setTrackMute(DRUM_TRACK, !sequencer.getTrackMute(DRUM_TRACK)); } } /** 获取Sprite碰撞的tile对象。只需要Sprite的x和y值需要被修改,但是不是同时修改。 如果返回null表示没有侦测到Sprite的碰撞。该方法是实现游戏的核心方法! */ public Point getTileCollision(Sprite sprite,float newX, float newY){ float fromX = Math.min(sprite.getX(), newX); float fromY = Math.min(sprite.getY(), newY); float toX = Math.max(sprite.getX(), newX); float toY = Math.max(sprite.getY(), newY); // 获取tile的位置 int fromTileX = TileMapRenderer.pixelsToTiles(fromX); int fromTileY = TileMapRenderer.pixelsToTiles(fromY); int toTileX = TileMapRenderer.pixelsToTiles( toX + sprite.getWidth() - 1); int toTileY = TileMapRenderer.pixelsToTiles( toY + sprite.getHeight() - 1); // 检查是否有碰撞的title for (int x = fromTileX; x <= toTileX; x++) { for (int y = fromTileY; y <= toTileY; y++) { if (x < 0 || x >= map.getWidth() || map.getTile(x, y) != null){ // 碰撞发现了,返回碰撞的tile pointCache.setLocation(x, y); return pointCache; } } } // 没有碰撞 return null; } /** 检查两个Sprite是否发生了碰撞。如果两个对象同一种类,那么返回false值。如果 一个Sprites是Creatue类并且是死的,那么也返回false值。 */ public boolean isCollision(Sprite s1, Sprite s2) { // 如果两个Sprite是同一对象,那么返回false值 if (s1 == s2) { return false; } // 如果有一个Sprite是死的,那么返回false值 if (s1 instanceof Creature && !((Creature)s1).isAlive()) { return false; } if (s2 instanceof Creature && !((Creature)s2).isAlive()) { return false; } // 否则获取Sprite的像素位置 int s1x = Math.round(s1.getX()); int s1y = Math.round(s1.getY()); int s2x = Math.round(s2.getX()); int s2y = Math.round(s2.getY()); // 然后检查两个sprite的边界是否交叉 return (s1x < s2x + s2.getWidth() && s2x < s1x + s1.getWidth() && s1y < s2y + s2.getHeight() && s2y < s1y + s1.getHeight()); } /** 获取与指定Sprite碰撞的Sprite对象,如果返回null值,那么表示Sprite没有与指定的Sprite碰撞。 */ public Sprite getSpriteCollision(Sprite sprite) { // 遍历Sprite列表 Iterator i = map.getSprites(); while (i.hasNext()) { Sprite otherSprite = (Sprite)i.next(); if (isCollision(sprite, otherSprite)) { // 如果发现碰撞,那么返回该Sprite对象 return otherSprite; } } // 否则没有碰撞发生 return null; } /** 更新当前地图中的所有Sprite的Animation、position和速率。 */ public void update(long elapsedTime) { Creature player = (Creature)map.getPlayer(); // 如果玩家死亡!那么重新启动地图 if (player.getState() == Creature.STATE_DEAD) { map = resourceManager.reloadMap(); return; } // 获取键盘/鼠标的输入 checkInput(elapsedTime); // 更新玩家 updateCreature(player, elapsedTime); player.update(elapsedTime); // 更新其它的sprite对象 Iterator i = map.getSprites(); while (i.hasNext()) { Sprite sprite = (Sprite)i.next(); if (sprite instanceof Creature) { Creature creature = (Creature)sprite; if (creature.getState() == Creature.STATE_DEAD) { i.remove(); }else { updateCreature(creature, elapsedTime); } } // 普通更新 sprite.update(elapsedTime); } } /** 更新create对象,让所有creaute没有飞行的下降,然后检查它们是否有碰撞 */ private void updateCreature(Creature creature,long elapsedTime){ // 应用加速度 if (!creature.isFlying()) { creature.setVelocityY(creature.getVelocityY() + GRAVITY * elapsedTime); } // 修改x值 float dx = creature.getVelocityX(); float oldX = creature.getX(); float newX = oldX + dx * elapsedTime; Point tile = getTileCollision(creature, newX, creature.getY()); if (tile == null) { creature.setX(newX); }else { // 画出tile边界 if (dx > 0) { creature.setX( TileMapRenderer.tilesToPixels(tile.x) - creature.getWidth()); }else if (dx < 0) { creature.setX( TileMapRenderer.tilesToPixels(tile.x + 1)); } creature.collideHorizontal(); } if (creature instanceof Player) { checkPlayerCollision((Player)creature, false); } // 修改y值 float dy = creature.getVelocityY(); float oldY = creature.getY(); float newY = oldY + dy * elapsedTime; tile = getTileCollision(creature, creature.getX(), newY); if (tile == null) { creature.setY(newY); }else { // 画出tile的边界 if (dy > 0) { creature.setY( TileMapRenderer.tilesToPixels(tile.y) - creature.getHeight()); }else if (dy < 0) { creature.setY( TileMapRenderer.tilesToPixels(tile.y + 1)); } creature.collideVertical(); } if (creature instanceof Player) { boolean canKill = (oldY < creature.getY()); checkPlayerCollision((Player)creature, canKill); } } /** 检查玩家是否与其它的Sprite发生碰撞。如果可以玩家Sprite对象,那么返回true值。 */ public void checkPlayerCollision(Player player,boolean canKill){ //如果玩家死亡 if (!player.isAlive()) { return;//那么不做为 } // 检查玩家是否与其它的sprite发生碰撞 Sprite collisionSprite = getSpriteCollision(player); if (collisionSprite instanceof PowerUp) { acquirePowerUp((PowerUp)collisionSprite); } else if (collisionSprite instanceof Creature) { Creature badguy = (Creature)collisionSprite; if (canKill) { // 杀死小怪,然后让玩家弹起 soundManager.play(boopSound); badguy.setState(Creature.STATE_DYING); player.setY(badguy.getY() - player.getHeight()); player.jump(true); } else { // 否则玩家死亡! player.setState(Creature.STATE_DYING); } } } /** 给玩家加技能、分、体力,然后从地图上移除这些元素。 */ public void acquirePowerUp(PowerUp powerUp) { // 从地图移除poswerUp map.removeSprite(powerUp); if (powerUp instanceof PowerUp.Star) { // 给玩家加点 soundManager.play(prizeSound); }else if (powerUp instanceof PowerUp.Music) { // 修改音乐 soundManager.play(prizeSound); toggleDrumPlayback(); }else if (powerUp instanceof PowerUp.Goal) { // 进入到下一张地图 soundManager.play(prizeSound, new EchoFilter(2000, .7f), false); map = resourceManager.loadNextMap(); } } /** 功能:该方法是一个非常重要的辅助方法--用来创建游戏的菜单 */ private JButton createButton(String name, String toolTip){ //装载图片 String imagePath = "images/menu/" + name + ".png"; ImageIcon iconRollover = new ImageIcon(imagePath); int w = iconRollover.getIconWidth(); int h = iconRollover.getIconHeight(); //给当前按钮设置光标的样式 Cursor cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); //让默认的图片透明 Image image = screen.createCompatibleImage(w, h, Transparency.TRANSLUCENT); Graphics2D g = (Graphics2D)image.getGraphics(); Composite alpha = AlphaComposite.getInstance(AlphaComposite.DST_OVER, .5f); g.setComposite(alpha);//设置透明度 g.drawImage(iconRollover.getImage(),0,0,null); g.dispose(); ImageIcon iconDefault = new ImageIcon(image); //显示被按下的图片 image = screen.createCompatibleImage(w,h,Transparency.TRANSLUCENT); g = (Graphics2D)image.getGraphics(); g.drawImage(iconRollover.getImage(),2,2,null); g.dispose(); ImageIcon iconPressed = new ImageIcon(image); //创建按钮对象 JButton button = new JButton(); button.addActionListener(this); button.setIgnoreRepaint(true); button.setFocusable(false); button.setToolTipText(toolTip); button.setBorder(null); button.setContentAreaFilled(false); button.setCursor(cursor); button.setIcon(iconDefault); button.setRolloverIcon(iconRollover); button.setPressedIcon(iconPressed); return button; } }
在我们计算了offsetX—它表示地图在屏幕中的位置,所以我们需要一个公式把offsetX转换成backgroundX的值。offsetX的范围是从0(地图的左半部分开始)到screenWidth-mapWidth(地图的右半部分),这样匹配backgroundX的范围(从0到screenWidth – backgound.getWidth(null))的值。这样我们可插入点:
int backgroundX = offsetX * (screenWidth – background.getWidth(null)) / (screenWidth - mapWidth); g.drawImage(background, backgroundX,0,null);
使用以上公式的条件是:
- 背景图片的宽度大于屏幕的点阵宽度
- 地图的宽度大背景图片的宽度
最后件事件就是:我们可以不使用图片使用背景,我们可以使用另外一个TileMap来自由绘制背景,它甚至可以与地图一样大,所以我们不必设置滚动的背景,而只需要把两个滚动速率调不一致即可,并且让前景地图是透明的,那么背景就可以看见了。如果我们需要使用背景作为tile图片,那么需要确保右边界匹配左边界,这样才会出无缝滚动效果。
Power-Ups
有一个事件我们必须做,那么就是使用close方法实现游戏中所有的sprite对象,PowerUp类个通用的clone()方法,用来反射克隆的对象,包含它的子类。在该方法中,它选择第一个构造方法来创建新的对象的实例。注意:PowerUp的子类基本不做实际的事件,它们只是一个占位符而忆,这些子类可以实现的功能如下:
- 当玩家需要一个星、一个声音时,但是不需要其它的行为发生
- 当玩家需要玩家播放音乐时,背景音乐可以开和关
- 最后,当玩家需要“goal”时,装载下一个地图
在示例游戏中有两种坏蛋,一个苍蝇和蠕虫。它们一直运行,直到碰到墙为止。在实现坏蛋之前,我们来看一下原图玩家与坏蛋都是面朝左的。所以,当我们需要让玩家向右移动时,那么玩家必须面向右,这就需要我们动态创建玩家面朝向右。
参见前面的ResourceManager类。
Creature类
在游戏中每个坏蛋都四种不同的游戏行为:
- 左移
- 右移
- 面朝左死亡
- 面朝右死亡
这样,我们通过动画类Animation来修改sprite的不同方向和死亡的样子。玩家有三种状态:STATE_NORMAL、STATE_DYING和STATE_DEAD状态。从死亡到已死亡只有一秒钟的时间。该类向Sprite添加了如下功能:
- wakeUp()方法在坏蛋第一次出现在屏幕中时被呼叫,这时,该方法呼叫setVelocityX(-getMaxSpeed())来开始一个坏蛋的移动,如果玩家没有看见坏蛋时,坏蛋不会移动的。
- isAlive()和isFlying方法可以方便检查坏蛋的当前状态。比如坏蛋死亡、是否伤害到玩家,正在飞行的坏蛋不能下降等
- 最后collideVertical()和collideHorizontal方法当坏蛋碰到一个tile时被呼叫。如果垂直碰撞了,那么坏蛋的垂直速率设置为0,水平碰撞一样。
package com.funfree.arklis.engine.tilegame.sprites; import java.lang.reflect.Constructor; import com.funfree.arklis.graphic.*; /** 功能:该类是一个Sprite类,用来表示下降或者死亡的Sprite对象。它有游戏行为四个: 左移、右移、左移死亡和右移死亡 */ public abstract class Creature extends Sprite { /** 从Dying到DEAD的时间: */ private static final int DIE_TIME = 1000; public static final int STATE_NORMAL = 0; public static final int STATE_DYING = 1; public static final int STATE_DEAD = 2; //指定游戏行为 private Animation left; private Animation right; private Animation deadLeft; private Animation deadRight; private int state; private long stateTime; /** 使用指定的游戏行为创建Creature对象 */ public Creature(Animation left, Animation right, Animation deadLeft, Animation deadRight){ super(right); this.left = left; this.right = right; this.deadLeft = deadLeft; this.deadRight = deadRight; state = STATE_NORMAL; } //针对Player和InputManagerTest类修订构造方法--可能无意义:为让编译通过!(版本ver 1.0.1) public Creature(Animation action){ super(action); } /** 从主怪克隆一个副本 */ public Object clone() { // 使用反射技术来创建一个正确的子类对象(呼叫上面的构造方法) Constructor constructor = getClass().getConstructors()[0]; try { return constructor.newInstance(new Object[] { (Animation)left.clone(), (Animation)right.clone(), (Animation)deadLeft.clone(), (Animation)deadRight.clone() }); }catch (Exception ex) { // 应该不会出现,如果出现返回null值 ex.printStackTrace(); return null; } } /** 获取该Creature的最大速度 */ public float getMaxSpeed() { return 0; } /** 当Creature第一次出现在屏幕上时唤醒该creature对象,一般creature开始向左移动 */ public void wakeUp() { if (getState() == STATE_NORMAL && getVelocityX() == 0) { setVelocityX(-getMaxSpeed()); } } /** 得到该Creature的状态:正常STATE_NORMAL、正在死亡STATE_DYING,或者已死亡STATE_DEAD。 */ public int getState() { return state; } /** 设置该Creature的状态:STATE_NORMAL,STATE_DYING或者STATE_DYING */ public void setState(int state) { if (this.state != state) { this.state = state; stateTime = 0; if (state == STATE_DYING) { setVelocityX(0); setVelocityY(0); } } } /** 检查该creature是否还活着 */ public boolean isAlive() { return (state == STATE_NORMAL); } /** 检查该creature是否正在飞行 */ public boolean isFlying() { return false; } /** 在update()方法之前呼叫,如果该creature与水平的tile发生碰撞情况的话。 */ public void collideHorizontal() { setVelocityX(-getVelocityX()); } /** 在update()方法之前呼叫,如果出现垂直碰撞的话 */ public void collideVertical() { setVelocityY(0); } /** 更新该creature对象 */ public void update(long elapsedTime) { // 选择正确的游戏行为 Animation newAnim = anim; if (getVelocityX() < 0) { newAnim = left; }else if (getVelocityX() > 0) { newAnim = right; }if (state == STATE_DYING && newAnim == left) { newAnim = deadLeft; }else if (state == STATE_DYING && newAnim == right) { newAnim = deadRight; } // 更新游戏行为 if (anim != newAnim) { anim = newAnim; anim.start(); }else { anim.update(elapsedTime); } // 变更死亡状态 stateTime += elapsedTime; if (state == STATE_DYING && stateTime >= DIE_TIME) { setState(STATE_DEAD); } } }
Player类
该类向Creature类添加了跳(jump)的功能。大多数情况下,我们希望玩家跳,如果玩家不在地上,所以重写setY()和方法collideVertical()方法可以追踪该玩家是否在地上。如果玩家在地上,那么该玩家可以跳,另外我们可强制玩家跳(jump(true))。
package com.funfree.arklis.engine.tilegame.sprites; import com.funfree.arklis.graphic.*; /** 功能:这是一个玩家 备注:根据第四章的Player来修订的类。 */ public class Player extends Creature { private static final float JUMP_SPEED = -.95f; //设置跳的速度 //版本ver 1.0.1添加开始 public static final int STATE_NORMAL = 0; public static final int STATE_JUMPING = 1; public static final float SPEED = .3F; public static final float GRAVITY = .002F; private boolean onGround; //标识是否在地上 private int floorY; private int state; //版本ver 1.0.1添加结束 public Player(Animation left, Animation right, Animation deadLeft, Animation deadRight){ super(left, right, deadLeft, deadRight); } //版本ver 1.0.1添加开始 public Player(Animation animation){ super(animation); state = STATE_NORMAL; } public int getState(){ return state; } public void setState(int state){ this.state = state; } /** 设置floor的位置,不管玩家是否开始跳,或者已经着陆 */ public void setFloorY(int floorY){ this.floorY = floorY; setY(floorY); } /** 让玩家产生的跳的动作 */ public void jump(){ setVelocityY(-1); state = STATE_JUMPING; } //版本ver 1.0.1添加结束 public void collideHorizontal() { setVelocityX(0); } public void collideVertical() { // 检查是否碰撞到地上 if (getVelocityY() > 0) { onGround = true; } setVelocityY(0); } public void setY(float y) { // 检查是否落下 if (Math.round(y) > Math.round(getY())) { onGround = false; } super.setY(y); } public void wakeUp() { // do nothing } /** 如果玩家在地下,或者强制中是true时,那么让玩家跳 */ public void jump(boolean forceJump) { if (onGround || forceJump) { onGround = false; setVelocityY(JUMP_SPEED); } } //获取最大的移动速度 public float getMaxSpeed() { return 0.5f; } /** 更新玩家的位置和动作,也可以设置玩家的状态为NORMAL状态,如果玩家已经着陆了 在这里可能无意义,因为需要让编译通过。版本ver 1.0.1添加方法 */ public void update(long elapsedTime){ //设置水平位置的速率(下降效果) if(getState() == STATE_JUMPING){ setVelocityY(getVelocityY() + GRAVITY * elapsedTime); } //移动玩家 super.update(elapsedTime); //检查玩家是否着陆 if(getState() == STATE_JUMPING && getY() >= floorY){ setVelocityY(0); setY(floorY); setState(STATE_NORMAL); } } }
碰撞侦测
游戏中我们必须确保玩家和坏蛋不能穿墙而过,但可以平台上跳。每次我们移动玩家或者坏蛋时,我们需要检查该creature是否与其它的tile发生了碰撞;如果是,那么我们必须调整相应的位置。因为我们使用基于tile的地图,所以碰撞侦测技术比较容易实现。理论上说,一个sprite可以一次跨多个tile,并且可一次可以定位在四个不同tile上。所以, 需要不断检查当前tile是否有sprite占用,并且每个sprite将要占用的下一下tile对象。在GameManager类的getTileCollision()方法就中完成该任务的。它检查一个sprite从原来的位置到新的位置是否跨跳了任何solid tile对象,如果是这样的情况,那么返回与sprite碰撞的tile的位置,否则返回null值。另外,该方法可以处理地图左边界或者右边界是否与create发生了碰撞,以保证creature对象在地图。
注意:该方法在处理一个sprite在多个帧之间跨跳多个tile时不是很完美,需要使用第十一章的sprite-to-environment碰撞侦测来完美,但是该代码可以处理大多数的碰撞情况了。
处理一个碰撞
如果sprite往下移,然后再右移时,我们会侦测到sprite会被碰撞tile的情况。同时,上面的sprite右移也会碰撞到tile,直观看很容易解决这些问题:让两个sprite左移一点就可以了。但是怎样计算左移、不上、不下和右移偏移量?
为解决这个问题,首先我们把sprite的移支分解成两个部分:水平移动和垂直移动。所以,我们首先解决水平移动的碰撞侦测。
如果一个碰撞被侦测到,那么只需要让sprite沿原路修正一下即可,从而限制了该sprite与title的边缘。
Sprite的水平移动碰撞解决之后,那么同样对于sprite的垂直移动问题,也是一样的解决方式。代码参见GameManager类的updateCreature方法。
updateCreature()方法也可以用于没有的飞行的creature的下降碰撞处理。因为下降总是会影响creature的,但是如果creature站在title上,那么该效果不明显,因为creature与tile之间是标准的碰撞。当一个碰撞被侦测到并且被校正之后,我们会呼叫creature的collideVertical()和collideHorizontal()方法。通过这些方法用来修改或者保存该creature的速率,防止再次发生碰撞。对于sprite的碰撞,如果sprite是一个player(玩家),那么它与其它sprite碰撞时,比如power-up和坏蛋在此示例游戏程序中,我们忽略这些碰撞,只是调整玩家的侦测碰撞,这样可以我们看到哪个玩家的sprite边界与另外一个sprite的边界发生了重叠。完成该功能的方法是GameManager类中的isCollision()方法。因为TileMap包含了所有的sprite列表,所以我们可以从这个列表中检查它们与否与玩家发生了碰撞。如下图
完整代码参见GameManager类中的getSpriteCollision方法。
当玩家与一个sprite发生完全碰撞时,如果该sprite是power-up,那么你可给玩家加点、播放声音,或者所有power-up支持的技能。如果sprite是一个坏蛋,那么我们可杀死坏蛋或者玩家。这时玩家落下,或者跳下时,换句话说,玩家的垂直运行是增加的:
boolean canKill = (oldY < player.getY());
当玩家行走时碰撞到坏蛋,那么玩家死亡。
现在我们已经把创建了所有的游戏的元素,比如键盘输入、声音、音乐等这些基本元素。
完成游戏
- GameManager类处理键盘处理、更新sprite,提供碰撞侦测,以及播放声音和音乐
- TileMapRenderer类绘制地图、视觉差背景和sprite对象
- ResourceManager类装载图片,创建动画和sprite图形。
在控制台中运行命令:java –jar bee.jar
或者双击bee.jar即可运行程序!
总结完成Java 2D游戏比较简单,我们只要完成三个核心类的书写,那么就有具备一个游戏引擎的功能,剩下的就是研究、扩展我们的Player类和Creature类就中完成各种游戏中的人物、NPC的功能。
图片来源:http://www.lyouxi.com/ 游戏盒子