分享使用Mathematica创建数值模拟框架的方法

Standard

在游戏分析领域,时常需要对游戏过程进行建模,从而观察游戏中的一些数值表现。以下我将以《Cookie Clicker》为例,分享在 Mathematica 中建立数值模拟框架的经验。


游戏简介

Cookie Clicker》是一款老少皆宜的挂机类游戏。目的是收集饼干,用赚到的饼干购买设备来自动产生饼干,例如请老奶奶帮忙、或是建造工厂等。还有很多升级选项和成就,让人欲罢不能。


框架搭建

数据包的结构

为了表现数值随时间变化的趋势,就需要对每个时间层建立当前数值的快照。或者说,把诸如饼干数、购买升级数等合在一起看成一个对象,那么这个包含“游戏状态”的对象就会随时间发生变化。而像购买升级这样的操作,则可以改变对象内存储的值,例如饼干数等。

以上描述的是“面向对象”方法建立模拟框架的思路。但是 Mathematica 并非面向对象的语言,在其中,甚至很难找到对象的影子。究其原因,是“变量的使用必须慎重”,尤其是“全局变量”,应能省则省。

Mathematica 编程原则:不要使用全局变量。

虽然 Mathematica 中没有提供可以存储变量的基本结构,但是却有另外的办法可以“变通”。一种经典的方法是将变量放到参数列表中进行传递。受此启发,我们可以直接定义一个包含所有可变量的数据包,用来储存状态。

定义的数据包结构如下:

Bundle[cookie, upgrade, time]

它本质上是一个贴了 Bundle 标签的数组,去除标签之后,它就和数组无异。

这样的定义方式有点像C++中的结构体(struct),但两者有一个很本质的不同,它是只读的。

只读的意思就是不能修改它的值,也即存储的是常量。那如果一定要修改它的值该怎么办呢?那就把原来的删掉,然后再建一个新的。

ReplacedTickets

想要修改一张不能被修改的纸条上的数字,就只能撕掉再重新写一张。

至此为止,便完成了模型对象的建立过程:把数据都写在一张张纸条上。

注:若是只接触过面向对象编程,可能会觉得上面更新对象的做法很无理取闹,十分野蛮。不过要是没什么感觉的话,那就继续往下看吧。 ╮(╯_╰)╭


数据包的操作

现在我们已经有一个 Bundle 类型的数据结构了,接下来要考虑可以修改它的操作。可行的操作一共就两个:

  1. 时间的流逝
  2. 购买升级

方便起见,将时间看成离散的,间隔是1秒。这样时间流逝之后,饼干数增加,时间加1。购买升级也比较简单,扣去对应数量的饼干即可。

关键的想法是把这两个操作看成函数,作用于 Bundle 对象上,返回的依旧是 Bundle 对象。这在面向对象编程里,可以看做是调用了对象的成员方法。而在函数式编程里,更像是直接把 f 作用于 x


所幸的是,Mathematica 中提供了一种面向 x 的函数定义方式(标签函数):

比方说,要定义一个名为 Tick 的函数,表示时间的流逝,就可以被如下定义:

Bundle /: Tick[Bundle[c_, u_, t_]] := 
 Bundle[c + sth., u, t + 1]

这表示当 Tick 作用于 Bundle 时会返回新的 Bundle 对象。


同理,购买升级的函数也类似如下:

Bundle /: Upgrade[Bundle[c_, u_, t_], n_Integer] := Bundle[...]

其中 Upgrade 不但接受 Bundle 类型参数,还接受一个整数,代表是第几个升级选项。


小结:以上便是定义数据包操作的方法。若把数据看成 x,操作看成 f,一般的形式如下:

x /: f[x] := ... 
x /: f[x, args] := ... 

也可以用作从一个对象中取出某个属性:

data /: attributeOf[data] := ... 

一些注释:

注:实际情况中,只有一个数据包,却有很多对应操作的情况很常见。这时,以数据为中心的结构化定义就会很方便,并且可读性也会好很多。

apple /: slice[a_apple] := < < 切片的苹果 >>
apple /: eat[a_apple]   := < < 苹果核 >> 
apple /: throw[a_apple] := < < 摔烂的苹果 >>
apple /: dance[a_apple] := < < 小苹果 >>
apple /: ...

这样的代码不写注释都可以,因为它是自解释的,逻辑也很通顺。

若用面向对象语言来写(以 Java 为例),就会像这样:

public Apple slice(Apple a){
    a.sliced = True;
    return a;
}

读者可以自行比较上面两种方法的异同。


代码实现

友情提醒:可以对照 源文件 来阅读接下来内容,需要安装有 Mathematica。

Part 1. Bundle Context

为方便起见,我将一些参数(例如初始购买价格、初始产量等)单独定义,并放在 BundleContext 上下文中:

Begin["BundleContext`"];
p$ = {15, 100, 500};        (* 初始价格 *)
q$ = {0.1, 0.5, 4};         (* 单位产量 *)
r$ = 0.15;                  (* 单价增长比率 *)
price[nl_List] := MapThread[Round[#1*(1 + r$)^#2] &, {p$, nl}] (* 购买价格函数 *)
quantity[nl_List] := q$*nl  (* 饼干产量函数 *)
End[]

值得说明的是,价格随购买次数是指数型增长,而产量随购买次数是线性增长。(此处只列出了三个升级选项)


Part 2. Bundle Definitions

这一节是有关 Bundle 结构与行为的定义。

先看结构,如下便是一个合理的定义:

b = Bundle[150, {2, 3, 0}, 60]

从左到右依次代表饼干数、升级的等级数、当前时间。


再看行为,行为包括时间的流逝和升级的购买。

时间的流逝函数:

Bundle /: Tick[Bundle[c_, u_, t_]] := 
 Bundle[c + Total@BundleContext`quantity[u], u, t + 1]

购买升级函数:

Bundle /: Upgrade[Bundle[c_, u_, t_], n_Integer] := 
 Module[{u2 = u, cost = BundleContext`price[u][[n]]}, 
  u2[[n]] = u2[[n]] + 1; 
  If[c >= cost, Bundle[c - cost, u2, t], 
   Message[Bundle::upgradefail, c, cost, n, u]; Bundle[c, u, t]]]

这段比较长是考虑到升级可能失败的情况,此时将保持数据不变,并打印出一条提示信息。


重载的购买升级函数:

Bundle /: Upgrade[b_Bundle, l_List] := Fold[Upgrade, b, l]

考虑到可能同时会有很多选项可以升级,故参数可以为列表形式。


剩下的则是一些辅助性的功能,方便后文中决策函数的书写。

Bundle /: UpgradeInfo[Bundle[c_, u_, t_]] := u
Bundle /: CrtQuantity[Bundle[c_, u_, t_]] := BundleContext`quantity[u]
Bundle /: NextPrice[Bundle[c_, u_, t_]] := BundleContext`price[u]
Bundle /: Cookie[Bundle[c_, u_, t_]] := c
Bundle /: Time[Bundle[c_, u_, t_]] := t
Bundle /: CanUpgradeQ[Bundle[c_, u_, t_], n_Integer] := 
 c >= BundleContext`price[u][[n]]

从上至下依次是:升级情况、当前产量、下一级价格、当前饼干数、当前时间、是否可以升级。


Part 3. Strategy Planning

这一节是考虑决策的部分,即在某个特定情况下该采取什么措施,这也是一个模拟系统中需要反复调节的部分。因为本文的重点是如何搭建整个框架,而非具体的决策函数是什么,故这里就简单写了一个随机决策函数,仅做示范用。

决策函数(随机选一个选项,若可升级则升级,否则不升级):

Bundle /: Choice[b_Bundle] := 
 Block[{n = RandomInteger[{1, 3}]}, If[CanUpgradeQ[b, n], n, 0]]

应用决策的函数:

Bundle /: ApplyChoice[b_Bundle, choice_] := Upgrade[b, choice]

应用决策也就是升级,因为这是一个将数据转换成代码的过程,所以函数名中用到了 Apply


Part 4. Simulation

接下来就是模拟过程了。

模拟过程的核心是一个很简单的逻辑脚本:

它会调用上一节提到的决策函数,如果决策非空,则应用它。最后,执行时间流逝的函数。

即先应用决策,后时间流逝。这个脚本定义出来是这样的:

sim = Function[b, 
  Tick[Block[{choice = Choice[b]}, 
    If[choice == 0, b, ApplyChoice[b, choice]]]]]

从内到外,由于 Choice 函数返回的结果可能是不确定的,故用一个局部变量去存储它,即 Block 中的内容:当选择为空时,保持不变,否则便 ApplyChoice 应用决策。
紧接着,便是 Tick 时间流逝函数。最后外层,是一个纯函数的标识 Function ,这意味着整体便是一个函数,作用于最初的 Bundle 对象。

最后把这个函数命名成 sim,便基本完成了模拟过程。模拟的过程便完全转换成这个函数的迭代过程。


先定义一个初始状态:

b = Bundle[15, {0, 0, 0}, 0]

接着的这一行代码便实现了整个过程的模拟:

Nest[sim, b, 3600]

Part 5. Visualization

一个好的框架要有很好的结果呈现的方式,而图像是一个不错的选择。

如下这段代码便可以绘制出至 T = 300 时刻,所拥有饼干数的变化情况:

With[{T = 300}, ListLinePlot[Cookie /@ NestList[sim, b, T]]]

注:图像会和类似某种周期性的震荡很相似,不同的是,周期会不停地减小,振幅则会不断增加。


结语

数值模拟框架的建立可以更好地分析和理解游戏中的数据,加速从想法到验证的反馈循环,可以更快地验证自己的想法。也可借此了解整个系统时如何实现的,了解它的运作方式。

本文所创造的是一个数值模拟框架。为此,我们分析了数据模型的结构,及与数据交互的行为。并进一步阐述把模拟过程看做函数迭代过程的思路,进而利用 Mathematica 中基于对象的标签函数定义方式,将交互的行为转换成标签函数,最终实现模拟过程。

文中省略了决策函数定义的一些细节,有兴趣的读者可以自行编写决策函数,来观察不同决策产生的结果。