风暴幻想游戏-家园系统设计
之前做过一个家园系统,今天和大家分享的是,关于在做家园系统的时候遇到的一些问题和当时的解决的方法。
遇到的几个问题:
1. 地图层问题。
(1)这些东西如何在场景中摆放:地面,围墙,小窝,人,未解锁区域等等。
(2)地图大小限制和地图最小单位的大小等因素。
(3)我们需要保存的数据有哪些。
当时的解决方法:
1)幻想的地图层,把地图分成好几块加载的。策划根据地图编辑器生成了对应的文件,底层加载地图的时候会加载这些地图。但是家园地图是一个全新的地图。而且不是地图编辑器编辑的。但是底层有不提供修改支持。因此原先那几块地图就用了空的文件进行修改。相当于进入这个场景的时候,是一个黑的世界。
2)地面,围墙,小窝,人,未解锁区域等,所有的家园场景的东西,我们通过不同的层加载到场景里。写了一个HomeMapDrawObject.lua 下面是它的初始化函数。
1 function HomeMapDrawObject:__init() 2 HomeMapDrawObject.number = HomeMapDrawObject.number + 1 3 self.core_pos = cc.vec2(0, 0) 4 5 --集合 6 self.coreNodes = {} 7 8 --保持对象应用,方可访问其成员方法 9 self.spriteLayers = {} 10 -- self.spriteLayers[cc.RenderQueue.GRQ_SHADOW] = {} 11 -- self.spriteLayers[cc.RenderQueue.GRQ_BUILD_AND_PLAYER] = {} 12 13 --草地 14 local gressNode = cc.Node:create() 15 self.coreNodes[HomeMapDrawObject.Layer.GRESS] = gressNode 16 HandleRenderUnit:GetCoreScene():addToRenderGroup(gressNode, cc.RenderQueue.GRQ_SHADOW) 17 18 --阴影 19 local shadowNode = cc.Node:create() 20 self.coreNodes[HomeMapDrawObject.Layer.SHOADOW] = shadowNode 21 HandleRenderUnit:GetCoreScene():addToRenderGroup(shadowNode, cc.RenderQueue.GRQ_SHADOW + 1) 22 23 --身体 24 local bodyNode = cc.Node:create() 25 self.coreNodes[HomeMapDrawObject.Layer.BODY] = bodyNode 26 HandleRenderUnit:GetCoreScene():addToRenderGroup(bodyNode, cc.RenderQueue.GRQ_BUILD_AND_PLAYER - 2) 27 28 --其他 29 local otherNode = cc.Node:create() 30 self.coreNodes[HomeMapDrawObject.Layer.OTHER] = otherNode 31 HandleRenderUnit:GetCoreScene():addToRenderGroup(otherNode, cc.RenderQueue.GRQ_BUILD_AND_PLAYER - 1) 32 33 end
我们将家园场景中的对象都称做家园碎片,用一个lua去维护。对于不同的类型,不同的点击效果做不同的处理等等。
下面是HomeMapPiece.lua
1 PriorityQueue = PriorityQueue or BaseClass() 2 3 function PriorityQueue:__init(infoVo) 4 self.list = {} 5 self.hash = {} 6 self.L = bit.lshift 7 self.And = bit.band 8 self.R = bit.rshift 9 10 if infoVo then 11 self:Push(infoVo) 12 end 13 end 14 15 function PriorityQueue:__delete() 16 self.list = nil 17 self.hash = nil 18 end 19 20 function PriorityQueue:Push(infoVo) 21 table.insert(self.list, infoVo) 22 23 local child = #self.list 24 local root = self.R(child, 1) 25 while child > 1 and self.list[root].F > self.list[child].F do 26 self.list[root], self.list[child] = self.list[child], self.list[root] 27 28 self:SetHash(self.list[root].value, root) 29 self:SetHash(self.list[child].value, child) 30 child = root 31 root = self.R(child, 1) 32 end 33 end 34 35 function PriorityQueue:Pop() 36 local firstInfoVo = self.list[1] 37 local length = #self.list 38 --特判 39 if not self.list[length] then return firstInfoVo end 40 if length == 1 then 41 self.list = {} 42 return firstInfoVo 43 end 44 45 local lastInfoVo = self.list[length] 46 table.remove(self.list, #self.list) 47 length = length - 1 48 49 local root, child = 1, 2 50 while child <= length do 51 --找出左右最小的值 52 if child + 1 <= length and self.list[child + 1].F < self.list[child].F then 53 child = child + 1 54 end 55 if lastInfoVo.F < self.list[child].F then 56 break 57 else 58 self.list[root] = self.list[child] 59 self:SetHash(self.list[root].value, root) 60 61 root = child 62 child = self.L(child, 1) 63 end 64 end 65 self.list[root] = lastInfoVo 66 self:SetHash(self.list[root].value, root) 67 68 return firstInfoVo 69 end 70 71 function PriorityQueue:SetHash(value, root) 72 self.hash[value] = root 73 end 74 75 function PriorityQueue:GetInfoVo(index) 76 return self.list[self.hash[index]] 77 end 78 79 function PriorityQueue:IsEmpty() 80 if self.list[1] then 81 return false 82 end 83 return true 84 end
下面是HomePieceVo.lua
1 --家园地图碎片vo 2 3 HomePieceVo = HomePieceVo or BaseClass() 4 5 function HomePieceVo:__init() 6 self.insId = 0 --家具唯一id 7 self.type = HomeMapManager.TYPE.GRASS --碎片类型 8 self.typeId = 0 --类型id 9 self.name = "" --家具名字 10 self.resId = 0 --形象id 11 self.sideType = 0 --布置区域(1室内2室外3任意) 12 self.width = 0 --宽 13 self.height = 0 --长 14 self.direction = 3 --方向 15 self.logicPosX = 0 --逻辑坐标点x(和服务端交互的) 16 self.logicPosY = 0 --逻辑坐标点y 17 18 self.leftNum = 0 --左边的数字 19 self.rightNum = 0 --右边数字 20 self.upNum = 0 --上方数字 21 self.bottomNum = 0 --下方数字 22 23 self.pointIndex = 0 --x,y转化成的数字 24 self.containPos = {} --覆盖的坐标点 25 self.shadowPos = {} --阴影区的坐标点(当人物走到这个点的时候,他会显示成半透明) 26 27 self.caveType = 1 --类型1坐骑/2宠物/3精灵 28 self.animalTypeId = 0 --牲口id 29 self.caveResId = 0 --资源id 30 self.masterId = 0 --牲口主人id 31 self.foodId = 0 --口粮id 32 self.feedRoleId = 0 --饲养员id 33 self.harvestTime = 0 --收获时间 34 self.expReward = 0 --经验奖励 35 self.propArr = {} --道具奖励 36 end 37 38 function HomePieceVo:__delete() 39 end 40 41 function HomePieceVo:ReadFromProtocal() 42 self.insId, 43 self.typeId, 44 self.name, 45 self.resId, 46 self.logicPosX, 47 self.logicPosY, 48 self.direction, 49 self.height, 50 self.width 51 = UserMsgAdapter.ReadFMT("IIsICCCHH") 52 53 local length = UserMsgAdapter.ReadFMT("h") 54 55 --处理占据的点 56 for i = 1, length do 57 local vo = {} 58 vo.x, 59 vo.y 60 = UserMsgAdapter.ReadFMT("cc") 61 self.containPos[i] = vo 62 end 63 64 --功能建筑部分(小窝) 65 self.caveType, --类型1坐骑/2宠物/3精灵 66 self.animalTypeId, --牲口(坐骑/宠物/精灵)id 67 self.caveResId, --牲口资源id 68 self.masterId, --牲口主人id 69 self.foodId, --口粮id 70 self.feedRoleId, --饲养员id 71 self.harvestTime, --收获时间 72 self.expReward --经验奖励 73 = UserMsgAdapter.ReadFMT("ciiiiiii") 74 length = UserMsgAdapter.ReadFMT("h") 75 for i = 1, length do 76 local tmp = {} 77 tmp.propId, --道具id 78 tmp.propNumber, --道具数量 79 tmp.bind --绑定 80 = UserMsgAdapter.ReadFMT("iic") 81 self.propArr[i] = tmp 82 end 83 84 local configPiece = Config.HomeFurniture[self.typeId] 85 --处理阴影区的点 86 local shadowHeight = configPiece.hight or 0 87 for i = 1, shadowHeight do 88 for k = 1, #self.containPos do 89 local vo = {} 90 vo.x = self.containPos[k].x 91 vo.y = self.containPos[k].y - i 92 93 table.insert(self.shadowPos, vo) 94 end 95 end 96 local homeMapManager = HomeMapManager:getInstance() 97 --家具类型 98 if configPiece then 99 self.type = homeMapManager:getPieceType(configPiece.type) 100 end 101 --寻路的时候构图 102 self.pointIndex = homeMapManager:getPointIndex(self.logicPosX, self.logicPosY) 103 end 104 105 --[[ 106 进入场景初始化处理草地信息 107 ]] 108 function HomePieceVo:sceneInitDispose(configVo) 109 self.logicPosX = configVo[1] 110 self.logicPosY = configVo[2] 111 self.typeId = configVo[3] 112 self.insId = configVo[4] 113 114 local configPiece = Config.HomeFurniture[self.typeId] 115 self.resId = configPiece.res_id 116 self.sideType = configPiece.sideType 117 self.width = configPiece.width 118 self.height = configPiece.length 119 self.name = configPiece.name 120 121 local homeMapManager = HomeMapManager:getInstance() 122 --碎片类型 123 self.type = homeMapManager:getPieceType(configPiece.type) 124 self.pointIndex = homeMapManager:getPointIndex(self.logicPosX, self.logicPosY) 125 126 --占据的格子 127 local posVo = {} 128 posVo.x = self.logicPosX 129 posVo.y = self.logicPosY 130 self.containPos[1] = posVo 131 end 132 133 function HomePieceVo:getHomeLogicPos() 134 return self.homeLogicPosX, self.homeLogicPosY 135 end 136 137 function HomePieceVo:setHomeLogicPos(x, y) 138 self.homeLogicPosX = x 139 self.homeLogicPosY = y 140 end 141 142 function HomePieceVo:getLogicPos() 143 return self.logicPosX, self.logicPosY 144 end 145 146 function HomePieceVo:setLogicPos(x, y) 147 self.logicPosX = x 148 self.logicPosY = y 149 end 150 151 function HomePieceVo:getVar(var) 152 return self[var] 153 end 154 155 function HomePieceVo:setVar(var, value) 156 if not self[var] then 157 print("ERROR HomePieceVo no has var:", var) 158 return 159 end 160 self[var] = value 161 end
3)地图大小限制和地图最小单位的大小等因素。当时策划的需求是最小的地图是3600*3600,以后可能会扩大道7200*7200的地图。
幻想的原先的地图的逻辑块大小是60*30代表一个格子。所有如果在原先基础上进行寻路和拾取就变成了60*120, 120*240的地图。
当时美术那边提出说,要使用菱形的地板,从美术角度来说,看起来的效果会好一些。最后使用的方案是这样的:
图中的坐标系是服务器的坐标系,一个菱形面积 等于 二个坐标系格子面积。这样的摆放,通过观察会发现一些规律。
规律1:例如第一排的点A, B 他们的坐标分表是(1, 1),(3,1);第二排的点C,D 他们的坐标分别是(2,2),(4,2);x,y满足奇偶性。
因为我们摆放的点不存在例如 (2,3),(5,6)类似这样的点。
规律2:因为我们需要进行拾取判定,我们的点击可能落在任意坐标系格子内。但是可分成8种情况进行讨论(一个菱形区域中)。
下面讨论其中的1种情况。
1.例如图中E点,根据图中可以看出,当我们点击E点的时候,实际上我们要算出,当前我们点击的是(7,3)这个点的菱形区域。
E点的周围4个点分别是(min_x, min_y), (min_x, max_y), (max_x, min_y), (max_x, max_y)。(这些点都可以通过e点向上取整或者向下取整得出)
这4个点中的一个点就是当前我们想求出的菱形区域的中心点。
但是此时,连接的是(min_x, max_y), (max_x, min_y)这两个点,构成了菱形的一条边。
经过观察,我们知道
E点的min_x = 6, max_y = 3, 和 E点相类的点F ,它的情况是 min_x = 7, max_y = 4, 可再根据几个点进行观察总结。
我们可以得出
当 min_x, max_y 不是同奇数或者同偶数的时候,连接的是 (min_x, max_y), (max_x, min_y)这两个点,构成了菱形的一条边。
当 min_x, max_y 是同奇数或者同偶数的时候,连接的是 (min_x, min_y), (max_x, max_y)这两个点,构成了菱形的一条边。
2.由于1.得出的判断,我们可以排除2个点(因为已经构成了菱形的一条边了),剩下2个点,我们要如何判断呢?
使用线性规划,根据1.求出的这2个点,我们构建直线,判断E点是在这条线段上方还说下方。这样,我们就可以得出需要找到的那个点了。
剩下的几种情况类似,同理进行讨论。
通过这样,我们就可以进行对拾取和放置场景碎片(草地,小窝等)进行操作。
4)我们需要保存和维护的数据有哪些呢?
1 self.pieceList = {} --家园碎片对象(草地上的东西等) 2 self.map = {} --家园地图 3 self.roadMap = {} --可行走的区域维护 4 self.shadowMap = {} --阴影区(人行走在这个区域会调整透明度) 5 self.tmpList = {} --临时展示的区域 6 self.lockList = {} --解锁格子信息
2.拾取问题。
如果通过模拟点击能判断到是具体哪一个地图最小元素。
下面代码是 通过服务端逻辑坐标,计算出当前点击的是哪一个地图逻辑块的中心位置的坐标。
1 --[[ 2 @brief: 逻辑坐标转像家园地图坐标 3 4 @param: logic_x 逻辑横坐标 5 logic_y 逻辑纵坐标 6 scale_size 缩放比例 7 @return: 逻辑坐标 8 ]] 9 function TerrainHelper:ToHomeMapPos(logic_x, logic_y, scale_size) 10 --对特殊点的判断。 11 local home_logic_x, home_logic_y = self:logicToHomePos(logic_x, logic_y, scale_size) 12 13 local min_x, max_x = math.floor(home_logic_x), math.ceil(home_logic_x) 14 local min_y, max_y = math.floor(home_logic_y), math.ceil(home_logic_y) 15 16 if GameMath.getEvenAndOdd(min_x) == GameMath.getEvenAndOdd(max_y) then 17 local k = (max_y - min_y) / (max_x - min_x) --斜率 18 local b = max_y - k * max_x 19 20 local value = k * home_logic_x + b 21 22 if home_logic_y < value then --在线性规划区域下方 23 home_logic_x = max_x 24 home_logic_y = min_y 25 else --在线性规划区域上方 26 home_logic_x = min_x 27 home_logic_y = max_y 28 end 29 else 30 local k = (max_y - min_y) / (min_x - max_x) 31 local b = max_y - k * min_x 32 local value = k * home_logic_x + b 33 34 if home_logic_y < value then 35 home_logic_x = min_x 36 home_logic_y = min_y 37 else 38 home_logic_x = max_x 39 home_logic_y = max_y 40 end 41 end 42 return home_logic_x, home_logic_y 43 end
下面代码是 家园中对场景拾取操作的处理
1 function HomeController:homePiecePick(location) 2 local pos = HandleRenderUnit:ViewToWorld(location) 3 pos = HandleRenderUnit:WorldToLogic(pos) --服务端的逻辑坐标 4 local scaleSize = HomeMapManager:getInstance():getScaleSize() --缩放比 5 local homeLogicX, homeLogicY = self.handleTerrainHandler:ToHomeMapPos(pos.x, pos.y, scaleSize) 6 7 self:modelHander(homeLogicX, homeLogicY) 8 end
3.寻路问题。
(1)使用一个怎么样的寻路算法。迪杰斯特拉,或者A*, 或者其他方式。
(2)如何维护一个动态地图。
首先要解决的是一个动态维护的过程。其实按照前面的铺垫,这个动态地图的维护并不难,等同于维护一个可行走区域。当加入一个逻辑快的时候,
我们会对这个数据进行分析,加入的是什么。是草地,还说小窝或者是其他什么东西。这里就可以需要添加一个判定函数,这个东西是行走有否。
然后分别对这个区域,和这个区域的相邻区域进行设置。这样加入和删除一个地图里的元素,我们都可以动态处理。
下面是动态删减的维护代码,其中SCENE_DIRECTION是当前地图最小格子,周围8个方向的table
1 --[[ 2 维护可行走的区域信息 3 pointIndex: x, y转化后的序号 4 x, y: 逻辑坐标x, y 5 types: 类型 1 添加 2 删除 6 ]] 7 function HomeMapManager:updateRoadMap(pointIndex, x, y, types) 8 local index = pointIndex 9 --无信息又要删除 10 if types == 2 and not self.roadMap[index] then 11 return 12 end 13 14 if types == 1 then --添加 15 self.roadMap[index] = {} 16 for i = 1, #SCENE_DIRECTION do 17 local newIndex = self:getPointIndex(SCENE_DIRECTION[i][1] + x, SCENE_DIRECTION[i][2] + y) 18 if self.roadMap[newIndex] then 19 self.roadMap[index][newIndex] = true 20 self.roadMap[newIndex][index] = true 21 end 22 end 23 elseif types == 2 then --删除 24 for newIndex, _ in pairs(self.roadMap[index]) do--删除和当前有关的信息 25 if self.roadMap[newIndex] then 26 self.roadMap[newIndex][index] = nil 27 local length = 0 28 for _, _ in pairs(self.roadMap[newIndex]) do 29 length = length + 1 30 break 31 end 32 if length == 0 then 33 self.roadMap[newIndex] = nil 34 end 35 end 36 end 37 self.roadMap[index] = nil 38 end 39 end 40 41 function HomeMapManager:getPointIndex(x, y) 42 return bit.lshift(x, HomeMapManager.PointLineBitLen) + y 43 end
有了动态地图后,就是对地图进行搜索。我们需要快速计算出,从一个坐标(x, y)到另一个坐标(x1,y1)的行走路径。
原先使用的方案是迪杰斯特拉。刚刚说起,这个最小的地图是3600*3600,然后根据我们铺的菱形他的大小是120*60的。所以最小的行走地图是
30*60 = 1800 个点。但是大多数情况下这个地图并不是稀疏的地图。极端情况可能存在路径是1800*8个方向 = 14400条路径。每一次行走,我们需要形成1800个点的最小生成树,这样消耗比较大。这仅仅是3600*3600的地图。后面扩展成7200*7200,会更明显。
A*算法。详细的这里不介绍了。详细请谷歌百度一下。
A*的实现
1 --[[ 2 A*寻路 3 map: 地图 4 startPoint: 开始点 5 endPoint: 结束点 6 ]] 7 8 AStar = AStar or {} 9 AStar.INF = 0xffffffff 10 11 function AStar.findWay(map, startPoint, endPoint) 12 local preList = AStar.BFS(map, startPoint, endPoint) 13 local tmpList = {} 14 local x = endPoint 15 while(preList[x] and preList[x] ~= 0 ) do 16 table.insert(tmpList, x) 17 x = preList[x] 18 end 19 table.insert(tmpList, startPoint) 20 21 local pointList = {} 22 for i = #tmpList, 1, -1 do 23 table.insert(pointList, tmpList[i]) 24 end 25 -- PrintTable(pointList) 26 return pointList 27 end 28 29 function AStar.BFS(map, startPoint, endPoint) 30 local hash = {} --标记已经被用过的点 31 local preList = {} --标记前继点 32 33 local Queue = PriorityQueue.New(AStar.getTable(0, 0, startPoint, 0, 0)) --优先队列 34 35 local homeManager = HomeMapManager:getInstance() 36 local endx, endy = homeManager:getPointXY(endPoint) 37 38 while not Queue:IsEmpty() do 39 local nowInfoVo = Queue:Pop() 40 local tmpPoint = nowInfoVo.value 41 42 if tmpPoint == endPoint then 43 Queue:DeleteMe() 44 return preList 45 end 46 47 if not hash[tmpPoint] then 48 hash[tmpPoint] = true 49 local x, y = homeManager:getPointXY(tmpPoint) 50 if map[tmpPoint] then 51 52 --枚举可以由当前点走向的点 53 for point, _ in pairs(map[tmpPoint]) do 54 if not hash[point] then 55 local x1, y1 = homeManager:getPointXY(point) 56 local H = AStar.max(AStar.abs(endx - x1), AStar.abs(endy - y1)) 57 local G = nowInfoVo.G 58 if x1 == x or y1 == y then 59 G = 2 + G 60 else 61 G = 1 + G 62 end 63 64 --不同方向权重增加(鼓励尽可能走直线,不要走弯曲的路线) 65 local dirx = x - x1 66 local diry = y - y1 67 if nowInfoVo.dirx ~= dirx or nowInfoVo.diry ~= diry then 68 G = G + 1.41 69 end 70 71 local tmpInfoVo = Queue:GetInfoVo(point) 72 if not tmpInfoVo then 73 local infoVo = AStar.getTable(G,H,point,dirx,diry) 74 Queue:Push(infoVo) 75 preList[point] = tmpPoint 76 else 77 --如果当前坐标的F值小于已经压入队列里面的F值 78 if tmpInfoVo.F > G + H then 79 local infoVo = AStar.getTable(G,H,point,dirx,diry) 80 Queue:Push(infoVo) 81 preList[point] = tmpPoint 82 end 83 end 84 end 85 end 86 end 87 end 88 end 89 Queue:DeleteMe() 90 return preList 91 end 92 93 function AStar.init(Queue, startPoint) 94 local infoVo = AStar.getTable(0, 0, startPoint, 0, 0) 95 Queue.Push(infoVo) 96 end 97 98 function AStar.getTable(g, h, point, x, y) 99 return {G = g, H = h, F = g + h, value = point, dirx = x, diry = y} 100 end 101 102 function AStar.abs(a) 103 if a >= 0 then 104 return a 105 else 106 return -a 107 end 108 end 109 110 function AStar.max(a, b) 111 return a > b and a or b 112 end
最小堆
1 PriorityQueue = PriorityQueue or BaseClass() 2 3 function PriorityQueue:__init(infoVo) 4 self.list = {} 5 self.hash = {} 6 self.L = bit.lshift 7 self.And = bit.band 8 self.R = bit.rshift 9 10 if infoVo then 11 self:Push(infoVo) 12 end 13 end 14 15 function PriorityQueue:__delete() 16 self.list = nil 17 self.hash = nil 18 end 19 20 function PriorityQueue:Push(infoVo) 21 table.insert(self.list, infoVo) 22 23 local child = #self.list 24 local root = self.R(child, 1) 25 while child > 1 and self.list[root].F > self.list[child].F do 26 self.list[root], self.list[child] = self.list[child], self.list[root] 27 28 self:SetHash(self.list[root].value, root) 29 self:SetHash(self.list[child].value, child) 30 child = root 31 root = self.R(child, 1) 32 end 33 end 34 35 function PriorityQueue:Pop() 36 local firstInfoVo = self.list[1] 37 local length = #self.list 38 --特判 39 if not self.list[length] then return firstInfoVo end 40 if length == 1 then 41 self.list = {} 42 return firstInfoVo 43 end 44 45 local lastInfoVo = self.list[length] 46 table.remove(self.list, #self.list) 47 length = length - 1 48 49 local root, child = 1, 2 50 while child <= length do 51 --找出左右最小的值 52 if child + 1 <= length and self.list[child + 1].F < self.list[child].F then 53 child = child + 1 54 end 55 if lastInfoVo.F < self.list[child].F then 56 break 57 else 58 self.list[root] = self.list[child] 59 self:SetHash(self.list[root].value, root) 60 61 root = child 62 child = self.L(child, 1) 63 end 64 end 65 self.list[root] = lastInfoVo 66 self:SetHash(self.list[root].value, root) 67 68 return firstInfoVo 69 end 70 71 function PriorityQueue:SetHash(value, root) 72 self.hash[value] = root 73 end 74 75 function PriorityQueue:GetInfoVo(index) 76 return self.list[self.hash[index]] 77 end 78 79 function PriorityQueue:IsEmpty() 80 if self.list[1] then 81 return false 82 end 83 return true 84 end
最后我们得到了一组行走的序列。(坐标集合),传给原先幻想的处理行走的逻辑部分,然后人物就可以走动了。
4.其他问题。
(1)进入家园场景的时间比较短,如何生成和维护这些大量的对象。
因为家园场景中对象太多,进入场景的时候,一次创建会卡帧。体验很不好。有一台比较老的安卓机子,等了好久才加载完这个场景。后面采取的方式是分帧加载。一帧创建几个对象。然后在x秒后能够完成创建完这个场景。这里的x需要各种尝试,应该多大,肯定不行,玩家进入场景的时候明显感觉到到周围是黑色的。如果过小也不行,代表一帧需要加载的数量太多,会卡帧。而且不同的手机性能不同,也会有不一样的效果。
(2)编辑模式下的各种操作。