用函数式编程在 JS 中开发游戏

1,969 阅读6分钟

作者:Karran Besen

翻译:疯狂的技术宅

原文:cheesecakelabs.com/blog/functi…

未经允许严禁转载

一段时间以来,函数式编程范式比较火热,并且在互联网上有很多关于它的精彩书籍和文章,但是要找到相关程序的真实示例并不容易。因此,我决定尝试使用 Javascript(当今最流行的编程语言)并遵循其概念创建一款游戏。在本文中,我将分享一些经验,并告诉你是否值得。

什么是函数式编程?

简而言之,函数式编程(FP)是试图重现数学函数概念的范式,数学概念是域集(有效输入)和共域(有效输出)之间的关系。数学函数的输出始终仅与一个输入相关,因此,只要使用相同的输入来计算数学函数,它就会返回相同的输出。这是函数式编程最重要的概念之一,也称为确定性

不确定函数示例

let x = 1
const nonDeterministicAdd = y => x + y
nonDeterministicAdd(2) // 3
x = 2
nonDeterministicAdd(2) // 4

确定性函数示例

const deterministicAdd = (x, y) => x + y
deterministicAdd(1, 2) // 3

除了确定性之外,FP 中的函数还寻求不引起超出其范围的修改。这些类型的功能称为 pure。最后但并非最不重要的一点是,FP 中的数据必须是不可变的,这意味着创建后不能更改其值。这些概念使测试、缓存和并行性更加容易。

除了这些基本概念之外,我还尝试在游戏开发期间使用无点样式,该样式能够使代码更简洁,因为它省略了不必要的参数和参数的使用。以下两个链接给你提供了很好的参考。

这个项目是一个在浏览器中运行的游戏。因为 Javascript(JS)是我很熟悉的一种语言,并且是一种多范式语言,所以我选择它为项目语言。

我推荐两本关于 FP 的优秀书籍:

项目

我们的项目是一个基于回合制的太空飞船游戏。在游戏中,每个玩家有 3 艘飞船,并且每回合必须选择他们要在其可达范围内移动飞船的位置以及要朝哪个方向射击。当飞船被射中时,它将失去部分防护罩。如果宇宙飞船没有防护罩将被摧毁,失去所有宇宙飞船的玩家将输掉比赛。

比赛的初始轮

到目前为止,该游戏仅允许一个玩家参与,并且控制屏幕顶部的 3 个太空飞船,去对抗一个控制底部 3 个太空飞船的脚本,该脚本将其太空飞船的位置和目标随机化。关于图形部分,我使用了 PixiJS 程序包来控制渲染,这是该项目唯一的依赖项,并且我还使用了从OpenGameart 网站上的 UnLucky Studio 免费获得的太空飞船精灵 。

基础和辅助函数

在开始,我们先创建一个文件,其中包含几乎所有项目文件中都会用到的基本函数。其中一些基本函数是 JS 固有的,例如 mapreduce。 JS还有一些其他功能,它们通过不更改输入值而适合FP范例,并且已在项目中使用,例如 filter, find, some, every。发现这些功能的一个很好的来源是Does it mutate。要遵循无点样式,还必须实现以下基本函数:

  • Curry:允许函数在单独的时刻接收其参数
const add = curry((x, y) => x + y)
add(1, 2) // 3
add(1)(2) // 3
  • Compose:函数作为参数传递并以相反的顺序执行。每个函数消耗前一个函数的返回值。
const addAndIncrement = compose(
   add(1), // previous add result + 1
   add // arg1 + arg2
)
addAndIncrement(2, 2) // 5

已经在其中实现了这些函数的几个库,例如 Ramda,但是在这个项目中,我决定实现它们以试图更好地理解它们的工作原理。这篇文章(medium.com/dailyjs/fun…) 是研究它们如何工作以及如何递归实现这些功能的重要资料。

为了简化所使用的本机 JS 函数的构成,我使用 curry 创建了helper,其中条目作为参数传递。

例:

const filter = curry((fn, array) => array.filter(fn))
const getAliveSpaceships =
    compose(
        filter(isAlive),
        getSpaceships

我们如何声明模型?

关于模型的实现,我们使用了 functional-shared 样式,其中模型实例是具有其属性和函数的对象。为了管理模型的状态,我们创建了以下 helper,其中 getState 返回实例的状态。 assignState 返回一个新实例,旧状态与新实例连接在一起,getProp 返回封装在 monad 中的传递属性的值。 Monad 在函数式中是一种流行的构造,并且很难总结出一个简介的定义,这篇文章对其做了一个很好的解释:jrsinclair.com/articles/20…

const modelFunctions = (model, state) => ({
    getState: () => state,
    assignState: newProps => model({ ...state, ...newProps }),
    getProp: name => getProp(state, name),
})

使用这个 helper,我们可以声明模型、创建实例并使用其函数,如下所示:

const Engine = state => ({ ...modelFunctions(Engine, state) })
Engine({ a: 'a' }).assignState({ b: 'b' }).getState() // { a: 'a', b: 'b' }

实现其余部分

定义了基本函数和模板后,仍有许多工作要做。下面是项目的其它一些函数,这些函数的可读性很好。

  • 移除玩家被摧毁的飞船
const removeDestroyedSpaceships = player => compose(
    setSpaceships(player),
    getAliveSpaceships
)(player) 
  • 减少飞船的护罩
export const reduceShield = curry((spaceship, damage) =>
    compose(
        checkDestroyed,
        shield => assignState({ shield }, spaceship),
        shield => sub(shield, damage),
        getShield
    )(spaceship)
)

与命令式编程相比,通过组合实现的代码通常更易于理解。例如我用 SonarQube 分析了此函数的认知复杂性,并获得了最高分。

  • 获取飞船的子弹
export const getBullets = compose(
    either([]),
    getProp('bullets')
)

在这里可以省略函数参数,因为它仅由复合函数使用。还可以保证返回的值将是有效的,因为 getProp 返回一个 monad,而 either 返回一个 monad 的封装值(如果它是有效值或空数组)。

  • 为子弹设置新的位置
const setPosition = curry((coordinate, bullet) =>
    compose(
        callListenerIfExist('onMove'),
        assignState({ coordinate })
    )(bullet)
)

函数式编程的组合要求函数始终具有返回值。如果 callListenerIfExist 未返回任何值,则执行后将无法与其他函数或 setPosition 链接其他函数。

它值得吗?

这是项目的github 存储库,并托管在此这里(zealous-lichterman-adc5bd.netlify.com/)。因为我以前没有使用… PixiJS 模块的大小。

欢迎关注前端公众号:前端先锋,免费领取前端工程化实用工具包。