« April 2010 | Main | June 2010 »

May 27, 2010

共享 lua state 中的数据

今天和倩女幽魂的同事讨论一个问题:他们的游戏 client 中,有大量策划填写的表格直接导入 lua state 中的大量数据。大约有 100M 以上。这样,如果玩家在一台机器上启动多个 client ,就会占用大量的内存。

而这些数据,一旦加载进 lua ,就不会再修改,且每个 client 中数据都是一致的,这是一种浪费。

问题是:如何利用进程间的数据共享,在多开 client 时节省这些空间。(同时也可以加快开第二个 client 的启动速度)

如果数据不存在于 lua state 中,而直接用 C 访问。可以用简单的共享内存的方式解决。关于 Windows 下共享内存的方法,我曾经写过一篇 blog 介绍

如果是非 windows 系统,这个问题解决起来也很容易。

在 lua 加载完所有数据后,做一次 fork ,让所有多开的 client 都是一个子进程即可。


一开始,我们讨论了设置多个 lua state 的方案。让策划的数据表放在一个独立的 state 里。访问这些数据用跨 lua state 的方式进行。

不过这个方案实现起来比较麻烦,而且性能很低。如果想透明的访问数据 state ,需要做大量的 metatable 。甚至并不能节省内存空间。

最终可行的方案是这样的。

使用一个自定义的内存管理器。第一份 client 启动后,

  1. 初始化 lua state ,不初始化任何用到的库。
  2. 通知内存管理器,切换一个 heap ,并加载所有策划表格。以及用到的 C 库。(因为 client 相同,这些库中函数指针地址也相同)
  3. 做一次完整的 gc 。再次通知自定义内存管理器切换一个 heap 。
  4. 此时应该有 3 个 heap 。一个保存了 lua state 的结构,一个保存了策划表的数据,一个是空的,用来存放以后 lua state 中的所有数据。把第一个 heap 复制一份共享,(并提供原始的地址信息)。第二个 heap 直接共享,如有可能,把这个 heap 的页设置成只读。
  5. 以后的内存管理全部在新的第三个 heap 中进行。并在 free 操作中对企图在老 heap 上的操作做 assert 。

第二份以后的 client 启动时,如果发现有共享的 heap ,把第一份 heap 取到,并复制到指定的地址空间。以只读方式映射第二个 heap 。并在指定位置创建第三个 heap (和主 client 的三个 heap 的地址保持一致)

btw. 如果实现上允许,可以让内存管理器讲第一和第三个 heap 合并成一个。中间只是一个切换的过程。那么,总共只需要两个 heap 即可。

在 client 进程退出时,应当按如下次序:

  1. 取消内存管理器中的 assert
  2. 所有针对第二份 heap (存放策划表格的那个)上数据的 free 操作全部忽略掉。
  3. 关闭 lua state

或者,直接选择不关闭 lua state


这样做之所以可行,基于以下几点:

  1. 我们可以利用自己的内存管理器,让每个 client 在初始化后,内存布局完全相同。所有的策划表格存放在一致的内存地址上。(相同地址的 heap page ),lua state 的指针也完全相同,结构体内的指针也正确的指向相同的位置(在独立的 heap page 中)。
  2. lua 的 C 库中函数指针在所有 client 中的地址一定相同,所以是一致的,可以共享。
  3. lua state 的结构在初始化完毕后是一致的,虽然以后会被修改,但我们是以复制的方式存在于每个 client ,所以相互不会受影响。

May 25, 2010

千呼万唤始出来,结果是这么白痴的设定

以前 popo 有一个内部版本很好用的,可以方便的查到同事的 popo ,并且建立联系。后来为推广 popo 取消掉了。今天,公司内部又重新推内部版本。当然,client 是没有新做一个版本的,只是换了一组服务器。用公司 email 帐号登陆。

接到通知后,我登陆了新的服务器,发现居然联系人里空空如野。心中暗骂,真是太白痴了。

就算没有一个分好组的检索名单,总该提供一个机制,可以让我提交一下原有的 popo 帐号吧。只要每个人都登记一下,服务器完全可以自动建立起原有的联系人关系网。这不过是一个程序员半天的工作。而现在呢,我不断的收到请求添加联系人的广播。试想,公司 2000 多员工,平均 50 个联系人。每个花一分钟时间重新建立联系,这可是 10 万分钟的时间。超过了一个人年。这还没有算重新建立工作用的群(群本身就是一个很傻 X 的设定,只需要比较一下 twiiter 的机制就明白群这个东西多么白痴了)用的时间成本。


好吧,就算是为好友网络做一次 Garbage Collection 吧。我只是为不断的收到广播消息请求加好友而心神不宁。

ps. 要不要监控员工日常的聊天记录是个不好讨论的问题。

好在我们自己有开发 popo 的 pidgin 插件(这次改了协议,需要补丁),有必要的时候自己加个加密通讯好了。

setjmp 的正确使用

setjmp 是 C 语言解决 exception 的标准方案。我个人认为,setjmp/longjmp 这组 api 的名字没有取好,导致了许多误解。名字体现的是其行为:跳转,却没能反映其功能:exception 的抛出和捕获。

longjmp 从名字上看,叫做长距离跳转。实际上它能做的事情比名字上看起来的要少得多。跳转并非从静止状态的代码段的某个点跳转到另一个位置(类似在汇编层次的 jmp 指令做的那样),而是在运行态中向前跳转。C 语言的运行控制模型,是一个基于栈结构的指令执行序列。表示出来就是 call / return :调用一个函数,然后用 return 指令从一个函数返回。setjmp/longjmp 实际上是完成的另一种调用返回的模型。setjmp 相当于 call ,longjmp 则是 return 。

重要的区别在于:setjmp 不具备函数调用那样灵活的入口点定义;而 return 不具备 longjmp 那样可以灵活的选择返回点。其次,第一、setjmp 并不负责维护调用栈的数据结构,即,你不必保证运行过程中 setjmp 和 longjmp 层次上配对。如果需要这种层次,则需要程序员自己维护一个调用栈。这个调用栈往往是一个 jmp_buf 的序列;第二、它也不提供调用参数传递的功能,如果你需要,也得自己来实现。

以库形式提供的 setjmp/longjmp 和以语言关键字 return 提供的两套平行的运行流控制放在一起,大大拓展了 C 语言的能力。把 setjmp/longjmp 嵌在单个函数中使用,可以模拟 pascal 中嵌套函数定义:即在函数中定义一个局部函数。ps. GNUC 扩展了 C 语言,也在语法上支持这种定义方法。这种用法可以让几个局部函数有访问和共享 upvalue 的能力。把 setjmp/longjmp 放在大框架上,则多用来模拟 exception 机制。

setjmp 也可以用来模拟 coroutine 。但是会遇到一个难以逾越的难点:正确的 coroutine 实现需要为每个 coroutine 配备一个独立的数据栈,这是 setjmp 无法做到的。虽然有一些 C 的 coroutine 库用 setjmp/longjmp 实现。但使用起来都会有一定隐患。多半是在单一栈上预留一块空间,然后给另一个 coroutine 运行时覆盖使用。当数据栈溢出时,程序会发生许多怪异的现象,很难排除这种溢出 bug 。要正确的实现 coroutine ,还需要 setcontext 库 ,这已经不是 C 语言的标准库了。

在使用 setjmp 时,最常见的一个错误用法就是对 setjmp 做封装,用一个函数去调用它。比如:

int try(breakpoint bp)
{
    return setjmp(bp->jb);
}

void throw(breakpoint bp)
{
    longjmp(bp->jb,1);
}

setjmp 不应该封装在一个函数中。这样写并不讳引起编译错误。但十有八九会引起运行期错误。错误的起源在于 longjmp 的跳转返回点,必须在运行流经过并有效的位置。而如果对 setjmp 做过一层函数调用的封装后。上例中的 setjmp 设置的返回点经过 try 的调用返回后,已经无效。如果要必要封装的话,应该使用宏。

setjmp/longjmp 对于大多数 C 程序员来说比较陌生。正是在于它的定义含糊不清,不太容易弄清楚。使用上容易出问题,运用场合也就变的很狭窄,多用于规模较大的库或框架中。和 C++ 语言提供的 execption 机制一样,很少有构架师愿意把它暴露到外面,那需要对二次开发的程序员有足够清晰的头脑,并充分理解其概念才不会用错。这往往是不可能的。

另外,setjmp/longjmp 的理念和 C++ 本身的 RAII 相冲突。虽然许多编译器为防止 C++ 程序员错误使用 setjmp 都对其做了一定的改进。让它可以正确工作。但大多数情况下,还是在文档中直接声明不推荐在 C++ 程序中使用这个东西。

btw,关于 RAII ,的确是个好东西。但和诸多设计模式一样,不是真理。如果你是一个从 C++ 进化来的 C 程序员,则更应该警惕思维的禁锢,RAII 是一种避免资源泄露的好方案,但不是唯一方案。

May 18, 2010

太空堡垒卡拉狄加

周末开了一局桌游,太空堡垒卡拉狄加。感觉还不错。

以后桌游的相关 blog 就写在 http://bg.codingnow.com 这里了。

为了玩这局游戏,我特地把电视剧集翻出来看了一遍。感觉很不错。推荐一下。我个人很喜欢这个调调的软科幻,有如当年看银河英雄传说。不过老实说,BSG 比银英传要深刻一些。

May 11, 2010

silenceisdefeat 关掉了 TCP Forwarding

自从我自己购买了 linode 的 vps 后,我就没再用过免费的 ssh server 翻墙。

今天有同学问起,说 silenceisdefeat 不能用了。我登上去看了一下,在 /etc/ssh/sshd_config 里加了一句

AllowTcpForwarding no

嗯,估计是觉得好多人用它来做 proxy ,流量受不了,关掉了。

如果还想继续用怎么办呢?

试了一下,服务器是允许编译 C 程序的,可是不能运行 :(

但居然装有 python :) 赶紧下了个简单的 http proxy 运行。发现可以正常工作,只是仅限于本地。估计是设了防火墙,从外面不能连接上这个proxy 。

不过问题不大。在自己机器上启一个 sshd 即可。从 silenceisdefeat ssh -R 回来就好了。

在办公室时,自己没有外部 ip 的情况下,就需要再找台国内的机器做跳板了。


在实施以上方案时,我发现它没有打开 ssh agent 的 forward 。自然不可能把自己常用的 key 传上去。就另外生成了一对 key 专门做 tunnel 用。

全部设置好,一切工作正常。可以利用 silenceisdefeat 上运行的 http proxy 翻墙。可乐极生悲的是,在自己的机器上配置新的 tunnel 帐号时,一个不小心,把主帐号的 key 文件 mv 走了。导致自己都登陆不上自己的机器。只好等另个有权限的同学上线帮忙。

以后在 root 下工作一定要小心。


2010 年 6 月 7 日补充:

今天收到 silenceisdefeat.com 的 samble 的 email ,说是我弄错了 :) sshd 的配置文件放在了

/etc/ssh/sshd_config2

这个是目前正在服务的 sshd 的配置文件,其中 AllowTcpForwarding yes 打开的。

经测试,ssh tunnel 可用。

May 07, 2010

给你的模块设防

我们设计任何一个模块,都应当对其实现细节尽可能的隐藏。只留下有限的入口和外部通讯。这些入口如何定义是重中之重。大多数情况下,我们都在模仿已有的系统来设计,所以对貌似理所当然的接口定义不以为然,以为天生就应该是那样,而把过多精力放在了如何做更好(更优化)的实现上。对接口设计方面缺乏深度的思考,使得在面对新领域时,或是随心所欲,或是不知所措。

即使是有成熟设计的模块,用户依然可能使用错误。模块的设计者不能要求模块的使用者对其内部实现了然于胸。不能指望自己能写出完善的文档去告诫使用者,当你怎样用的时候会用错。即使写出了完善的文档,也不能指望每个人都仔细的读过。就算读了,也有百密一疏的时候。越是专有的模块,越不能指望文档或是口述的教导,更不能指望程序员去精读源码。人生苦短,如无特别的理由,逐行去读同僚的代码,何不自己重写一遍?你设计的系统若用出了问题,与其怪别人用错,不如怪自己没设计好。MSDN 洋洋洒洒文字以 G 计算,也属无奈之举。依然有无数人犯下那些被人提及的错误。

现举一个一切 C 语言程序员都用过的模块的设计:内存管理模块。

标准 API 为三个:

malloc

free

realloc

大多数程序员都以为这理所当然。直到接触到 gc 的方式管理内存,方知另有一片天地。即使在 C 库中引为标准,也并不是所有内存管理器都承认这种简洁的。比如在 Windows 的 API 中,HeapAlloc 系列的内存管理模块的 API 就更复杂一些。

当我们考察 malloc 这组 API ,我们会发现其实它并不是绝对可靠的。稍有经验的 C 程序员就会指出:我们不能给 free 传入随意的指针。一个典型的错误就是对一个由 malloc 分配出的地址,调用 free 两次。

次要一点的一个问题是,分配了一块内存,但丢失了地址指针。导致了无法释放内存。

还有一些问题,比如分配了一个内存块,没有初始化内容,而直接引用了里面不可预知的数据;或是释放了一块内存,但依旧引用里面的数据。读写一个内存地址时,超过了分配时的边界;没有检查分配失败的情况;realloc 扩展内存后,地址发生变化,但有地方依旧引用老的地址。

为了防备这些问题,最常用的方法不是反复告戒程序员规避这些错误,而是给模块本身设防。让程序员犯错误时及早发现。当然,如果条件允许,应该更谨慎的定义模块接口,甚至在发现设计问题后,重新设计。

内存管理模块,很多情况下是由第三方提供的。我们不太会去研究其源代码并做出修改。即使由自己开发,也希望其足够独立,在软件的发行版中提供最高的性能(而不因为过多的错误检查而拖慢系统)。

给 api 加个壳是个很不错的方案。


malloc ,分配一块内存,返回这块内存的内存地址。按照 malloc 的 ISO 标准,当分配失败的时候,返回空指针。当试图请求分配 0 字节长的内存块时,返回值依赖实现:可以返回空指针,或是返回一个有效内存地址。返回的内存块里的数据未定义。

根据我们自己项目的事情情况,以及开发团队的开发规范。我们可以加强 api 的定义。对于一些无定义的特性,应该尽量去除,保证其不会被用到(使用断言)。以我个人的经验,我所开发的软件中,软件使用的内存总量可以被预估出来,不可能超过系统能提供的内存上限。这时,我不需要 malloc 对分配失败返回空指针。一旦出现这种情况,一定是我错误的传递了请求的长度值。这时,我会在自定义的壳中检查返回值,并断言它一定不为空指针。

注:每次内存分配都成功的断言,不一定在每个项目中都成立。有些情况下,允许内存分配失败,并判断返回失败情况还是有必要的。

对于分配零字节长度内存的情况,我倾向于统一使用一致的设定,即返回一个唯一有效的内存地址,让 malloc(0) 等价于 malloc(1) 。这可以很好的保持于 C++ 的 new 行为一致。( C++ 中,使用 operator new 时,若申请的长度为 0 ,按标准所定,是不会返回空指针的)

对于内存块的内容,我倾向于做一次填充。这可以减少出错的随机性。但不要填充成 0 。这极有可能掩盖掉没有正确初始化内存的问题。在 x86 的机器下,可以考虑填充 0xCC 。0xCC 在 IA32 指令集中是 int3 中断的机器码。万一函数指针错误跳转到数据区,可以自动触发一次调试中断。而且 0xCCCCCCCC 在大多数操作系统下,是个无效地址。

free ,释放一块由 malloc 或 realloc 分配的内存。按标准,free 可以接受空指针,并不做任何事情。这一点在自己设计内存管理器时,一定需要遵循。free 后的内存块的数据处于未定义状态。如果我们给 free 加上壳的话,我的个人建议是对 free 后的内存块做数据填充。这样可以帮助提前发现程序中引用以释放的内存。

realloc ,改变已分配的内存块的大小。如果内存块无法在原地址扩展,在搬移到新地址上时,realloc 会复制原有的数据。根据标准的定义,当 realloc 扩展内存失败时,返回空指针,并保留由有的内存块。这反而会导致程序员的一些疏忽,在某些对内存管理比较苛刻的库设计中,让内存泄露。(忘记释放 realloc 扩展失败后的原地址)有些系统(例如 freeBSD)提供了另一个相似的 api ,reallocf 来回避这个问题。

所幸,很多软件中,我们可以按上面 malloc 的处理方案来规避这个问题。扩展内存失败往往意味着别的地方出了 bug 。在壳中断言 realloc 一定成功即可。


如何找到多次 free 同一块内存,如何找到内存的写越界 ?虽没有万无一失的方案,但我们可以在壳中多做一些工作来尽可能的提前发现问题。

典型的方法是给分配出来的内存块前后加上狗牌。例如:

#define DOGTAG_VALID 0xbadf00d #define DOGTAG_FREE 0x900dca7e #define DOGTAG_TAIL 0xd097a90 struct cookie { size_t sz; int tag; }; void * my_malloc(size_t sz) { if (sz == 0) sz = 1; struct cookie * c = malloc(sizeof(struct cookie) + sz + sizeof(int)); assert(c != NULL); c->sz = sz; c->tag = DOGTAG_VALID; int * tail = (int *)((char *)(c+1) + sz); *tail = DOGTAG_TAIL; memset(c+1, 0xCC, sz); return c+1; } void my_free(void *p) { if (p==NULL) return; struct cookie * c = p; --c; assert(c->tag != DOGTAG_FREE); assert(c->tag == DOGTAG_VALID); int *tail = (int *)((char *)p + c->sz); assert(*tail == DOGTAG_TAIL); c->tag = DOGTAG_FREE; memset(p, 0xCC , c->sz); free(c); }

如何检测内存泄露?即申请的内存块没有释放。

这个问题应该分类讨论。

以我个人的习惯,有些内存块在整个软件的生命期中,只会被申请一次,而随程序退出而由系统回收。主动调用 free 是一种额外的负担,更容易引起错误。这种做法,在 C++ 程序中是不推荐的。但 C++ 软件的诸多常见 bug 和难以解决的问题,正在于单件的构造析构次序。但在基于 C 的软件中,这类做法却很常见。

我的个人喜好是,增加一个 api 专门用来分配不会动态释放的内存。这类内存打上特殊的 tag ,一旦 free 它就会断言出错。

而另一些动态管理的内存,则双向链表链起来。软件退出的时,检查这个链表。很多人在 MFC 的框架中已经见识了这个做法,这里不再列出详细的代码。


如何给已有的内存管理器加壳?

有些 C 库,比如 GNU C 库,提供了钩子。设置这些钩子可以加上壳帮助你调试代码。如果你用的 C 库没提供这个能力,通常的解决方案是在头文件中使用宏替换。如果是 C++ 代码,可以重定义全局的 new / delete 运算符。在 MFC 中,采用的就是宏替换 new 和 delete 运算符的作法。这个做法是为了记录使用这些运算符的源代码位置。但这种做法有一些弊病,下面我会推荐一个在基于 C 开发的软件中更灵活的方案。

首先说说直接宏替换,可能存在的问题:

#define malloc(sz) malloc_proxy(sz)

这样做在大多数情况下都是有效的。但当我们想把 malloc 作为函数指针传递时会有些麻烦。其实我们可以采用另一个方案。

typedef void* (*malloc_f)(size_t sz); #define malloc malloc_proxy() malloc_f malloc_proxy() { return my_malloc; }

如果我们希望在调用 malloc 时,记录调用的源代码位置,并记录在 cookie 中,或是断言出错的时候输出到标准错误设备中。可以这样。

#define malloc malloc_proxy(__FILE__ , __LINE__) malloc_f malloc_proxy(const char *filename, int line) { g_filename = filename; g_line = line; return my_malloc; }

这里采用了全局变量来记录源码信息,而没有通过参数传递。在多线程环境可能会有一点小问题,但是无伤大雅。也容易回避。自定义的壳从全局变量去获得源代码信息,也更容易保持接口一致。

接下来我们只需要扩展 cookie 结构,来提供更多调试信息。

注:在设计 cookie 时,可以考虑把 cookie 前面第一项放无关紧要的信息,或空出来。至少不要把狗牌放在最前面。这是因为,free 对内存块的破坏是不可预知的。如果想检测出多同一内存的多次释放,需要对内存管理器有一定的了解,尽量回避 free 对内存内容的改写。大多数高效的内存管理模块都采用了 freelist 技术。这种技术会利用已释放的内存块的开头区域存放 freelist 指针。如果你把狗牌设在这个位置,可能会被改写掉。

May 03, 2010

Delve 迷你地下城冒险游戏

有很多桌面游戏是极富魅力的。甚至只用极少的道具就可以一个人自娱自乐。我在逛 bgg 时,用高级搜索找到了这么一个只需要 6 个六面骰就可以独自享受地下城冒险的小游戏,试了一下,果然非常有趣。进一步搜索,发现了基础版的汉化版本(由 :Coolforest 翻译),把图片附在后面。

Delve 由 Drew Chamberlain 设计,如果你有兴趣,可以去这个网站下载更多的内容。

游戏的道具就是投骰子。一组 6 个骰子,投三次。在第一次和第二次后,都可以保留你投出的喜欢的数字,而只投剩余的几个,直到第三次达到最终的组合。第一次重掷和第二次重掷保留的骰子可以不同。即,第一次保留的骰子可以在第三次投掷中继续保留或重掷。

你,扮演的是一支四人小队。经典的战法牧铁三角组合,外加一个伤害输出的盗贼。每遇到一组怪物,按上面的规则投 3 次 6 颗骰子后,按最后的数字组合安排行动方案。

每回合,可以使用任意角色的若干技能。这些技能的发动来源于 6 颗骰子中的若干颗。但如果一个技能使用了某颗骰子,其他技能就不能重复使用这颗骰子。

技能表如下:

  • 战士
    • 冲锋 : 每个 6 造成 1 点伤害,但是每回合最多使用 2 个 6 用来冲锋。
  • 盗贼
    • 偷袭 : 每个 1 造成 1 点伤害,可以使用任意多的 1,但是每回合只能偷袭一个目标。
    • 要害攻击 :Full house (即一对加 3 个相同数,如 22444),伤害值为第6 个骰子的数值。这个伤害只能用在一个目标上。
  • 牧师
    • 次级治疗 :4 个连续数,如2345. 治疗 2 点生命,可加在不同的角色身上。
    • 治疗 : 5 个连续数,如 12345. 治疗量为第 6 个骰子的数值。可加在不同冒险者身上。另外,如果第 6 个骰子是 1,为每个角色加 1 点生命。
    • 奇迹 : 6 个连续数,即 123456. 所有角色满血满状态原地复活。
  • 法师
    • 寒冰箭 :3 个相同数。这个回合,怪物少投 2 个骰子。
    • 闪电链 :4 个相同数。每个目标受到1 点伤害。
    • 火球术 :5 个相同数。6 点伤害,可以分配给多个目标。
    • 即死 : 6 个相同数。直接杀死这场战斗中的所有怪物。

怪物的攻击很好计算。如非特别注明,为场景中每个怪的每点 HP 准备一个骰子,按怪物种类分批投下。按设定的伤害点数出现次数扣我方角色的 HP 。一般不太厉害的小怪会设定为 6 伤害,即,只有掷出 6 才计伤害。厉害的怪可能设定成 4,5,6 伤害。那么每颗骰子就有一半的几率对我方造成伤害。(这个设定就是 AA 的规则 :)

如非特殊注明,哪个角色来承受伤害由自己选择分配。

每场战斗,都是我方先攻,然后是怪物,轮流行动。


示例:

开始游戏,遭遇 3 个兽人,每个 2 HP 。

第一次投骰,掷出 4 2 5 2 6 1 。可以考虑保留 6 和 1 ,这样战士和盗贼就可以分配使用冲锋和偷袭攻击。如果以后能投出 4 个相同的数字,法师就能发动闪电链。保留两个 2 ,来尝试一下投出 4 个 2 。

重掷第一次(第二次投骰),只重掷 4 5 。结果投出 3 3 。没什么用。这时候全员满血,也不需要出顺子发动治疗回血。

再重掷一次(第三次投骰),依旧保留最初的 2 2 6 1 。投出两个 1 。结果就是 1 1 1 2 2 6 。

现在,可以使用盗贼的要害攻击,打出 6 点伤害。不过这意义不大,只能干掉一个兽人。不如用 2 个 1 的偷袭杀掉一个兽人,再用 6 的战士冲锋打掉另一个兽人的 1 点血。

当然,还有一个选择是,使用 1 1 1 发动法师的寒冰箭减轻敌人对我的伤害(少投两个骰子),但这样就一个兽人都杀不死了。(只能用剩下的 6 发动一次战士冲锋)


最初的冒险剧本如下:

  • 牧师 3 HP
  • 法师 1 HP
  • 盗贼 3 HP
  • 战士 6 HP

地下层一共七层,每层你将面对:

  1. 兽人(2 HP)* 3 ,命中 5 6
  2. 小妖(1 HP)* 12 ,命中 6
  3. 大蜘蛛 (3 HP) + 小蜘蛛(2 HP)* 3 ,命中 5 6 。击晕 1 。注:一个击晕效果,会使一个角色不能行动。
  4. 宝箱。掷一颗骰子。若是
    1. 火舌(战士装备):战士可以发动任意次冲锋。
    2. 黑箭(盗贼装备):三个 1 就可以发动黑箭直接杀死恶龙。
    3. 古老之杖(法师装备):所有技能都可以少用一个骰子
    4. 神圣符记(牧师装备):牧师在场时,骷髅兵只需要 1 点伤害既可被杀死
    5. 神圣祝福:每回合可至多投 4 次骰子而不是 3 次
    6. 爆炸神符:立刻杀死一个角色
  5. 巨人(9 HP),命中 4 5 6
  6. 骷髅兵(1 HP)* 6 ,命中 5 6 。骷髅兵需要同回合两点伤害才能减一点 HP 。
  7. 恶龙(15 HP),命中 5 6 。恶龙每回合攻击使用固定的六颗骰子。

有兴趣的同学可以直接打印出下图,找出 6 颗骰子游戏。我试玩了一盘,还是比较轻松在半小时内屠龙的。

最初的冒险

如果觉得不够过瘾,可以试一下挑战时之沙:

时之沙


更高精度的图可以在这里下载到。