Musoucrow' BLOG


  • Home

  • Categories

  • Archives

  • Tags

  • Search

《DFQ》开发随录——图集

Posted on 2018-06-23 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  在游戏开发的领域里,图集(SpriteSheet)是一个很重要的概念,其好处在链接处也已言明。但若是引擎没提供相关的支持,那么便需要自己搞个解决方案了。而LÖVE也恰好是没有提供相关支持的,那么只好自己动手丰衣足食了,本文便记录其中心得。

装箱问题

  要实现图集的核心便是对图片进行拼合打包,其实类似的工具市面上亦有存在(如TexturePacker)。从功能上而论,TexturePacker完全可以满足需求(有提供命令行模式,可实现自动化)。可惜TexturePacker的免费版根本不堪使用,而破解也相继失败。而其他类似的工具要么无法满足需求,要么不支持macOS。只好自己手写一套了。
  实现的图集的难点无非在于拼合时图片排列的算法,由Claris告知得这种属于装箱问题,目前并无最优解。由装箱问题为关键字进行展开搜索,发现一种名为MaxRectsBinPack的算法可解决问题,我将之翻译成了Python版。如此装箱问题便解决了。

拼合问题

  接下来的问题便是“谁和谁拼合成一张图”了,我对此立下三个原则:

  • 关联性不高者不拼(拼成大图的代价便是成为资源共同体,如果关联性不高的拼合一块则会造成极大的内存浪费)
  • 黑底与透明者不拼(黑底图拼成大图必须得保证全图无透明点,否则游戏里会出现奇怪的线条)
  • 拼合后过大者不拼(需保证图片大小在4096*4096及以下,否则恐怕出现上限问题)

  以这三原则来看,是无法做到以文件夹为单位进行粗暴的拼合了。所以采用了编写配置的方式进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"effect": {
"map": {
"lorien": [
"lorien",
"/actor/article/lorien/pathgate",
"/actor/article/lorien/largegrass"
]
},
"death": [
"death",
"dieFlash"
],
"buff": {
"freeze": "freeze"
}
}

  配置以JSON形式存储,配置中的key代表着合图文件夹的层级,value则为欲拼合的图片文件夹,若无/开头则代表以当前文件夹层级为路径,反之则为全路径。以这套方案便可很自由地选择拼合的方案了。

配置问题

  图片的拼合问题解决后,便是游戏要如何以最低的代价去兼容新的图片形式了。解决方案自然是为原图片生成路径一致的配置文件,游戏通过读取配置文件以无缝对接新的图片形式。配置文件格式如下:

1
2
3
4
5
6
7
return {
image = "ui",
x = 0,
y = 151,
w = 45,
h = 41
}

  配置文件记录了所属合图的路径以及在合图中的坐标宽高,如此便可清晰无比地取得了。由于Python的lupa模块装不上,为此还专门写了个Lua与JSON的转换器。

大小问题

  一般而言,因为光栅化需要对纹理采样进行快速取值,图片大小需要遵循2的N次幂(256、512、1024…)。这种符合的图片被称为POT(Power-Of-Two),同理不满足的称为NPOT(Non-Power-Of-Two)。在早期POT纹理可以说是必须的,而今在OpenGL ES2.0后支持了NPOT。但为了能满足ETC压缩以及兼容性,个人推荐还是对合图进行POT化。

后记

  其实从这个问题来看,选择流行的大引擎的确会更为方便。在Unity里可以由后台自动完成的事情现在却要一篇文章来总结,不过贼船已经上了,就只能走到黑了。

Lua的local变量探究

Posted on 2018-04-17 | In ProgramDesign | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  先前看到用好Lua+Unity,让性能飞起来—LuaJIT性能坑详解一文中提到:

3.2 寄存器分配失败->减少local变量、避免过深的调用层次
很不幸的一点是,arm中可用的寄存器比x86少。LuaJIT为了速度,会尽可能用寄存器存储local变量,但是如果local变量太多,寄存器不够用,目前JIT的做法是:放弃治疗(有兴趣可以看看源码中asm_head_side函数的注释)。因此,我们能做的,只有按照官方优化指引说的,避免过多的local变量,或者通过do end来限制local变量的生命周期。

  对此自然是可以理解的,哪怕是一般语言,local变量过多也会有堆栈溢出的问题。不过我对此一直有个隐忧:Lua是拥有模块级local变量的,不知是否也受此规则影响?尽管有此隐忧,却一直没有去做相关的探究。恰逢今日遇到相关话题,便来个刨根问底吧。

200限制

  首先的发现是:一段过程下最多拥有200个local变量,且do end不算。类似这样:

1
2
3
4
5
6
7
local Class = {}
local test1 = 1
local test2 = 2
... --to 199
return Class

  如果超过199,则会报出main function has more than 200 local variables的错误。当然这里说的是一段过程,所以函数是另算的,同样一个函数的过程最多也不能超过200个local变量(调用函数则算转入下一个过程了)。
  这个限制是Lua与LuaJIT共有的,显然是想限制local数量的泛滥。

函数嵌套调用

  接下来便是试试函数嵌套调用了:

1
2
3
4
5
6
7
8
9
10
function Class.Do(v)
if (v > n) then --n is a custom value
return
end
local test1 = 1
... -- to 199
Class.Do(v + 1)
end

  注意参数v也算是local变量的一员,所以test变量最多只能延伸到199个。以此进行递归调用的话,根据实验结果来看:

版本 嵌套上限 local变量上限
LuaJIT 325 65000
Lua5.3 4975 995000

  测试的环境为macOS x86-64,LuaJIT方面无论JIT开启与否结果皆一致。根据前文所言来看,到了ARM环境这个数量将会进一步下降。虽然从对比来看差距有点大,但实际上在函数调用方面也算够用了。

模块级local变量

  接下来便是我最关心的一点了:以上local变量上限是否会影响到模块级local变量?所谓模块级local变量即作用域为整个文件:

1
2
3
4
5
6
7
--test.lua
local Class = {}
local function Func()
end
...
return Class

  这种模块级local变量在Lua开发的应用还是很广泛的,它能有效的做到信息分隔的效果。但若是这些变量也受之前的上限规则影响,咁就扑街了!

  首先是测试加载多个满载local变量的模块:

1
2
3
for n=1, 5000 do
require("test" .. n)
end

  天可怜见,无论读取多少个文件,都不会存在上限问题。可见对于模块级local变量的处理是不一样的。到了这里基本上可以放心了,不过为防万一,我还做了模块的嵌套引用实验:

1
2
3
4
5
6
7
8
9
---test1.lua
local Class = {}
print("1")
local Next = require("test2")
local test1 = 1
local test2 = 2
... --to max
return Class

  以这种形式生成了5000个文件,以此进行嵌套引用,结果也是成功通过了。由此可见,对于模块级local变量是可以放心地去使用了。

后记

  尽管模块级local变量是可以随便用了,但是也要考虑到热更新方面的问题:若是选择使用模块级local变量去存储模块的数据,那么在热更新方面的处理将会变得十分麻烦。从这点考虑的话,模块级local变量最好只是用于引用别的模块为妙。

ECS框架的初步探究

Posted on 2018-03-19 | In ProgramDesign | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  在阅读这篇文章之前,你需要了解一下何为ECS框架。关于ECS框架,其实近年来一直想去尝试,终于在近日有所体悟,遂有此文。

详解

  ECS框架的存在实际上很早就出现了(我记得最初在2003年),近年随着《守望先锋》架构设计与网络同步一文出现后瞬间成了炙手可热的新星。
  ECS框架与帧同步锁定类似,皆只是拥有一个概念,但无确切的实现标准。但事实上已经不少现成的实现(如Entitas),不过我觉得Entitas在与Unity的结合上不符合我的审美,于是自己动手造了个轮子。
  ECS框架的概念其实相当直观:Entity-Component-System三件套。

  • Entity即实体,作为Component的经纪人,可拥有多个Component。
  • Component即组件,作为数据存储的容器,原则上只包含内部数据自处理的函数。Component以Entity作为标识,以此判断所属。
  • System即系统,作为业务函数的集合,会与Component对接实现业务运行(System处理Component)。

  以上三点可谓看过相关文章的都懂,只是落实到具体实现上仍会有不少不明不白之处(Entity是作为容器还是标识符?Component可否嵌套Component?System之间可否相互调用?)。以上问题并没有确切的答案,只能是落实实现时根据需求而定。

实现

  所谓实践出真知,在此之前我写了个贪吃蛇,这是个不错的素材,于是便将其ECS化。这下也可将两者进行对比,品味其中区别。

Entity

  由于这款游戏是使用Unity制作的,那么自然最好与Unity本身相结合。我首先考虑到的便是与Unity本身的GameObject-Behavior(其实是Component,为防误解,特此改称)框架结合(业务环境下有调用它们的需求),于是选择将Entity做成一个Behavior:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System;
using UnityEngine;
namespace Game.Core {
public class Entity : MonoBehaviour {
public static event Action<Entity> NewTickEvent;
public static event Action<Entity> DestroyTickEvent;
protected void Start() {
if (Entity.NewTickEvent != null) {
Entity.NewTickEvent(this);
}
}
protected void OnDestroy() {
if (Entity.DestroyTickEvent != null) {
Entity.DestroyTickEvent(this);
}
}
}
}

  可以看出,Entity的生命周期也与GameObject进行了捆绑,并且设置了两个event令System可以进行监控。
  再来看看Entity的具体实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;
namespace Game.Entitys {
using Core;
using Components;
public class Food : Entity {
public Position position = new Position();
protected void Awake() {
this.position.Init(this);
}
protected new void OnDestroy() {
base.OnDestroy();
this.position.Destroy();
}
}
}

food

  可以看出Food实体创建了一个Position组件,托Unity编辑器的服,我们可以清晰地看到Position的数据构成,并可方便地进行编辑(包括运行时)。当然可以看得出这里Component的创建方式相当别扭(实例化后仍需Init),这是为了对接Unity的序列化功能,若不这么做的话,某些数据将会序列化失败(如Collision Slot)。

Component

  Component的初始实现便很简单了,只需要对接Entity以及预留Init与Destroy接口即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System;
namespace Game.Core {
[Serializable]
public class Component {
[NonSerialized]
public Entity entity;
public virtual void Init(Entity entity) {
this.entity = entity;
}
public virtual void Destroy() {}
}
}

  这里令Component拥有entity是为了便于识别身份,[Serializable]标识表示该对象可序列化(与编辑器交互),[NonSerialized]标识表示不让该变量序列化(没有显示在编辑器的需求)。接下来看看Position组件的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Game.Components {
using Core;
using Solts;
public class Position : Component {
public static Dictionary<Entity, Position> Map = new Dictionary<Entity, Position>();
public static List<Position> List = new List<Position>();
public Vector2Int value;
public Collision collsionSlot;
public override void Init(Entity entity) {
base.Init(entity);
Position.Map.Add(entity, this);
Position.List.Add(this);
}
public override void Destroy() {
Position.Map.Remove(this.entity);
Position.List.Remove(this);
}
}
}

  关于ECS框架有一个很普遍的问题:在System要如何获取到Component?我的解决方法便是为有获取需求的Component设立存储容器,当然这种写法有点死板,应该专门设立容器管理类进行自动化处理,这是个可改善的方向。

System

  System纯粹来看便是个函数集,在Entitas的实现是专门设立Behavior装载System以运行。而我选择分离:System即Behavior,两者倒没什么根本上的区别,全凭个人喜好罢了。在以Behavior的实现下并不需要System基类,以下以涉及到坐标与碰撞的Field系统为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
using System.Collections.Generic;
using UnityEngine;
namespace Game.Systems {
using Core;
using Components;
using Entitys;
public class Field : MonoBehaviour {
public const float SIZE = 0.32f;
private static List<Entity> SyncList = new List<Entity>();
public static Vector2 ToPosition(int x, int y) {
return new Vector2(x * SIZE + SIZE * 0.5f, y * SIZE + SIZE * 0.5f);
}
public static void AdjustPosition(Position position, Transform transform=null) {
transform = transform == null ? position.entity.transform : transform;
transform.position = Field.ToPosition(position.value.x, position.value.y);
}
private static void Collide(Position a, Position b) {
if (a.value == b.value) {
if (a.collsionSlot != null) {
a.collsionSlot.Run(a.entity, b.entity);
}
if (b.collsionSlot != null) {
b.collsionSlot.Run(b.entity, a.entity);
}
}
}
private static void Sync(Position position, Joint joint) {
joint.laterPos = position.value;
}
protected void Awake() {
Entity.NewTickEvent += this.NewTick;
Entity.DestroyTickEvent += this.DestroyTick;
Director.UpdateTickEvent += this.UpdateTick;
}
private void NewTick(Entity entity) {
bool hasPos = Position.Map.ContainsKey(entity);
bool hasJoi = Joint.Map.ContainsKey(entity);
if (hasPos) {
Field.AdjustPosition(Position.Map[entity]);
}
if (hasPos && hasJoi) {
Field.SyncList.Add(entity);
}
}
private void UpdateTick() {
for (int i = 0; i < Position.List.Count; i++) {
for (int j = i + 1; j < Position.List.Count; j++) {
Field.Collide(Position.List[i], Position.List[j]);
}
}
foreach (var entity in Field.SyncList) {
Field.Sync(Position.Map[entity], Joint.Map[entity]);
}
}
private void DestroyTick(Entity entity) {
if (Field.SyncList.Contains(entity)) {
Field.SyncList.Remove(entity);
}
}
}
}

  可以看出,继承Behavior的System可以很方便地使用自带的各种回调函数(如Awake),业务函数也变得清晰无比,只需要提供相应Component即可(如AdjustPosition)。对于一些需要复合组件的业务(如Sync),则会专门设立容器(SyncList)进行存储,对Entity的NewTickEvent与DestroyTickEvent进行监控便可筛选出合适的对象,且所有组件可通过Entity从组件容器进行获取,十分方便。
  当然也不要忘记与编辑器结合的优势,System也可以将变量序列化与编辑器交互:
system
  当然Unity可进行序列化的部分只有实例变量,所以需要作此处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test : MonoBehaviour {
private static Test Instance;
public static int Get() {
return Instance.value;
}
public int value;
protected void Awake() {
Food.Instance = this;
}
}

  因为System是单例Behavior,所以这么做是安全的。如此便可操作实例对象了。

后记

  总的而言,ECS框架主要是一种对OOP思想的反思,甚至可以说是一种复古(函数式编程风格)。也是一种彻底的组件模式实现,彻底地奉行数据-逻辑分离。它使得我们更容易地去抽象、描述游戏事物。当然我认为它在某种程度上是反直觉的、抽象的(某些只会属于某个对象所属的业务却要分开写,并且用组件去涵盖)。所以我认为它更适用于某些场景下,如动作游戏里的地图单位,分为多种样式(物件、道具、战斗单位、NPC、飞行道具等),这种时候使用传统的继承+子对象写法确实不如ECS来得好了。再比如UI方面,我认为还是MVC框架更为王道。所以切忌教条主义,一切跟着实际需求走。

《Brick & Ball》开发总结(三)——游戏性

Posted on 2018-03-12 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  总算到了最后一篇了,这篇我想写一些关于本游戏在画面表现与游戏性方面的总结,毕竟游戏项目的重点不仅在于程序,还在于策划与美术。
  《Brick & Ball》(下称BNB)从游戏元素来看其实就是乒乓球与打砖块的结合,但其实创作灵感是源自《Table football》,这是一种实体游戏,当时我玩了之后便爱不释手,起了将其搬上手机的想法(实际上已有人这么做了)。但因其操作的复杂度对于手机而言过高遂进行了精简,便有了现在的BNB。

画面表现

界面风格

  界面的美术风格选择的是类似《Persona5》的黑白风格,这种风格可以明显地与场景区别出来。个人认为界面与场景的分隔是很重要的,这样方能轻松识别两者。

p5

ui

Tween运动

  Tween运动出自tween.js,意为包含多种样式的插值运动。由此可实现多种曲线的运动效果,在本项目使用的库为DOTween。
  Tween运动在游戏的运用可谓方方面面,如LOGO与按钮的弹出、镜头的运动、计分板的变化等等。它使得运动的表现更为柔和与多变,而不是朴素的线性运动。

tween

打击感

  作为存在碰撞交互的游戏,打击感的表现自然是重中之重。在搭载物理引擎的基础上,还表现为在高速下球会产生扭曲现象(如下图所示)以及产生碰撞后会出现特效、音效以及物体抖动、镜头抖动和手机震动的效果。

hit

战时无界面

  鉴于游戏性质的原因,游戏进行时并不适合存在界面显示,而事实上存在着计分以及冷却提示的需求。于是利用了两边的墙壁作为计分板,利用砖头的颜色变化作为冷却进度以及冷却完毕的提示。

游戏性

渐进的节奏

  由于本游戏的核心玩法实际上是较为单调单一的,所以必须严格控制游戏时长,不能陷入长期的拉扯战。所以必须加入逐渐让游戏加速的设定,具体表现为背景音乐的速度以及球的速度。当加速到一定程度便能穿透砖头,所以不会出现理论上的无限期游戏,达到了控制游戏时长的目的。

速度控制

  球的速度与游戏体验息息相关,所以对此作了严格的控制。球的初始速度来自发射,碰到砖块时会保留一定比例的当前速度并追加速度。所以理论上是会越来越快的,但速度累积到一定程度后便会陷入暴走,所以在保留比例上会采用反比例函数使得高速下的保留比例更低,而反之低速下的保留更多,从而达到更健康的游戏体验。另外撞到墙壁或拉伸下的砖块都会有速度加成。

场景变化

  随着游戏进度会使得场景产生变化,从而影响游戏性是一种不错的做法。BNB在这方面的体现则是计分板的变化,因为计分板的也是作为实体而存在的,球撞到计分板的边角会产生不一样的运动反应,以及撞到计分板上会获得更大的加速。通过这点使得游戏得到了更为渐进的体验,从而提高游戏性。

后记

  第三篇的内容显然比之一二少了很多,毕竟小游戏就是小游戏,在游戏内容上注定单一,但也可以看出蕴含不少讲究的。

《Brick & Ball》开发总结(二)——服务端

Posted on 2018-03-11 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  书接上文,这次要讲的是服务端。《Brick & Ball》(下称BNB)所需要的网络功能仅为匹配与联机对战而已,所以在最初对此是轻视的,在尝试了UNet与Photon之后感觉各有硬伤(UNet不支持纯服务端架设、Photon服务端为Windows),遂放弃了这些看似完备的服务端套件,转为使用小有名气的Skynet后,不负所望地顺利完成了,于此做个总结。

结构

  在阅读本文之前,你需对Skynet有个大致的了解。Skynet的业务单位称为服务,它是一种Actor模型的实现。以下是BNB服务端的服务结构:flow

  • Gate: 网关服务,负责管理用户(接入、踢出、发信、心跳包)。
  • Queue: 队列服务,负责用户的匹配,当有新的用户到来就会进入队列。
  • Lobby: 大厅服务,负责Room的管理(创建、关闭),完成匹配的用户就会为他们创建Room。
  • Room: 房间服务,负责用户的游戏提供(帧锁定同步)。

KCP

  对于BNB这种高实时性的游戏,自然是不方便使用TCP了(三次握手、非快速重传、滑动窗口),然而直接使用UDP又会有可靠性的问题(丢包、非顺序到达),业界流行的做法一般是在UDP的基础上实现重传保证可靠性,而其中比较著名的实现则是KCP(再次感谢Skywind!)。KCP虽然是用C语言实现的,但还是有C#和Lua的移植与封装的版本。

封包

  在封包的设计上我图省事使用了JSON,并在封包的首部使用了1字节作为标志,并未考虑加密(因为觉得没有意义)。C#方面直接使用内置函数解决,Lua则是使用了CJSON,字节处理则是使用Lua5.3新增的string.pack与string.unpack函数(非5.3需安装struct)。

接入用户

  按理来说客户端与服务端的初次连接使用TCP更为适合(一个KCP对象只服务一个连接,所以初次连接的客户端在服务端并没有对应的KCP对象),但是为了偷懒我采用了这样的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- Check ID of packet is ID.connect
if (not _agentMap[from] and string.unpack("b", data, #data) == _ID.connect) then
local addr = _SOCKET.ToAddress(from)
_SKYNET.Log("connect", addr)
_agentMap[from] = _Agent.New(1, from, function (_data)
_SOCKET.sendto(_udp, from, _data)
end)
_clientCount = _clientCount + 1
_agentMap[from]:Send(_ID.connect, {addr = addr, version = _version, isFull = _clientCount > _maxClient})
elseif (_agentMap[from]) then
_agentMap[from]:Input(data)
end

  对于不在用户列表的来源,则直接判定该包尾部1字节是否等于_ID.connect(KCP会在封包的头部添加信息,所以在没有JSON内容的情况下,该包尾部则是原封包的头部),这种野蛮的方式缺点自然是只能填写标识而不能添加JSON。所以如果客户端还需要一些信息的话还需要收到回执后补充,当然事实上就需要回执:存在着因版本不对、服务器爆满的情况而拒绝连接的情况。所以服务端需要回执后方正式将其接入。

心跳包

  鉴于UDP的无连接特性,是无法判断用户是否掉线的(事实上TCP的机制也非完美)。业界通行的做法是做心跳包,即每隔一段时间进行通信以确定对方仍存活。BNB采用的方式是礼尚往来(客户端每隔一段时间发送心跳包,服务端收到后发送回执),即双端皆有心跳状态:在客户端看来,无论是超时没有收到心跳包、亦或是自身无法发出心跳包,都视为掉线。在服务端看来,只要该客户端超时没有发过心跳包,即踢出之:

1
2
3
4
5
6
7
8
9
10
-- Server
function _FUNC.Heartbeat()
for k, v in pairs(_agentMap) do
if (not v.heartbeat) then
_FUNC.Kick(k)
else
v.heartbeat = false
end
end
end
1
2
3
4
5
6
7
8
9
10
11
// Client
private void HeartbeatTick() {
if (!this.heartbeat) {
this.Disconnect();
}
else {
this.Send(EventCode.Heartbeat);
this.heartbeat = false;
this.heartbeatTimer.Enter(HEARTBEAT_INTERVAL, this.HeartbeatTick);
}
}

匹配队列

  BNB的匹配规则就是没有规则,匹配到了两名玩家就开始游戏,所以只需设计一个队列即可。每逢有新用户接入后便会进入队列,如用户离去则从队列消除,若匹配成功则为他们创建一场游戏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function _CMD.OnHandshake(id, fd)
if (not _SKYNET.Call(_gate, "CheckAgent", fd)) then
return
end
if (not _readyFd) then
_readyFd = fd
else
_SKYNET.Send(_lobby, "NewRoom", _readyFd, fd)
_readyFd = nil
end
end
function _CMD.OnDisconnect(id, fd)
if (_readyFd == fd) then
_readyFd = nil
end
end

创建游戏

  在匹配完成后,便会由Lobby服务为一对用户创建房间(Room服务),在此之前会对两名用户是否在线进行检查,若不满足则将两名用户进行踢出,需重新进行连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _CMD.NewRoom(leftFd, rightFd)
local fds = {leftFd, rightFd}
if (not _SKYNET.Call(_gate, "CheckAgent", fds)) then
_SKYNET.Send(_gate, "Kick", fds)
return
end
local deviceModels = _SKYNET.Call(_gate, "GetAgentValue", fds, "deviceModel")
_leftFdMap[leftFd] = rightFd
_rightFdMap[rightFd] = leftFd
_roomMap[leftFd .. rightFd] = _SKYNET.newservice("room")
_SKYNET.Send(_roomMap[leftFd .. rightFd], "Start", leftFd, rightFd, deviceModels[1], deviceModels[2])
_SKYNET.Log("start room", _SOCKET.ToAddress(leftFd), _SOCKET.ToAddress(rightFd))
end

  创建房间之后,会为对应的客户端发送开始游戏所需的数据(随机数种子、双方阵营所属)。待客户端初始化完毕后,游戏正式开始:

1
2
3
4
5
6
7
-- Server
function _CMD.Start(leftFd, rightFd, leftDevice, rightDevice)
_fds = {leftFd, rightFd}
_deviceMap[leftFd] = leftDevice
_deviceMap[rightFd] = rightDevice
_FUNC.Send(_ID.start, {seed = os.time(), leftAddr = _SOCKET.ToAddress(leftFd), rightAddr = _SOCKET.ToAddress(rightFd)})
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Client
private void OnStart(byte id, string data) {
var obj = JsonUtility.FromJson<Datas.Start>(data);
Random.InitState(obj.seed);
Judge.PlayerType = this.addr == obj.leftAddr ? PlayerType.A : PlayerType.B;
Judge.SetAddr(obj.leftAddr, obj.rightAddr);
this.startGameSlot.Run(this.gameObject);
this.online = true;
this.updateTimer = 0;
this.frame = 0;
this.playFrame = 0;
this.exitCode = ExitCode.None;
this.sendInLoop = false;
this.playDataList.Clear();
this.playDataList.Add(new PlayData());
Networkmgr.MovingValue = 0;
Networkmgr.WillElaste = false;
}

帧锁定同步

  如上文所言(在采用传统帧锁定同步的基础上,服务端设定等待时长,超时则继续),服务端的业务设计成当接收到一名用户的输入包后,就会开始进行计时(9毫秒,约等于客户端的5帧,即WAITTING_INTERVAL)。若超时或在时间内抵达第二名用户的输入包,则进行结算(广播用户们的输入数据)。用户的输入数据在服务端是作为缓存式的,超时了也会进行记录,作为下一次结算所用,每次结算后输入数据则会清空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function _CMD.ReceiveInput(fd, obj)
_inputMap[fd] = obj.data
-- Must be current frame, otherwise only save input data.
if (obj.frame == _playFrame) then
if (not _readyPlay) then
_readyPlay = true
local time = _playInterval - (_SKYNET.now() - _timer)
time = time < 0 and 0 or time
_SKYNET.timeout(time, _FUNC.Play) -- Server will run _FUNC.Play after the time goes on.
else
_FUNC.Play()
end
end
end
function _FUNC.Play()
if (not _readyPlay) then
return
end
_TABLE.Clear(_playSender.addrs)
_TABLE.Clear(_playSender.inputs)
for k, v in pairs(_inputMap) do
table.insert(_playSender.addrs, _SOCKET.ToAddress(k))
table.insert(_playSender.inputs, v)
end
_FUNC.Send(_ID.input, _playSender)
_TABLE.Clear(_inputMap)
_readyPlay = false
_timer = _SKYNET.now()
_playFrame = _playFrame + 1
end

维护

  服务端不同于客户端,发生错误使程序崩溃的代价是很大的,所以有必要建立完善的应对措施。所幸目前发现Skynet发生错误时会影响的仅为Skynet.fork的函数(发生错误后函数会停止运行),并不会导致整个服务崩溃乃至进程崩溃。于是只要利用Lua的pcall(func, ...)函数进行异常处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function _SKYNET.Loop(Func, sleepTime)
local LoopFunc = function()
while true do
local ret, text = pcall(Func)
if (not ret) then
_SKYNET.Log(text)
_SKYNET.Warn()
end
_ORIGIN_SKYNET.sleep(sleepTime)
end
end
_ORIGIN_SKYNET.fork(LoopFunc)
end

  当然遇到问题仅仅是堵住那只是治标不治本,所以我采用了邮件报警机制。只要在config文件填写mail,然后调用_SKYNET.Warn()即会发送到目标邮箱,且整个进程生命周期内只会发送一次,避免疯狂轰炸的情况:

1
2
3
4
5
6
function _SKYNET.Warn()
if (_mail and not _DATA_CENTER.get("hasWarn")) then
os.execute(string.format("shell/warn.sh %s '%s'", _mail, _logger))
_DATA_CENTER.set("hasWarn", true)
end
end

  虽然理论上没有会令Skynet进程崩溃的情况,但以防万一,还是专门做了崩溃重启的措施:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
basepath=$(cd `dirname $0`; cd ..; pwd)
cd $basepath
while true
do
count=`ps -ef | grep skynet | grep -v "grep" | wc -l`
if [ $count -gt 0 ]; then
:
else
echo "program has crashed, restarting..."
screen shell/run.sh
fi
sleep 10
done

  还有一点就是,帧锁定同步的浮点数问题并不是那么令人放心的存在。所以有必要对其进行监控(这个在上文也有提到),同理遇到异常情况也会进行邮件报警:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function _CMD.ReceiveComparison(fd, obj)
if (not _comparsionHandler[obj.playFrame]) then
_comparsionHandler[obj.playFrame] = {}
end
local map = _comparsionHandler[obj.playFrame]
map[fd] = obj.content
if (_TABLE.Count(map) == _playerCount) then
local lk, lv
for k, v in pairs(map) do
if (lv and v ~= lv) then
-- Output current frame, each device name and comparsion data.
_SKYNET.Log(obj.playFrame, _deviceMap[k], v, "!=", _deviceMap[lk], lv)
_SKYNET.Warn()
end
lk = k
lv = v
end
_comparsionHandler[obj.playFrame] = nil
end
end

后记

  这次是本人初次进行服务端开发,如有不妥之处但请指教。虽无涉及数据库、反作弊、集群、运维等方面,但也不失为一个匹配-房间-帧锁定同步的好范例。

一次移植代码时关于For循环的发现

Posted on 2018-03-11 | In ProgramDesign | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  今日使用Python移植一份C++实现的算法发生了效果不一致的问题,经过仔细分析后发现问题竟出自For循环,由此引申此文。

详情

  首先来看看C++的For循环:

1
2
3
4
5
int i = 0;
for (i = 0; i < 5; i++) {
//do something
}

  然后再来看看根据直觉进行移植的Python代码:

1
2
3
4
i = 0
for i in range(0, 5):
# do something

  咋看之下似乎并没有问题,但若是在循环结束后输出i值,其结果竟是不一致的(C++为5,而Python为4)!在大多数情况下,C++的循环变量并不会在外部定义,所以其生存域仅在For循环之中,这种情况下并不会有什么问题。然而一旦如此,便代表i肯定要用于后续了,如此结果相差1便可让整个程序炸掉。
  仔细想来C++和Python的循环实现本就不一致,C++是使用一个逻辑值和两个表达式展开的,而Python则是一个生成器,且Python的生存域向来独树一帜,结果不一致也是理所当然的。可是在凭直觉或未了解过的情况下进行移植时便很容易中招,这便是本文的意义,以示警戒。

其他语言的For循环

  俗话说举一反三,遇到这种问题自然会想到其他编程语言关于For循环的细节,我便选择了我日常使用的Lua和C#做了尝试:

1
2
3
4
5
local n = 1
for n=1, 5 do
--do something
end
1
2
3
4
5
int i = 0;
for (i = 0; i < 5; i++) {
//do something
}

  Lua循环完毕后n的值为1,可谓相当遵守生存域了(循环是一个域,与外部变量无关)。而C#则是与C++一致,这也是理所当然的,毕竟以上代码跟C++长得完全一致嘛(估计Java也是如此)。

后记

  仔细想来编程语言之间的循环代码虽然看起来相似,但实际上他们的具体实现乃至表达式都是有所不同的,可见不可想当然,否则就容易遭遇今日这样的问题了。

《Brick & Ball》开发总结(一)——帧锁定同步

Posted on 2018-03-09 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  辗转反侧三个月,《Brick & Ball》(下称BNB)的开发工作总算告一段落了,游戏也顺利地在TapTap上架以及在Github开源了。接下来将会对帧锁定同步、服务端、游戏性三方面进行开发的总结,预计会用三篇完成,敬请关注。
  由TapTap上的介绍可知,BNB是拥有联机对战模式的,而联机对战的重点自然在于同步,本文便对同步的实现的相关问题做个总结。

实现思路

  在阅读此文之前,你需要对帧锁定同步有个大致的了解,关注我的博客应该知道在去年我已经对此做了个初步的探究。从现在来看当时的实现还不够好,于是很有必要重新梳理一遍。
  若是有尝试过实现帧锁定同步的朋友相信对于传统的帧锁定同步(Lockstep,所有玩家的延迟都是延迟最差的那位)实现还好说,但对于“乐观帧锁定(不会等待延迟高的玩家)”的实现,则是各说纷纭。除了上文所说的那种方式(服务端主动每隔一段时间广播,客户端输入数据随时上传),还有一种《Warcraft III》的实现(在采用传统帧锁定同步的基础上,服务端设定等待时长,超时则继续),个人认为这种实现更为靠谱,也通过这次实践证明了其可行性。

帧锁定

  顾名思义,帧锁定同步分为帧锁定与同步两大部分。所谓帧锁定,个人认为便是将Update部分严格管控起来,以此修正了玩家之间帧率不一致的问题以及收到多个输入包时的快进处理。接下来看看其具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void Update() {
this.updateTimer += Mathf.CeilToInt(Time.deltaTime * 1000);
//DT = 17
while (this.updateTimer >= DT) {
this.client.Update(); //Receive packet
if (this.playDataList.Count > 1) {
var lateFrame = this.frame;
this.sendInLoop = true;
do {
this.client.Update(); //Receive packet
this.LockUpdate(true);
} while(this.playDataList.Count == 1 && this.frame == lateFrame);
//Go to latest process
}
this.LockUpdate();
this.updateTimer -= DT;
}
}

  帧锁定的核心便在于此,在每次Update进行时间累积,只有累积到了额度(DT)后会进行真正的更新(LockUpdate),且每次更新后会扣除额度,以求最精确,这里对计时进行毫秒化也是为此。这种手段在Unity被称为FixedUpdate,当然我们的需求不仅于此,因此并没有使用它。
  除此之外,便是收到多个输入包进行快进的处理了。这里的this.frame代表当前进度下的帧号,每当进入下一个进度后便会清0。于是我们只要快进到最新进度下的当前帧即可,随后再进行一次正常的LockUpdate。当然不要忘记快进时也有必要进行接收数据包,因为在快进时仍可能有后续输入包的到来。

LockUpdate

  接下来便来看看LockUpdate的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
private void LockUpdate(bool inLoop=false) {
//WAITTING_INTERVAL = 5
if (this.online && this.frame + 1 == WAITTING_INTERVAL && this.playDataList.Count == 0) {
return;
}
if (this.online) {
this.frame++;
if (this.frame == WAITTING_INTERVAL) {
var data = this.playDataList[0];
this.playDataList.RemoveAt(0);
if (Judge.IsRunning && data.addrs != null) {
//Apply later input
for (int i = 0; i < data.addrs.Length; i++) {
Judge.Input(data.addrs[i], data.inputs[i]);
}
}
this.playFrame++;
this.frame = 0;
if (!inLoop || (inLoop && this.sendInLoop)) {
//Send now input
var input = new Datas.Input() {
data = new InputData() {
movingValue = Networkmgr.MovingValue,
willElaste = Networkmgr.WillElaste
},
frame = this.playFrame
};
this.sendInLoop = false;
Networkmgr.WillElaste = false;
this.client.Send(EventCode.Input, input);
}
//Send comparison data
var comparison = new Datas.Comparison() {
playFrame = this.playFrame,
content = Judge.Comparison
};
this.client.Send(EventCode.Comparison, comparison);
}
}
//Game world update
Networkmgr.UpdateEvent();
Networkmgr.LateUpdateEvent();
}

  LockUpdate主要做的事情为每隔一定帧数(WAITTING_INTERVAL)上传当前的操作输入,以及应用上次的操作输入(来自服务端),如果到了关键帧时上次的操作输入包仍未抵达,则会陷入等待。也就是说,帧锁定同步其实就是一种每隔几帧的回合制而已。具体的流程图可参考Skywind的提供:
framelock

  当然这里还有个细节要注意:在处于快进的时候,上传输入只会在初次进行,因为在快进下实际上能响应到玩家的操作其实一开始就定下了,后续进行的上传也只会是相同的,所以没有意义。

LockBehaviour

  由LockUpdate可知两行代码Networkmgr.UpdateEvent();和Networkmgr.LateUpdateEvent();,它们是两个event,负责绑定执行整个游戏涉及到帧锁定的Update,毕竟Unity并不存在主宰一切的主Update,所以只好用这种方式实现。为此专门涉及了LockBehaviour:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LockBehaviour : MonoBehaviour {
public enum OrderType {
Normal,
Late
}
[SerializeField]
protected OrderType orderType = OrderType.Normal;
protected void Awake() {
if (this.orderType == OrderType.Normal) {
Networkmgr.UpdateEvent += this.LockUpdateWrap;
}
else {
Networkmgr.LateUpdateEvent += this.LockUpdateWrap;
}
}
protected void OnDestroy() {
if (this.orderType == OrderType.Normal) {
Networkmgr.UpdateEvent -= this.LockUpdateWrap;
}
else {
Networkmgr.LateUpdateEvent -= this.LockUpdateWrap;
}
}
private void LockUpdateWrap() {
if (this.isActiveAndEnabled) {
this.LockUpdate();
}
}
protected virtual void LockUpdate() {}
}

  LockBehaviour继承于MonoBehaviour,且设立了LockUpdate函数,启动后便会对UpdateEvent进行注册,同理销毁后便会去除。如此一来涉及到帧同步的组件只要继承LockBehaviour并将业务写在LockUpdate便可。当然由此可以看出,Unity官方实现的组件并不吃这套,所以为此我专门引入了一款第三方物理引擎——Jitter。

同步优化

  由于BNB的操作方式并非点击鼠标、按下键盘这种间歇性操作,而是最为不适合用于联机的拖动。所以在正常情况下的表现效果很差(每隔5帧瞬移一下,形成顿顿的感觉),于是PVP模式下采取了与PVE不同的拖动表现:赋予拖动表现为流畅变速的运动,当然在那短短的时间里是不可能做出流畅的运动表现的,所以选择运动的时间基准其实更长(0.25秒)。当收到新的输入时便会直接完成运动(直接到目的坐标)且继续新的运动。使用这种方式达到了相对不错的效果,当然代价便是砖块的运动相应并非实时性的,变相增加了操作难度。这也是无奈之举,好在这实际上是公平的(双方皆如此)。
  除此之外便是为向上拖动做了缓存处理,只要你曾进行了此行为,便会标记你做了该行为,在下一个进度时生效。这样比之到了关键帧时才响应操作要好多了,增加了操作的命中率。

浮点数处理

  帧锁定同步的一大心病便是不同设备下浮点数的处理结果不一致导致的不同步,著名的解决方案有定点数和尾数截断。而BNB采用的方式为尾数截断,其实现方式为:

1
2
3
4
5
6
7
public static float ToFixed(this float value) {
return Mathf.Floor(Mathf.Abs(value * 1000)) * 0.001f * value.ToDirection();
}
public static int ToDirection(this float value) {
return value >= 0 ? 1 : -1;
}

  这种尾数截断的方式便是主动限制小数点范围,以减少精度的方式阻止错误的发生。如此运用在各种涉及到同步方面的浮点数进行处理即可。而在这方面最大的敌人便是物理引擎了,众所周知物理引擎拥有自己的生态环境,牵涉内容甚多,经过一番艰苦尝试后最终选择了放弃修改其内核,而是改为自己实现物理运动。毕竟物理引擎的两大组成为运动和判定,如此只使用其判定即可。
  当然这浮点数的处理总体而言仍是涉及甚广,所以需要进行专门的监控调试。LockUpdate里的comparison即是为此,其涉及属性Judge.Comparison内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static string Comparison {
get {
var sb = new StringBuilder();
var pos = Ball.Position;
var vel = Ball.Velocity;
sb.Append(pos.x + ",");
sb.Append(pos.y + ",");
sb.Append(pos.z + ",");
sb.Append(vel.x + ",");
sb.Append(vel.y + ",");
sb.Append(vel.z + ",");
sb.Append(INSTANCE.teamA.brick.transform.localScale.x + ",");
sb.Append(INSTANCE.teamA.brick.transform.position.x + ",");
sb.Append(INSTANCE.teamA.brick.transform.position.z + ",");
sb.Append(INSTANCE.teamB.brick.transform.localScale.x + ",");
sb.Append(INSTANCE.teamB.brick.transform.position.x + ",");
sb.Append(INSTANCE.teamB.brick.transform.position.z + ",");
sb.Append(INSTANCE.teamA.wall.transform.position.z + ",");
sb.Append(INSTANCE.teamB.wall.transform.position.z + ",");
return sb.ToString();
}
}

  是的,很粗暴的处理方式,将游戏影响同步的相关数据进行文本化,在每个关键帧都将其上传令服务器进行匹配,当然也可以选择做成MD5码,当然这样便无法知晓具体哪个部分出了问题,故直接上传。

后记

  总体来说帧锁定同步涉及的内容还是挺多的,另外还有诸如防作弊之类的问题没有探讨,因为我认为BNB没有做反作弊的必要(小游戏)。具体许多细节还是要亲力亲为去实践一遍方可出真知。

C#之弱事件(Weak Event)的实现

Posted on 2018-02-18 | In ProgramDesign | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  最近使用C#开发项目时,发现一个会导致内存泄漏的陷阱——event里的成员并非弱引用,这样便会导致与event相关联的对象都不会被回收,从而导致内存泄漏。如此便很有必要实现一款弱事件(Weak Event)以解决此问题。

分析

  首先当然是找找是否存在现成的方案,答案是有的,不过很奇怪的是,该解决方案隶属于WPF,那么便没戏了。从网上来看也有不少各自的实现,不过个人对此都不算太满意,于是便打算自己造个轮子。
  实现弱事件自然需要用到弱引用,而弱引用的具体实现则是WeakReference,可以根据Delegate提供的Target作为弱引用对象,Method作为调用。
  剩下的问题便是Delegate的参数问题了,很可惜Delegate似乎不支持作为泛型,但是Delegate的参数还是支持的。但即便是支持,也不方便作为多个参数来进行了。那么只能选择继承EventArgs了,EventArgs本身是个空类,一般做法是继承它然后自定义,这也是微软官方所推荐的做法。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using System;
using System.Reflection;
using System.Collections.Generic;
public class WeakEvent<TEventArgs> where TEventArgs : EventArgs {
public delegate void Func(TEventArgs e);
private static object[] ARGS = new object[1];
private class Unit {
private WeakReference reference;
private MethodInfo method;
private bool isStatic;
public bool IsDead {
get {
return !this.isStatic && !this.reference.IsAlive;
}
}
public Unit(Func callback) {
this.isStatic = callback.Target == null;
this.reference = new WeakReference(callback.Target);
this.method = callback.Method;
}
public bool Equals(Func callback) {
return this.reference.Target == callback.Target && this.method == callback.Method;
}
public void Invoke(object[] args) {
this.method.Invoke(this.reference.Target, args);
}
}
private List<Unit> list = new List<Unit>();
public int Count {
get {
return this.list.Count;
}
}
public void Add(Func callback) {
this.list.Add(new Unit(callback));
}
public void Remove(Func callback) {
for (int i = this.list.Count - 1; i > -1; i--) {
if (this.list[i].Equals(callback)) {
this.list.RemoveAt(i);
}
}
}
public void Invoke(TEventArgs args=null) {
ARGS[0] = args;
for (int i = this.list.Count - 1; i > -1; i--) {
if (this.list[i].IsDead) {
this.list.RemoveAt(i);
}
else {
this.list[i].Invoke(ARGS);
}
}
}
public void Clear() {
this.list.Clear();
}
}

  以上便是弱事件的实现代码了,其实原理与Caller基本一致。接下来是演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;
public class Obj {
public void Do(EventArgs e) {
Console.WriteLine("test");
}
public static void StaticDo(EventArgs e) {
Console.WriteLine("static");
}
public static void Main(string[] args) {
var a = new Obj();
var b = new Obj();
var weakEvent = new WeakEvent<EventArgs>();
weakEvent.Add(a.Do);
weakEvent.Add(b.Do);
weakEvent.Add(StaticDo);
weakEvent.Add((EventArgs e) => Console.WriteLine("lambda"));
a = null;
weakEvent.Remove(StaticDo);
GC.Collect();
weakEvent.Invoke();
Console.WriteLine(weakEvent.Count);
}
}

  输出结果为:

1
2
3
lambda
test
2

  以上分别演示了静态方法、实例方法、匿名方法,其中静态方法和匿名方法需要手动调用Remove将之移除,如演示一般那样匿名方法便无从回收了,这点需要注意。如此弱事件便完成了,当然它带来了一定的性能损耗,这是无可避免的。也并未经过长久实践的磨砺,可以说只是一个原型罢了。

后记

  类似这样的内存泄漏问题在开发过程中可有不少,尤其是有了GC的庇护下对此更为麻痹。一般需要定期使用专业工具进行检测,这也是优化的一环啊。

《DNFMobile》图片资源提取笔记

Posted on 2018-01-20 | In Analysis | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  《DNFMobile》的新一波内测到来了,恰好得到了安装包。便欲对比其资源较之端游有何不同,遂试图提取之。在尝试的途中遇到了不少问题,特此记录之。源码地址

读取资源包

  与众多手游的习惯一样,初始的安装包所携带的资源甚少,皆需经过更新方才完整。更新后经过观察得知游戏采用Unity制作,那么事情便简单了,直接上UnityStudio读取。虽然资源文件的后缀名为.npk,但实际上则是Unity的AssetBundle,且并未作加密。然而诸多现成的Unity提取工具皆有多少缺陷(无法识别pvr格式、无法批量化操作、导出资源过于原始等),且图片资源是大图形式存在的,需要进行切图,而使用切图工具一则怕不够精确,二则怕无法批量化。于是我选择直接使用Unity制作工具以面对此需求。
  由于资源文件本身即是Unity的格式,那么直接调用API加载即可,类似如此:

1
2
3
4
5
6
var assetBundle = AssetBundle.LoadFromFile(path);
var assetBundle.LoadAllAssets<Texture2D>();
foreach (var tex in texs) {
//...
}

切图

  这样可谓相当方便,接下来的问题便是切图了。我本以为大图是由Unity自动生成,所以理应资源内会有对应的Sprite资源,这样通过Sprite资源的信息即可进行切割。但实际上并非如此:大图是事先生成好,然后使用脚本填写每帧配置在运行时自动生成Sprite。这种做法也是理所当然的,毕竟Unity的Sprite的pivot与DNF的IMG包提供的偏移点可谓天南地北。(一者为当前图片下的浮点百分比,另一者为实际坐标)通过直接在配置直接对接IMG包的数据然后进行转化这是很正常的做法。可这下子就麻烦了,我们并无法直接知道这脚本的具体信息。幸好UnityStudio的解析中包括了关于MonoBehavior资源配置的信息。
bwanga
  可即使知道也无法直接Unity进行获取,毕竟我们本身是没有该脚本的。鉴于UnityStudio开源的特性,我起初打算阅读源码掌握其解析之法。最后也成功了,可我突然脑内灵光一闪,想到了直接建立一个同名脚本,并根据配置的信息进行模拟。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class DNFAtlasAsset : MonoBehaviour {
[Serializable]
public struct Rectf {
public float x;
public float y;
public float width;
public float height;
}
[Serializable]
public struct DNFAtlasElement {
public string name;
public int originIndex;
public int referenceIndex;
public int originWidth;
public int originHeight;
public int offsetX;
public int offsetY;
public Rectf rect;
}
[Serializable]
public struct DNFAtlasSlot {
public int matType;
public DNFAtlasElement[] elementList;
}
public string atalsName;
public DNFAtlasSlot[] atlasSlotList;
}

  天可怜见,居然成功了!那么接下来采用类似如下方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int width = (int)element.rect.width;
int height = (int)element.rect.height;
if (width == 0 || height == 0) {
continue;
}
var colors = tex.GetPixels((int)element.rect.x, (int)element.rect.y, width, height);
var newTex = new Texture2D(width, height, tex.format, tex.mipmapCount > 1);
newTex.SetPixels(colors);
newTex.Apply();
var path = this.GetPath() + tex.name + "/";
var bytes = newTex.EncodeToPNG();
var json = JsonUtility.ToJson(element);
var name = this.ToNumber(element.name);
this.CreateDirectory(path);
File.WriteAllText(path + name + ".json", json);
File.WriteAllBytes(path + name + ".png", bytes);
Texture2D.DestroyImmediate(newTex, true);

  主要思路便是通过GetPixels方法读取区域像素并覆盖至新图。最后将图片转换为PNG、配置转换为JSON并输出即可。
  当然这里还有关于Texture2D的readable问题,隶属于资源包的Texture2D并无法直接使用GetPixels方法,需要对其进行复制,然后利用新图施为,为此我写了个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Texture2D GetTexture(Texture2D tex){
if (!this.texureMap.ContainsKey(tex)) {
if (tex.width == 0 || tex.height == 0) {
return null;
}
var copyTex = new Texture2D(tex.width, tex.height, tex.format, tex.mipmapCount > 1);
copyTex.LoadRawTextureData(tex.GetRawTextureData());
copyTex.Apply();
var writeTex = new Texture2D(copyTex.width, copyTex.height);
writeTex.SetPixels32(copyTex.GetPixels32());
writeTex.Apply();
writeTex.name = this.ToName(tex.name);
this.texureMap[tex] = writeTex;
Texture2D.DestroyImmediate(copyTex, true);
}
return this.texureMap[tex];
}

  以上便是关于切图方面的问题,具体可参阅源码。

内存问题

  游戏目前的资源包数量高达2900以上,在尝试一口气全部提取时内存竟然高达5G!最终电脑不堪重负倒下收场。这很显然是资源并未回收所致,是以作此函数:

1
2
3
4
5
6
7
8
9
10
public void Destroy() {
foreach (var texture in this.texureMap) {
Texture2D.DestroyImmediate(texture.Key, true);
Texture2D.DestroyImmediate(texture.Value, true);
}
this.texureMap.Clear();
this.assetBundle.Unload(true);
AssetBundle.DestroyImmediate(this.assetBundle, true);
}

  这里采用的是DestroyImmediate方法,好处是立即进行回收,但却会因此阻塞,影响提取效率。若使用Destroy方法则不会如此,不过峰值内存会上升。但大规模读取时还是以稳定为主,而小份读取则两者并无所谓。是以选择DestroyImmediate方法。
  另外在其他地方涉及到资源生成且是继承自UnityEngine.Object的,皆需注意此问题。在经过优化后,占用由5G跌倒了500M-1.5G之间,成功提取了全部图片资源。大功告成!

后记

  这次《DNFMobile》的声音资源经过了高压,原本几M的音乐变成了上百K,可谓惨不忍听,遂无提取的价值。而纸娃娃方面则是采用了类似NPK_Ver4的色板做法,也并无法直接提取到成品。由此可见制作组为了节省空间可是下了不少功夫呀。

2017年度总结

Posted on 2017-12-31 | In Talk | | Visitors

  时境过迁,不知不觉一年又要过去了。不禁感慨,特此总结下今年的经历与收获,以作归纳。
  要说今年最以往不同的那便是开通了个人的bolg,从2月26日开始共写了13篇,可谓差不多是月均一篇了。写博文最大的好处便是可以记录问题、归纳知识,乃至传播分享。感觉还是很不错的,目测接下来仍会继续。
  学业方面由于上了本科,开始有机会去补充理论知识的空缺了。在《数据结构》和《操作系统》课程上得到了不少收获。并且还会抽空阅读《C++ Primer》和《计算机网络》,虽不算学有所成,但也算开阔眼界。
  出于积累算法底蕴及应付面试,今年开始刷起了LeetCode,不过要说刷其实也不对,毕竟只是一日一题而已。只能说是当成益智游戏了。
  项目方面,出于《Lavivagnar》和《Brick&Ball》的缘故,对于Unity的掌握可谓正式上了轨道,果然最佳的学习方式就是实践,JUST DO IT。
  《DFQ》方面,总算是摆脱了去年以来的迭代旋涡,项目结构也算是稳定下来了,开发正在有条不絮的进行中,目前对于资源管理方面积累了不少经验,来年分享。
  《GreatFighter》因为忙碌的关系,算是停工了,可惜无法见识到Manistein的大展施为,以及少了个实践C++的机会。不过时间就这么多,勉强不来的,还是要知足,做好能做的事情。
  要说今年还有个收获的话,那便是将Python作为了生产力工具,开发了各种工具和脚本。也因此有幸参与了ChawDoe的《SOB》,对于Qt及网络编程有了不少的积累。
  以上便是本人的2017年度总结,明年一月还会有《Brick&Ball》的开发总结,以及开发中得出的一些经验和问题记录,敬请期待。
  无双草泥马
  2017.12.31

1…3456
Musoucrow

Musoucrow

53 posts
7 categories
37 tags
RSS
Coding Github
© 2022 Musoucrow
Powered by Hexo
Theme - NexT.Mist