用 JavaScript 和 Python 编写多人策略游戏时我学到的 53 件事

2025-05-24

用 JavaScript 和 Python 编写多人策略游戏时我学到的 53 件事

我从2019年4月开始开发Selfies 2020,想用现代手法重现一款名为“sissyfight 2000”的1999年游戏。这是一款“玩笑式”的社交媒体博弈论多人游戏,玩家需要制定策略来获得粉丝。玩家获得的粉丝数量取决于其他玩家在该轮的表现。我是唯一一个贡献者。5个月后,我仍未完成,于是开始反思……我为什么要这么做?是为了学习更多编程概念(当然,如果能有人玩就更好了),尤其是让应用从前端到后端都达到生产级水平所需的所有知识。我还想学习一些我不太熟悉的技术,比如Django和React Hooks。我的最终目标是开发一款达到生产级质量的应用。

这个项目还没有达到“生产级”质量,但我认为它基本“完成”了。如果你想支持它,请在GitHub上点赞、体验一下,并提供你的反馈!

我按类别学习了:

前端:设计
前端:CSS + HTML
前端:Javascript
前端:React/Redux
前端:工具
后端:Django
后端:Python
后端:基础设施
工具:Github
总体:元学习

前端:设计

1) Sketch 太棒了。我不是设计师,但学了足够多的 Sketch 来提升我的设计水平。导入谷歌字体、导出 svg/png/css,以及使用数千个插件中免费提供的 iPhone 组件,这些都极大地改进了我的设计流程。而且,你还可以快速制作原型

2)Coolers对于生成配色方案很有用

3)学习 CSS 而不是组件框架。我一开始尝试学习并决定使用哪个组件框架。让库组件按照你的要求工作很耗时。通过学习 CSS 基础知识,我的效率更高,技能也得到了提升。

4) 如果您确实使用组件框架,Grommet是一个看起来很现代的不错的框架。

5)在主页上,告诉用户你的网页功能。导航要清晰。在读这本书之前,我忽略了导航。我的主页上只有“点击进入!”,没有其他信息。我修复了这些问题,这些问题对设计师来说可能很明显,但我却没有注意到。

前端:CSS + HTML

6)重置 CSS以减少浏览器不一致。链接的 CSS 可以标准化您的网站在不同浏览器上的显示效果。

7) 在我看来,内联组件样式比使用 CSS 更易于管理。虽然 CSS 代码比较丑,但将样式放在一个文件中更容易。我有时会稍后再将其移到 CSS 中。

8) 组件不应该知道自己在页面上的位置。容器负责定位组件。例如,我之前所有的按钮都放在margin-right: 5px容器上,但我移除了它们,因为相对定位是容器的工作。这提高了组件的可复用性。

9) 如何使用flexbox,以及它在 Safari 中看起来不一样(Safari 需要特殊-webkit前缀才能显示 flexbox)。 特别flex-grow有用。
flex-grow: 1;

没有:

10) 将 div 定位到屏幕中间并不简单。

const WithAuth = () => (
<React.Fragment>
<div
style={{
textAlign: 'center',
margin: 'auto',
position: 'absolute',
height: '100px',
width: '100px',
top: '0px',
bottom: '0px',
left: '0px',
right: '0px',
}}
>
Loading
</div>
</React.Fragment>
)
view raw CenterADiv.js hosted with ❤ by GitHub

11)设置outline: none;输入和按钮的样式,否则当点击/交互时它们将获得如下所示的轮廓:

12) 如何设置滚动条的样式。Firefox的滚动条样式有所不同,直到 2018 年 Firefox 64 才添加了部分支持。我没有设置滚动条的样式来兼容 Firefox。对于 Safari 和 Chrome,我的 CSS 如下:

::-webkit-scrollbar {
  width: 0.5em;
  background: none;
}

::-webkit-scrollbar-thumb {
  background: black;
  outline: 1px solid slategrey;
}
Enter fullscreen mode Exit fullscreen mode

替代文本
13)对象、图片、SVG。最初,我在落地页上使用的 SVG 文件大小为 440KB,很难动态调整其大小(相对于其周围的粉色 div),因为它本质上是一个嵌入了 base64 编码的 iPhone .png 文件,我把它做成了 SVG 文件(这个做法很不妥)。如果直接加载它并将其包装在 React 组件中,我的打包体积会非常大。我尝试过一些解决方案:

  • img我使用了 img 文件,其 src 是 svg 的相对路径。这使得我的文件包更小,我可以调整大小,但我丢失了 Roboto 字体。img 标签默认使用系统字体。
  • 然后,我将 svg 嵌入到对象object中。这解决了上述两个问题,但我无法再点击图像了。我不得不解决这个问题,用 来设置包裹对象的链接样式display: inline-block。但这并没有达到我想要的样式。
  • png寓意是一定要选择最适合该图像类型和用途的图像格式,在本例中是 png。

14) 在 React 中添加动画的方法有很多。在我看来,CSS 是最好的。我这么说基于两个标准:性能和简洁性。我研究了React Transition Group和一些库。我花了一分钟尝试学习 Transition Group,但感到很困惑。然后我发现了这个库。它有很多很棒的示例,你可以将所需动画的 CSS 复制到 CSS 文件中,这样就不必导入整个库了。我喜欢通过示例学习,所以看到所有这些 CSS 动画让我了解了它们的工作原理。

15) 一点点 SCSS。我不喜欢添加包,但这是一个编译成 CSS 的开发依赖项。它有一些附加功能,比如允许你嵌入属性和lighten()/或darken()按一定数量。这是我设置按钮样式的示例:

button {
  border-radius: 20px;
  cursor: pointer;
  border: 3px solid darken(#44ffd1, 5%);
  background-color: #44ffd1;
  box-shadow: 0 1px 1px 0 rgba(0, 0, 0.5, 0.5);
  font-size: 14px;
  padding: 5px;
  outline: none;
  &:hover {
    background-color: lighten(#44ffd1, 10%);
  }
  &:disabled {
    opacity: 0.5;
    cursor: default;
  }
}
Enter fullscreen mode Exit fullscreen mode

前端:Javascript

16)如何编写原始 JS websockets 并将其与 redux 集成。

17)next()函数。在编写 websocket 中间件时,我深入研究了 JavaScript 内部,以了解有关迭代器和生成器的更多信息。

18) 使用 fetch 处理错误。我希望 fetch 在后端返回 400+ 错误时抛出错误。为此,您必须首先检查响应的状态。400+错误会在响应主体中显示一条消息,但该消息在 Promise 完成解析之前不可用(res => res.json())。但是,如果您在 Promise 解析之前抛出错误,那么您就无法访问响应主体。为了解决这个问题,我添加了 async/await,以便可以将响应主体传递给 catch 语句。

const status = async (res) => {
  if (!res.ok) {
    const response = await res.json();
    throw new Error(response);
  }
  return res;
};

export const getCurrentUser = () => dispatch => fetch(`${API_ROOT}/app/user/`, {
  method: 'GET',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Token ${Cookies.get('token')}`,
  },
})
  .then(status)
  .then(res => res.json())
  .then((json) => {
    dispatch({ type: 'SET_CURRENT_USER', data: json });
  })
  .catch(e => dispatch({ type: 'SET_ERROR', data: e.message }));
Enter fullscreen mode Exit fullscreen mode

前端:React/Redux

19)如果没有更高级的配置,则无法将redux devtools与 websockets 一起使用。

20) 如何useRef与 React Hooks 一起使用。我需要用它来滚动到 div 的底部。这个项目是我第一次使用 React Hooks

21) 如何使用 PropTypes。我通常使用Flow.jsPropTypes,但尝试过。PropTypes 库的优点是可以在代码编辑器和控制台中显示类型问题。

// example propTypes for my game object
Game.propTypes = {
  id: PropTypes.string,
  dispatch: PropTypes.func.isRequired,
  history: PropTypes.shape({
    push: PropTypes.func.isRequired,
  }).isRequired,
  game: PropTypes.shape({
    id: PropTypes.number.isRequired,
    game_status: PropTypes.string.isRequired,
    is_joinable: PropTypes.bool.isRequired,
    room_name: PropTypes.string.isRequired,
    round_started: PropTypes.bool.isRequired,
    users: PropTypes.arrayOf(
      PropTypes.shape({
        id: PropTypes.number.isRequired,
        followers: PropTypes.number.isRequired,
        selfies: PropTypes.number.isRequired,
        username: PropTypes.string.isRequired,
        started: PropTypes.bool.isRequired,
      }),
    ),
  }),
  time: PropTypes.string,
};

Game.defaultProps = {
  id: PropTypes.string,
  game: PropTypes.null,
  time: PropTypes.null,
  currentPlayer: PropTypes.null,
};
Enter fullscreen mode Exit fullscreen mode

22) 如何使用 React Hooks 在组件之间共享逻辑。我使用了一个 hook 来决定不同组件中按钮的颜色。

23) 使用时需要返回一个空数组,useEffect否则组件可能会不断重新渲染。但是,如果您使用了任何 props,则会收到错误react-hooks/exhaustive-deps。要修复此问题,请将您使用的 props 传递给数组。

24) 如何设置环境变量 create-react-app它让设置变得简单,我从未意识到它能为你带来多大的create-react-app帮助。只需定义一个env.production文件env.development,并以REACT_APP_

# my env.development file
REACT_APP_WS_HOST=localhost:8000
REACT_APP_HOST=http://localhost:8000
REACT_APP_PREFIX=ws
Enter fullscreen mode Exit fullscreen mode
# my env.production file
REACT_APP_WS_HOST=selfies-2020.herokuapp.com
REACT_APP_HOST=https://selfies-2020.herokuapp.com
REACT_APP_PREFIX=wss
Enter fullscreen mode Exit fullscreen mode
// using the environment variable
const HOST = process.env.REACT_APP_WS_HOST;
const PREFIX = process.env.REACT_APP_PREFIX;

const host = `${PREFIX}://${HOST}/ws/game/${id}?token=${Cookies.get('token')}`;
Enter fullscreen mode Exit fullscreen mode

25) 如何创建错误边界我的代码是示例代码的复制/粘贴,只是去掉了日志错误记录,如果我的应用有用户,我可能会这么做。

import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error) {
    // Catch errors in any components below and re-render with error message
    this.setState({
      hasError: error,
    });
    // You can also log error messages to an error reporting service here
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return (
        <React.Fragment>
          <h1
            className="animated infinite bounce"
            style={{
              textAlign: 'center',
              margin: 'auto',
              position: 'absolute',
              height: '100px',
              width: '100px',
              top: '0px',
              bottom: '0px',
              left: '0px',
              right: '0px',
            }}
          >
            Something went wrong
          </h1>
        </React.Fragment>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;
Enter fullscreen mode Exit fullscreen mode

前端:工具

26)如何分析 bundle 大小。具体说明请见这里

[13:45:24] (other-stuff) selfies-frontend
🙋 yarn run analyze
yarn run v1.9.4
$ source-map-explorer 'build/static/js/*.js'
build/static/js/2.e1a940a4.chunk.js
  Unable to map 130/173168 bytes (0.08%)
build/static/js/main.17442792.chunk.js
  Unable to map 159/29603 bytes (0.54%)
build/static/js/runtime~main.a8a9905a.js
  2. Unable to map 62/1501 bytes (4.13%)
✨  Done in 0.52s.
Enter fullscreen mode Exit fullscreen mode

我的一个构建的示例输出:
替代文本

27)充分配置eslint以保持我的代码井然有序。

28)Safari 在隐私模式下不支持 localStorage 我已改为使用 Cookie 存储令牌。我希望支持主流浏览器,包括移动版 Safari。

29)浏览器中的 WS 选项卡。我以前从未注意到它,也从未用过它。

后端:Django

30) 在 Django 中,将模型从一对一关系更改为外键关系非常困难。我不得不进行了四次迁移,并删除了数据库中的所有记录才得以实现。

get31)如果获取的对象实际上不存在,Django方法会返回错误。类get_or_none可以用来获取对象(如果存在)或不返回任何内容:

class GetOrNoneManager(models.Manager):
    """Adds get_or_none method to objects"""

    def get_or_none(self, **kwargs):
        try:
            return self.get(**kwargs)
        except self.model.DoesNotExist:
            return None
Enter fullscreen mode Exit fullscreen mode

32) 在 Django 中重写对象。如果我想使用上面定义的类,我需要重写Django 中的objects 管理器。管理器是你在 Django 中执行数据库查询的方式(例如等),默认情况下,每个 Django 类都会添加Model.objects.get, Model.objects.save一个名为的管理器。objects

class GamePlayer(models.Model):
    #...
    objects = GetOrNoneManager()
Enter fullscreen mode Exit fullscreen mode

33) on_delete在 Django 模型上的作用是什么?这是 SQL 标准,我这样设置:MODELS.CASCADE如果删除了一个项目,那么对该项目的引用也会被删除。

例如,我的模型中有这样的定义:

class Message(models.Model):
    game = models.ForeignKey(Game, related_name="messages", on_delete=models.CASCADE)
Enter fullscreen mode Exit fullscreen mode

如果我删除的实例Game,则所有具有该实例的外键的模型Game也将被删除:

[14:10:42] (other-stuff) selfies-frontend
🙋 docker exec -it e079c83c8e1c bash
root@e079c83c8e1c:/selfies# python manage.py shell
Python 3.7.4 (default, Jul 13 2019, 14:20:24) 
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from app.models import Game
>>> Game.objects.get(id=17).delete()
(5, {'app.Move': 1, 'app.Message': 1, 'app.GamePlayer': 1, 'app.Round': 1, 'app.Game': 1})
>>> 
Enter fullscreen mode Exit fullscreen mode

34) Django 有一个User内置身份验证的模型,但你无法在其中保存数据。这对我来说本来不成问题,直到我需要一个排行榜。我通过创建一个名为 的新模型解决了这个问题,该模型Winner与该模型具有一对一的关系User

35)使用Django REST 框架自定义错误处理
我认为使用 Django Rest 框架构建错误消息的方式对于前端来说很难处理,因此决定覆盖它们。

36) 使用带有 token 认证的 Django Channels 并不理想,我不得不编写自定义中间件我已将 token 附加到请求中,因为无法使用 websockets 的标头

37) 如何使用Django Channels完成文档中的教程并遵循教程,我获得了启动项目所需的一切。这是一个很大的话题,我可能会专门写一篇文章来阐述。

后端:Python/Pytest

38)在项目中设置 pytest 。它相当简单,而且是少数几个没有需要我修复的陷阱或 bug 的设置之一。

; pytest.ini, create this file in your root folder

[pytest]
DJANGO_SETTINGS_MODULE = selfies.settings
addopts = -s --ignore integrations --ignore tests --ignore integration_tests
python_files = tests.py test_*.py *_tests.py
Enter fullscreen mode Exit fullscreen mode

39)使用pytest-factoryboy和 pytest 进行测试。

40)线程。我需要将计时器的更新时间发送到前端,同时又不阻塞其他需要发送的数据,比如消息。为了实现这一点,我将计时器放在另一个线程中,thread这个线程负责更新计时器,并将正确的时间发送到前端。

以下是我的 websocket 类中的代码:

    def start_round(self, data=None):
        """Checks if the user has opted in to starting the game"""

        game_player = GamePlayer.objects.get(user=self.scope["user"], game=self.game)
        game_player.started = True
        game_player.save()
        self.send_update_game_players()
        if self.game.can_start_game():
            # start the timer in another thread
            Round.objects.create(game=self.game, started=True)
            # pass round so we can set it to false after the time is done
            self.start_round_and_timer()

    def start_round_and_timer(self):
        """start timer in a new thread, continue to send game actions"""

        threading.Thread(target=self.update_timer_data).start()
        self.send_update_game_players()

    def update_timer_data(self):
        """countdown the timer for the game"""

        i = 90
        while i > 0:
            time.sleep(1)
            self.send_time(str(i))
            i -= 1
            try:
                round = Round.objects.get_or_none(game=self.game, started=True)
            except Exception:
                round = Round.objects.filter(game=self.game, started=True).latest(
                    "created_at"
                )
            if round.everyone_moved():
                i = 0
                j = 10
                while j > 0:
                    time.sleep(1)
                    self.send_time(str(j))
                    j -= 1

        # reset timer back to null
        self.send_time(None)
        self.new_round_or_determine_winner()
Enter fullscreen mode Exit fullscreen mode

后端:基础设施

41)如何使用black命令检查 Python 文件。输入black,然后输入要检查的文件夹,即可获得:

[15:27:16] (master) selfies
🙋 black app
reformatted /Users/lina.rudashevski/code/selfies/app/services/message_service.py
All done! ✨ 🍰 ✨
1 file reformatted, 54 files left unchanged.
Enter fullscreen mode Exit fullscreen mode

42) 什么是 Web 服务器?肯定不想用它python manage.py runserver来在生产环境中启动你的服务器。

43)如何让 Docker 与 Django Channels 兼容。有一段时间,无论我怎么做,我的服务器都无法热重载。在看了这个有用的示例Dockerfile后,我终于意识到我的顺序错了。

44)如何使用 Heroku部署 Django Channels 。我没有部署我的 Docker 容器,而是用它Procfile启动了我的服务器。

45)如何在 Heroku 中查找环境变量,以及如果不存在该怎么做。

从网站创建应用程序没有生成我需要的环境变量:
替代文本

创建插件后,我使用 来查找数据库 URL,heroku config但什么也没找到。解决这个问题的方法是:从浏览器中删除我的插件,然后从命令行重新创建它们

工具:Github

46)如何忽略已提交的.gitignore 文件。我以前不需要这么做,因此,这是一个我忽略的基本命令。

总体:元学习

47) 要想完成任何事情,只要下定决心开始就行。我以前不想在这个项目上投入太多精力,尤其是在我遇到瓶颈的时候。今天重要的是说“我要在一件事上取得进展”,而不是担心是否能达到某个目标或花费多少时间。大多数时候,只要开始做,我有时会工作一整天,有时什么也不做。

48) 很难理解“完成”是什么意思。我应该添加排行榜吗?我的游戏需要兼容移动端吗?游戏规则能带来“乐趣”吗?最终,我对所有这些问题都回答了“不”,所以我添加了排行榜,重写了CSS使其在移动端显示正确,并彻底重写了游戏规则。现在我觉得它已经完成了,但我还有很多内容想添加,比如:

  • 来自服务器的更好的图像/动态图像服务器
  • 音效
  • Jenkinsfile 和脚本用于构建和部署应用程序,并运行管理命令来执行某些常规操作,例如删除旧游戏
  • 让用户定制他们的 iPhone
  • 显著扩展了用户菜单,用户可以填写个人资料,还可以查看以前的游戏和消息
  • 用户可以直接向其他玩家发送消息

49)不要因为懒惰而把提交命名为“ok”。这个坏习惯让我在查看提交历史时很难找到这篇文章所需的信息。

50) 看教程或阅读并不能让我学得好,因为我会觉得无聊,而且想要获取相关的信息。我也不擅长通过口头解释来学习,尤其是代码方面的知识,因为人们通常要么对某个主题了如指掌,要么完全不了解。作为新手,你通常也分辨不出两者的区别。我最擅长看代码示例和示例项目。

51)有时候你几周都无法修复一个 bug。如果发生这种情况,请绕过它,等你精力充沛的时候再处理。我的游戏使用了 Docker 和 Django Channels 进行 WebSocket 通信。我遇到了 Docker 服务器无法热重载的问题。这个问题困扰了我很长时间,最终我放弃了,选择在本地运行所有程序。这让我取得了一些进展,最终我通过复制 Django Channels 示例项目中的 Docker 设置解决了这个问题。

52) 这是一场在努力完成和努力学习之间的斗争。我迫不及待地想要完成我的游戏。尽管如此,我还是记得我的个人使命宣言,无论我是否完成,它都不会影响任何人,除了我自己。它并不是一个供其他人使用的有用软件包。我不需要用 React Hooks 进行重构、配置 linters、使用 propTypes 等等,但我的目标是尝试制作一个达到生产级质量的应用程序并学习很多新概念。在做大项目时,一定要坚持你的个人使命宣言!

53)回溯有时是必要的。我早期在 User 和 GamePlayer 模型之间定义了一对一的关系。但这最终被证明是一个错误的设计,因为玩家没有理由不能同时存在于多个游戏中,而且我必须确保他们退出一个游戏,或者他们不能加入另一个游戏。这实际上不可行。我不得不

  • 重做架构,
  • 从我的生产数据库中删除所有内容
  • 我发现在 Django 中将一对一关系改为外键非常麻烦。我差点就没这么做了,而是想尽一切办法确保用户每次只玩一个游戏(设置了很多事件监听器)。现在我很高兴这么做了,因为我的应用 bug 少了,而且比起自己费劲地绕过糟糕的设计,这应该更省事。

我还彻底重写了游戏规则,使其与原版游戏规则保持一致。这是一次巨大的改写,最终带来了更佳的体验。

文章来源:https://dev.to/aduranil/53-learnings-from-writing-a-multiplayer-strategy-game-3ijd
PREV
React Context 指南
NEXT
10 个对主动开发有用的 docker-compose 和 docker 命令