Free Arch: 将 React 井字棋搬到小程序之三

哈德韦

共 7370字,需浏览 15分钟

 · 2022-01-23

Free Arch 是我的杜撰,这是一种个人开发者的架构风格。个人开发者,穷,所以是尽量使用免费资源。架构说到底就是一种权衡,采用免费资源,是像我这样穷困的个人开发者不得已的选择。为了免费,甚至不惜牺牲用户体验。当然,这只是 Free Arch 的第一层。


Free Arch 还有第二层。这个境界的 Free Arch 居然可以提供极致的用户体验。参考:

给GraphQL添加CDN缓存

哈德韦,公众号:哈德韦Free Arch:给 GraphQL 增加 CDN 缓存


Free Arch 还有第三层,即自由。当然,互联网技术本来就是自由且免费的,但随着技术的普及,有另外一拨人也掌握了技术,他们开始垒高墙,筑高塔,互不联互不通,导致换取自由仍需要付出昂贵的价格。比如要想在微信小程序里嵌入 webview,就需要注册企业版,各种认证才行。

前两篇文章分别把 React 官方井字棋游戏教程的初始状态和最终状态渲染在了个人版微信小程序里。由于个人版微信小程序的限制,不能使用 webview,从而退而求其次,采用了 Free Arch 提供的 react-view。


FreeArch_ 将 React 教程的井字棋游戏搬到微信小程序

Free Arch:将React 井字棋搬到微信小程序之二


今天继续这个话题,让无聊的井字棋稍微有趣一点:给 React 官方井字棋游戏增加一点人工智能


但是,今天不会分享人工智能方面的任何知识,因为这个在很早的文章里已经分享过了,并且这不属于 Free Arch 相关的内容。Free Arch 偏向于重用已有的免费轮子,组合成新的应用。


将人工智能应用于棋类游戏开发中的一般步骤


今天的内容是将代码看作乐高积木,乐高积木推崇不断拆掉重新拼搭,号称:“rebuild the world”。这和 Free Arch 的精神太契合了!我们今天将已有代码拆卸重新组合,搭建出一个新的应用。


Free Arch 口号:Old dogs, New tricks.


新的应用:微信小程序上的人工智能井字棋


砖块代码1:前两篇文章创建出来的 react-view。


砖块代码2:https://tictactoe.js.org 的部分代码。这里多提一下,这个应用是我好多年前学习《机器学习》时做的一个小练习。但是这个练习除了给井字棋添加人工智能功能外,还有很多其他的增强,不是本文的重点,故只取其中的一部分代码(好代码不仅易于修改,还易于随便拆除)。


砖块代码3:React 官方井字棋游戏教程最终状态的 js 代码,即 https://codepen.io/gaearon/pen/gWWZgR?editors=0010 这里的代码。


总之,砖块代码2 和砖块代码3 合并成了一个文件,然后和砖块代码1 组合在一起,便形成了以下的结果:

下面详述改造过程。


零、搭建开发环境

如果开发环境必须要怎样怎样,那就不自由。Free Arch 不仅要运行时环境的免费和自由,而且连开发环境也得免费和自由。如果没有电脑,怎么办?比如手头上只有一个 iPad?


这个问题,云原生时代的开发者根本不会去想。云原生的开发者当然不会去搭建本地环境,直接云上开发。但是云 IDE 多如牛毛,这里大力推荐 replit,这一点在下面这篇文章里也对比和推荐过:


FreeArch_ 将 React 教程的井字棋游戏搬到微信小程序


采用 vite,只需要一个 html,从 CDN 上加载 react,即搭建成了一个应用脚手架:https://replit.com/@Jeff_Tian/TicTacToeTs#src/GameAI.tsx,当然,这是在浏览器环境运行这个应用,但浏览器现在是我们的开发环境。我们最终会部署到微信小程序上的。


浏览模式默认看到的运行效果,在登录状态下,或者点击显示文件,可以看到开发环境是这样的:


但是在部署到小程序前,我们的应用已经在跑起来了,并且也是上线了,虽然还只停留在浏览器端:https://tictactoets.pa-ca.me/。


通过将砖块代码3,即 React 官方教程的代码粘贴进入 GameAI.tsx,我们得到了一个可以互动的井字棋游戏版本,和官方教程的最终状态一致。


要实现我们自己的功能,只需要后面通过迭代,小步前进并持续发布,就可以了。

一、将砖块代码2 和砖块代码3 拼合成一个文件

如上图所示,我们在应用脚手架里添加了一个文件,叫 GameAI.tsx 文件。里面的内容,即是将砖块代码2 和砖块代码3 拼合后的结果。


有人注意到代码中有很多红色波浪线警告,对于严谨的工程来说,这些警告是要去除的,但是在本文中,将忽略这些警告:既然能跑,就先别动了。因为要赶时间说重点。


警告的原因很简单,我的文件命名为 tsx,但贴的是 js 代码(React 官方教程是 js 代码,以及几年前写的人工智能练习,用的也是 js)。


虽然文件命名为 jsx,就能消除警告,但是除了这些老的遗留代码外,后面还是要写一点点新的代码的,这一点点胶水代码,用来将砖块黏在一起。这一点点新的代码,我想用 ts 来写,于是文件就命名为 tsx。


总之,这是有意为之。即展示一下 Free Arch 的自由度,可以多种不同的代码以及风格共存。因为系统要往好的方向演进的同时,又不要重复做以前做过的事情,那么就必须允许新的老的共存并和谐相处。如果这个系统后面还要继续扩展,会采用 ts 代码来写。虽然不会专门去把 js 修改成 ts,但是顺手改到时,就改造一点点。这样长期趋势是 js 代码越来越少,甚至完全消除波浪线警告。


这个拼合的提交源码详见:https://github.com/Jeff-Tian/TicTacToeTs/commit/a638d4e9e37d57792236ef3b131709064d7e1f78


这个拼合后的文件,一共 600 行代码不到。

二、让玩家 O 的下棋过程自动化


以上提交后,应用会自动部署,仍然是一个可以运行的版本,只不过功能上没有任何的变化。


现在做一点小改变,即让玩家 O 下棋的过程自动化。这个改变导致原来是用户自己和自己下棋,变成了用户和一个傻瓜机器人下棋。这个傻瓜机器人,总是往一个空格里放入它的棋子。相关的代码改动也很小。


但是,不要直接修改代码!我们先把项目的自动化测试机制建立起来。尽管 React 官方教程没有测试代码,但是它能跑呀。我们现在要改代码,尽管是很小的改动,但还是需要一个自动化的反馈方式,不要等到部署后,应用挂了才知道。


当然,对于 TDD 的拥趸来说,可能需要对遗留代码都添加测试。这里不这样做,仅对要新增的代码和修改到的代码做测试。


首先,安装 jest 等依赖。然后,添加 GameAI.test.tsx 文件。


考虑到即将要添加的这个傻瓜机器人,其下棋策略是往可以放子的空格里,放上 O,它就需要知道哪些格子是空的。而这首先需要知道当前的所有格子状态,所以我打算在官方教程的 Game 类中添加一个获得当前格子状态的方法,于是先添加了一个测试用例,如上图所示:这个方法应该能够返回当前的所有格子,其数量应该是 9(九宫格)。


然后,写了个实现,直到测试通过:

...    getCurrentSquares() {        const history = this.state.history.slice(0, this.state.stepNumber + 1);        const current = history[history.length - 1];        return current.squares.slice();    }...


接着,我将 handleClick 方法,重命名为 handleXClick。因为 O 的click,将自动完成。为了做叙述方便,后面不再啰嗦“测试-失败-实现-通过-重构-测试”这样的细节,以免干扰读者的思路。完整的测试文件可以通过 https://replit.com/@Jeff_Tian/TicTacToeTs#src/GameAI.test.tsx 查看。


然后,我又给 Game 类添加了一个 getAvailableSquares 方法,这样,O 要落子,只需要从这个方法的返回结果里取出第一个空格的索引即可。

getAvailableSquares() {        return this.getCurrentSquares().filter(q => !q)}

落子的方法我取名为 simulateOClick,并且,在调用上述方法直接重用了 handleXClick 方法:

simulateOClick() {        const [firstAvailableSquareIndex] = this.getAvailableSquareIndices()                if (firstAvailableSquareIndex === null) {            console.error(`玩家 O 尝试在位置 ${firstAvailableSquareIndex} 走子,但是已经没有空余的格子了!`)            return        }
this.handleXClick(firstAvailableSquareIndex)}

在这之后,再将 handleXClick 方法增加了一个 callback 参数,该参数为空。但当不为空时,就会被触发,从而做为一个 O 在 X 落子后立即运行的钩子。

@@ -55,7 +55,7 @@ export class Game extends React.Component {        };    }
- handleXClick(i) {+ handleXClick(i, callback = undefined) { const history = this.state.history.slice(0, this.state.stepNumber + 1); const current = history[history.length - 1];@@ -72,16 +72,17 @@ export class Game extends React.Component { ]), stepNumber: history.length, xIsNext: !this.state.xIsNext- });+ }, callback); }
+ simulateOClick() {+ const [square] = this.getAvailableSquares()+ if (square) {+ const [firstAvailableSquareIndex] = this.getAvailableSquareIndices()+ if (firstAvailableSquareIndex === null) {+ console.error(`玩家 O 尝试在位置 ${firstAvailableSquareIndex} 走子,但是已经没有空余的格子了!`)+ return+ }+ this.handleXClick(firstAvailableSquareIndex)+ }
getCurrentSquares() {@@ -137,8 +138,7 @@ export class Game extends React.Component { squares={current.squares} onClick={i => {- this.handleXClick(i);- this.simulateOClick();+ this.handleXClick(i, this.simulateOClick); }} /> div>


在这之后,O 的自动化下棋的改造就完成了,提交上线。


提交记录详见:https://github.com/Jeff-Tian/TicTacToeTs/commit/0ab8c304a869dc47ab4569db7fb2318f1c865db6

三、将玩家 O 的自动化下棋的决策交给人工智能


这一步,把上面实现的幼稚的下棋算法,替换成智能的。如果不了解原来的 https://tictactoe.js.org,那么就缺少很多上下文。在有了上下文后,改动其实非常小。


首先,要重用 https://tictactoe.js.org 的智能算法,需要将 React 官方教程中的 squares 数据结构稍作修改。其映射关系如下:


null --> 0

X --> -1

O --> 1


于是我加了一个方法:convertsSquaresToBitmap,其测试如下,在初始状态,所有格子都是 0:

   describe('AI', () => {        const game = new Game({})
test('converts squares to bitmap', () => { const res = convertsSquaresToBitmap(game.getCurrentSquares()) expect(res).toStrictEqual([ 0, 0, 0, 0, 0, 0, 0, 0, 0 ]) })...

实现仅一行:

export const convertsSquaresToBitmap = (squares: Array<string | null>) => squares.map(q => q === 'X' ? -1 : (q === 'O' ? 1 : 0))

有了这个方法,就只需要让 O 在落子前问一下 AI 对象,我应该放在哪个位置?井字棋有个窍门,即如果能抢先占掉九宫格中央,那就有更大机会取胜(这也是为什么先走的人有优势,因为可以占到这个位置)。这个方法,我取名 nextMove,并且测试用例反映了这个占中优势(中央的方格的索引是 4):


...
test('gets next move', () => { const res = new AI().nextMove(convertsSquaresToBitmap(game.getCurrentSquares())) expect(res).toEqual(4) })...

实现上只需要改少量代码:

...    simulateOClick() {-        const [firstAvailableSquareIndex] = this.getAvailableSquareIndices()
+ const firstAvailableSquareIndex = new AI().nextMove(convertsSquaresToBitmap(this.getCurrentSquares())) if (firstAvailableSquareIndex === null) { console.error(`玩家 O 尝试在位置 ${firstAvailableSquareIndex} 走子,但是已经没有空余的格子了!`) return...

完整的提交记录见:https://github.com/Jeff-Tian/TicTacToeTs/commit/b838d2b806bdb646e3e2560259e4175c23906af3

四、在 react-view 里渲染拼合的文件


这一步在小程序的工程里完成:https://github.com/Jeff-Tian/weapp。和前两篇文章中没有任何差别,只是动态渲染的是 GameAI.tsx 文件。而前两篇文章中渲染的是 Game.tsx 和 Game2.tsx 文件,分别对应官方教程的初始状态和最终状态。


原理是使用 eval5 解析使用了 babel as a service 转译后的代码。


Free Arch: babel as a service


五、彩蛋


这样不仅实现了个人版小程序部分渲染 webview 的自由,还部分突破了个人订阅号的自定义菜单,不能打开外链的限制。即使用自定义菜单打开小程序,在小程序里渲染外链的页面内容。


终于,用户可以从我的个人订阅号的菜单栏直接打开 AI 井字棋游戏了!




浏览 33
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报