2.5D游戏,虽然在外观上近似于3D游戏,却又不是严格意义上讲的3D游戏,故此2.5D游戏又常被称为[伪3D游戏]。
在笔者的观念中,2.5D严格上说并不能算是一种技术,而只是一种实现方式或者说应用手段。大多数时候,游戏公司之所以会采取2.5D方式开发游戏,常是为解决3D及2D技术混用而采取的一种折中,而并不是说这种手段有多么先进。2.5D游戏的实现方式虽然很多,但主要无非有三类,即:2D角色+3D场景(比如RO1)、3D角色+2D场景(比如生化复刻版)、2D角色+2D场景(比如仙剑),另外有些纯3D游戏出于操作性考虑而固定视角,勉强擦了个2.5D的边,但严格上讲依旧是3D。
除了2D角色+2D场景是利用斜45度的2D图片来“冒充”3D效果以外,其它组合方式大多有真正的3D演算参与其中,不过是出于开发效率或者游戏风格等外部因素考虑而混入2D,可以说是一种产生在2D向3D演变过程中的过渡物。
使用2.5D方式开发游戏的好处在于,能够免去纯3D图像渲染所涉及的海量运算,从而减低不必要的系统资源损耗,并且在处理单纯的2D画面时,贴图也明显较系统渲染为快,最主要的是——开发周期明显较3D游戏更短。
当然,缺点也很明显,最核心的一点就够——不伦不类,非驴非马。
具体到2.5D游戏开发,根据选择的技术不同,也会产生不同的实现手段,笔者在[Java中2.5D游戏(斜45度角)的设计与实现]系列博文中内所要介绍的,是最简单,同时也最方便的2D角色+2D场景——伪45度角方式,不需要的可以无视此文了,具体到真3D与2D的混合,笔者将留到介绍JME时再去讲解。
在正式开始本回之前,我们先来简要介绍下不同维度空间的特点:
零维,简单讲就是没有维度,表现形式上就是一个[点],没有长度,更没有高度。实际上,很多人认为宇宙最初就是个零维空间。
一维,多体现为一条[直线],也可以理解为一个只有长度,而没有高度的无限线性空间。
二维,即我们常说的[平面],由X及Y两点交织而成,即只有长与宽,典型的二维空间存在方式是数据表格。
三维,三维即[立体],若谁不能理解三维,请随意向显示器四周看看,举目所及全是三维空间。从技术角度解释,三维就是在二维的长、宽基础上再加个高度(厚度)形成的体积面,典型的三维存在方式就是我们这个世界。
四维,四维空间是个非常模糊的概念,实际上它象征着[n维]。在物理学及数学概念中,一个n的序列可以被理解为一个n维空间中的位置,当n=4时,所有这样的位置的集合都叫做四维空间,但是当n-1或者n+1时,它又会自然过渡成其它空间。这种空间与我们熟悉并在其中居住的三维空间不同,因为它多一个维度。这个额外的维度既可以理解成时间,也可以直接理解为空间的第四维,即第四空间维度。当我们说到四维空间时,常会扯出天堂、地狱、阴阳界等超自然理论,实在玄之又玄,笔者也不知道它究竟怎样表示才好……
由于人类生存在三维空间,我们周围的空间自然也都具有三个维度(上下(长)、左右(宽)、前后(厚度)),用中式思维解释就是世有八荒(又称八方,即“东、西、南、北、东南、东北、西南、西北”八个方向),人居六合(即“上、下、东、西、南、北”六个方位),我们也都很自然的会用“八荒六合”来进行方位判定,当然,最先决的方位判定条件是“我在其中”。
如果一个人能在“八荒六合”之内与我一样行动,我当然会认为他与我一样练成了八荒六合唯我独尊功……咳,我是说认为他与我同样是一个立体的人,而不是一张纸片,嗯嗯(=_=|||)。
同样的道理,在游戏中如果一个2D Sprite的行动模式与3D Sprite一致,那么用户便很容易“误认”此单元为3D,而非2D。原因就在于,人眼是极好蒙蔽的,比如好莱坞早期大片中就经常使用纸制建筑来冒充城镇或者某个名声古迹;即便游戏2.5D游戏中没有实际的3D坐标及多面计算,只要能给人眼以“距离感”或者说“立体感”,我们也会认为这个游戏是三维存在的,而没人会去关心它是否真正使用了3D渲染方式。
换句话说,只要我们创建的游戏单元“看上去”能“行八荒”,“游六合”,也就是看上去它的行为是3D立体的,玩家就会认为角色正处于一个三维空间之内,而不是穿越到某个2D世界。
那么,这个“看上去移动”的效果要如何达到呢?
我们用下图作为示例:
通过上图中我们可以发现,此图中角色的单元动作被分解为八种不同类型,即上、左、右、下、左上、右上、左下、右下,而此八种动作正好对应着“八荒”中的“北、西、东、南、西北、东北、西南、东南”。由于人眼所能观测到的运行是相对的,只要背景或者角色中任意一者发生移动,我们都会产生“移动”的“错觉”。所以我们并不追求角色的实际3D运动,而只是令角色单元能做出对应“上、左、右、下、左上、右上、左下、右下”这八方向的动作,在普通人眼中,便与角色移动向“北、西、东、南、西北、东北、西南、东南”这八个方向无异。
因此,在2D制作2.5D效果时,每个角色的动作单元实际上都只是一幅分帧小图罢了。
如何判定对应的单元图像呢?
具体到单元动作的显示判定,和游戏采取的斜视角产生方式息息相关,大体上分可分两类,即非等距坐标实现与等距坐标实现。
1、非等距坐标实现:
逻辑如下图:
以0点为常模(参照物),在2D地图上“错位”绘制角色,每次角色移动时偏移相应坐标,配合图片产生45度移动错觉。
优点:在编程上极容易实现,并且更利于地图及角色的联合操作。
缺点:如果不对单元与地图的交织部分进行细节处理,很容易产生移动“生硬”感,并且很难融入一些较复杂的地形判定。
2、等距坐标实现:
逻辑如下图:
实际上此图没有任何坐标变化,而是在不改变2D图形X,Y坐标系的基础上,等距转换坐标点位置为斜45度时的状态,由于坐标系经过等距转换,所以实际操作中与2D无疑。
优点:除了坐标转换外,基本上还是2D那一套,纯2D时怎样处理便怎样处理。
缺点:为了配合倾斜后的X,Y坐标拼接,大部分平面图必须转换为45度图,当然这是美工的事(^^)(PS:虽然也可以在平面图上自动换算出所需的斜视图形,但细节处通常不够理想,而且耗费不必要的运算资源,还是交给美工直接切出成品图最好,鄙人大原则就是能麻烦美工就不劳驾程序员(^^)),不过象笔者这样个人研究就超麻烦……
在本系列博文在后面会涉及到此部分,在这里先给出一个基本概念。
首先,要转换坐标为斜45度时位置,至少需要以下参数。
1、mapX(地图X坐标)
2、mapY(地图Y坐标)
3、mapMaxY (Y轴的最大纵深)
4、tileWidth(每块小图宽度)
5、tileHeight(每块小图高度)
而后我们才可以根据基础参数换算坐标位置:
screenX (屏幕坐标X)
screenY (屏幕坐标Y)
screenX = (mapX - mapY + mapMaxY) * (tileWidth / 2);
screenY = (mapX + mapY) * (tileHeight / 2);
bevelMapX (倾视的X坐标)
bevelMapY (倾视的Y坐标)
bevelMapX = ((screenY / tileHeight) + (screenX - (mapMaxY * tileWidth/2)) / tileWidth);
bevelMapY = ((screenY / tileHeight) - (screenX - (mapMaxY * tileWidth/2)) / tileWidth);
这时得到的bevelMapX及bevelMapY,就是斜45度时的绘图位置,以此坐标绘制准备好的斜视图,就自然会呈现在斜视情况下的X,Y点位置上。
由于本例中为非等距实现,也就是在2D地图上直接位移角色单元到斜点,故此不需要额外的进行地图坐标换算处理,但是人物坐标则需偏移。
下面开始我们用代码示例说话:
Role.java(负责描述一个角色单元的行为)
package org.loon.game.simple.alldirection.rpg; import java.awt.Color; import java.awt.Graphics; import java.util.List; import org.loon.game.simple.alldirection.GraphicsUtils; public class Role implements Config { private static final int SPEED = 4; public static final double PROB_MOVE = 0.02; private int x, y; private int px, py; private int direction; private int count; private boolean isMoving; private int movingLength; private int moveType; private String message; private Thread threadAnime; private RpgSprite sprite; private RpgMap map; private String name; private String partyName; private int ioffsetX; private int ioffsetY; private boolean autoFinder; private boolean isLoop; public Role(String fileName, int x, int y, int direction, int moveType, RpgMap map) { sprite = new RpgSprite(fileName); this.x = x; this.y = y; px = x * CS; py = y * CS; ioffsetX = sprite.getImageWidth() - CS; ioffsetY = sprite.getImageHeight() - CS; this.direction = direction; this.count = 0; this.moveType = moveType; this.map = map; this.roleLoop(); } private void roleLoop() { isLoop = true; threadAnime = new Thread(new AnimationThread()); threadAnime.start(); } public void stop() { isLoop = false; threadAnime = null; } public void setXandY(Cell2D cell) { setXandY(cell.x(), cell.y()); } public void setXandY(int x, int y) { this.x = x; this.y = y; } public Cell2D getCell2D() { return new Cell2D(x, y); } private synchronized void redress() { if (autoFinder) { move(); } if (px < 0) { px = 0; } if (py < 0) { py = 0; } if (px > map.getWidth() - CS) { px = map.getWidth() - CS; x = map.getRow() - 1; } if (py > map.getHeight() - CS) { py = map.getHeight() - CS; y = map.getCol() - 1; } } public synchronized void draw(Graphics g, int offsetX, int offsetY) { redress(); int aspect = 0; switch (direction) { case UP: aspect = RpgSprite.UPPER_RIGHT; break; case DOWN: aspect = RpgSprite.LOWER_LEFT; break; case LEFT: aspect = RpgSprite.UPPER_LEFT; break; case RIGHT: aspect = RpgSprite.LOWER_RIGHT; break; case TUP: aspect = RpgSprite.UP; break; case TDOWN: aspect = RpgSprite.DOWN; break; case TLEFT: aspect = RpgSprite.LEFT; break; case TRIGHT: aspect = RpgSprite.RIGHT; break; } int nx = px + offsetX - ioffsetX; int ny = py + offsetY - ioffsetY; g.drawImage(sprite.getMove(aspect)[count], nx, ny, null); if (name != null) { int fontHeight = g.getFontMetrics().getHeight(); int nameFontWidth = g.getFontMetrics().stringWidth(name); int size = 20; int mx = nx + ioffsetX - nameFontWidth / 2; int my = ny + ioffsetY + size + fontHeight; GraphicsUtils.drawStyleString(g, name, mx, my, Color.black, Color.white); GraphicsUtils.drawStyleString(g, partyName, mx, my + size, Color.black, Color.white); } } public synchronized void autoDirection(List startPath) { Cell2D cell1 = (Cell2D) startPath.get(0); try { if (startPath.size() > 1) { Cell2D cell2 = (Cell2D) startPath.get(1); int sx = cell2.x() - cell1.x(); int sy = cell2.y() - cell1.y(); direction = Field2D.getDirection(sx, sy); } } finally { startPath.remove(0); } } public synchronized boolean move() { switch (direction) { case LEFT: if (moveLowerLeft()) { return true; } break; case RIGHT: if (moveLowerRight()) { return true; } break; case UP: if (moveUpperRight()) { return true; } break; case DOWN: if (moveUpperLeft()) { return true; } break; case TLEFT: if (moveLeft()) { return true; } break; case TRIGHT: if (moveRight()) { return true; } break; case TUP: if (moveUp()) { return true; } break; case TDOWN: if (moveDown()) { return true; } break; } return false; } protected boolean moveLeft() { int nextX = x - 1; int nextY = y; if (nextX < 0) { nextX = 0; } if (!map.isHit(nextX, nextY)) { px -= Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x--; px = x * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveRight() { int nextX = x + 1; int nextY = y; if (nextX > map.getCol() - 1) { nextX = map.getCol() - 1; } if (!map.isHit(nextX, nextY)) { px += Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x++; px = x * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveUp() { int nextX = x; int nextY = y - 1; if (nextY < 0) { nextY = 0; } if (!map.isHit(nextX, nextY)) { py -= Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { y--; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveDown() { int nextX = x; int nextY = y + 1; if (!map.isHit(nextX, nextY)) { py += Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { y++; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveLowerLeft() { int nextX = x - 1; int nextY = y - 1; if (nextX < 0) { nextX = 0; } if (nextY < 0) { nextY = 0; } if (!map.isHit(nextX, nextY)) { px -= Role.SPEED; py -= Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x--; px = x * CS; y--; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveLowerRight() { int nextX = x + 1; int nextY = y + 1; if (nextX > map.getRow() - 1) { nextX = map.getRow() - 1; } if (nextY > map.getCol() - 1) { nextY = map.getCol() - 1; } if (!map.isHit(nextX, nextY)) { px += Role.SPEED; py += Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x++; px = x * CS; y++; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveUpperLeft() { int nextX = x - 1; int nextY = y + 1; if (nextX < 0) { nextX = 0; } if (nextY > map.getCol() - 1) { nextY = map.getCol() - 1; } if (!map.isHit(nextX, nextY)) { px -= Role.SPEED; py += Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x--; px = x * CS; y++; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } protected boolean moveUpperRight() { int nextX = x + 1; int nextY = y - 1; if (nextX > map.getRow() - 1) { nextX = map.getRow() - 1; } if (nextY < 0) { nextY = 0; } if (!map.isHit(nextX, nextY)) { px += Role.SPEED; py -= Role.SPEED; movingLength += Role.SPEED; if (movingLength >= CS) { x++; px = x * CS; y--; py = y * CS; isMoving = false; return true; } } else { isMoving = false; px = x * CS; py = y * CS; } return false; } /** * 触发事件 * * @return */ public Role talkWith() { int nextX = 0; int nextY = 0; switch (direction) { case LEFT: nextX = x - 1; nextY = y; break; case RIGHT: nextX = x + 1; nextY = y; break; case UP: nextX = x; nextY = y - 1; break; case DOWN: nextX = x; nextY = y + 1; break; } Role chara; chara = map.getRoles().roleCheck(nextX, nextY); if (chara != null) { switch (direction) { case LEFT: chara.setDirection(RIGHT); break; case RIGHT: chara.setDirection(LEFT); break; case UP: chara.setDirection(DOWN); break; case DOWN: chara.setDirection(UP); break; } } return chara; } public int getX() { return x; } public int getY() { return y; } public int getPx() { return px; } public int getPy() { return py; } public void setDirection(int dir) { direction = dir; } public synchronized boolean isMoving() { return isMoving; } public synchronized void setMoving(boolean flag) { isMoving = flag; movingLength = 0; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public int getMoveType() { return moveType; } private class AnimationThread extends Thread { public void run() { while (isLoop) { if (count < sprite.getSize()) { count++; } else { count = 0; } try { Thread.sleep(300); } catch (InterruptedException e) { } } } } public boolean isAutoFinder() { return autoFinder; } public void setAutoFinder(boolean autoFinder) { this.autoFinder = autoFinder; } public int getIoffsetX() { return ioffsetX; } public int getIoffsetY() { return ioffsetY; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPartyName() { return partyName; } public void setPartyName(String partyName) { this.partyName = partyName; } }
RprMap.java(负责描述角色单元集合在地图上的具体位置)
package org.loon.game.simple.alldirection.rpg; import java.awt.Color; import java.awt.Graphics; import java.io.IOException; import java.util.List; import org.loon.game.simple.alldirection.GraphicsUtils; import org.loon.game.simple.alldirection.LSystem; public class RpgMap implements Config { private ImageMapFactory imageMap; private Roles roles; private boolean showGrid; private Field2D map2d; private int firstTileX; private int firstTileY; private int lastTileX; private int lastTileY; public RpgMap(String imageFile, String mapFile) { try { imageMap = new ImageMapFactory(imageFile, mapFile); } catch (IOException e) { throw new RuntimeException(e); } this.map2d = new Field2D(imageMap.getMap()); this.roles = new Roles(); } public void addRole(Role role) { roles.addChara(role); } public Role getHero() { return roles.getHero(); } public void setupHero(Role hero) { roles.mainHero(hero); } public synchronized void draw(Graphics g, int offsetX, int offsetY) { firstTileX = pixelsToTiles(-offsetX); lastTileX = firstTileX + pixelsToTiles(LSystem.WIDTH) + 1; lastTileX = Math.min(lastTileX, getRow()); firstTileY = pixelsToTiles(-offsetY); lastTileY = firstTileY + pixelsToTiles(LSystem.HEIGHT) + 1; lastTileY = Math.min(lastTileY, getCol()); for (int i = firstTileX; i < lastTileX; i++) { for (int j = firstTileY; j < lastTileY; j++) { g.drawImage(imageMap.getImages()[i][j], tilesToPixels(i) + offsetX, tilesToPixels(j) + offsetY, null); if (showGrid) { if (imageMap.getMap()[j][i] == 1) { g.setColor(Color.white); g.drawRect(tilesToPixels(i) + offsetX, tilesToPixels(j) + offsetY, CS - 2, CS - 2); GraphicsUtils.setAlpha(g, 0.5d); g.fillRect(tilesToPixels(i) + offsetX, tilesToPixels(j) + offsetY, CS - 2, CS - 2); GraphicsUtils.setAlpha(g, 1.0d); } else if (imageMap.getMap()[j][i] == -1) { g.setColor(Color.blue); g.drawRect(tilesToPixels(i) + offsetX, tilesToPixels(j) + offsetY, CS - 2, CS - 2); GraphicsUtils.setAlpha(g, 0.3d); g.fillRect(tilesToPixels(i) + offsetX, tilesToPixels(j) + offsetY, CS - 2, CS - 2); GraphicsUtils.setAlpha(g, 1.0d); } } } } roles.draw(g, offsetX, offsetY); } public int getSelfFirstX() { return firstTileX; } public int getSelfFirstY() { return firstTileY; } public int getSelfLastX() { return lastTileX; } public int getSelfLastY() { return lastTileY; } public int getSelfFirstWidth() { return tilesToPixels(firstTileX); } public int getSelfFirstHeight() { return tilesToPixels(firstTileY); } public int getSelfLastWidth() { return tilesToPixels(lastTileX); } public int getSelfLastHeight() { return tilesToPixels(lastTileY); } public List findPath(Role hero, Cell2D goal) { return AStarFinder.find(map2d, hero.getCell2D(), goal); } public void showGrid(boolean show) { this.showGrid = show; } public ImageMapFactory getFactory() { return imageMap; } public boolean isHit(int x, int y) { try { int[][] map = imageMap.getMap(); if (map[y][x] == 1) { return true; } if (roles.isHit(x, y)) { return true; } return false; } catch (Exception e) { return false; } } public static int pixelsToTiles(double pixels) { return (int) Math.floor(pixels / CS); } public static int tilesToPixels(int tiles) { return tiles * CS; } public int getRow() { return imageMap.getMapWidth(); } public int getCol() { return imageMap.getMapHeight(); } public int getWidth() { return imageMap.getImageWidth(); } public int getHeight() { return imageMap.getImageHeight(); } public Roles getRoles() { return roles; } }
Main.java(初始配置及程序运行)
package org.loon.game.simple.alldirection.main; import org.loon.game.simple.alldirection.GameCursor; import org.loon.game.simple.alldirection.GameFrame; import org.loon.game.simple.alldirection.rpg.Config; import org.loon.game.simple.alldirection.rpg.Role; import org.loon.game.simple.alldirection.rpg.RpgLayout; import org.loon.game.simple.alldirection.rpg.RpgMap; /** * Copyright 2008 - 2009 * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. * * @project loonframework * @author chenpeng * @email:[email protected] * @version 0.1 */ public class Main { public static void main(String[] args) { java.awt.EventQueue.invokeLater(new Runnable() { public void run() { GameFrame frame = new GameFrame( "Java 2.5D游戏开发中的八方走法实现
示例截取图如下所示:
本回提供的源码内容包括:八方走法(支持鼠标及键盘)、地图移动、角色碰撞、NPC随机行动,详细请下载参看.
至于脚本处理、事件触发,对话框、场景转换、角色对战等部分将在以后逐步讲解。
下载地址如下:http://code.google.com/p/loon-simple/downloads/list
PS:这个jar有315KB(源码在jar内),但代码实际并不多,空间都是图占的……
——————楚河汉界分割线——————
一直说写关于JME的文章,直到最近两天才着手弄了点JME的介绍及例子,等五一整理下再发出来。(鄙人的懒性是很惊人的,比如07年底我就想装个VS2008,结果到今天也没安上……)
另外到我五一时准备把前一阵说过的TLOH发出来,这东西实际上就是非组件化的LoonFramework-Game应用,发的目的就是让大家帮着改改,差不多就定形了,等到发LoonFramework-Game包时我可不想和某些东西似的不同版本间接口与函数都没个连贯性……
还有随着笔者手头积累的Java游戏代码越来越多,功能越来越复杂,又产生了想写个类似于RpgMakerXP的游戏编辑器的念头,于是业余时间大体上是这样度过的:找编辑器资料(学习其业务实现)-〉找解释器(比如TVP使用的TJS脚本,参考其脚本模式)-〉下游戏(下载同人游戏,学习其打包及应用模式)-〉玩游戏(很多日寇的同人游戏也让人上瘾,-_-|||),这样一晃就三个多星期,笔者一直以来的致命缺点就是心太散,注意力超级不集中|||……