04月 16, 2013

数据结构中的树结构在抽象复杂事物时非常常见,在图形引擎中,多用于场景以及 sprite 的层级管理。在 GUI 相关的模块中也是必备的结构。其它领域,比如对程序源码本身的解释翻译,以及对数据文件的组织管理,都离不开树结构。

我觉得,这是因为一个对象,除了它自身的属性(例如大小、形状、颜色等)之外,还需要一些外部属性(例如位置、层次、方向等)需要逐级继承。每个对象都可以分解成更细的对象组合而构成、这些对象在组成新的对象后,它们的聚合体又体现出和个体的相似性(至少具有类似的外部属性)。这使得采用树状数据结构最容易描述它们。

我最近的一些工作代码做了很多这方面的工作,回想这些年里,我不只一次的思考类似的问题(参看 2009 年的一篇 blog),而每次最后解决问题的代码的都有些不同,编程风格也有一些变化。总结一下这段时间的思考,今天再写这么一篇 blog 。

树结构的基本操作无非是遍历整棵树、遍历一层分支、添加节点、移动节点、删除节点这些。但在大部分应用环境下,我们最多用到的只是遍历,而非控制树的结构本身。

所以我认为,遍历应该是对外的 API 、而结构控制则应该是内部的 API 。也就是说,作为一个模块,对外不用显露其实现用到的数据结构,而只关心怎样取得模块内部的状态。这时,合适的遍历接口足以。典型的文件系统的接口就是这样做的:我们可以用 / 连接目录和文件名变成一个完整的路径名,用它打开一个文件;而不必一级级取得目录对象,再从中获得文件对象。

相较于结构控制的 API (增加、删除、移动节点这些),更重要的是树的持久化。因为最终用户关心的是最终的数据,以及怎样使用这些数据,而不大关心数据的构建过程。常见的做法是把树状结构的数据呈现为一个 XML 文件,或是生成一张 Lua 表,然后一次加载它,得以在内存中重建树结构。持久化数据的格式不重要,你可以根据性能需要优化它,也可以因为健壮性而采用易读的文本。在大多数应用需求里,一旦树被重建为编辑产生它时构建的样子,就不会再修改它了。无论多简单的结构,把构建过程直接用代码的形式写在源文件中,是最不值得做的事情。早期的 GUI 程序或许还会一行行调用 API 把窗口以及布局搭建起来,而现代 GUI 框架几乎都为界面布局定义一套 DSL ,鼓励设计人员独立描述它们了。

我认为,即使是动态性要求很高的场合,最好也定义出数据格式,便于和使用了树结构的模块交互。比如在 GUI 程序中,我们不建议把一个按钮被按下的行为注入到那个按钮对象中,而是从外部捕获按钮被按下的消息,忽视掉界面的层次结构而直接处理这些消息。为了做到这一点,我们可以定义出类似 HTML 的语言来定位界面上的按钮。

少量修改已经事先创建好的对象的需求也是普遍存在的。往往对象的结构很复杂,但可以调整的部分却很少。有一些支持对象的语言中,直接提供了蓝图对象的概念。比如早期网络游戏常用的 LPC ,让程序员实现一个对象,再给出方法复制它。

我最近读到 狂刃 的图形引擎也用了类似的方法。它在编辑器中编辑出需要的怪物/粒子等对象,然后持久化到文件中。运行时,加载这些数据构造出编辑得到的对象,再根据需要的数量 clone 它们。

对于那些需要对象中需要少量修改的部分,按蓝图复制出一个复制体,再根据需要修改即可。这是图形引擎常见的需求,比如动画节点当前播放的帧号就是一个随时间变化的节点属性,需要在运行期修改的。

我想再谈一点实现上的细节以及优化。

采用了树结构组织起来的数据体,往往就意味着复杂且零碎的数据片段。因为每个结点下都可能有很多子节点,而子节点上保存的对象类型也可能不同,最终是大量不同尺寸内存片的组合体。但实际上,整棵树的绝大部分是不变的。

如果我们在编辑器里编辑出一个复杂的怪物,以它为蓝图在运行期 clone 出多份的话,会发现,这些克隆体之间的共有数据远多过易变量。所以我觉得,把这些易变量和不变量分开储存可能是个更好的方案。易变量(比如每个节点的当前空间状态等)可以平坦化储存,不变量虽然在逻辑层次上是一棵树,但在编辑完成时就决定了它是怎样的,完全可以持久化为连续的数据,并还原到连续内存中,且作为一个对象来管理。作为公有的蓝图,也不必真的复制为多个克隆体,而只需要指针引用即可。

在我最近的另一个项目中,我用 C + Lua 实现了类似的结构,发现代码没有我一开始想的那么复杂。Lua 和 C 的层次还是能分的很清楚。一些需要靠字符串索引储存的数据,我放到 Lua 表中,而 C 中的数据结构则专注于树结构的表述。这样不会在 Lua 中保存太多零碎的 table ,在 C 结构中保有高效的树结构索引能力,又可以把比较麻烦的字符串管理扔给 Lua GC 。而且在很多情况下,对于字符串常量,Lua 语言比 C 语言要高效的多。

____________________________________________________________________________________

Tags: ,,.
03月 19, 2013

最近稍微学习了一点 Objective-C ,做笔记和做编码练习都是巩固学习的好方法。整理记录脑子里的新知识有助于理清思路,发现知识盲点以及错误的理解。

Objective-C 和 C++ 同样从兼容 C 语言开始,以给 C 语言增加面向对象为初衷,他们的出现的时间都很类似(1983 年左右)。但面向对象编程的源头却不同:C++ 受 Simula 和 Ada 的影响比较多,而 Objective-C 的相关思想源至 Smalltalk ,最终的结果是他们在对象模型上有不小的差异。

以我这些天粗浅的了解,Objective-C 似乎比 C++ 更强调类型的动态性,而牺牲了一些执行性能。不过这些牺牲,由于模型清晰,可以在今天,由更先进的编译技术来弥补了。

我对 C++ 的认知比 Objective-C 要多的多,所以对 C++ 开发中会遇到的问题的了解也多的多。在学习 Objective-C 的过程中,我发现很多地方都可以填上曾经在 C++ 开发中遇到的问题。当然,Objective-C 一定也有它自己的坑,只是我才刚开始,没有踩到过罢了。

ObjC 的类方法调用的形式,更接近于向对象发送消息。语法写作:

[obj message]

如果方法带有参数,那么就写作

[obj param:value]

方法和名称和参数的名称是一体的,参数决定了方法是什么。如果有多个参数,那么写作:

[obj param1:value1 param2:value2]

注意,如果一个类有两个方法,一个有一个参数,一个有两个参数。即使两个参数的版本中有一个参数名称和单个参数版本的相同,它们也是两个不同的方法。ObjC 不支持默认参数的语法。

C++ 调用对象的方法就更接近于 C 的函数调用。两相比较,可以发现 ObjC 的语法让代码可读性更强。你可以很容易的理解参数的用途,也不怕方法参数过多时,一串参数写漏或写错次序了。

和 C++ 一样,ObjC 的类声明和实现是分离的。但做的比 C++ 更彻底。ObjC 不能在声明的代码段中写 inline 函数。这看起来牺牲了一些运行性能,但当实现部分更好的分离。作为补充,ObjC 有 @property ,可以帮助程序员简化实现,也可以让编译器生成更好的代码。

声明一个类写成这样:

@interface class : baseclass {

type a;

}

- (void) method;

- (void) messge: (type) param;

+ (id) create ;

@end

ObjC 利用了 C 语言中没有使用的符号 @ 来扩展 C 的语法,而不是用 C++ 里增加关键字的方式。这或许是一个对语言扩展更简单的做法,而不用考虑兼容性。C++ 就得精心挑选新增加的关键字,尽量回避那些已有代码中高频出现的单词。

类的数据段和方法是分离的。数据描述放在 {} 中,方法写在其后,在 @end 之前。

开头的方法是实例方法,也就是 C++ 中的成员方法。成员方法中可以通过 self 取到实例指针,也就是 C++ 中的 this 指针。

同样,ObjC 也支持类方法,也就是 C++ 中的 static 成员方法。通常是用来构造实例。声明方法是在方法名前写一个 + 号。

和 C++ 不同,ObjC 是有类对象的。类对象里有超类指针、类名、类方法列表指针,还有类对象的字节大小等元信息。而 C++ 中是用 RTTI 类实现不完全的类似功能的。

调用类方法和调用实例方法在语法上没有什么不同。类名就是类对象的名字。

ObjC 不支持多继承,没有私有、公开这些修饰符。

ObjC 的类方法实现必须写在同一个源文件里。不像 C++ 有 :: 操作符,ObjC 在实现方法时不写类的名字,而是把所有实现都写在 @implementation class … @end 之间。访问基类,也可以方便的使用 super 关键字。

那么,如果一个类的方法太多,不适合写在同一个源文件中怎么办?

ObjC 提供了 category 这个概念。

可以通过 category 为一个类添加一些方法。category 和继承是不同的,不能为类添加新的成员变量,所以它不会改变类对象的内存布局。添加了方法的类还是原来那个类。

category 的语法是这样的:

@interface class (category)

- newmethod;

@end

这样,就给 class 类添加了一个方法 newmethod ,并归类在 category 下。

和 C++ 不同,ObjC 的方法更具动态性。你可以在运行时任意调用一个对象的方法,而不用管它是否存在。ObjC 支持 id 这个类型。 id 其实就是对象指针,任何类型的对象都可以被 id 引用,并可以方便的向其发送消息(方法调用)。如果方法不存在,会抛出运行时错误。

向一个指定类型发送一个不存在的消息,会得到一个编译期警告,而不是编译错误。当然,我们不能随便忽略编译期警告,如果我们清楚的知道运行期这个对象可以处理这个消息,那么可以给类加一个 category 但不必实现它。这样,编译器就能了解新的方法了。

利用 category 可以方便的一个庞大的类拆分成独立的模块。在 C++ 中,比较接近的概念是 friend ,不过 friend 不易被优雅的使用。

既然方法可以被运行期检查,那么方法本身在 ObjC 中也可以被当成一种类型来处理。比较接近的 C++ 中的概念是 成员方法指针。回顾学习 C++ 的经历就能回忆起当年使用 ::* 或是 ->* 的头痛经历。ObjC 中的方法可以运行期绑定, @selector(method:) 的语法也简单的多。

在 NSObject 中就提供了一个叫 respondsToSelector: 的方法,接受一个 selector 用来检查自己是否可以接受这个消息。

ObjC 也提供了类似 Java 的 interface 或是 C++ 的纯虚类的东西,在 ObjC 中被称为 @protocol 。

@protocol 可以看成是一种没有数据成员的虚类。一个实际的类可以声明自己实现了某些协议,语法是

@interface class : base

{

// variables

}

// methods

@end

和继承不同,一个类可以声明多个协议。然后在 @implementation 中必须一一实现它们。

如上所述,ObjC 已经做到了运行期的方法绑定,所以 @protocol 只是做了更严格的编译检查。在新版的 ObjC 2.0 中,追加了 @optional 和 @required 用来描述那些方法的实现是可选的,哪些必须实现。

ObjC 的基础库比 C++ 更完整,标准化要好的多,也和语言结合的更紧密。

比如 NSString 是一个基础类,用于处理字符串。同时,语言也提供 @”string” 的语法方便的生成 NSString 对象。

ObjC 保留了 C 中的 printf 式的字符串操作形式,对比 C++ 重载移位操作符的形式,我想要更清爽一些。

对于 ObjC 对象,使用 %@ 来表示。给对象增加 description 方法就可以让处理函数知道该如何处理这个对象的 %@ 行为。

Tags: ,.
01月 31, 2013

今天开电话会议,帮助合作方排查 C++ 开发的程序崩溃的原因。

现象是这样的:

有一个 C++ 对象,其中有一个 vector ,里面存放了大量对象指针。这个 C++ 对象在构造完毕后,没有任何改写操作,全部是只读的。

在某个退出环境中,C++ 对象被析构,析构函数需要调用 vector 中所有对象指针的删除方法。这时,这个长度大约为 100 多的 vector 第 95 个指针是错的。比它应该的指针位置偏移了一到三字节(多次运行现象不一)。

整个数组只有这一个指针错误。因为这个错误,引发了程序崩溃。

从电话中的描述,我推断:

这不太可能是由错误的调用此 C++ 对象的方法改写 vector 的内容导致的,因为这个对象全部是读方法,并加了 const 修饰。且,这个数组保存的是对象地址。一个由正常的内存分配器分配出来的地址都是对齐的,但此处错误的被改写为奇数。

这个错误也不太可能是由内存越界写造成的,因为在这一长片内存中,只有一个指针异常。

最大的可能是:某个对象被删除,但其指针还在使用造成的,所谓悬空指针问题。

那么,导致是这个出问题的对象指针悬空,还是另一个对象指针悬空影响的呢?

最大的可能是,这个出问题对象所占据的内存空间,曾经被别的对象使用过。但是前任使用者释放了内存,却在某处保留了指针。然后出问题的对象复用了这块地址。

做这个判断是因为,如果出问题的对象本身是悬空指针,那么后来者占用了它的内存的话,应该成片的改写。

从现象可以推断,内存异常一定是对原有数据进行递减造成的,而不是简单的覆盖了一个新的值。

从退出时出错可以判断,这个 C++ 对象在有效生命期间很有可能一直是正常的,否则指针错误很可能很早就暴露出来了。

那么,我认为最大的可能性就是:有另一个 C++ 采用了引用计数的机制。这个对象曾经引用到 0 而被正确的析构并释放掉了。但某种原因在另一个地方还保留了针对它的 raw 指针。

这个被释放掉的对象的内存被后来的出问题的这个 C++ 对象复用,一系列的退出析构操作导致了一系列的对象析构函数的调用。那个悬空的 raw 指针被调用了。这个指针指向的对象没有虚表,所以它的析构函数可以正确的执行。引用计数这个量存在的位置恰巧在后来出问题的 C++ 对象的中间。

减引用的函数把这个位置的值,也就是另一个对象的指针作为数值减了 1 ,发现不到 0 就跳过了。接下来的析构操作到这个位置时,访问了不正确的指针。这里期盼的指针引用的对象有虚表,偏移的差别导致跳转到虚析构函数出错。

从电话里可以获得的信息有限,我的推断只能到这里了。

为了进一步的盘查错误,我建议:把所有的对引用计数的操作,包括标准库中的智能指针的代码,都加上 assert 判断。断言所有的引用计数的值都应该在 0 到 10000 之间。

我并不是说智能指针的实现会有问题,尤其是采用标准库的实现一定没有问题。

但是,当一个对象本身被释放后,它的(悬空)指针还可以被调用析构函数却是一个隐患。悬空指针调用的对象中如果有一个智能指针的话,那么这个智能指针的析构函数依旧是会做递减操作的。

顺便吐槽 C++ 。

我现在看到的这个 C++ 项目,如果用一个标准的 windows 下的 malloc ,也就是一个性能比较低下的内存管理器,性能简直不能接受。你必须换一个非常优秀的内存管理器才能正常工作。这样的依赖内存分配器的性能,在我见过的 C 开发的项目中几乎不可能存在。

这是因为 C++ 的项目多半层次混乱。我说的混乱,不一定指开发逻辑层次上的混乱,而是假借高性能之名,看起来在源代码层次把软件的层次分清楚了,但是在二进制层面却是混杂在一起的。

一个小小的内存管理模块,就穿插于最底层到最上层。

一个层次分明的系统,在物理上就应该是相互隔离的,这种隔离,仅仅存在于人阅读的源代码层是绝对不够的。这就好比 OS 管理下的应用进程,它绝对不依赖应用进程的程序的工作正常,不依赖应用进程准确的申请和释放资源。而是当应用进程结束后,干净的回收它申请过的所有东西。

小到同一进程下的软件,也理所当然要按这个思路进行才对。C++ 不强调这一点,反而鼓励程序员生产出庞大的软件,并美其名曰:信任程序员。但却把用 C++ 的程序员引向一条邪路。信任程序员和放任程序员是两码事。

Tags: ,,.
01月 17, 2013

这几天无意中发现一款开源的 3d engine ,名为 pixel light 。文档 虽然不多,但写的很漂亮。从源码仓库 clone 了一份,读了几天,感觉设计上有许多可圈可点的地方,颇为有趣。今天简略写一篇 blog 和大家分享。

ps. 在官方主页上,pixel light 是基于 OpenGL 的,但实际上,它将渲染层剥离的很好。如果你取的是源代码,而不是下载的 SDK 的话,会发现它也支持了 Direct3D 。另,从 2013 年开始,这个项目将 License 改为了 MIT ,而不是之前的 LGPL 。对于商业游戏开发来说,GPL 的确不是个很好的选择。

这款引擎开发历史并不短(从 2002 年开始),但公开时间较晚(2010 年),远不如 OGRE 等引擎有名。暂时我也没有看到有什么成熟的游戏项目正在使用。对于没有太多项目推动的引擎项目,可靠性和完备性是存疑的。不推荐马上在商业游戏项目中使用。但是,他的构架设计和源代码绝对有学习价值。

对于 3d 游戏这种技术演化迅速,需求多变的领域。我的个人看法是,越晚起步的引擎有更少的历史包袱,通常会比历史悠久的 engine 更清爽一些(虽然历史悠久的 engine 可能要结实一点)。

这次仅谈谈 pixel light 中的场景管理部分

Pixel Light 中和 3d 渲染有关的类分落在 PLRenderer PLMesh PLScene 三个模块中。从名字上就可以了解,PLRenderer 主要承担系统 API 的接口粘合层;而 PLMesh 负责单个模块的渲染,包括和模块相关的骨骼动画的数据维护;而 PLScene 则负责把多个模块联系起来。

还有一些底层辅助模块,比如 PLMath 进行数学运算,PLGraphics 处理图片。上层的图形界面由 PLGui 承担,物理系统则是 PLPhysics 。

其中最难设计的莫过于场景管理。到底哪些东西是属于它的管理范畴,该如何组织,都是个难题。

Pixel light 中,和绝大多数 3d engine 的场景管理方式一样,所有的物件都是以场景节点 scene node 的形式存在于若干个场景树中。它没有给出所谓世界坐标空间,因为现在很多游戏的场景都做的非常巨大,需要动态组建。

scene node 都是场景树上的叶结点,只有 scene container 才可以组合其它 scene node 。这点和其它一些 engine 的设计有些许不同。对于场景元素,比如 mesh , light 等等,是从 scene node 对象继承下来的类,而不是附着( attach )在 scene node 上。这决定了,scene container 节点一定是一个纯粹的容器,一切可能参与渲染的实际元素都处于叶节点上。

Scene container 类的公开方法中,只有 Create 方法,而不能把已存在的 scene node 挂接到容器中。也就是说,你无法把一个 scene node 从容器中取出来,然后放到另一个容器中。这在一定程度上简化了 scene node 的生命期管理。也不太会在 scene node 间发生不合法的引用关系。

场景树在一定程度上描述了空间层次关系,在渲染流程中,每个 scene container 会影响子节点的空间状态(位置、旋转、缩放)。同时,也影响渲染行为,比如,SCRenderToTexture 就是一种特殊的容器,放在这个容器中的元素都会被渲染到一张贴图上。

游戏中对可渲染物件的空间管理,从性能因素考虑,仅仅依靠场景树的层次来管理是远远不够的。当我们需要和 3d 场景中的物件交互,从镜头中选取某个物件。渲染场景时,需要剔除不可见的物件。这些,依赖简单的 scene node 的层次管理是很低效的。Pixel light 引入了 Scene Hierarchy 来用不同的算法管理空间层次。每个容器可以根据需要有不同种类的 Hierarchy (简单的链表结构,或是复杂的 K-D 树)。

那么,针对 scene container 的处理过程,Pixel light 把它们称之为 Scene Query 。从字面上的理解,就是对一个 Scene (以某个 scene container 为根)所有节点的选取过程。Scene Query 的基类仅仅定义了对 scene container 一种操作方式,对其中符合要求的节点唯一的发出一个 touched 的信号。从这个意义来说,渲染本身也含有一个 query 过程。遍历一个 container ,touch 可视节点。这些被实现在 SQCull 类中。

容器的存在本身是由于 Query 的需求存在(Container 是 Query 操作的对象),其中 scene renderer 也是一种 query 。和直觉不一样,我们通常不会利用 scene node 的树层次关系来组织可渲染物件的空间关系。例如,一块面包放在桌子上,我们不必让面包挂在桌子上。面包和桌子都属于同一个容器。如果它们真的有从属关系的话,也仅存在于场景编辑器中。同样,即使是做第一人称视角的游戏,摄像机对象也不需要在 scene node 的层次是归属于主角模型。它应该和主角模型在同一个 scene container 中。

那么,总存在有一些需求,某些 scene node 的空间关系和另一个 node 有关。一个节点动了,另一个应该跟着移动,等等。这些相互关系甚至很难用树结构去表达。在 Pixel light 中,解决这一问题的方法是叫做 scene node modifier 的东西。每个节点可以有多个 modifier ,这些 modifier 会根据需要改变 node 的状态。modifier 可以利用物理系统作用于结点,可以让模型播放动画,也可以容一个结点随另一个运动。

比如让摄像机跟随一个节点,只需要在跟随节点上添加一个 SNMAnchor ,然后设置它的 AttachedNode 属性为摄像机节点。这样当跟随节点的坐标变化时,会自动修改摄像机的坐标。SNMAnchor 只能让同一个容器内的两个 scene node 相互起作用,也不能产生有循环引用的关系。这一切基于 Event 机制起作用的,在 Pixel light 里 Event 并没有放在一个消息队列中,而是在 Signal 发生时,Event 立刻传递到 Slot 中产生一次函数调用。

如果想把一把剑挂接在人物模型的手上,同样可以利用 SNMAnchor 。除了设置 AttachedNode 属性外,还需要设置 SkeletonJoint 属性为骨骼数据中的手这个关节。这一切通过名字字符串来耦合。

Tags: ,,.
01月 10, 2013

如何绑定 C/C++ 对象到 Lua 里?通常是创建一个 userdata ,存放 C/C++ 对象指针,然后给 userdata 添加元表,用 index 元方法映射 C/C++ 中的对象方法。

也有另一个手段,直接用 lightuserdata 保存 C/C++ 对象指针放到 Lua 中,在 Lua 中创建一个 table 附加元表来来包装这个指针,效果是类似的。区别在于对象生命期的管理方式有所不同。就这个问题,几年前我写过一篇 blog 。

绑定 C/C++ 对象到 Lua 里的设计难点往往在这个正确的生命期管理上。因为 C/C++ 没有 GC 系统,依赖手工管理资源;而 Lua 则是利用 GC 做自动回收。这两者的差异容易导致在 Lua 中的对象对应的 C/C++ 对象已经销毁而 Lua 层不自知,或 Lua 层中已无对象之引用,而 C/C++ 层中却未能及时回收资源而造成内存泄露。

理清这个问题,首先你要确定,你打算以 Lua 为主干来维护对象的生命期,还是以 C/C++ 层为主干 Lua 部分只是做一些对这些对象的行为控制。

我个人主张围绕 Lua 来开发,C/C++ 只是写一些性能相关的库供 Lua 调用,即框架层在 Lua 中。这样,C/C++ 层只提供对象的创建和销毁函数,不要用 C 指针做对象的相互引用。Lua 中对象被回收时,销毁对应的 C 对象即可。

但是,也有相当多的项目做不到这点。Lua 是在后期引入的,之前 C/C++ 框架层中已做好了相当之复杂的对象管理。或者构架师不希望把脚本层过多的侵入引擎的设计。

那么,下面给出另一个方案。

我们将包装进 Lua 的 C 对象称为 script object ,那么只需要提供三个函数即可。

int

script_pushobject(lua_State *L, void * object) {

void **ud;

if (luaL_newmetatable(L, “script”)) {

// 在注册表中创建一个表存放所有的 object 指针到 userdata 的关系。

// 这个表应该是一个 weak table ,当 Lua 中不再存在对 C 对象的引用会删除对应的记录。

lua_newtable(L);

lua_pushliteral(L, “kv”);

lua_setfield(L, -2, “__mode”);

lua_setmetatable(L, -2);

}

lua_rawgetp(L,-1,object);

if (lua_type(L,-1)==LUA_TUSERDATA) {

ud = (void **)lua_touserdata(L,-1);

if (*ud == object) {

lua_replace(L, -2);

return 0;

}

// C 对象指针被释放后,有可能地址被重用。

// 这个时候,可能取到曾经保存起来的 userdata ,里面的指针必然为空。

assert(*ud == NULL);

}

ud = (void **)lua_newuserdata(L, sizeof(void*));

*ud = object;

lua_pushvalue(L, -1);

lua_rawsetp(L, -4, object);

lua_replace(L, -3);

lua_pop(L,1);

return 1;

}

这个函数把一个 C 对象指针置入对应的 userdata ,如果是第一次 push 则创建出新的 userdata ,否则复用曾经创建过的。

void *

script_toobject(lua_State *L, int index) {

void **ud = (void **)lua_touserdata(L,index);

if (ud == NULL)

return NULL;

// 如果 object 已在 C 代码中销毁,*ud 为 NULL 。

return *ud;

}

这个函数把 index 处的 userdata 转换为一个 C 对象。如果对象已经销毁,则返回 NULL 指针。 在给这个对象绑定 C 方法时,应注意在 toobject 调用后,全部对指针做检查,空指针应该被正确处理。

void

script_deleteobject(lua_State *L, void *object) {

luaL_getmetatable(L, “script”);

if (lua_istable(L,-1)) {

lua_rawgetp(L, -1, object);

if (lua_type(L,-1) == LUA_TUSERDATA) {

void **ud = (void **)lua_touserdata(L,-1);

// 这个 assert 防止 deleteobject 被重复调用。

assert(*ud == object);

// 销毁一个被 Lua 引用住的对象,只需要把 *ud 置为 NULL 。

*ud = NULL;

}

lua_pop(L,2);

} else {

// 有可能从未调用过 pushobject ,此时注册表中 script 项尚未建立。

lua_pop(L,1);

}

}

这个函数会解除 C 对象在 Lua 中的引用,后续在 Lua 中对这个对象的访问,都将得到 NULL 指针。

Tags: ,,.
12月 26, 2012

今天收到人民邮电出版的杨海玲同学寄来的几本书,首先感谢一下。看来短期内是没有那么多精力全部去读了,所以先随便翻翻感兴趣的章节。

在《游戏人工智能编程案例精粹 》 和 《 Windows 游戏编程大师技巧 》 中都分别有一章谈及模糊逻辑。记得前几年我的同事 Soloist 同学曾经研究过一小段时间,给我做过简单介绍,我便仔细把这两章书读了一遍。感觉都是点到为止,所以又翻了一下 Wikipedia 的 Fuzzy Logic 的介绍。午饭时跟做 AI 的同事交流了一下,觉得可以做一点笔记记录理解的部分。

在游戏人工智能编程中举了个实际的例子来说明这个问题:在一个 FPS 游戏中,NPC 有多种武器可供选择,这些武器的威力、射程等有所差异;策划决定根据 NPC 和玩家的距离以及 NPC 武器弹药的余量,两个因素来考虑 NPC 当下应该选择哪一种武器对抗玩家。这个例子不错,不过我有另一个自己的例子:在 MMO 中,NPC 在追击玩家时可能会考虑几个因素:离开他的出生点的距离,以及玩家的实力,或是自己的 HP 量等等。下面我用自己的例子来说明问题。

先甩开模糊逻辑是什么不谈,我们在做 AI 定制的时候会遇到怎样的很难决策的问题呢?

策划往往会定义规则:当 NPC 距离出生点很远时,他们停止攻击玩家并返回出生点。

这里有一个问题,即使是最新手的策划也看得出来,这个规则描述是不严谨的。到底什么叫“很远”。所以通常,需要加一个定义,追击半径,变成“当 NPC 距离出生点超过追击半径时,他们停止攻击玩家并返回出生点”。然后,在策划表格里估计就会多出一个表项:追击半径 40 米 之内的东西。

顺便吐槽:把设计这个规则(追击条件),和填写这个数字( 40 米追击半径)的工作分开,分别称做系统策划和数值策划,我怎么看都是件极不靠谱的事情。

程序不难实现,规则也很清晰。可如果规则多于一条,就不那么简单了。

假设我们加了一条规则:当 NPC 的 HP 很少(比如以 20% 为界限),他们不再追击玩家。

NPC 做决策就需要考虑两个因素,这两个因素导出的决策结果可能截然相反,这时怎么办更合理?比如,当 NPC 的 HP 充足时,它们应该追击玩家;可 NPC 可能离开出生点已经太远了,以距离原则来讲,它又应该放弃追击。

在黑白分明的逻辑判断角度讲,我们可以简单的定义出严格的规则。比如两条规则去交集,只有都满足才追击,否则放弃。但你能发现其中不合理之处么?

如果我们以 20% 的 HP ,或距离 40 米为分界线。某一个条件上,19% 和 20% 的细微差别,或是 39 到 40 米的变动,都足以改变决策结果。但是又有些看起来巨大的差异,比如 1% 到 19% ,或是 1 米到 39 米却对结果无影响。

一个更生动(更接近人)的决策行为可能是这样的:当其中一个条件处于边界左右时,我们更多的考虑另一个条件的影响。比如,当 NPC 的 HP 在 20% 左右时,无论是 19% 还是 21% 都对决策结果影响不大,这时,距离成了更重要的参考依据。反之亦然,无论 NPC 离开出生点 39 米还是 41 米,影响决策结果更多的是他的 HP 值。

要做到这点,不是简单的 if else 就可以达到效果的。无论用什么方案,都会增加 AI 的计算量。决定在何种环境下为哪些 NPC 附加这种 AI 是另一个问题。我们先不谈成本,仅说说怎样做到。

我的第一反应和中午饭局上许多同学的反应一样,为每个决定因素加上一定的概率因子。不再以 40 米为分界非 0 即 1 ,而是让追击概率成为和距离有关的一个函数。把几个影响决策结果的因素的概率全部算出来,然后用随机数去模拟决策过程。

但,模糊逻辑并不依赖随机的模拟。简单说,利用模糊逻辑决策,所有的输入都确定的情况下,一定会得到确定的结果。所谓的模糊指那些边界条件是模糊的。

一般我们会给每个变量定义三档强度标准,比如对于距离远来说,我们可以定义 10 米算近,20 米算有一定距离,40 米算远。那么低于 10 米都算很近了,而超过 40 米都算很远了。但介于 10 米到 20 米之间的,即可以算很近也可以算有一定距离;而 20 米到 40 米之间则可以算有一定距离,称之为远亦尚可。画在坐标图上,是左右两个半梯形,和中间一个三角形的形状。事实上,用曲线来表现也可以,但不利于计算,一般不会采用。

从数值上分析,可以看作,低于 10 米就 100% 算近,而远于 40 米则 100% 算远。处于 20 米的地方则 100% 算保持一定距离。中间状态则是平滑过渡的。

然后,对结果的期待程度也可以分成三类。就追击而言,可以分为,完全不想追击、希望追击、强烈希望。用同样的方式定义出来。

最后,我们可以排列出所有的规则,比如:

当离开出生点很远,且 HP 很少时,完全不想追击。 当离开出生点一定距离,且 HP 很多时,强烈希望追击。 当离开出生点一定距离,且 HP 适中时,希望追击。 等等。

由于我们有两个条件,每个条件有三档状态,所以完整的规则会产生 9 条。定义这些规则需要策划的专业知识。但略微有偏差的规则不会让结果突变,缺少一些条件和规则,计算也能进行下去。

我们把参数依次代入所有的规则,可以得到决策结果分别处于“完全不想,希望,和强烈希望”三个类别中的置信度。这里,一条规则中引用两个条件时,我们取教小的值。假设,当 NPC 距离出生点为 30 米时,对于“很远”的置信度为 0.5 ;而 HP 为 30% 时,对于“HP 很少”的置信度为 0.4 ;那么,对于完全不想追击的置信度为 0.4 和 0.5 中较小值 0.4 。

计算完所有的规则,取那些重复的决策结果中较大的一个。即若超过一条规则的推导结果是强烈希望追击的话,就取最大的一个数。最终,我们会得到“完全不想、希望、强烈希望”三档分别的置信度。

到底最终决策的期望度有多高,这是一个被成为去模糊化的过程。简单的说,它是对前面得到的三挡置信度形成的图形取质心的过程。我不想详述。

采用模糊逻辑,可以让 AI 在同一时刻能做出的所有反应都计算得到一个期望实施的程度值。如果我们可能需要 NPC 去判断,是应该追击玩家,还是原地防御,或是逃跑做一个选择;又或者要根据自己的 HP MP 量,对手的状态等,在自己可以释放的若干技能中做一个合理选择;那么,只需要单独为每个决策都按配置好的模糊规则做一个计算,选择最强烈的倾向即可。

可以看出,采用这种方法,决策结果和决策条件是严格对应的。对于决策条件较少的情况,我们完全可以用配表的形式,以及设计调整合理的公式的方式达到同样的效果。但是随着决策条件的增加,复杂度会发生组合爆炸,人力不可控制。

当决策条件太多时,一个明显的问题是规则的组合爆炸。我们需要描述每个条件处于每种状态时,决策结果应该是怎样的。而 Combs method 可以帮我们应对这种情况。通俗说,我们可以把“当离开出生点很远,且 HP 很少时,完全不想追击。”这条规则分解成两条独立规则“当离开出生点很远,完全不想追击”以及“当 HP 很少时,完全不想追击。”

用这个方法简化规则,有时会产生矛盾或是违反直觉。但实践表明,用这种一维的规则列表,会得到与前面那种完整的规则组合非常近似的结果。

ps. 我没想过通过短短一小篇文字就把这个问题讲清楚,因为那是不可能的。学习从来不是件轻松的事情。

Tags: ,,,.
12月 6, 2012

在网络游戏中倒卖货物是一大乐趣(如果经济系统做的不坏的话) 。我见到许多 wow 玩家以此为乐,在国产游戏中,以梦幻西游为首,开店摆摊也让人乐此不疲。我最近对此有一些想法,和我们公司的策划交流以后,发现几句话很难说清楚。

大部分人第一反应都是增强版的拍卖场,比如增加求购系统,或是更方便的拍卖寄售系统,各种其它的拍卖场增强。

对于稀有品,我认为现在 wow 的拍卖场已经可以解决大部分问题,是不需要替换的。但一般商品,我认为应以增加流通,方便需求类玩家为主。最简单的方案是让系统根据商品流通速度来自动调节价格,系统统一销售和收购。但我们也知道光靠这种计划经济是很难得到一个合理的市场环境的,也少了许多玩家参与的乐趣

所以我才有了下面这些想法:

玩家可以学习一个技能开始向系统盘店. 玩家可以决定这个店卖些什么. 可以卖的商品种类是有限的,比如 5 种. (并可以提升商业等级增加) 同时向系统申请一个商店货仓,以存放商品。

所有玩家的店其实对于卖家来说是在一起的. 如同 wow 的拍卖场, 卖家可以看到所有商品, 并不需要知道从谁的店里卖到的. (除非对买家有商品分类的需要,才考虑提供实体摆摊这样的设计)

商家可以决定他的店里卖的商品的收购数量(必须达到设定的数量),最高收购价格, 最低卖价. 统一提交给系统. 玩家把战利品卖给收垃圾的 NPC 时,其实是和这整个系统关联的. 会有一个关联收购价.

系统定期收集整个世界所有店的收购总数和收购价, 经过系统抽税后, 卖给整个收购市场. 然后所有收购到这批货物的商家和平摊这笔货款, 也就是说,无论你收购预定价是高还是低, 都不会比同期的商家收购价有所不同. 但是你的预期收购价过低,有可能导致补不全货.

系统补货完毕后,会根据集体的出售价经过抽税后, 决定一个商品在这个时期的销售价. 然后玩家在购买这类商品的时候, 会在一段时间内以一个固定价格买到. 玩家买到货物, 并不直接打款给商家, 而是等待到一个周转期后, 按平均规则打款.

资本需求和使用需求在整个游戏系统中是严格分离的. 从表现上简单说就是:

即使你从事大豆买卖, 你承接了系统的大豆收购和销售任务, 并从中获利(或承担损失风险) , 当你需要大豆自己吃的时候, 也不能从你收购的大豆里拿一部分出来自己用. 你一样要从系统那里买, 也就是间接从自己仓库里买的. 从事大豆业务的时候, 大豆本身是不会进入你的私人银行仓库的. 商人货仓作为一个玩法的设计是独立存在的.

如果真要类比的话,就是现实里你可以炒大豆期货, 但你要自己磨豆浆还是要去超市买大豆的. 你炒大豆的单可以获利,也可以影响大豆的市场价格. 不同的是,我们并没有提供期货类似的东西, 没有人可以炒单.

对于低等级装备这种在 wow 里几乎不可能存在于拍卖场的东西. 装备等级相同的东西可能有属性不同以适应不同的职业. 对于商人来说,是统一收购的. 比如我要收购 40 级装备 10 件, 那么不具体指定收购什么. 你收购到什么也是不用知道的. 如果有多个人同时收购 40 级装备. 那么系统再卖掉后, 不会对应到具体某个人仓库里有什么而把货款打到特定人的账上. 换句话说, 不会因为你运气后, 收到特定有人需要的东西而赚到钱. 而是在一个订货周期后,系统把入账平均分给所有经营这种货物的人. 你可以看成每种货物只有系统一个卖家. 商人拥有的这种商品的经营股份而已. 商人因为是股东而有定价权.

每个想做商人的人在系统里都可以查到这类商品的现金流, 压货数量(可能是因为没有人买, 也可能是因为价格过高, 这需要商人自己判断). 在游戏规则内没有垄断. 如果某件商品压了 100 件货而没有卖掉. 你如果判断是因为价格过高, 可以申请 20 件销售, 并把价格设低. 但总价格不会直接降低到你设置的价格上(你甚至可以直接设为 0) , 而是用你设置的 20 件价格和原价做一个运算, 得到一个更低的价位出来. 如果不出现新的商人介入, 系统就会充当这个角色以自动降低收购价和卖出价. (这个时候压的 100 件的商人如果死不降价, 多出的货就会自动在系统间以低价流通, 交易还是会顺利在有需求的玩家间进行的)

这里的关键在于隔离使用需求者. 让做生意的人专心做生意, 并承担风险. 使用需求者更容易买到自己的东西, 并有一个相对公允的价位.

设计这个系统想做到:

所有玩家的战利品都会进入玩家市场流通, 而不会被系统直接回收变成货币投放. (在少数情况还是会保留这种设计)

商品价格一定程度上受供求关系影响, 但又不是特别敏感, 有一定的稳定性. 直接影响价格的是少数开商铺的商人玩家. 这是一个小群体, 但不是系统(避免计划经济) , 也很难垄断。商人起的最重要作用是调节商品价格在一个合理位置。并因为这个玩法中风险和收益并存而显得有趣。

系统可以向收购和销售两方面抽税, 并在商品无人需求或暂时被人垄断时, 做一些系统补贴。根据商品产出地的地图不同, 可以税率不一样. 然后我们可以结合运镖玩法(物流因素), 自动调节税率.

Tags: ,,.
12月 5, 2012

程序员是可以当作一生的职业。但首先,你需要热爱编程,而不是把它作为完成其他人生目标的工具。

既然计划投入数十年的人生,那么一定会不断的反思自己在哪些方面的努力是更有效率的。换句话说,现在的你,和十年前的自己到底有哪些不同,十年后又怎样超越现在的自己?

我的职业生涯还远远没有过半,深深感觉沉淀不足。总会有新的理解迭代到旧有的想法上。这次应邀来用简短的文字探讨这个深刻的话题,只能尽力来表达一些现阶段的浅薄之见。

我认为,一个程序员,无论他在哪个子领域工作,都需要在三个方面提高自己。

首先,保持对未知领域的好奇心,尽力开阔视野。

如果你只精通一门编程语言,那么就赶快去学习另一门,最好和之前的那门语言亲缘关系越远越好。这可以让你从不同的视角去看待过去的问题。

如果你只专注于一个领域,那么深入研究一下其它领域会有很大的帮助。大多数人都喜欢在自己熟悉的知识结构下解决问题,因为全新的东西总有学习门槛,你需要去了解很多基础知识才能开始实际的工作。在大脑里把相关信息组织起来轻松调配,和借助外部资料是很不一样的。后者要经历一个相当痛苦的过程。但是,一旦你习惯经常学习,可以逐步掌握一套自己的方法减轻这种痛苦。大多数人实际会遇到的领域有限,看似没有价值的知识,学习起来更为困难。要做到这点,需要保持单纯的好奇心。

其次,把握各个层次上的细节。

尽可能向人解释清楚系统每个层面的运行。硬件如何调度机器指令;数据在硬盘、内存、缓存、CPU 间的流向;代码如何被编译链接,代码经历了何种过程被加载到内存,JIT 怎样加速字节码的运行;操作系统怎样管理线程、处理 IO ;软件用到的第三方模块和工具如何在处理数据;在网络环境中,数据流的通讯协议;你的代码中每个模块逐个层次中的相互关系……

对细节掌握的越多,思路会越清晰。在每个层次上,你会看到不同层次的设备对上一层业务逻辑的抽象方式,直到最终你直接面对的业务。对业务的抽象能力,不仅仅来至于你的业务的熟悉程度。这种能力是随同细节把握能力同时俱备的。了解的越多,就越能知道你经手工作的合理性。

第三,对代码的直觉。

优秀的程序员能很快的发现性能热点、找到系统崩溃的原因、找出不合理的代码…… 准确的估算能力非常重要,快速心算出每个模块的开销和输入的数据量之间的关系;在写下每行代码时能够判断其对性能的影响、以简洁去挑战各个层次模块间的耦合复杂度。

培养这种能力,以我个人浅见,除了不停不断的编写代码,别无良方。在写代码的同时,时刻保持着思考,对坏味道的地方零容忍。只要在最早的时刻动手,任何推倒重来的代价都不会太大;而放任它们在那里只会让局面演变到不可收拾。

保持自己总有代码可写,不断的去发掘自己新的兴趣点,拓展新领域。单纯一些,编程本身就是一件有趣的脑力活动,而不必仅仅为了解决一些问题而写程序。

Tags: ,,.
12月 3, 2012

前段时间在微薄上对 windows 以及 vc 发牢骚。对于已经熟悉的软件,我很难忍受图形界面。

但图形界面对于大多数用户还是必不可少的,学习门槛低是最大的原因。但仅仅为降低学习门槛为原则去设计界面却绝对不是好的设计。

图形界面和命令行界面最大的区别是用户了解软件的角度不同。对于命令行界面的软件,用户的第一直觉大约是,我想做一件事情,所以我使用(或编写)这个软件。那么,这个软件应该如何达到我的目的。先有需求,然后寻找答案。通常,软件会有使用说明、可能和软件一起分发放在一个文本文件里,更多的是用命令行参数,传入一个 -h 之类的命令列出来。也可以在网上搜索。总之,需要用户去挖掘。当然,有的软件有所有默认配置,不需要参数就可以启动。如果更改了需求,可以去修改配置文件。修改配置文件的过程,往往可以通过观察默认配置的写法来学习。

图形界面的软件则不是这样。

当用户打开软件,图形界面直接展现出来。即使他是带着疑问:我该怎样完成我的需求?但看到界面后,第一反应却会是:那些第一眼直观可见的操作控件是做什么用的?

这个次序就变成了:我看见了一些功能,我想了解这些功能能达到什么目的,这些能做的事情里是否有我需要的。

如果按钮无害,用户会点点试试看发生了什么,现代软件通常还会在他鼠标移动过去时浮动一个提示框来做详细告之。受过往操作经验的影响,他可能还会按一下右键看一下右键菜单;拖动一下看起来可以拖动的控件;点一下可以点的下拉菜单;进一步学习软件能做什么。整个学习过程也就自然完成了。当软件的界面排布完全符合他以前用过的类似品时,这个过程可能省略到看一眼这么简短,然后直奔主题去点击他需要的那个按钮。

有时为了让用户可以直达目的,而不是先让用户自行尝试了解所有功能,再去匹配自己的需求(后者可能相当低效,以至于让用户中途放弃),软件需要制作一个简短的新手引导。这在最近的手机软件或网络游戏中相当常见。

在我看来,图形界面设计之所以被称为设计。必然需要经过思考,先将软件要解决的问题抽象分类,理清数据组织结构,帮用户把一些从用户角度看起来不太相关的事务提取出共性,归纳到类似的物件上。

比如 windows 把文件,文件夹等都图形实体化,然后用拖动来代表对其的操作,并把类似删除这种操作也实物化为垃圾站这种东西可以作为一个操作对象。

然后经过合理的布局和分类,排布到不同层级的界面展示。同时需要做一些创造,用恰当的形式来占现。我所说的形式,并不指更漂亮的图标,线框,字体;而是指类似弹出菜单,工具条这样的东西。在 windows 软件的年代,开发者似乎并不太愿意去考虑新形式,许多人觉得现有的控件已经很丰富了,我们只需要去选择。反正用户都已经接受了这些东西,他们熟悉,可以不用担心不一样的东西带来的风险。当然,最重要的是,可以减少开发成本。而最近几年,这种想法已经少的多了。在手机 app 领域,我们时常能看到耳目一新的东西。虽然弄巧成拙的也有不少,但,原来还可以这样方便的操作啊,这种感慨也挺多的。

这些才是设计师存在的价值。只是重新排布一下按钮的摆放、计算一下留白空间、选择好看的字体、画几个精美的图标,从视觉上讨好用户,只是界面设计中需要考虑一个方面而已,我觉得,甚至谈不上是重要的一个。

好的界面设计,我认为要考虑以下几个方面。

一是展示功能。主界面是否能展示软件解决的关键问题。用户都是从这里开始摸索软件,然后把他们看到的功能一一记下来,然后和自己心中的疑问去匹配,判断能否完成自己的需要。

一个软件,或是软件中一个模块。往往只需要解决一两个关键需求。用户心中的疑问也只有一两个。过多的功能堆砌都是不恰当的。因为接受用户的指令只是达成目的的一个开始环节,界面还需要承担信息反馈这个任务。大多数软件都需要让这个占据最大的屏幕空间。

从易于了解功能的角度讲,界面控件的可辨识程度,就相当重要。简洁的控件设计,恰当的留白以突出主要功能都是为了让用户视觉上可以直击我们需要引导让他看到东西。

二是操作效率。界面控件应该有合适的大小和间距易于定位,相关操作应有尽量短的移动距离。用图标区分相似元素的可辨识度是很重要的。合适的二级菜单可以避免主界面上信息量过大的问题,又可以方便的引导用户做多步操作,但是形式却很重要。这方面,我觉得 tweetbot 就做的不错。

三是高级操作带来的快捷操作。chrome 以前,我一直在用 opera 。最大的喜爱原因就是鼠标手势。虽然不是所有人都习惯复杂的鼠标手势,但一旦学会,可以更便捷的操作。高级操作形式不应是达成目的的唯一操作方法,但作为一个可选项却很有意义。

最后,我觉得最重要的是,应该充分理解软件是要做什么,而不是一味的模仿。没错,用户总希望新的软件和他用过的软件是一样的,这样就不用再学习。用户习惯什么的,是那些模仿设计最好的借口。直到我们看到新的设计被用户接受后,然后更新我们的界面。当然,也有很多新设计从始至终都没被人接受。我觉得那大多数不是因为扭曲了用户习惯,而是本身就设计的不够好。

比如,我认为 QQ 的好友分组展现就是一个糟糕的设计。当然,作为山寨软件之王,那也是从 MSN 里抄来的。

最糟糕之处在于,为什么一个好友就只能存在于一个组里?(或许是我不知道如何把一个好友分到多个组里)我的同学最近在和我一起打魔兽,因为这个原因,我就需要把他从同学分组里拖去魔兽组?

每个分组的组名都占据有限的界面空间也是一个浪费。更何况,这个组里有多少人,且其中多少人在线根本不是主要需要展现的信息,也侵占了屏幕。打开主界面,用户的主要目的是快速找到联系人,而不是独子欣赏:我有好多朋友啊。

假设允许同一个人在不同分组里也有许多问题。

首先,分组这个操作就变得复杂了。到底是复制一个好友去一个分组,还是移动一个好友到一个分组就会困扰用户。删除好友和从一个分组里删除好友也变得不同。网易泡泡就曾经面临这个问题。其结果就是,让各种无所谓的操作充斥着右键菜单。另外,当一个人同时处于多个组内,也很可能在主界面上重复出现同一个人的信息。

这个问题的本质在哪里?或许分组本身就是错误的。分组的本意或许是想借鉴 Windows 的文件夹的图形抽象方式,但一开始就错了。大多数分组行为是为了给人打上标签方便找到人,而不是让人归属到文件夹下。抽象角度出了问题,怎么改良都对不了。

现代社交网站几乎都是用给人打标签来解决这个问题的。一个人可以没有,或有多个标签。大多数操作都是围绕人而不是标签的。标签只是检索人的一个手段而已。其实不光是社交网站,我们看到,在 gmail 这样的 email 软件、douban 这样的管理自己读书电影评论网站中,也是如此。

假设用标签系统来取代好友分组系统,我会这样去做。

在界面的顶端保留一条标签栏,横向罗列现在关注的标签。我可以展开这个标签栏,展示我创建的所有标签,并可以通过鼠标点击选择或屏蔽掉我的关注点。然后,在界面下方列出所有拥有至少一个关注标签的好友。我可以通过直接点击标签栏中某个特定标签把相关人排在最前面,而不用因为人员名单过长而去拉动滚动条。甚至,滚动条都应该是隐藏的,就如 gtalk 那样,必须用户强制开启。

给好友打标签或去掉标签的操作都放在好友下方,可以点击好友名字展开(类似 tweetbot) 。

我不保证这样的设计一定会被用户接受,因为用户习惯往往是可怕的固执。但这样的设计可以节省许多界面空间以展示更多用户需要的信息。对组的操作也被简化了。

我能找到的另一个例子是计算器。我有点计算器 app 控,在 ios 上购买或免费下载了许多计算器 app 了。计算器的功能其实都差不多,但是却少有让人使用感觉方便的。

我觉得这个坏头是 windows 自带计算机开始的。他区分了基本计算器和科学计算等。结果大多数计算器 app 也沿袭了这个设计(当然,几乎所有 app 也想到,可以利用重力感应横置设备来快速切换,而不用再让用户选菜单了)。且几乎所有的计算机 app 都模仿了传统实物计算器的布局。

区分不同类型的计算器版面的初衷或许是好的。如果界面按钮过多,或降低用户的操作效率。因为按钮越少越密集,定位的难度就越高。当用户没有那么多功能需求时,应该让界面更简洁。但这真是矛盾不可解决的吗?

许多计算机 app 还自带单位转换器,可惜多数却做成一个额外的模式。可为什么我需要切换模式去转换单位?不应该是计算完毕后,我需要数字结果的另一种表达吗?

同样的问题还出在进制转换等等需求上。

现代图形界面还带来了比传统计算器液晶显示屏更丰富的展示能力,cpu 的处理能力也远非那些机器可比。怎么用好它却是一个难题。

我不想在这里花太多文字来解释怎么把这些问题解决好。我想说,如果你用过 Calculator∞ 自然会和我有一些新的想法。我可以负责任的说,作为一个计算器 app 控,我用了它的免费版第一天就毫不犹豫的付费了。而在昨天我在微薄上夸了它两句,它居然就限免了。有兴趣的同学赶紧装一个试试。

Tags: ,,,.
11月 23, 2012

文/iDoNews新锐作者 云风

魔兽世界从巫妖王之怒那个版本开始完善了他的 相位 (Phasing) 技术。简单说,就是在场景的相同区域,不同的玩家根据一些条件(一般是任务进度)的不同,会感受到不同的环境:场景可以不同、能交互的 NPC 不同等等。

这个技术可以给玩家在 MMO 中带来许多以往在单机游戏中才能体验的融入感。在 wow 之前,许多游戏策划都曾经想过让玩家可以通过完成剧情改变游戏的环境,但苦于找不到好的表现方式。这或许是因为大部分游戏策划都在以现实世界为参考去考虑技术能够实现的可能性,而在这个微创新年代,没有摹本可以参考就没有了思路。但我有些奇怪的是,在 wow 把相位技术展现给世界已经超过 4 年了,为啥山寨大国还没有全力跟进呢?

莫非是因为这里面还真有什么技术难点?

以前没有认真思考过相位技术如何简单的实现。我们在这期里程碑里还没有把这个提上案子,但读到我们的策划已经编写了大量剧情,似乎没有相位技术或类似的东西就无法实现啊。许多情节写的跟小说一样,动不动就天亮了、环境变了、NPC 死了、人群散了、BOSS 复活了 …… 说起来好像 wow 里某个任务就是这样做的,我们以后天然就要支持相位技术没啥问题似的。

昨天晚上就这个问题跟 mike 同学讨论了一下,初步定了个方案。

据说原来在网易的一些同学还真认真讨论过相位技术如何实现,道听途说,我也不了解细节。大致说是希望放在 AOI 模块中解决一部分问题。就是让每个玩家的可见对象受底层模块的一些参数控制,他只能看到他能看到的景物。

也就是说,不同位面的 NPC 实际上全部存在于一个场景,只是被位面约束,可能不可见而已。

我们就这个可能的解决方案做了讨论,一致认为过于复杂了。相位技术不仅仅是要解决可见性问题,还需要解决互动问题。用这个方案的难点在于,即使我们可以在玩家对象上做一个可见性过滤(这倒是在我们现在的架构上,很容易实现),也很难去处理交互问题。比如释放一个 AOE 法术攻击一个区域内的对象,就需要给法术本身也设置位面影响。而玩家可以独立在一个服务内计算,将法术对象也独立出来就不太现实。这样做的结果会带来不可控的复杂性,以及很多性能问题。

所以我们觉得采用更纯粹的方案可能更好。不一定要完全和 wow 的一致,但能满足剧情任务表现的要求即可。

简单的方案是,如果一个场景因为上面的任务线的不同,而可能有不同的位面。那么、我们就实际去制作多个不同的场景(指服务器的配置)然后全部位面都独立启动起来。

指定一个主位面,它是覆盖全地图的。其它位面都有一定的影响区域。也就是说,次位面在地图大小上和主位面一样,只是布置的 NPC 对象不同,但一般只在一个较小区域上布置。

玩家会因为任务进度的关系进入次位面,但在次位面上移动超过影响区域就自动切回主位面。玩家处于不同位面实际上是处于不同的场景服务中。但这个切换的代价是相当之小的。首先客户端并不需要移动位置,也不需要加载场景。玩家不会感觉到停顿。服务器那边,相同地图的不同位面一定是在同一进程中,只是把绑定在玩家上的 agent 切换了交互 map 而已。

第一步我们暂时实现任务链对位面切换的影响,这样策划通过配置任务表格就可以切换到他们专门为任务配置的次位面中。区分主次位面,而不是等同对待,是为了配置规则简单,而非实现上的区别。

之后,我们需要制定一些基本规则,防止在相位穿帮时有一个明确的处理规范。当然策划在应用这个技术时也需要积累一些经验,考虑对一些 PvP 玩法的影响。

这次先写这么多记录一下想法,估计要到一个月后才会开始实现。

一天一分钟,业界在听你回声。如果你有更加丰满、个性化的互联网点评视角,欢迎奔跑加入iDoNews业内点评团,私信@沸话小欧 即可。

转载请注明 iDoNews新锐作者/云风

Tags: ,,.