CocosBuilder的Cocos2dx-lua绑定方案

CocosBuilder的Cocos2dx-lua绑定方案

前情提要

似乎写每一份代码,每一篇文章,都有些理由,所以忍不住要写个前情提要。

CocosBuilder虽然已经2年没有更新了,已经被SpriteBuilder所取代,但是由于我们早期的项目使用的就是CocosBuilder,而SpriteBuilder增加的功能并没有特别出彩,没有足够的理由让我们抛弃CocosBuilder,于是沿用下来。但是如果拿不开源的CocosStudio来比较,我还是推荐使用CocosBuilder

据我所知,最早让Cocos2dx的lua版本支持CocosBuilder的,是这个项目:LuaProxy。不知道为什么,CocosBuilder官方库只做了对js的支持,甚至做了html5的支持,却完全没有考虑对lua的支持。而LuaProxy就是建立在官方对js支持的基础上,做了lua的实现。但是读完CocosBuilder中js方案的实现,以及Cocos2dxCCBReader对js方案的实现,我居然产生了,这不可能是一个人写的吧,这种想法。因为js方案与cpp方案的思路完全不同,如果是同一个人做的,这也太蛋疼了吧?(不过我没有看js那边的使用方法,只是按照LuaProxy的使用方法去理解的。)

cpp方案用了一种非常优雅的方式,加载的过程中,自定义类需要有一个对应的Loader,使用字符串来索引Loader,以帮助CCBReader生成对应的类的实例。

LuaProxy的方案的缺陷

  1. 首先最不能忍的,就是用了一个全局table ccb来替代cpp中的registerCCNodeLoader,看起来好像可以解决,但是必须事先把所有需要绑定的Controller的名字写入这个ccb里面,也就意味着很可能许多不需要的代码会被提前加载,对于lua这样的脚本语言来说,这完全是不必要的代价。
  2. 全局ccb也带来了另外一个问题,同屏加载多个相同ccbi时,动态更新数据的问题。
    • 试想这种情况:你用CocosBuilder的粒子编辑器做了一束烟花,但是粒子贴图要在几种里面随机,这个需要在代码里面实现。于是你把这个CCParticleSystemQuad绑定到了ccb的一个table中。然后需求变了,策划需要同时放多个烟花,于是同时出现的烟花,只有最后一个的贴图可以随机了。因为他们共享同一个全局ccb的table,后加载的覆盖了前面加载的。这里只是举一个简单的例子,实际应用可能比这个情况复杂得多。
  3. 嵌套CCBFile时,无法对子CCBFile做操作的问题。可能子CCBFile绑定了某个lua对象,有一些函数可以调用,有一些成员变量可以使用。但是全在全局ccb里面,父节点根本不知道子CCBFile对应全局ccb的哪个对象。

解决方案

对于lua这样的脚本语言来说,完全可以在加载ccbi的过程中,动态加载需要的lua文件,进行绑定,我们需要做的只是设定一个绑定规则。然后对CocosBuilder做一些小改动,让publish的时候,同时生成lua文件,那整个开发流程将大大被缩短。

So,为了解决上面的3个问题,我做了两件事

  1. 修改了CCBReader读取的一些方法,提供给lua读取嵌套CCBReader的方法。并且重写了lua读取ccbi的代码。
  2. 修改了CocosBuilder的源代码,让它在publish的时候,直接生成对应的lua文件。

准备工作

1.下载以下cpp文件,覆盖掉cocos2dx的相应文件,然后重新编译Runtime

PS: 值得注意的是,我修改的是cocos2dx 3.2,不确定覆盖更高或者更低的版本会不会出问题,如果不是3.2的用户,可以查看这个commit,自行修改相应代码。

2.下载以下lua文件,放到lua项目的src对应目录下

3.获取修改过的CocosBuilder

有2个方法

  1. 从Github clone源代码自己编译:https://github.com/jennal/CocosBuilder
  2. 下载我编译好的版本:https://github.com/Jennal/CocosBuilder/blob/master/Release/CocosBuilder.app.tar.gz?raw=true

PS: 值得注意的是,由于我们的项目使用的默认设计尺寸是1200×800,所以代码中的缩放比例都是针对这个分辨率的。修改也不难,在这里:CocosBuilder/ccBuilder/ResolutionSetting.m,把顶部的两个define改成你自己要的设计尺寸就行了。

使用方法

改完Cocos2dx的框架代码,下载了lua代码,有了Cocosbuilder的修改版,准备完成,正式进入使用阶段。

1.ccb文件制作

ccb文件的制作基本没有什么特别,只是有几个点需要注意:

  1. Publish Settings里面多了个选项,记得勾起来。新建项目,默认是勾的。
    Publish Settings
  2. 文件名与该文件的Controller名,必须保持一致,这是为了简化接口,也为了让代码与设计统一
    文件名与该文件的Controller名,必须保持一致

2.代码绑定

生成的lua文件已经包含了一些注释,几乎不用有太多额外的代码,就可以很容易地绑定ccbi文件与lua类。

下面讲解几个需要注意的地方

  1. CCBLoader:setRootPath的两个参数

  1. 生成的lua文件的ctor相当于cpp中使用ccbi的onNodeLoaded,换句话说,代码执行到这里的时候,这个节点的子节点以及绑定都应该已经完成,可以放心使用了。可以在这里做一些初始化数据的工作。
  2. 创建ccbi节点的方法

示例下载

https://github.com/Jennal/CocosBuilder/blob/master/Sample/CCBSample-lua-binding.tar.gz?raw=true

感谢

虽然LuaProxy的方案并不完美,但也为我的改进铺平的道路,在这里感谢LuaProxy的作者shawnclovie

lua的面向对象库

接上一篇文章cocos2d-x中lua-binding的面向对象开发的研究,终于有时间整理一下我自己写的面向对象方案。
先上github地址:https://github.com/Jennal/LuaUtils/blob/master/src/Oop.lua

为什么要写这个库?

我们一直使用cocos2d-x来作为底层框架进行开发。之前用的是cpp,最近转到lua,所以难免保留一些cpp的习惯,包括饱受诟病的多继承。我不想讨论多继承有多好,或者有多糟。我认为,作为库来说,应该尽量提供符合大家习惯的机制,至于是否使用,如何使用,取决于用户。

我们知道lua在语言级别并没有支持类,而是提供了更灵活的metatable,我们可以利用metatable来实现类似cpp的类。cocos2d-x的lua绑定提供了类的支持,支持创建lua级别的类,也支持从cpp继承,但是对于面向对象机制来说,只提供继承,远远不够。

我想要的面向对象机制

  • 多层级继承
    • 从lua继承
    • 从lua继承后的lua继承
    • 从cpp继承
    • 从cpp继承后的lua继承
  • 单继承
  • 多继承
    • 调用不同父类的构造函数
    • 调用不同父类的同名函数
    • 多继承不允许从2个或者以上的cpp继承
  • 接口继承
    • 接口用来约束必须实现的成员函数
  • 判断是否是某个类的实例
    • 包括是否是某个父类的子类的实例

如何使用我的库

单继承

从lua创建对象

从cpp创建对象

多继承

纯lua多继承

lua与cpp的多继承

接口

判断是否某个类或接口的实例

在MacOSX下用cmake编译cocos2dx的笔记

最近项目想用Qt来做cocos2d-x的跨平台编辑器。由于对Qt和OpenGL都是新手,所以想用相对低成本的方法来做,参考了不少项目。发现好多人有用Qt来做cocos2d-x的编辑器的想法,包括cocos2d-x官方,但不知道什么原因,几乎所有的项目都中途流产了。大部分项目使用的是相对较旧的cocos2d-x版本,有些甚至无法编译成功。不过这些项目也给了我不少帮助,参考了很多有价值的信息。

最后,我决定自己做一个QtPort,选用CMake来做项目管理,因为新版的cocos2d-x已经带了CMakeLists.txt,如果直接用Qt的pro/pri/prf等项目文件,那工程量就太大了。

工具

otool

这个工具帮了很大忙,因为使用CMake,所有需要链接的库都需要自己设置。而我不知道需要哪些库,利用otool -L xxx来查看用XCode编译出来的xxx使用了哪些库,也让我顺利把所需的库都加进来。

安装组件

首先要用到的就是HomeBrew了。安装完HomeBrew,就可以用它安装各种东西了。

这里教大家一个小技巧,当编译提示

这里的-l后面就是缺失的库的名字,可以把这个名字直接放到brew install后面进行安装。
如果名字不对的话,也可以使用brew search freetype进行搜索。

问题

  • Spine库的CCSkeleton.cpp文件会编译不过,我们也用不到Spine库,所以暂时不编译它。
  • Audio相关的库编译不过,我在CMakeLists里加了个AUDIO_BUILDoption的选项,也暂时关闭它。
  • 编译lua时遇到了比较大的问题
    • 首先,因为我们禁用了Spine和Audio的编译,但是cocos2dx的tolua代码中,并没有根据这个条件编译进行判断,是否包含这些文件。为了防止使用这些文件出问题,我加了#ifdef来判断,当定义了不要Spine或者Audio时,把相关的lua函数都注册成空函数。
    • 然后是luasocket的问题,cocos2dx官方根本就没有写luasocket的CMakeLists.txt,不过写起来还算顺利,唯一需要注意的是luasocket的wsocket.c和usocket.c,是需要分平台编译的,wsocket.c是针对Win平台写的,usocket.c是针对unix-like系统写的。

关于CMakeLists的代码

好像没有特别需要说明的,基本就是CMake的规则,唯一值得一提的是,MacOSX下面的编译,记得这么做

这样生成的目录结构才会是这样的

最后

最后给出我的项目地址:https://github.com/Jennal/cocos2dx-3.2-qt

[转]在MacOSX下制作icns图标

以下内容转载自:http://www.cocoachina.com/bbs/read.php?tid=113763

步骤1

在任何一个目录下面创建一个文件夹,命名为 [name].iconset 例如 icon.iconset

步骤2

在该文件里面放入以下图片文件,并核对尺寸是否正确

图片名称 尺寸
icon_512x512@2x.png 1024×1024
icon_512x512.png 512×512
icon_256x256@2x.png 512×512
icon_256x256.png 256×256
icon_128x128@2x.png 256×256
icon_128x128.png 128×128
icon_32x32@2x.png 64×64
icon_32x32.png 32×32
icon_16x16@2x.png 32×32
icon_16x16.png 16×16

步骤3

打开终端,在里面输入以下命令

例如:

注:可以先输入iconutil –c icns(后面需带一个空格),再把步骤1所创建的文件夹拖到终端,则会自动把该文件夹的路径添加到刚输入的命令后面。

步骤4

在步骤3后,系统会自动生成[name].icns(icon.icns)文件,将该icns文件导入工程,并设置为Icon即可。

Apple官方原文由此进

解决cocos2dx-lua绑定print不能打印指针的问题

cocos2d-x的lua绑定,把原生的print函数给改掉了,就为了加个cocos2d: [LUA-print]的前缀,实在让人无语。临时工为了方便,直接去掉了table、user data可以显示指针地址的功能,所以我只好去改源代码了。

有2种改法,先说复杂的

找到这个文件

cocos2d-x/cocos/scripting/lua-bindings/manual/CCLuaStack.cpp

修改这个函数

lua_print

直接替换整个函数的代码

简单的办法

还是这个文件

cocos2d-x/cocos/scripting/lua-bindings/manual/CCLuaStack.cpp

找到这段代码

直接注释掉,这样就可以用lua原生的print函数了。如果还是想要那个前缀,很简单,只需要在lua文件里面写就可以了,具体就不用多说了。

cocos2d-x中lua-binding的面向对象开发的研究

起因

网路上已经有很多关于lua面向对象开发的文章,为什么我要自己写一篇呢?

一切都是因cocos2d-x 3.2的lua绑定而起。我们的新项目打算用lua进行开发,一直在跟进quick-x项目,从独立、第三方,到现在的被触控收编。cocos2d-x 3.2是最新的稳定版,把quick-x的很多好东西吸纳进来。与其说吸纳,不如说是直接把源代码拿过来了。所以,现在cocos2d-x 3.2直接就支持lua的面向对象开发,提供了原来只有quick-x才有的class等方法来方便的创建对象以及做继承。

既然cocos2d-x官方都认为quick-x的东西很好,我也不得不研究一下这套针对lua开发的创建类和继承的方法。不研究不要紧,一研究吓一跳。备受关注的quick-x,把自己宣传的那么好,那么便捷(我刚开始研究quick-x的时候,用它写过一个项目,提供的接口确实很便捷,大大提高了开发效率,但是没有研究实现细节),没想到最最底层,最最基础的部分,创建类、类继承的方案却如此业余,简直是一个刚学lua两三天的“高手”写出来的。说刚学两三天,是因为,从这个方案可以看出,完全没有理解lua语言的真谛,没有做到thinking in lua,而“高手”并不是贬义,一般人真的做不出这样的设计,写不出这样的代码。

分析

当然,也不是说这份代码一无是处(下个章节会详细分析),我这里先说说这份代码的优点和缺陷,仅代表个人意见,希望能引起共鸣。

优点

  1. 分解了创建实例(cls.__create)和构造函数的方法(instance:ctor),这样做的好处很明显,让混沌的思路清晰起来,并且“支持”从function继承,这也是支持从cpp对象继承的基础。
  2. 创造了类与类的实例之间的区别
    • 类:cls.class == nil
    • 类的实例:instance.class ~= nil
  3. 支持从cpp、lua的table继承,也支持创造新的类
  4. 支持默认构造函数
    • 当创建新的类时,有一个什么都不做的构造函数
    • 当lua的table继承时,可以调用父类的构造函数

缺陷

  1. 不支持多继承,这对于从cpp开发转过来的开发人员来说,可能是最大的问题,因为lua的特性其实是可以支持多继承的
  2. 没有充分利用lua的特性(下面的review详细展开讲)
    • 从lua的table继承时,不需要做clone,clone会失去很多好处,并且带来额外的内存开销
  3. 构造函数在继承过程中存在设计缺陷(下面的review会展开讲)

小结

所以总体上来看,还是优点多于缺点的。但是对于用惯了多继承(即使是不支持多继承的语言,比如Java、C#也是支持多接口实现的)的我来说,这点实在不可忍,所以不得不自己写了一套继承机制,为了避免这篇文章太长,将在下一篇文章中公开。

代码review

下面是重头戏了,让我们来看看class函数的代码。

为了让没有使用过这个函数的读者也能读懂,先说一下函数的用法吧

总体上看,这个函数可以分为3个部分(我已经在代码中用注释标明)。

  1. 局部变量声明以及参数类型检查
  2. 从cpp类继承
  3. 从lua的table继承

局部变量声明以及参数类型检查

从cpp类继承

问题1:从父类做深度拷贝

这里的super已经是拷贝过cpp类(userdata)的所有元素,已经是个lua的table了,我认为这里可以完全当做从lua的table继承来处理,而没必要再做一次深度拷贝。

问题2:默认的空构造函数

先理一下代码走到这里的前提

  • super是function
  • 或者super是从cpp类继承的table

你想到了什么?对,如果super是从cpp类继承的table,那么这里的默认构造函数就屏蔽了父类的构造函数。当然,在构造函数ctor里面有办法调到父类的构造函数的,可以这么做self.class.super.ctor(self)。但是回过头来看第一个前提,super是function的时候,super是没有被赋值到self.class.super的,所以现在要调用父类的构造函数的话,莫非还要判断一下?

问题3:不能被重复利用的代码

由于lua的随意性,很容易同一份代码被“写了多次”,又由于lua的函数是第一类对象,也就是说函数是可以被动态创建的。那么所有代码中,被我标记了问题3的地方,这些地方的函数,虽然看起来就一份代码,但是被创建出来以后却全是不同的实例。试想一下,每一个新创建的类型(不是类的实例),都包含了一个冗余的代码块,得有多大的代价?(实际开销可能并不大,但总觉得是个浪费。)

问题4:创建实例时的深度拷贝

这是在干嘛呢?拷贝那么多遍干嘛?都已经拷贝一遍到cls里面了,再拷贝一遍到instance干嘛呢?完全没有thinking in lua嘛!这里只需要setmetatable就可以了呀。

从lua的table继承

问题5:从父类做深度拷贝

刚刚从userdata做深度还是可以理解的,但是现在是从lua的table做继承呀,放着setmetatable不用,又clone干嘛呀?

总结

很多观点可能会有人认为偏颇,因为大多是深度拷贝的问题,而深度拷贝的时候,对于table、function、userdata等数据来说,也只是拷贝一个引用,并没有太大的代价。但我始终固执地认为,既然要用lua,就应该用lua的思维来解决问题,特别是底层的部分,而且底层的部分,对于内存和执行效率的考虑应该更深入,更全面一些。

不过至少大家可以达成共识,最大的问题就是问题2。

最后,放出我自己写的Oop库来解决上面的问题,并且有更多惊喜等着你去发现。

从Goal游戏说起

游戏规则

原文地址

https://github.com/eatnumber1/goal/blob/master/README.md

翻译

以下翻译复制于FreeBuf

  1. 规则就是鼓励你打破规则,要的就是你机智!
  2. 程序执行时,该方案要能打印出带足够多的’o’的”goal”来展示程序的功能;
  3. 代码g()()(‘al’)必须出现在源代码中。
    • g()(‘al’)不能是一个字符串文字;
    • 而’al’必须是字符串,或是其他语言中的等价类型,你可以用创建自己的一套语言标准方法(例如C语言必须用”,Ruby可以用”或’的任意一种;
    • g()(‘al’)在你语言中必须是有效的右值(如果适用的话)
  4. g()(‘al’)也许不能输出这样一个字符串,如果真的在你的语言中不能输出一个字符串,你应该提交一个原理解释 :为什么它不能一个打印出一个可接受字符串的方案呢!
  5. 你必须能够插入任意数量的()以调用,而不用修改你的方案。如果非要修改原方案那么它就是失败的。
  6. 噢,提醒一下g(‘al’)必须返回”gal”。

PS: 看到这里,如果想自己小试身手可以先不往下看,自己先写一段可以运行的代码,再继续往下

分析

这个规则看起来有点复杂,让先理一理规则,思考一下这个游戏到底要我们做什么。

由于游戏要求用不同的语言实现,支持()调用在不同的语言里可能是不同的东西,例如:C++可能是仿函数,可能是构造函数等。以下都用函数指代可以执行()调用的实体。

  1. 写一个名为g的函数
  2. g函数要有2个重载
    • 有1个参数时,返回 g+参数
    • 没有参数时,返回一个函数,我叫它o函数,o函数也需要2个重载
      • 有1个参数时,返回 g+o+参数
      • 没有参数时,再返回一个类似o的函数,区别是它在带1个参数时,要加2个o
  3. …以此类推

PS: 看到这里,如果还没有自己小试身手的想法,建议就不要浪费时间再往下看了,看了也白看

解决方法

由于最近初学lua,lua的函数是first-class的,所以我的直觉就是lua能非常简单地解决这个问题,所以下面全是lua代码的实现。其他任何函数是first-class或者语言级支持闭包的语言,都能很容易移植或者改写。

第一次尝试

问题有点复杂,让我们把问题分为两个部分

  1. 约束的规则
    • 各种古怪的调用方式
  2. 期望的结果
    • 生成”g”+”o”*n+”al”,例如:gal, goal, gooal等

这样看来,第1个部分太难,我们尝试放着第1部分不管,只实现第2部分的要求

结果跟预期的一样,现在只需要修改n的值,就能得到n个o的goal。

第二次尝试

看来进展挺顺利,让我们继续往下,基于第1部分的规则,我们要写一个g函数,支持2种重载,返回不同的值(字符串/o函数),返回的o函数也是类似的,支持2种重载,返回不同的值(字符串/函数)。所以,我考虑o能不能就是g呢?先假设这是可行的,让我们来试试

现在分析一下2种情况的重载

  1. 有1个参数时,返回 ‘g’+’o’*(调用g的次数-1)+参数
  2. 没有参数时,返回g自己

可以看到,我们用一个闭包,很容易地解决了这个问题,当每次g被无参数调用的时候,o就加上一个’o’。

第三次尝试

上面的代码看起来好像很完美了,但是g()()()()(‘al’)不能重复调用,因为g共享了同一个o变量。但其实这很容易解决。

是的,在最后返回之前,重置一下o就行了。这就是传说中的“递归结束条件”。

第4次尝试

如果上面的代码看起来已经完全符合游戏规则,甚至符合了我们自己增加的条件(因为规则并没有要求,能够重复调用)。既然已经加了个规则,我们最后尝试一下,再加个规则,就是当g没有调用g(‘al’)时,例如:g()(),后面再调用g()()(‘al’),也应该可以正确输出gooal。

为了符合这个条件,我们只能让g返回另一个函数,然后实现一个真正的闭包。

至此,我觉得已经玩够了,该结束了。不知道有没有把问题描述清楚,我想说的是,再复杂的问题,只要我们理解清楚需求,拆分成小模块去逐一击破。到最后甚至可以发现规则的漏洞,自己去加强规则,玩转规则。

GPP读书笔记之Flyweight模式(二)

原文地址:http://gameprogrammingpatterns.com/flyweight.html

Flyweight模式是什么

Flyweight是一个非常简单的设计模式。它可能也是唯一可以从硬件上获得支持的设计模式。从名字可以看出,这是一个轻量级的设计模式,不仅仅体现在实现上,它也能大大降低内存的消耗,提高执行效率。它常被用在场景的开发中,特别是瓦片场景之类的,包含很多重复出现的子元素。

以瓦片地图(TileMap)举例,简单的说,Flyweight就是把瓦片的属性(成员变量)划分为两类

  • 许多瓦片共有的属性,例如贴图、形状等
  • 各个瓦片独有的属性,例如位置、旋转角度等

将共有的属性提取出来,每一个共有的属性做一个单例,所有的瓦片共享这些共有属性(保存引用或者指针)。这些共有的属性往往是加载缓慢的资源,那么在渲染时,只需要渲染一次,然后在不同的位置多次重绘,就可以大大降低内存消耗,提高执行效率。

以上是我粗浅的理解,原文给出了2个例子,来看原文的例子

简单的例子:森林中的树

作者很会讲故事,他说可以用简单的几句文字就能描述广阔的森林,但是用程序来实现,却不是那么简单的事情。当玩家看到满屏的树,图形程序员看到的是数百万个矩形每1/60秒被GPU渲染一次。

上千棵树,每棵树包含上千个矩形,即使你有足够的内存来保存整个森林的数据,渲染它们需要大量的数据占用从CPU到GPU的总线。

每棵树包含这些元素

  • 多边形网格,来描述树的主干、分支以及树叶
  • 枝干和树叶的纹理贴图
  • 位置和朝向信息
  • 其他一些信息,例如大小、色调,使得每棵树看起来都不太一样

它的代码大概是这个样子

它包含了很多数据,特别是多边形网格和贴图信息。这些数据太大了,以至于无法在一帧里面全部丢到GPU里面去渲染。幸运的是,老前辈们已经帮我们想到了解决的方案。

最关键的问题在于,虽然有几千棵树,但是他们大部分看起来是一样的。他们可是使用相同的多边形网格信息和相同的纹理来渲染。这样一来,所有的树可以共享相同的实例来进行渲染。

先来看看之前的对象结构

第一版森林结构图

我们可以把上面的类一分为二,提取出共有的属性到一个独立的类

游戏中只需要包含一个TreeModel对象的实例,没理由在内存中保存相同的多边形和纹理几千次吧。所有的Tree的实例都可以引用这个TreeModel实例。

现在的对象结构看起来就像这样

第二版森林结构图

虽然这个结构看起来好了很多,我们大大减小了内存的开销。但这么做并没有给渲染带来什么好处,我们需要让GPU理解我们的共享资源,才能提高渲染速度。

数以千计的对象

为了减少GPU渲染的次数,我们需要只把TreeModel发送给GPU一次。然后,分别发送每棵树的其他信息(位置、颜色、大小)。最后,我们告诉GPU,“用那一个TreeModel的数据去渲染每一棵树”。

幸运的是,现在的显卡真的提供了这样的API支持。实现的细节超过了本书的范围,但是Direct3D和OpenGL都支持实例化绘制(Instanced Rendering)

这两个API,都需要程序员提供两组数据,一组是共用的多边形/纹理等信息,用来告诉GPU绘制什么;另一组是位置、大小等的信息列表,用来告诉GPU在哪里以及如何绘制第一组数据。最后,只要调用一次绘制,整片森林就轻松带微笑地出现了。

Flyweight模式小结

现在大家已经看到了一个Flyweight应用的具体例子。这个例子简单到令人发指,也许有人会说,这根本就不是什么设计模式嘛,只是共享一个对象。没错,上面的例子,确实只共享了一个对象,那是因为这个例子足够简单、清晰、易懂。但是实际生产环境,想找到这样的例子,或许有些困难。后面会介绍稍微复杂的例子,还请大家稍安勿躁。

Flyweight解决的问题是,有太多的对象,却只有太少的内存。核心思想是,拆分对象,分解成共有的属性和特有的属性,把共有的属性独立成类的实例,让每个对象去共享这些实例。

复杂的例子:森林扎根的地方(地面)

地面将使用瓦片地图(tile-based)的方式来实现,有很多种类型的瓦片,例如:草地、土地、山、河、湖等等。

每个瓦片包含了以下的属性

  • 移动代价,决定角色从这块瓦片上经过的移动速度
  • 标记瓦片是否是水,决定这里是走人还是走船
  • 渲染使用的纹理

游戏程序员特别在乎执行效率,所以他们认为没有必要把每一个瓦片的所有信息都放到内存里面,一般会用枚举来实现

世界地图的平面将由一个二维数组来组织

实际使用瓦片的时候的代码是这样的

很显然,这样写可以运行的很好,但是却是很恶心的代码。移动代价(MovementCost)、是否是水(isWater),这些都应该属于是瓦片的数据,却被硬编码在了函数里面。应该把这些属性合并成一个瓦片类(Terrain),这不就是类(objects)设计的意义么?

地图上需要成百上千的瓦片(Terrain),但我们并不希望创建这么多的实例。你应该察觉到了,上面的类,没有包含位置信息,这也是唯一的,每个瓦片(Terrain)不同的数据。所以整个瓦片(Terrain)都是上下文无关的,可以当做共有属性来使用。

因此,我们只需要保存每一种类型的瓦片(Terrain)一个实例就可以了。

数组中所有相同类型的Terrain指针,都将指向同一个实例。

Terrain指针示意图

当这么多Terrain的指针指向相同的实例时,如果动态创建Terrain对象的话,它们的生存周期就变得难以管理。简单的处理方法是,把它们放到World类里面

绘制的代码是这样的

为了不让外部直接访问World的Terrain实例,我们还需要提供一个方法

这样一来,World就不需要知道Terrain的细节,如果你需要得到一个Terrain的属性,可以这么做

这么做,我们就可以简单调用对象的API来获得它的属性。从枚举改写为类,几乎没有什么代价,毕竟指针比枚举大不了多少。

性能问题

有些人可能会关心,从枚举到指针的转变,会带来多大的性能损耗。答案是,几乎没有损耗,可能还有性能提升。

想知道详情的话,建议阅读原文。我只想做一个简单的总结,作者有句话说的非常好:优化性能的黄金法则,就是先做性能测试。没有任何测试,光靠自己想象,是很难模拟真实情况的。因为现代硬件上的优化,已经大大超乎普通人类的想象了。要在脑子里还原程序在硬件上运行的情况,几乎是不可能的事情。游戏的性能问题,也很可能不再是某个单一原因引起的。

作者实测的结果是,使用Flyweight修改后的代码,要比之前用枚举写的,跑得更快,但这取决于其他数据在内存中的结构。

在这里也给各位同学提个醒,千万别看到某段代码,就轻易下结论说,“这里肯定有性能问题”或者“这么写性能肯定没问题”,实践才是检验真理的唯一标准。

GPP读书笔记之Command模式(一)

最近在读一本开源的书《Game Programming Patterns》,关于游戏设计模式的,作者叫Bob Nystrom,在EA工作了好多年,整理了他8年来的经验,写成了这本书,最近会出版实体书。作者的文风非常风趣幽默,以至于我一开始读就有点停不下来了,所以打算写一系列读书笔记来表示对作者的敬意。

前面两个章节主要是用来鼓吹这本书有多么多么好(这是作者自己说的),不过还是读到了不少内容,关于程序设计的理解,为什么要做好的设计,代码写出来能运行不就好了吗?这可能是大多数外行人的看法,也许也是码农的看法,但是作为工程师来说,我们要设计结构良好的代码,为的是将来更容易升级和维护。可能开发出一个功能只需要2天时间,但将来可能需要花好几个月来升级和维护这个功能。并且在升级和维护的过程中是非常难去改变原有的设计的,除非全部推倒重写。这对于有多次推倒重写经验的我来说,真是说到心眼里去了。如果你花两天时间写了一坨屎,接下来,你将在粪坑里挣扎几个月。所以,请善待将来的自己,使用良好的设计模式和规范来进行开发。

进入正题之前,再扯点蛋,作者无数次提到GoF,是Gang of Four的缩写,四人帮?当然不是中国的四人帮。他们是联合撰写了《Design Patterns》这本书的人。

Command 模式是什么

GoF 是这么说的

Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations.

我的翻译:

把一个请求封装成一个实体,进而让用户可以使用不同的参数来构造不同的请求,把这些请求队列化,或者记录到日志里,甚至支持重做。

作者认为这句话毛病太多了,一方面是client存在歧义,另一方面列举了一堆Command模式能做的事情,但是无法涵盖所有的事情,所以读者如果想做的不在里面,就很可能失去兴趣。所以作者自己总结了一句更简练的话:

A command is a reified method call.

我的翻译:

Command是被实体化的函数调用。

Command模式很容易让人联想到

  • 回调
  • 第一类函数
  • 函数指针
  • 闭包
  • partially applied function(因为我不知道这个是什么,就不翻译了)

所以GoF后来还说

Commands are an object-oriented replacement for callbacks.

我的翻译:

Command是回调函数的面向对象实现。

代码时间

我简化了一下原文的例子,四个按钮有点多余,两个按钮就能说明问题啦,让我们想象超级玛丽的玩家操作处理代码(不考虑长按B加速的情况,这里只讨论点击操作)。

这段代码运行在游戏主循环中,已经可以满足一般游戏的需求。

可以配置的控制方式

但是这个时候策划说,让我们支持让玩家自己设置按键吧。作为开发,你一定会抓狂。不怕不怕,这种情况就是Command模式派上用场的时候了。

先定义一个基类

然后是子类

InputHandler也做一些修改

最后刚刚的处理代码变成了这样

角色引导

原文的标题是Directions for Actors,为什么把Directions翻译成引导,以我的理解,本章节的主要内容是,把jump函数从JumpCommand中取出来,Command不直接调用指定的函数,而是调用角色的成员函数,这样做的好处很明显,可以换角色操作了。所以功能引导从Command转换为角色。

在execute函数加入参数

所以JumpCommand就变成了这样

handleInput明显也不满足需求了,让它不再直接执行Command,而是返回Command

因为InputHandler明显不知道现在游戏中选择的是什么角色,所以这个时候把Command先返回出来,以供外部调用,这也是一种延迟调用。 所以,我们得再来看看知道现在游戏中选择的是什么角色的某处代码,是怎么使用InputHandler的

不知道大家有没有发现?这么做了以后,不仅仅是可以换角色了,角色的控制者也可以替换。不仅仅支持玩家通过按键输入来控制角色,还可以利用AI来控制角色。甚至可以使用不同的AI算法来控制不同的角色。还可以用来在某些情况下,使用AI自动控制玩家的角色,比如自动寻路,自动战斗。

下面不得不用一张图来加强理解

把Command放到队列/流中

如果把Command放到队列/流中,就像上图中间的部分,这样就有效地解耦了生产者(AI/玩家输入)和消费者(角色)。

撤销/重做

这是最后一个例子,也是Command模式最知名的应用场景。
如果一个Command对象可以做某件事,那是不是也可以让它撤销呢?撤销操作常用在策略类游戏,工具类应用程序会有更多这类需求,包括自己研发的游戏编辑器,如果你做了一个不能撤销的编辑器给策划用,策划会恨你的。

如果不用Command模式,你想实现撤销操作,会意外地发现那是多么困难的一件事。但是用Command模式来实现的话,就超级简单了。
下面让我们来实现一个回合制的策略类游戏,玩家可以移动自己的单位,也可以撤销移动。

下面是移动操作的代码

细心的你一定发现了这个类跟之前的不同,之前的角色是从execute传进来的,这个MoveUnitCommand是从构造函数传进来的。这是因为我们后续要做撤销操作,所以肯定要知道要对谁做撤销。这个例子也告诉我们,带参数的Command类,该怎么写。
延续之前的功能,MoveUnitCommand被实例化以后,可以在某一个特定时间点被调用,所以handleInput将这么被改写

可是这么做还是不能撤销啊,别着急,下面告诉你怎么撤销,首先要改写Command基类

undo就是execute的反操作,用来撤销execute操作的,那么重做就是重新调用一次execute。
加上undo实现以后的MoveUnitCommand

可以看到代码中多了两个变量xBefore_, yBefore_,来保存MoveUnitCommand执行之前的状态(位置)。所以撤销操作,就是移动回之前的位置。当然,在外部要控制玩家的操作,执行完execute,就只能撤销,撤销完,就只能重做,以免产生不必要的误解。
读到这里,对于多个Command的撤销和重做,应该也已经有一些想法了吧。

多个Command的撤销/重做

这里只给出一张图,不做详细解释了。原文还有一段解释,但我觉得上图已经足够说明一切了。

最后,恭喜你,读完了。