游戏引擎mota-js-v3.0 施工记录

前言

mota-js是一款用于做出魔塔类型游戏的HTML5 2d游戏引擎(github项目地址),目前最新的版本是v2.66,由于原主力开发已经工作,因此很长一段时间没有大版本的更新。

最近在用样板做一个游戏的时候,体会到了样板的一些限制。主要有:1. 地图尺寸受限,超过一定尺寸(30x30)会到达性能瓶颈,尤其是在手机上会很卡顿。2. 素材尺寸受限。贴图种类被限制在32x32或32x48,尺寸更大做起来会很麻烦,没有一个精灵系统。3. 实现一些特效很困难。样板中大量使用了dom来分割地图场景与状态栏UI,一些联动特效难以实现,并且使用dom对做游戏开发而非前端开发的人来说是个噩梦。

去年针对这些问题改了一版pre3.0的运行时系统,但现在回看感觉问题很多,首先是设计模式选取不当,造成理解困难,其次对原样板的耦合太多,被原有框架所限制,导致渲染引擎的能力没有发挥出来。因此这两天决定重启项目,解决之前的问题。

运行时系统简介

这里主要施工的部分是运行时,编辑器有另外一位大佬在施工。游戏引擎的运行时系统,如果是复杂的游戏,其系统规模将会是庞大的,如图是《游戏引擎架构》一书中对现代游戏引擎的系统架构描述。

引擎架构
这样的架构在unity、ue4等引擎中有完整的体现,但在我要施工的对象上不可能有这么多。
HTML5游戏是运行在浏览器上的,因此浏览器解决了A1-A3、B、C以及部分D的问题。其次因为是2d游戏,许多核心系统中的东西用不到。然后因为魔塔是棋盘类单机游戏,位置是确定的方格,因此也不需要碰撞检测、骨骼动画、在线多人等模块。最后,因为是在原样板基础上进行的二次开发,很多游戏算法都已经实现过了。因此算下来,借助成熟的第三方库,工作量并不大,对预期的架构图修剪如下,预期工期在15天左右。
在这里插入图片描述

施工日志

4-19

调研第三方库:

  • 渲染引擎PIXI
  • 动画库tweenjs

4-20

实现资产管理(AssetsManager)。
资产管理中存在的坑:

  1. PIXI的材质对象在载入图片时,如果图片超出一定尺寸(网上说是1024)会导致图片渲染失败,这是webgl存在的问题,无法解决。因此在导入材质之前,需要将原始图集进行手动裁剪,切分成一个个img元素。

4-21

实现动画管理(AnimationManager)、精灵管理(SpriteManager)的部分功能。

4-22

实现一个地图的原型:瓦片地图绘制与角色移动。

对系统框架的进一步总结:
在这里插入图片描述

4-23

  • 实现一个UI的原型:对话框的绘制与控制消失
    – 对话框基于窗口基类(WinBase),这是UI的基本单元,包含基本的坐标、长宽属性,并且可以有皮肤(WinSkin),具有聚合组件的功能。
    – 组件(Components)是使能窗口的重要部分,可以接收消息控制窗口属性,如,在对话框窗体注册一个“点击响应”(tapScreen)组件,其功能为关闭当前窗口。
    – 对话框文字格式控制。控制字符,比如"\r \n"之类的,对文字内容进行格式控制,重写了原样板的实现,可以通过注册实现任意格式控制,如字体变化、透明度变化等等。
  • 实现寻路与路线绘制
    – 寻路使用bfs,复用原样板的函数。路线绘制重写,功能性与原样板一致。
    – 对控制器(Controller)概念思考:一个控制器,响应用户操作,控制一个对象,对象可以是角色、UI,也可以没有特定对象,表示实现某种操作(比如响应点击操作)。当有特定对象时,被控制的对象是完全“受控”于控制器的。举个例子:勇士在被控制移动时,使用了寻路功能,那么此时,寻路走动的过程中,会不断调用的move,而这个move,是勇士自身的方法,还是控制器的方法?如果注意了控制器的概念,那么这里就应该是调用控制器的方法,这样做的好处是,把操作与对象的行为分离了,便于后续实现录像系统——操作全部由控制系统管理,对象无需关心操作。

记录一个坑:
ES6中的箭头函数()=>{} 与 function有一个重要区别,那就是箭头函数中的this绑定的是函数体外的,也就是说在声明的时候就绑定了this,而不是像function一样在调用的时候才绑定。

这两部分需要后续补充的内容:

  1. 窗口的更多功能,包括不同位置显示,自适应大小,需要后续补充。
  2. 需要后续补充更多组件
  3. 寻路移动的路线目前没有UI显示,后续UI完善了在这里加上。

4-24

今天主要实现事件系统和消息管理的部分架构。

之前的进度到了勇士能在地图移动,但是没办法和地图上的事件进行交互,主要就对这块进行施工。

事件、角色的概念

事件,在传统角色扮演类游戏中一般特指会产生一系列剧情、动作的触发点或者npc,角色,通常指的是主角和npc一类会动会产生行为的对象。
在设计事件系统的架构的过程中,理论上可以把地图上能跑能动的有属性的对象都当成是角色,但纯图块(block)、和角色(带事件的块如npc)和勇士(玩家控制的对象)显然不属于同一种,单明显带有一种递增的关系:

block -> actor -> hero (具体的关系等之后施工完了再完善)

在实现中,如果把所有带事件的点,都当成是一个个“角色”,那么勇士触发事件就可以当作是与角色之间的【交互】,简单的例子比如碰撞事件。

实现碰撞事件过程如下:

  1. 载入当前地图后,把地图上事件层的图块都升级为actor(这是为了方便起见,实际上只需要对含有事件的块进行升级)
  2. 勇士移动过程中,如果前方不可移动,判断是否有碰撞角色,是就获取这个碰撞角色存在的事件,执行该事件。

这个过程实现的是【碰撞】这类交互,但是实际游戏中,不止碰撞,比如有的点是空点,有的点是【战后事件】、【拾取后事件】……对原样板有的或没有的总结有如下类型的事件:

事件类型:

  1. 碰撞类事件,包括与块碰撞、角色碰撞(@code: collision) !
  2. 到达地图点事件,(@code: arrive) !
  3. 离开地图点事件,(@code: leave)!
  4. 战后事件:战后触发(@code: afterBattle)!
  5. 战前事件:战前触发(@code: beforeBattle)!
  6. 拾取后事件(@code: getItem)!
  7. 开门后事件(@code: afteropendoor)!
  8. 开门前事件(@code: beforeopendoor)!
  9. 自动事件(@code: auto)
  10. 楼层转换(@code: changeFloor)!(@code: firstArrive)(@code: eachArrive)(@code: firstLeave)(@code: eachLeave)
  11. 并行事件(@code: tick) —— 慎用

其中打!的是在地图上定义的事件,这些可能还不是全部,那么问题来了,要对所有事件单独写代码去判断吗?原样板是这么做的,感觉工作量会很恐怖,我怕工期赶不及,所以有了下面的消息管理。

消息管理

消息管理(MessageManager)是一个处理行为产生的消息的模块。
这里定义一下“行为”的概念,一般来说,游戏中的对象都能产生行为,但不是所有行为都会产生消息。比如之前的控制器,是用户行为,但由于控制器是同步的,即时控制勇士,没有必要产生消息。但是勇士的行为会产生消息,因为勇士走出去触碰地图点,可能会产生诸如碰撞、到达、离开等一系列情况,这些情况是不由勇士自己处理的,必须交由消息中心处理。当勇士发送消息时,是一个生产者,消息管理中心的任务是找到一个消费者处理这个消息。

比如前面11类事件,就是11种以上的消息,这些消息会在对应的代码执行时,如果有消费者成功消费了这条消息,就会返回消费情况,让生产者来进行判断下一步的情况——这个过程中,生产者不需要知道自己的消息被谁消费。这样只需要在合适的地方加入消息生产,就可以比较快速灵活地实现以上事件的处理。

移动事件

在原样板中,事件与地图点是绑定的,这在写一些剧情的时候会很头疼,npc不能频繁移动,否则会满地都是事件点,真·移动事件可以解决这个问题。
在上面的事件实现中,所有事件点都会成为角色(Actor),角色可以包含数据,这个数据可以用于定位事件原点。这样移动事件本质上是移动角色,角色移动后,原点将不存在块,勇士不再触发事件,但触发移动后的角色时,就会触发其定位到原点的事件。
这一块刚开始做,需要对事件系统进行进一步的施工。

存在的问题
  • 同类消息多次注册导致的冲突。当一个生产者有多个消费者时会出现这种问题,同样的问题也会出现在控制器,比如点击绑定了屏幕响应和勇者瞬移,一旦优先级不当,就有可能出现事件触发的瞬间就点下了响应。只能从设计上避免。
  • 移动事件需要存储数据,但只有移动过的事件才需要,如果全部成为角色,很有可能没法区分,或者是需要一定开销去区分谁的数据需要进存档。(也许这不是问题,需要进一步测试)

4-25

首先处理两个bug。
第一个,开启新页面时,一些sprite会加载不出来,经过调试发现是PIXI的材质缓存机制导致的,开启strict模式即可。
第二个,在手机上出现点击失效的情况。经过调试发现是强制横屏时,指针对象没有做对应的适配导致的,对tink.js的源码进行修改后修复。

然后基本完成移动事件和一个简单的打字机,然后做了一部分异步处理。
移动事件中移动事件点的关键在于对块信息和事件信息及时修改,事件管理以及地图管理订阅事件移动时发出的leave和arrive消息,对事件和块进行重定位即可。

打字机之前就留了空,做起来也简单了,做了一个动画管理,申请一个【等待】的动画对象,作用于对话框绘制中的【依次绘制每个字符】,然后处理好回调即可。另外实现了一个控制字符\w,可以控制说话的速度,比如下面图就改变了两次语速。
动画这块主要的问题在于解决异步,比如,如何实现说话过程中执行下一个事件?目前还没有好的思路。

(打字机演示图因审核问题删去

4-26

最近事多,估计工期又要延后了……

今日主要做了两个部分,一个是对资产管理进行了一定的补充,之前是基于原样板的图集包装的,为了测试sprite的优先级变化,就用PIXI导入了其他类型的材质,然后在编辑器里做了一些改动,通过调整数据的材质来改变贴图

另一个是试图解决异步的问题。据说有一种叫做promise的东西,我去找来看看大概明白了是怎么回事,但似乎原生的实现效率并不高,而且不一定适合引擎里的架构,就先在角色类和动画类进行实验,因为这两部分异步最多。
比如角色类,主要是移动,涉及到发送消息、移动动画,如果不用promise,会出现大量回调,可读性很差。
用promise改进后移动是这样的:

const dx = core.utils.scan[direction].x, dy = core.utils.scan[direction].y;
this.createAction('leave')
    .then(success=>{
        if(this.animate.walk){
            this.isMoving = true;
            this.animate.walk
                .get(this.sprite, {direction: direction, time: this.moveSpeed})
                .onChange(()=>{this.refreshPriority()})
                .call(success);
        }else success()
    })
    .then(success=>{
        this.trasnform(dx, dy);
        this.isMoving = false;
        success();
    })
    .send('arrive')
    .then(success=>{
        callback();
        success();
    });

其中涉及动画的部分使用了一个onChange,用于移动过程中的优先级变化。

这种形式相比于不停callback看上去好多了,当然仍然没有解决多个角色同时移动异步问题,因为这个还没有实现Promise.all的效果。这个留到明天解决。

4-27

今天打算完成异步事件执行的部分。

角色部分全部架构基本完成,目前全部写在ActorManager文件中,后续如果有增加新的角色部分可以考虑拆分,目前没必要。

关于多角色异步执行(即昨天提到的promise.all),本质上和原样板没有区别,就是用一个唯一code挂在全局,执行完毕后回调取消掉code。当所有异步code都取消时,即为完成一次all。

以一个行走事件的异步过程为例。

角色 消息中心 事件管理 地图管理 异步移动(Async) 生成并记录uid 返回uid 开始移动(Leave) Leave Actor Leave Actor 在此期间可以 执行其他事件 移动结束(Arrive) Arrive Actor Arrive Actor 异步结束(取消uid) 撤销uid, 检查等待事件 角色 消息中心 事件管理 地图管理

角色每次移动开始时和移动到达后都要发送消息,事件管理和地图管理接收该消息,并对地图和事件数据进行修改(实现事件移动的方法),此外还需要在移动开始前和结束后对异步进行记录,如果在移动的过程中,执行了等待全部异步事件(类似promise.all),则会在消息中心挂载一个一次性的回调函数,等到全部执行完毕后进行调用。同样也能实现竞争式如promise.race的效果。但目前暂未用到。目前有一个问题尚未解决——事件的碰撞,比如A要到B的位置,B要到C的位置,看上去能执行成功,但发往事件管理和地图管理的消息出现了竞争——无法确定谁先到达,如果A先到,发现B处已经有一个块,就会发生重叠的错误情况。预想的解决办法是同一个点碰撞后成为一个队列,先进先出,这样一来,即使A到达B的时候,B还没离开,在B离开时也能正确取出自己的事件。

最后做了一些关于地图特效的实验和接口,学习了一下PIXI包装的filter,实现一个简易的色调变化。但后来尝试做光影但遇到了一些困难,这块还是缺乏一些理论基础,有空了补一下。但特效毕竟不是目前引擎的重心,明天重点还是做核心的部分,至少要能执行完一个魔塔游戏的基本流程。

明日施工计划对象:事件系统(修bug,以及继续做基本事件的补充),战斗系统。

4-28

总结一下目前实现的结构。

数据库相关
AssetsManager
SpriteManager
AnimationManager
BattleManager

AssetsManager: 资产数据库单例,管理包括材质、敌人信息、道具信息、技能信息、事件信息、角色信息等原始静态数据。只有加载这些数据后,才能初始化后续三部分。
SpriteManager:精灵管理单例,处理包括角色精灵、动画精灵、窗口精灵等动态数据的基本管理。后续计划做一个精灵缓冲池,防止进行大量增删行为(比如浏览地图)带来的开销。
AnimationManager:动画管理单例,实现各种特效的地方。提供动画执行单元实例的获取接口。
BattleManager(施工中):战斗管理单例。进行战斗数据管理,主要包括获取敌人数据、战斗伤害的计算。由于战斗本身是属于事件的部分,所以这部分纯粹是作为一个API接口,如,输入勇士信息,查询敌人、获取敌人信息并返回,不涉及到对实际运行数据产生的影响——但实际运行中的数据会影响到这里的计算结果。

游戏性相关
分发消息
extends
extends
extends
extends
ControlManager
MessageManager
Listener
SceneManager
MapManager
EventManager
ActorManager

ControlManager:控制管理单例。包括两部分:1. 用户的输入指令管理 2. 输入指令后产生消息的管理。
MessageManager:消息处理单例。负责汇总各种消息来源的消息,并分发给各个监听者。
Listener:监听基类,相当于是为MessageManager专门配的一个接收者。所有被动接收消息进行处理的管理器都需要继承此类。
SceneManager:场景管理单例。负责场景绘制,包括状态栏、菜单栏、地图界面、UI界面等。
MapManager:地图管理单例。负责地图的状态存储,包括地图上的角色信息,地形信息等。
EventManager:事件管理单例。负责响应事件消息,如移动事件、战斗事件、自定义事件、转场事件等。


今天首先做了一个Listener类,把之前的几个消息接收者都归总了起来,使之具有高扩展性。
使用的一个例子如下,增加一个新的战前事件,改变角色的属性:

EventManager.on('beforeBattle', (obj, callback)=>{
	obj.xxx = ...//处理可以异步,比如可以进行动画播放一类的操作
	callback();//处理结束后要通知处理完毕
})

理论上所有继承了Listener的实例都能注册接收这个消息然后进行处理,也能work,但是不应该这么做。因为在目前的实现中,如果消息接收者都进行异步处理,那么将是一种伪并发状态,无法确定先后,而事实上,不同模块的消息处理优先级是不同的,所以后续可能会调整模块消息接收的优先级,部分模块的消息处理很可能需要等其他模块都完成后才进行。

然后是战斗系统。借用了一些原样板的战斗计算内容,实现了技能的部分。

魔塔战斗系统介绍

战斗系统是魔塔类游戏的核心,属于一种固定数值的回合制战斗,即勇者、敌人每回合互相造成伤害,直到一方倒下,在没有加特殊技能的情况下,其结果是能够通过公式解析出来的。在传统的三原塔里(4399的50层、新新、24层),是有战斗动画演示这个回合过程的,但在现代魔塔游戏里(以RM魔塔、H5魔塔为代表),这个战斗动画被基本取消了,玩家更多的重点关注在路线中,尤其以H5为甚,不仅取消了动画,还引入了瞬移,以加快游戏节奏,此外,玩家还需要查看大量的伤害数据,以及更高阶的数据信息,包括临界减伤表(加x点攻击减少x点伤害)、防御减伤表(1防减少x点伤害)等。这使得战斗系统有一定的计算负担,但是传统的战斗算法是有解析解的,所以问题不大。

但是,在一些蓝海塔加入一些特殊技能后,比如“第x回合造成x点伤害”、“怪物每回合增加x点防御”、这种,很难有解析算法,再加上魔塔中勇者数据是变化很快的,到处都是引起属性变化的宝石,使用缓存计算基本不太可能,很可能在计算一些大数据塔的过程中产生严重的卡顿。

因此关注战斗系统,首先就对技能进行一定的关注。技能本质上是一个影响战斗进程的特殊变量,正常的战斗过程如下(感觉这个过程可以叫做战斗管线了…):

战斗基本信息
勇者信息
敌人信息
伤害信息
beforeBattle
getHeroInfo
getEnemyInfo
计算伤害
afterBattle

怪物技能可以继承一个基类,基类的以上函数全部留空,即按默认来。技能覆盖对应的函数后,可以对特定过程的数据流发生变化。

举例来说,【硬化皮肤】技能1:怪物的防御力额外增加勇者50%攻击的数值。那么就继承getEnemyInfo函数,将敌人的防御数据修改即可。
但这么修改也有问题:修改不是线性的,多个技能时会发生冲突。比如有个技能2:【防御强化】怪物防御力增加20%。
因此每个函数添加一个修改单元,存放每个技能产生的修改结果,主要有两种,一种是百分比(percentage)、一种是固定数值变化(hardchange),接下来的写法就是:

function(
	src_info, //原始信息 可参考 不可修改
	modify_info, //修改信息,用来存放修改结果
	){
	let hero = src_info.hero_info;// 来自上一个流程的结果
	modify_info.def = ~~(hero.atk * 0.5);// 写法1:固定变化数值
	modify_info.def = 0.2;//写法2:这样写的效果就是自身防御上升20%,会在固定数值修改完成之后进行
}

一般来说这个模型对于大部分数值类的技能是够用的,但对于一些特定需求可能无法满足,一方面,比如勇者的某种属性依赖于怪物(比如勇者防御力增加怪物的某个属性值),另一方面比如回合类的技能,其本质是循环,需要对伤害计算进行大量修改,暂时不考虑。
此外一个优化点,现代魔塔的地图显伤包含了上图过程2、3、4的计算,而且互相独立,据鹿神介绍可以用Worker实现异步计算,明天学习一下。

4-29

今天继续完善战斗系统。

昨天提到的Worker去看了,发现这个东西需要独立的上下文环境进行线程计算,和主线程之间只能通过通信进行交互,这就很麻烦了。后来想到可以用空闲计算的方法进行异步计算,但目前测试还没有到性能瓶颈,先暂时放弃优化这块。

战斗部分完善伤害计算,将显伤加入地图场景中。效果如下:
在这里插入图片描述
目前已经能够完成一个最简单的游戏流程:打怪、捡宝物、切换地图、对话,但还缺少一个重要的模块:状态管理。

这里的状态,指的是包括勇者的数值、游戏变量、录像等实时信息,之所以要对这块进行管理,是因为涉及到一个重要功能:存读档。
SL大法玩过游戏的都知道,遇事不决就存档是rpg中的常见操作,在魔塔中更甚,玩一座有一定难度的塔会产生大量的存档,因为其中包含大量的路线分歧,经常需要频繁存读档,再加上H5有一个【自动存档】功能,因此对存读档的性能有一定的需求。
就之前的经验看,当塔层数较低(低于一百层)时,几乎不会有卡顿,但在层数上升到一百多以上时,由于地图数据读档和存档过程中反复刷新,会产生一定的延迟。
因此,如果要用样板制作大型的蓝海塔,有必要对存读档进行优化。

明天进行状态管理部分的施工。

4-30

状态管理参考了一些博文(存读档功能在unity3d的实现 ),将游戏中状态分为如下几个部分:

  • 勇士状态(生命、攻击、防御、道具……)
  • 进程(游戏变量、统计信息)
  • 地图(块的设置和移除情况)
  • 事件(位置、激活情况)

本质上来说,这些都可以归为变量,但在考虑到对状态的存档读档的时候,又有一些区别,其中勇士状态和进程在存读档时是需要完全存储和加载的,不可分割,但是地图和事件就不一定了。

举例来说,一个游戏玩到第三关,后面还有四关没打,这时读取第二关的存档,那么存档中关于后面四关的信息是无需加载的,存储也一样。这可以通过脏标记来实现。但这样还不够。当游戏进行到中后期,已经改动了很多的地图状态,脏标记已经很难有优化效果了,需要另外找办法。

通过对原样板的观察测试发现,此时存读档的主要开销在于对全部地图数据的记录和读取,测试中两百层地图的读取大约需要200毫秒(5fps),这对逐帧绘制的一些特效会产生明显卡顿,可以通过懒加载的方式避免:只对读档的目标地图进行加载,其他的部分等到访问时再进行加载。

存档相对高效一些,但是也只有10fps,在自动存档时,也会影响一些需要高fps的画面,存档的优化可以通过建立存档树来解决。存档树演示如下:

当前
x
存档1
存档2
存档3

假设存档3是之前试错的一条路,通过读档回存档1后,进行另一个选择,存储了存档2。注意到,无论是存档3还是存档2,都是在存档1的基础上进行的改动,那就意味着:存储的时候不必存储全部内容,只需要存储相对于父存档的改动即可。这个本质上也是一种脏标记的应用,但是会在每一次存档的时候,清除脏标记,所以脏标记会很少。

但是如果存档进行了这样的改动,读档也必须与之匹配才行。如果只存储相对改动,这无疑会增加读档的开销:读档需要去找存档树的关系进行拼接,不断查询存档,这是很费时的,因为存档不是全部都存在内存中。

这就产生了矛盾:加速存档,就会减慢读档,加快读档,就会减慢存档,有没有两全其美的办法,既能迅速读档也能迅速存档?……很遗憾,暂时没有,但可以优先解决自动存档,这样就解决了大部分可能的卡顿。

在游戏过程中,触发最为频繁的是自动存档,自动存档指的是在进行一些操作,如切换地图、战斗、开门等不可预知行为时进行的存档,相当于上个保险,防止误操作。目前的版本中支持一定步数的连续回档,即自动存档成一个队列,可以回到前几步的状态。

自动存读档是典型的适合存档树+懒加载的优化点,原因是每次存储和读档都修改极小,而且存档都在内存中(自动存档不会全部持久化),是一个天然的链式结构,因此可以对其进行着重优化。

实现上,先实现一个最简单的缓存基类,用于优化自动存读档。包含方法有:

  1. dirty(key) :将一个数据字段标记为脏
  2. save(tosave, data) : 缓存data到tosave,同时会存储所有脏标记
  3. load(toload) : 从toload中取出数据,脏标记会还原到该数据时刻

然后地图管理包含一个继承缓存基类的对象,切换楼层以及对图块增删时进行标脏。
在战斗后加上自动存档,测试发现每次存储量都很小(一张地图),存取时间约20毫秒,基本不影响性能。
明天再考虑如何做手动存读档以及更复杂的树形分支。

5-1

考虑以下三个基本功能:(规则1)

  1. 存档(save):存储当前的状态
  2. 读档(load):读取前一个存储的状态
  3. 回退(back):读取后一个存储的状态

回退是一个之前没考虑的新功能,就我个人来说,一般用于load手滑多退了一步的情况,但也有说能用来分析路线?不过这些不重要。

演示如下,状态1~3是存储的三个存档,当前状态是未保存的进度。

状态1
状态2
状态3
当前状态

往回读(load):

X
load
状态1
状态2
状态3
当前状态

此时无法使用back,原因是前一个【当前状态】并没有存储,已经丢失(如果在读取时进行了存储则另算,先不考虑)。
再次load:

back
load
状态1
当前状态2
状态3

此时可以通过back回到状态3。

为了实现手动存档读档,将在这个模型基础上对save、load、back进行第一次扩展:(规则2)

  1. save:不变。每一个保存的状态都是上一个状态的back status。
  2. load:可以设定一个相对索引(index),读前index的状态
  3. back:同上,可以读后index-1个状态。

如下:

load:index=2
back:index=2
状态1
状态2
状态3
当前状态

这样可以实现链式存档的手动存读。但存在一个问题:如果在读回状态1后,进行了新的状态保存,就会变成:

状态1
状态2
状态3
状态4
当前状态

此时之前的模型不能适用,将再次扩充(规则3):

  1. save: 保存时检查是否存在后续,如果存在,记录一个前一个存档的索引到当前链状态中,记为父节点表 T p a r e n t T_{parent} Tparent
  2. load: 检查读取目标是否在同一条链上,是则回到规则2,否则在 T p a r e n t T_{parent} Tparent中检索自己和对方的相同的第一个父节点。然后按规则2 load到该节点,再back至目标节点。
  3. back:无变化。

改动比较大的在load,演示如下,从状态4读到状态3:

1.load
2.back
状态1
状态2
状态3
状态4

这样实现的性能瓶颈在于查询各个存储的状态然后进行合并。据鹿神说并发读取存档开销并不大,试了一下localforage,读取900个存档只用了70ms。因此存读这块并不是问题,难的是如何实现这一块。有可能并不会需要这种LCA操作,一定深度后做一次全存是一个好的方法。

明天再尝试进行具体的实现施工…

5-2

存档实现预期超过预期时间,放弃,改为懒加载优化。

存档时:如果有没有修改过的楼层,就不必重新压缩,直接存入。
读档时:读取未解压的存档,只有访问目标楼层时,才解压目标楼层。

完善材质类型,增加对tileset的支持。

调研自动元件的实现:自动元件参考文章:RMXP的自动元件绘制原理

原样板的绘制方法不再适合PIXI的框架,需要基于ActorSprite增加一种多模态的元件,情况略有些复杂,但原理不变。

5-3

自动元件竣工。

每个自动元件图块包含四个小sprite,通过放置在四个角落拼凑为一个完整的图块,这四个图块的模式一共47种情况,由九宫格的边角决定。

上面的参考博文给出了“绘制情况-小元件”的映射表,但没有给出“边角-绘制情况”的映射,在此记录如下:

// javascript
let edge = {};
/**
 * 对mask符合filter的edge填充角落
 * @param value
 * @param filter
 */
function fillCorner(filter, value, mask) {
	mask = mask || 0xf;
	for(let i = 0; i < (1<<8); i++){
		if((i & mask) == filter){
			edge[i] = value;
		}
	}
}
// 0 边
fillCorner(0, 47);
// 1 边
fillCorner((1<<0), 42); // 下
fillCorner((1<<1), 43); // 右
fillCorner((1<<2), 44); // 上
fillCorner((1<<3), 45); // 左
// 2. 2边
fillCorner((1<<0) + (1<<2), 32); // 下 + 上
fillCorner((1<<1) + (1<<3), 33); // 右 + 左 —— 对角无影响

fillCorner((1<<1) + (1<<0), 35, 0xf | (1<<4)); // 右下*
fillCorner((1<<1) + (1<<0) + (1<<4), 34, 0xf | (1<<4)); // 右下* —— 4

fillCorner((1<<1) + (1<<2), 41, 0xf | (1<<5)); // 右上*
fillCorner((1<<1) + (1<<2) + (1<<5), 40, 0xf | (1<<5)); // 右上* —— 5


fillCorner((1<<3) + (1<<2), 39, 0xf | (1<<6)); // 左上*
fillCorner((1<<3) + (1<<2) + (1<<6), 38, 0xf | (1<<6)); // 左上* —— 6

fillCorner((1<<3) + (1<<0), 37, 0xf | (1<<7)); // 左下*
fillCorner((1<<3) + (1<<0) + (1<<7), 36, 0xf | (1<<7)); // 左下* —— 7

// 3. 3边

// 缺左 左角无影响
// 右满
fillCorner((1<<0) + (1<<2) + (1<<1) + (1<<4) + (1<<5), 16, 0xf | ((1<<4) + (1<<5)));
// 右下
fillCorner((1<<0) + (1<<2) + (1<<1) + (1<<4), 17, 0xf | ((1<<4) + (1<<5)));
// 右上
fillCorner((1<<0) + (1<<2) + (1<<1) + (1<<5), 18, 0xf | ((1<<4) + (1<<5)));
// 无右
fillCorner((1<<0) + (1<<2) + (1<<1), 19, 0xf | ((1<<4) + (1<<5)));
// 缺上
fillCorner((1<<0) + (1<<1) + (1<<3) + (1<<4) + (1<<7), 20, 0xf | ((1<<4) + (1<<7))); // 缺 上 + 下满
fillCorner((1<<0) + (1<<1) + (1<<3) + (1<<7), 21, 0xf | ((1<<4) + (1<<7))); // 缺 上 + 左下
fillCorner((1<<0) + (1<<1) + (1<<3) + (1<<4), 22, 0xf | ((1<<4) + (1<<7))); // 缺 上 + 右下
fillCorner((1<<0) + (1<<1) + (1<<3), 23, 0xf | ((1<<4) + (1<<7))); // 缺 上
// 缺右
fillCorner((1<<0) + (1<<2) + (1<<3) + (1<<6) + (1<<7), 24, 0xf | ((1<<6) + (1<<7))); // 缺 右 + 左满
fillCorner((1<<0) + (1<<2) + (1<<3) + (1<<6), 25, 0xf | ((1<<6) + (1<<7))); // 缺 右 + 左上
fillCorner((1<<0) + (1<<2) + (1<<3) + (1<<7), 26, 0xf | ((1<<6) + (1<<7))); // 缺 右 + 左下
fillCorner((1<<0) + (1<<2) + (1<<3), 27, 0xf | ((1<<6) + (1<<7))); // 缺 右

fillCorner((1<<1) + (1<<2) + (1<<3) + (1<<5) + (1<<6), 28, 0xf | ((1<<5) + (1<<6))); // 缺 下 + 上满 (存疑 26 27 44 45 ?)
fillCorner((1<<1) + (1<<2) + (1<<3) + (1<<6), 30, 0xf | ((1<<5) + (1<<6))); // 缺 下 + 左上
fillCorner((1<<1) + (1<<2) + (1<<3) + (1<<5), 29, 0xf | ((1<<5) + (1<<6))); // 缺 下 + 右上
fillCorner((1<<1) + (1<<2) + (1<<3), 31, 0xf | ((1<<5) + (1<<6))); // 缺 下

// 4. 4边
let four = (1<<0) + (1<<1) + (1<<2) + (1<<3);
// --------  右下     右上      左上      左下 -----------
edge[four + (1<<4) + (1<<5) + (1<<6) + (1<<7)] = 0;
edge[four + (1<<4) + (1<<5) + (0<<6) + (1<<7)] = 1; // 缺左上
edge[four + (1<<4) + (0<<5) + (1<<6) + (1<<7)] = 2; // 缺右上
edge[four + (1<<4) + (0<<5) + (0<<6) + (1<<7)] = 3; // 缺左上 右上
edge[four + (0<<4) + (1<<5) + (1<<6) + (1<<7)] = 4; // 缺右下
edge[four + (0<<4) + (1<<5) + (0<<6) + (1<<7)] = 5; // 缺右下 左上
edge[four + (0<<4) + (0<<5) + (1<<6) + (1<<7)] = 6; // 缺右下 右上
edge[four + (0<<4) + (0<<5) + (0<<6) + (1<<7)] = 7; // 缺右下 右上 左上
edge[four + (1<<4) + (1<<5) + (1<<6) + (0<<7)] = 8; // 缺左下
edge[four + (1<<4) + (1<<5) + (0<<6) + (0<<7)] = 9; // 缺左上 左下
edge[four + (1<<4) + (0<<5) + (1<<6) + (0<<7)] = 10; // 缺左下 右上
edge[four + (1<<4) + (0<<5) + (0<<6) + (0<<7)] = 11; // 缺左上 左下 右上
edge[four + (0<<4) + (1<<5) + (1<<6) + (0<<7)] = 12; // 缺左下 右下
edge[four + (0<<4) + (1<<5) + (0<<6) + (0<<7)] = 13; // 缺左下 左上 右下
edge[four + (0<<4) + (0<<5) + (1<<6) + (0<<7)] = 14; // 缺左下 右上 右下
edge[four] = 15; // 都缺

最后得到的一个edge,是一个边角情况到绘制情况256-47的映射,这个可以作为绘制依据的查询。

摄像机(Camera)的概念

地图的进一步完善是实现大地图。当前的地图只能提供一定宽度的显示,超过则会溢出到边界,无法正常运行。要实现更大尺寸的地图,在此先引入摄像机的概念。

Camera一般在3d游戏中使用比较多,因为涉及到成像透视等一系列需求,需要这个概念来帮助理解。在2d游戏中因为是平面的缘故,一般都是用画布概念,动画和游戏过程就是不断绘制的过程。但涉及到遮挡的时候,画布就无法帮助理解了。

2d摄像机本质也是一个画布,但它对应着一个游戏实体,也即照射的场景(Scene),之前做场景管理,不仅管理场景中的数据,还混入了渲染,而引入摄像机后,渲染的过程就应放到摄像机中,其逻辑为:

数据
渲染
场景
摄像机
canvas

摄像机要存储视角(viewPoint),作为场景绘制的依据,其次,还需要一个渲染区域(renderArea)作为绘制目标——即画到窗口的何处。当绘制超出边界时,需要进行剪裁。最后,为了让视角跟随主角,需要有一个绑定对象到视角的方法。

目前实现能够在电脑上基本能够较为流畅地绘制一个52x52的大地图,但在低性能手机上会严重的帧率下降(下降至20fps),推测是由于大量的sprite更新导致的,可以考虑进行优化——使用缓冲池,只对当前画面的一部分进行刷新。

5-4

给大地图加了缓动后基本竣工,留下两个问题:1 手机上的性能优化 2 读档优化(大地图读档会导致大量sprite申请和销毁 这是没必要的),等之后细节优化再进行吧,今天主要进行事件部分的施工。

事件部分的改进点如下:

  1. 事件-角色名,对地图上的npc标注名字,在使用对话框,移动角色之类的事件时,可以通过名字进行索引而非位置。这个数据不用进存档,只是方便使用者。
  2. 事件状态,在RM中有类似独立开关事件页的操作,可以用不同开关执行不同的事件逻辑,类似这种开关值是需要和事件绑定的。此外还有通行性设置、事件是否开启等动态值,均需进存档。

后记

架构的施工记录到此基本接近尾声,后面就是添砖加瓦充实游戏性系统、细节优化以及找bug。对其中有价值的部分会专门开文章写,这篇不会再更新了,主要最近又忙起来了,不知道咕到什么时候才能全部做完……就这样吧。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值