继上一次介绍了《神奇的六边形》的完整游戏开发流程后(),这次将为大家介绍另外一款魔性游戏《跳跃的方块》的完整开发流程。
(点击图片可进入游戏体验)
因内容太多,为方便大家阅读,所以分多次来讲解。
若要一次性查看所有文档,也可。
接上回()
三. 游戏世界
为了能更快的体验到游戏的主体玩法,调整游戏数值,这里我们先来搭建游戏世界。
建立基础世界
在《跳跃的方块》中,下一关的信息尤为关键。如果能提前获知阻挡点或者通道位置,会为当前的操作提供一定的指导。为了保证所有玩家获取的信息基本一致,屏幕中显示的关卡数量需要严格的控制。
所以这里我们将屏幕的高度通过UIRoot映射为一个固定值:960,添加一个锁定屏幕旋转方向的脚本,并创建游戏的根节点game,设置game节点铺满屏幕。
操作如下所示:分步构建世界
- 游戏配置
- 构建世界逻辑
- 控制展示游戏世界
(一)游戏配置
设置可调整参数
这个游戏中,一些参数会严重影响用户体验,需要进行不停的尝试,以找到最合适的设置。所以,这里将这些参数提取出来,群策群力,快速迭代出最终版本。
分析游戏内容后,将游戏数据分为两类:1. 关卡数据 如何生成关卡、如何生成阻挡。把这些数据配置到一个Excel文件JumpingBrick.xls中,并拷贝到Assets/excel目录下。内容如下:
2. 物理信息 游戏使用的物理碰撞比较简单,而且移动的方块自身有旋转45度,不太适合直接使用引擎的物理插件。故而这里直接设置方块上升的速度,下落的加速度等物理信息,由游戏脚本自己处理。
新建一个脚本GameConfig.js,内容如下:
1 /* 2 * 游戏配置 3 */ 4 var GameConfig = qc.defineBehaviour('qc.JumpingBrick.GameConfig', qc.Behaviour, function() { 5 var self = this; 6 7 // 设置到全局中 8 JumpingBrick.gameConfig = self; 9 10 // 等级配置 11 self.levelConfigFile = null; 12 13 // 游戏使用的重力 14 self.gravity = -1600; 15 16 // 点击后左右移动的速度 17 self.horVelocity = 100; 18 19 // 点击后上升的速度 20 self.verVelocity = 750; 21 22 // 点击后上升速度的持续时间 23 self.verVelocityKeepTime = 0.001; 24 25 // 锁定状态下竖直速度 26 self.verLockVelocity = -200; 27 28 // 块位置超过屏幕多少后,屏幕上升 29 self.raiseLimit = 0.5; 30 31 // 层阻挡高度 32 self.levelHeight = 67; 33 34 // 层间距 35 self.levelInterval = 640; 36 37 // 普通阻挡的边长 38 self.blockSide = 45; 39 40 // 方块的边长 41 self.brickSide = 36; 42 43 // 计算碰撞的最大时间间隔 44 self.preCalcDelta = 0.1; 45 46 // 关卡颜色变化步进 47 self.levelColorStride = 5; 48 49 // 关卡颜色的循环数组 50 self.levelColor = [0x81a3fc, 0xeb7b49, 0xea3430, 0xf5b316, 0x8b5636, 0x985eb5]; 51 52 // 保存配置的等级信息 53 self._levelConfig = null; 54 55 self.runInEditor = true; 56 }, { 57 levelConfigFile: qc.Serializer.EXCELASSET, 58 gravity : qc.Serializer.NUMBER, 59 horVelocity : qc.Serializer.NUMBER, 60 verVelocity : qc.Serializer.NUMBER, 61 verVelocityKeepTime : qc.Serializer.NUMBER, 62 raiseLimit : qc.Serializer.NUMBER, 63 levelHeight : qc.Serializer.NUMBER, 64 levelInterval : qc.Serializer.NUMBER, 65 blockSide : qc.Serializer.NUMBER, 66 preCalcDelta : qc.Serializer.NUMBER, 67 levelColorStride : qc.Serializer.NUMBER, 68 levelColor : qc.Serializer.NUMBERS 69 }); 70 71 GameConfig.prototype.getGameWidth = function() { 72 return this.gameObject.width; 73 }; 74 75 GameConfig.prototype.awake = function() { 76 var self = this; 77 78 // 将配置表转化下,读取出等级配置 79 var rows = self.levelConfigFile.sheets.config.rows; 80 var config = []; 81 var idx = -1, len = rows.length; 82 while (++idx < len) { 83 var row = rows[idx]; 84 // 为了方便配置,block部分使用的是javascript的数据定义语法 85 // 通过eval转化为javascript数据结构 86 row.block = eval(row.block); 87 config.push(row); 88 } 89 90 self._levelConfig = config; 91 92 // 计算出方块旋转后中心到顶点的距离 93 self.brickRadius = self.brickSide * Math.sin(Math.PI / 4); 94 }; 95 96 /* 97 * 获取关卡配置 98 */ 99 GameConfig.prototype.getLevelConfig = function(level) {100 var self = this;101 var len = self._levelConfig.length;102 while (len--) {103 var row = self._levelConfig[len];104 if (row.start > level || (row.end > 0 && row.end < level)) {105 continue;106 }107 return row;108 }109 return null;110 };
(二)构建世界逻辑
《跳跃的方块》是一个无尽的虚拟世界,世界的高度不限,宽度根据显示的宽度也不尽相同。为了方便处理显示,我们设定一个x轴从左至右,y轴从下至上的坐标系,x轴原点位于屏幕中间。如下图所示:
基础设定
- 方块的坐标为方块中心点的坐标。
- 方块的初始位置为(0, 480)。
- 关卡的下边界的y轴坐标值为960。保证第一个屏幕内,看不到关卡;而当方块跳动后,关卡出现。
- 关卡只需要生成可通行范围的矩形区域,阻挡区域根据屏幕宽度和可通行区域计算得到。
- 阻挡块需要生成实际占据的矩形区域。
创建虚拟世界
创建虚拟世界的管理脚本:GameWorld.js。代码内容如下:
1 var GameWorld = qc.defineBehaviour('qc.JumpingBrick.GameWorld', qc.Behaviour, function() { 2 var self = this; 3 4 // 设置到全局中 5 JumpingBrick.gameWorld = self; 6 7 // 创建结束监听 8 self.onGameOver = new qc.Signal(); 9 10 // 分数更新的事件11 self.onScoreChanged = new qc.Signal();12 13 self.levelInfo = [];14 15 self.runInEditor = true;16 }, {17 18 });19 20 GameWorld.prototype.awake = function() {21 var self = this;22 // 初始化状态23 this.resetWorld();24 };
游戏涉及到的数据
在虚拟世界中,方块有自己的位置、水平和竖直方向上的速度、受到的重力加速度、点击后上升速度保持的时间等信息。每次游戏开始时,需要重置这些数据。 现在大家玩游戏的时间很零碎,很难一直关注在游戏上,所以当游戏暂停时,我们需要保存当前的游戏数据。这样,玩家可以再找合适的时间来继续游戏。
先将重置、保存数据、恢复数据实现如下:1 /** 2 * 设置分数 3 */ 4 GameWorld.prototype.setScore = function(score, force) { 5 if (force || score > this.score) { 6 this.score = score; 7 this.onScoreChanged.dispatch(score); 8 } 9 };10 11 /**12 * 重置世界13 */14 GameWorld.prototype.resetWorld = function() {15 var self = this;16 17 // 方块在虚拟世界坐标的位置18 self.x = 0;19 self.y = 480;20 21 // 方块在虚拟世界的速度值22 self.horV = 0;23 self.verV = 0;24 25 // 当前受到的重力26 self.gravity = JumpingBrick.gameConfig.gravity;27 28 // 维持上升速度的剩余时间29 self.verKeepTime = 0;30 31 // 死亡线的y轴坐标值32 self.deadline = 0;33 34 // 已经生成的关卡35 self.levelInfo = [];36 37 // 是否游戏结束38 self.gameOver = false;39 40 // 当前的分数41 self.setScore(0, true);42 };43 44 /**45 * 获取要保存的游戏数据46 */47 GameWorld.prototype.saveGameState = function() {48 var self = this;49 var saveData = {50 deadline : self.deadline,51 x : self.x,52 y : self.y,53 horV : self.horV,54 verV : self.verV,55 gravity : self.gravity,56 verKeepTime : self.verKeepTime,57 levelInfo : self.levelInfo,58 gameOver : self.gameOver,59 score : self.score60 };61 return saveData;62 };63 64 /**65 * 恢复游戏66 */67 GameWorld.prototype.restoreGameState = function(data) {68 if (!data) {69 return false;70 }71 var self = this;72 self.deadline = data.deadline;73 self.x = data.x;74 self.y = data.y;75 self.horV = data.horV;76 self.verV = data.verV;77 self.gravity = data.gravity;78 self.verKeepTime = data.verKeepTime;79 self.levelInfo = data.levelInfo;80 self.gameOver = data.gameOver;81 self.setScore(data.score, true);82 return true;83 };
动态创建关卡数据
世界坐标已经确定,现在开始着手创建关卡信息。 因为游戏限制了每屏能显示的关卡数,方块只会和本关和下关的阻挡间产生碰撞,所以游戏中不用在一开始就创建很多的关卡。而且游戏中方块不能下落出屏幕,已经通过的,并且不在屏幕的内的关卡,也可以删除,不予保留。
所以,我们根据需求创建关卡信息,创建完成后保存起来,保证一局游戏中,关卡信息是固定的。 代码如下:1 /** 2 * 获取指定y轴值对应的关卡 3 */ 4 GameWorld.prototype.transToLevel = function(y) { 5 // 关卡从0开始,-1表示第一屏的960区域 6 return y < 960 ? -1 : Math.floor((y - 960) / JumpingBrick.gameConfig.levelInterval); 7 }; 8 9 /**10 * 获取指定关卡开始的y轴坐标11 */12 GameWorld.prototype.getLevelStart = function(level) {13 return level < 0 ? 0 : (960 + level * JumpingBrick.gameConfig.levelInterval);14 };15 16 /**17 * 删除关卡数据18 */19 GameWorld.prototype.deleteLevelInfo = function(level) {20 var self = this;21 22 delete self.levelInfo[level];23 };24 25 26 /**27 * 获取关卡信息28 */29 GameWorld.prototype.getLevelInfo = function(level) {30 if (level < 0) 31 return null;32 33 var self = this;34 var levelInfo = self.levelInfo[level];35 36 if (!levelInfo) {37 // 不存在则生成38 levelInfo = self.levelInfo[level] = self.buildLevelInfo(level);39 }40 return levelInfo;41 };42 43 /**44 * 生成关卡45 */46 GameWorld.prototype.buildLevelInfo = function(level) {47 var self = this,48 gameConfig = JumpingBrick.gameConfig,49 blockSide = gameConfig.blockSide,50 levelHeight = gameConfig.levelHeight;51 52 var levelInfo = {53 color: gameConfig.levelColor[Math.floor(level / gameConfig.levelColorStride) % gameConfig.levelColor.length],54 startY: self.getLevelStart(level),55 passArea: null,56 block: []57 };58 59 // 获取关卡的配置60 var cfg = JumpingBrick.gameConfig.getLevelConfig(level);61 62 // 根据配置的通行区域生成关卡的通行区域63 var startX = self.game.math.random(cfg.passScopeMin, cfg.passScopeMax - cfg.passWidth);64 levelInfo.passArea = new qc.Rectangle(65 startX, 66 0, 67 cfg.passWidth,68 levelHeight);69 70 // 生成阻挡块71 var idx = -1, len = cfg.block.length;72 while (++idx < len) {73 var blockCfg = cfg.block[idx];74 // 阻挡块x坐标的生成范围是可通行区域的左侧x + minX 到 右侧x + maxX75 var blockX = startX + 76 self.game.math.random(blockCfg.minx, cfg.passWidth + blockCfg.maxx - blockSide);77 // 阻挡块y坐标的生成范围是关卡上边界y + minY 到上边界y + maxY78 var blockY = JumpingBrick.gameConfig.levelHeight + 79 self.game.math.random(blockCfg.miny, blockCfg.maxy - blockSide);80 81 levelInfo.block.push(new qc.Rectangle(82 blockX,83 blockY,84 blockSide,85 blockSide));86 }87 return levelInfo;88 };
分数计算
根据设定,当方块完全通过关卡的通行区域后,就加上一分,没有其他的加分途径,于是,可以将分数计算简化为计算当前完全通过的最高关卡。代码如下:
1 /** 2 * 更新分数 3 */ 4 GameWorld.prototype.calcScore = function() { 5 var self = this; 6 7 // 当前方块所在关卡 8 var currLevel = self.transToLevel(self.y); 9 // 当前关卡的起点10 var levelStart = self.getLevelStart(currLevel);11 12 // 当方块完全脱离关卡通行区域后计分13 var overLevel = self.y - levelStart - JumpingBrick.gameConfig.levelHeight - JumpingBrick.gameConfig.brickRadius;14 var currScore = overLevel >= 0 ? currLevel + 1 : 0;15 self.setScore(currScore);16 };
物理表现
方块在移动过程中,会被给予向左或者向右跳的指令。下达指令后,方块被赋予一个向上的速度,和一个水平方向的速度,向上的速度会保持一段时间后才受重力影响。 理清这些效果后,可以用下面这段代码来处理:
1 /** 2 * 控制方块跳跃 3 * @param {number} direction - 跳跃的方向 < 0 时向左跳,否则向右跳 4 */ 5 GameWorld.prototype.brickJump = function(direction) { 6 var self = this; 7 // 如果重力加速度为0,表示方块正在靠边滑动,只响应往另一边跳跃的操作 8 if (self.gravity === 0 && direction * self.x >= 0) { 9 return;10 }11 // 恢复重力影响12 self.gravity = JumpingBrick.gameConfig.gravity;13 self.verV = JumpingBrick.gameConfig.verVelocity;14 self.horV = (direction < 0 ? -1 : 1) * JumpingBrick.gameConfig.horVelocity;15 self.verKeepTime = JumpingBrick.gameConfig.verVelocityKeepTime;16 };17 18 /**19 * 移动方块20 * @param {number} delta - 经过的时间21 */22 GameWorld.prototype.moveBrick = function(delta) {23 var self = this;24 25 // 首先处理水平方向上的移动26 self.x += self.horV * delta;27 28 // 再处理垂直方向上得移动29 if (self.verKeepTime > delta) {30 // 速度保持时间大于经历的时间31 self.y += self.verV * delta;32 self.verKeepTime -= delta;33 }34 else if (self.verKeepTime > 0) {35 // 有一段时间在做匀速运动,一段时间受重力加速度影响36 self.y += self.verV * delta + 0.5 * self.gravity * Math.pow(delta - self.verKeepTime, 2);37 self.verV += self.gravity * (delta - self.verKeepTime);38 self.verKeepTime = 0;39 }40 else {41 // 完全受重力加速度影响42 self.y += self.verV * delta + 0.5 * self.gravity * Math.pow(delta, 2);43 self.verV += self.gravity * delta;44 }45 };
碰撞检测
这样方块就开始运动了,需要让它和屏幕边缘、关卡通道、阻挡碰撞,产生不同的效果。
- 当方块与关卡阻挡碰撞后,结束游戏。
- 当方块与屏幕下边缘碰撞后,结束游戏。
- 当方块与屏幕左右边缘碰撞后,将不受重力加速度影响,沿屏幕边缘做向下的匀速运动,直到游戏结束,或者接收到一个向另一边边缘跳跃的指令后恢复正常。
旋转45°后的方块与矩形的碰撞:
- 当方块的包围矩形和矩形不相交时,不碰撞。
- 当方块的包围矩形和矩形相交时。如下图分为两种情况处理。
代码实现如下:
1 /** 2 * 掉出屏幕外结束 3 */ 4 GameWorld.GAMEOVER_DEADLINE = 1; 5 /** 6 * 碰撞结束 7 */ 8 GameWorld.GAMEOVER_BLOCK = 2; 9 10 /** 11 * 块与一个矩形阻挡的碰撞检测 12 */ 13 GameWorld.prototype.checkRectCollide = function(x, y, width, height) { 14 var self = this, 15 brickRadius = JumpingBrick.gameConfig.brickRadius; 16 17 var upDis = self.y - y - height; // 距离上边距离 18 if (upDis >= brickRadius) 19 return false; 20 21 var downDis = y- self.y; // 距离下边距离 22 if (downDis >= brickRadius) 23 return false; 24 25 var leftDis = x - self.x; // 距离左边距离 26 if (leftDis >= brickRadius) 27 return false; 28 29 var rightDis = self.x - x - width; // 记录右边距离 30 if (rightDis >= brickRadius) 31 return false; 32 33 // 当块中点的y轴值,在阻挡的范围内时,中点距离左右边的边距小于brickRadius时相交 34 if (downDis < 0 && upDis < 0) { 35 return leftDis < brickRadius && rightDis < brickRadius; 36 } 37 38 // 当块的中点在阻挡范围上时 39 if (upDis > 0) { 40 return leftDis < brickRadius - upDis && rightDis < brickRadius - upDis; 41 } 42 // 当块的中点在阻挡范围下时 43 if (downDis > 0) { 44 return leftDis < brickRadius - downDis && rightDis < brickRadius - downDis; 45 } 46 return false; 47 }; 48 49 /** 50 * 碰撞检测 51 */ 52 GameWorld.prototype.checkCollide = function() { 53 var self = this; 54 55 // game节点铺满了屏幕,那么节点的宽即为屏幕的宽 56 var width = this.gameObject.width; 57 var brickRadius = JumpingBrick.gameConfig.brickRadius; 58 var leftEdge = -0.5 * width; 59 var rightEdge = 0.5 * width; 60 61 // 下边缘碰撞判定,方块中心的位置距离下边缘的距离小于方块的中心到顶点的距离 62 if (this.deadline - self.y > brickRadius) { 63 return GameWorld.GAMEOVER_DEADLINE; 64 } 65 66 // 左边缘判定,方块中心的位置距离左边缘的距离小于方块的中心到顶点的距离 67 if (self.x - leftEdge < brickRadius) { 68 self.x = leftEdge + brickRadius; 69 self.horV = 0; 70 self.verV = JumpingBrick.gameConfig.verLockVelocity; 71 self.gravity = 0; 72 } 73 // 右边缘判定,方块中心的位置距离右边缘的距离小于方块的中心到顶点的距离 74 if (rightEdge - self.x < brickRadius) { 75 self.x = rightEdge - brickRadius; 76 self.horV = 0; 77 self.verV = JumpingBrick.gameConfig.verLockVelocity; 78 self.gravity = 0; 79 } 80 81 // 方块在世界中,只会与当前关卡的阻挡和下一关的阻挡进行碰撞 82 var currLevel = self.transToLevel(self.y); 83 for (var idx = currLevel, end = currLevel + 2; idx < end; idx++) { 84 var level = self.getLevelInfo(idx); 85 if (!level) 86 continue; 87 88 var passArea = level.passArea; 89 // 检测通道左侧和右侧阻挡 90 if (self.checkRectCollide( 91 leftEdge, 92 passArea.y + level.startY, 93 passArea.x - leftEdge, 94 passArea.height) || 95 self.checkRectCollide( 96 passArea.x + passArea.width, 97 passArea.y + level.startY, 98 rightEdge - passArea.x - passArea.width, 99 passArea.height)) {100 return GameWorld.GAMEOVER_BLOCK;101 }102 103 // 检测本关的阻挡块104 var block = level.block;105 var len = block.length;106 while (len--) {107 var rect = block[len];108 if (self.checkRectCollide(rect.x, rect.y + level.startY, rect.width, rect.height)) {109 return GameWorld.GAMEOVER_BLOCK;110 }111 }112 }113 114 return 0;115 };
添加时间处理
到此,游戏世界的基本逻辑差不多快完成了。现在加入时间控制。
1 /** 2 * 游戏结束的处理 3 */ 4 GameWorld.prototype.doGameOver = function(type) { 5 var self = this; 6 self.gameOver = true; 7 self.onGameOver.dispatch(type); 8 }; 9 10 /**11 * 更新逻辑处理12 * @param {number} delta - 上一次计算到现在经历的时间,单位:秒13 */14 GameWorld.prototype.updateLogic = function(delta) {15 var self = this,16 screenHeight = self.gameObject.height;17 if (self.gameOver) {18 return;19 }20 // 将经历的时间分隔为一小段一小段进行处理,防止穿越21 var calcDetla = 0;22 while (delta > 0) {23 calcDetla = Math.min(delta, JumpingBrick.gameConfig.preCalcDelta);24 delta -= calcDetla;25 // 更新方块位置26 self.moveBrick(calcDetla);27 // 检测碰撞28 var ret = self.checkCollide();29 if (ret !== 0) {30 // 如果碰撞关卡阻挡或者碰撞死亡线则判定死亡31 self.doGameOver(ret);32 return;33 }34 }35 36 // 更新DeadLine37 self.deadline = Math.max(self.y - screenHeight * JumpingBrick.gameConfig.raiseLimit, self.deadline);38 39 // 结算分数40 self.calcScore();41 };
经过前面的准备,虚拟游戏世界已经构建完成,下次将讲解如何着手将虚拟世界呈现出来。敬请期待!
其他相关链接