4

游戏《蔚蓝山》教我的编程道理

 3 years ago
source link: https://www.zlovezl.cn/articles/what-celeste-teaches-me-about-programming/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

游戏《蔚蓝山》教我的编程道理

发布于 2020-02-23

如果有这么一款游戏,你操作的角色平均每 20 秒就会死亡一次,正常通关一次,总共需要死掉超过 2000 次。你猜这是一款神作还是垃圾?

《Celeste》(译名:“蔚蓝山”)就是这么一款游戏。在游戏里,你扮演一个名为 Madeline 的女孩,通过跳跃、抓墙、冲刺等动作,去努力登顶一座名为 “Celeste” 的高山。

celete_gameplay_1.jpg
图:《蔚蓝山》游戏画面,它是一款点阵画风 2D 平台动作游戏

正如我在开头说的,这款游戏的难度高到令人发指,玩家平均得死上千次才能通关。但奇怪的是,这款游戏获得的成就似乎和它的难度一样高。在 2018 发售那年,它获得了 TGA “年度游戏”提名并成功拿下了“最佳独立游戏”奖项。截止到 2018 年底,它总共卖出了超过 50 万份。

极低的犯错成本

让《蔚蓝山》大获成功的原因有很多。精妙的关卡设计、出色的动作手感、令人惊艳的游戏配乐,以及剧情里流露出的真诚人文关怀,都是非常关键的因素。但除开这些,我在玩游戏时,还注意到了一个有意思的细节:在游戏里,玩家的犯错成本非常低。

假如你操作跳跃的时机不对,角色掉入坑里死掉了。然后,在 不到 3 秒钟 内, Madeline 就会在房间入口处复活。你可以对自己的打法稍作调整,马上进行下一次尝试。

celeste_play_die_2.gif

并非所有游戏都给予了玩家这种快速试错能力。比如在 PS4 游戏《血源诅咒》里,一次死亡可能代表你过去一小时获得的资源全都化为乌有。注1

所以,在《蔚蓝山》里,游戏设计者给了玩家一种可以 “低成本犯错” 的能力。有了它,我们可以快速从错误中学习,更好的完成挑战。那么,如果用编程来类比,我们在写代码时的犯错成本又如何呢?

编程时的“犯错成本”

假设我在开发一个新闻稿管理系统,系统里目前只有一种用户:“管理员”。但因为需求变更,我现在得给系统加上两个新角色:“编辑”和“主编”。

每类角色能做的事是有区别的:

  • 编辑:可以提交稿件、修改自己的稿件
  • 主编:在编辑的权限上,增加刊登稿件的功能
  • 管理员:可以做任何事以及管理所有人的权限

为了支持不同的角色,我需要改进现有的用户权限体系。首先,我得把和权限控制相关的所有功能点整理出来,然后开始写权限控制相关的代码。

没人能一次写出不出错的代码,所以写代码,其实就是一个在不断重复 “开发” -> “试错” -> “修改” 的过程:

  1. 修改后端代码,增加新角色:“主编”
  2. 在“主编”相关的功能点,增加权限保护代码片段
  3. 保存代码,等待本地服务器重启加载改动 (5-10 秒)
  4. 打开浏览器,点击各个功能页面,确认我的改动是否生效 (10 秒以上)
  5. 如果测出问题,回到步骤 2,重复整个过程

在很长一段时间里,我在工作时的开发流程就是上面这样。我总是在接到需求后就马上对代码修修改改,然后打开浏览器,点点这里、点点那里,用肉眼观察一切是否正常。

使用这种开发方式,假如我某次写的代码有问题,那么从我每次改完代码,到一直走完步骤 3、4、5,整个过程至少得花费超过 30 秒

如果你不觉得 30 秒很多,请你想想《蔚蓝山》吧。在《蔚蓝山》里,角色每次死亡到下次重试的时间间隔是不到 3 秒钟,二者相差 10 倍。所以,上面这种开发模式的“犯错成本”太高了。

如何降低“犯错成本”

其实,在开发这类 web API 时,我们完全没有必要傻乎乎的手工用浏览器点来点去。作为功能的开发者,我们可以(而且有义务)利用自动化测试来加速整个试错过程。

很多 web 框架都为这类测试提供了帮助。拿 Django 为例,你可以使用 django.test.Client 来轻松编写这类测试:

# 以下代码片段来自 Django 官方文档
import unittest
from django.test import Client

class SimpleTest(unittest.TestCase):
    def test_details(self):
        client = Client()
        response = client.get('/customer/details/')
        # 测试某次请求是否返回了 200 状态码
        self.assertEqual(response.status_code, 200)

对于前面的需求,我们可以直接编写下面这样的单元测试代码。

# 针对不同的角色定义不同的单元测试类

class RoleEditorTestCases(TestCase):
    """编辑角色的测试类
    """

    def test_create_post(self):
        # 编辑角色可以正常调用创建帖子接口
        response = self.request_post('/posts/', {'title': 'foo'}, current_user=self.user)
        assert response.status_code == 201
        assert isinstance(response.data, dict)

    def test_create_admin(self):
        # 编辑应该无权调用创建管理员接口
        response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
        assert response.status_code == 403


class RoleAdminTestCases(TestCase):
    """管理员角色的测试类
    """

    def test_create_admin(self):
        # 管理员可以调用创建管理员接口
        response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
        assert response.status_code == 201

有了这些单元测试后,整个试错流程可以得到极大改进。每当我改完代码后,只要运行 pytest 命令跑一遍相关的单元测试,就能知道改动是否奏效了。

❯ pytest
======== test session starts ========
platform darwin -- Python 3.8.1, pytest-5.3.5
collected 5 items
tests/api/test_permissions.py .....
======== 5 passed in 0.72s ========

不需要等待开发服务器加载变更、不需要打开浏览器点这点那。一切试错任务都可以在几秒钟之内完成。

编写测试其实也是 DRY

我在前面说过,在游戏《蔚蓝山》里,如果角色死掉了,那么她马上会从当前这个 房间入口处 重生。让我们设想一下,假如游戏没有采用这种设计:在新机制下,角色每次死亡后,玩家都得回到本章开始的地方,重新挑战一遍好几十个已经通过的房间。那会怎么样?估计很多人会气的把手柄摔地上。

但是,依赖人工测试的开发流程,其实就非常接近于让人摔手柄的设计。

拿用户权限功能来说,因为这个功能非常关键,所以我每次做出大改动后,都需要重复验证一下每个功能点在各角色下的表现是否正常。假如系统里一共有 20 个功能点需要和权限挂钩,那么 20 * 3 个角色,就是 60 个需要测试的点。

即便我有三头六臂,每个功能点只花 20 秒测试,整套东西测下来也需要 20 分钟。

但是,如果你已经为这些场景写好了单元测试,那么事情就变得简单多了。每次做了改动之后,你只需要重新执行一遍单元测试,就能把所有场景都验证一次。

Django 框架有一条设计哲学叫 “Don't repeat yourself (DRY)” - “不要重复你自己”。多数情况下,我们说 DRY 是指不要写重复代码。但我认为“不要重复手工测试已经测过的东西”其实也可以算是 DRY 的一种。

所以,每当你手动测试一次功能时,其实就是在重复你自己。既然如此,何不将它写成一个单元测试呢?

“所以,就是在劝我写单元测试?”

是的,我就是在劝你写单元测试。作为对比,让我们看看利用单元测试的开发流程是什么样的:

  1. 修改后端代码,增加新角色:“主编”
  2. 在“主编”相关的功能点,增加权限保护代码片段
  3. 编写与功能代码相关的单元测试代码,与 2 同步进行
  4. 执行单元测试,如果失败,从 2 开始调整代码,重复整个过程 (几秒钟)

通过把测试行为自动化,我们可以大大减少整个开发过程的试错成本。事实上,自从若干年前养成了写单元测试的习惯,我就一直坚持至今。那么,我到底是因为什么在写单元测试呢?

  • 单元测试让我的代码 Bug 更少?
  • 单元测试帮助我写出扩展性更强的代码?
  • 单元测试让我在重构时更不容易出错?

以上可能都是。但现在,我可以往上面的列表里再加上一点:使用单元测试来开发的过程,有一种流畅感,失败后就马上重试,一切就犹如在操作 Madeline 登顶那座蔚蓝色的山。

  1. 在《血源诅咒》中,玩家死亡后会丧失当前拥有的所有血之回响(一种游戏内资源)。如果要找回它们,你需要从一个又一个怪物堆里穿过,回到你的死亡地点。如果你在路上再次死掉,那么那些血之回响就会全部消失。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK