继上一次介绍了《神奇的六边形》的完整游戏开发流程后(可点击这里查看),这次将为大家介绍另外一款魔性游戏《跳跃的方块》的完整开发流程。
(点击图片可进入游戏体验)
因内容太多,为方便大家阅读,所以分多次来讲解。
若要一次性查看所有文档,也可点击这里。
接上回(《跳跃的方块》Part 1)
三. 游戏世界
为了能更快的体验到游戏的主体玩法,调整游戏数值,这里我们先来搭建游戏世界。
建立基础世界
在《跳跃的方块》中,下一关的信息尤为关键。如果能提前获知阻挡点或者通道位置,会为当前的操作提供一定的指导。为了保证所有玩家获取的信息基本一致,屏幕中显示的关卡数量需要严格的控制。
所以这里我们将屏幕的高度通过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.score 60 }; 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 + maxX 75 var blockX = startX + 76 self.game.math.random(blockCfg.minx, cfg.passWidth + blockCfg.maxx - blockSide); 77 // 阻挡块y坐标的生成范围是关卡上边界y + minY 到上边界y + maxY 78 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 // 更新DeadLine 37 self.deadline = Math.max(self.y - screenHeight * JumpingBrick.gameConfig.raiseLimit, self.deadline); 38 39 // 结算分数 40 self.calcScore(); 41 };
经过前面的准备,虚拟游戏世界已经构建完成,下次将讲解如何着手将虚拟世界呈现出来。敬请期待!
其他相关链接
开源免费的HTML5游戏引擎——青瓷引擎(QICI Engine) 1.0正式版发布了!
青瓷引擎之纯JavaScript打造HTML5游戏第二弹——《跳跃的方块》Part 1