我如何(意外地)用 Vanilla JS、CSS、Grid、Javascript 类、Hitboxes、碰撞检测和碰撞事件、数据库和效率从头开始制作游戏引擎

2025-06-09

我如何(无意中)用 Vanilla JS 从头开始​​制作游戏引擎

CSS网格

JavaScript 类

碰撞箱、碰撞检测和碰撞事件

数据库和效率

后续步骤

2019年7月29日更新:“The Fire Mage”现已在 Heroku 上部署!点击此处查看:https://the-fire-mage.herokuapp.com/

浏览器中播放“火焰法师”的动图
游戏引擎的实际运行,以及概念验证游戏“The Fire Mage”

这是我的 Github 链接,指向 Javascript 前端这是 Rails 后端
(抱歉,这两个项目都还没有 Readme —— 继续阅读,看看它是否在我的待办事项清单上!)

最近在西雅图 Flatiron 工作室,我们有一个项目要做一个单页应用,前端用 JavaScript,后端用 Rails。我决定利用这个项目尽可能多地学习 CSS,并练习 DOM 操作。受到复古电子游戏的启发,我决定做一个类似《魔兽争霸 II》和《星际争霸》的小型实时策略游戏。我的目标很简单:创建一个单位,选择它,控制它移动,让它与物体交互,最后用一些消息和动画把所有元素串联起来。

魔兽争霸 2 正在运行的动图

我一开始没有意识到我需要构建一个完整的游戏引擎来实现所有这些小事件!

最初,有人帮我设置了 Bootstrap、Canvas 和 Phaser,作为游戏开发工具。但我越看这些工具,就越觉得自己没有完成核心任务。我草草地尝试设置了 Bootstrap,把遇到的小困难当成了一个信号:我应该用原生 JavaScript 从头开始​​构建整个游戏引擎。

在这篇博文中,我想回顾一下我在工作过程中学到的一些 Javascript 和 CSS 技巧和经验教训。

CSS网格

截图
CSS 网格的实际作用。

有用的链接:
https://www.w3schools.com/css/css_grid.asp
https://hacks.mozilla.org/2017/10/an-introduction-to-css-grid-layout-part-1/
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout

当我放弃 Bootstrap 时,我很高兴地发现 CSS 有一个内置的网格功能。以下是我了解到的一些信息:

网格术语:列、行、间隙

上面的链接有精彩的插图,展示了这个术语以及它如何在页面上翻译,但作为快速概述:

是列,
行。Column
-Gaps是列与列之间的间距, Row -
Gaps是行与行之间的间距。Gap是column-gap 和 row-gap的简写。

可以对每个进行编号和大小调整以创建所需的网格。

设置它们

要设置网格,请为网格容器创建一个 CSS 类。将“display”属性设置为“grid”(块级)或“inline-grid”。



.grid-container {
  display: grid;
}


Enter fullscreen mode Exit fullscreen mode

设置列/行大小

有几种方法可以设置列数和行数以及它们的大小,但我发现最方便的方法是使用“repeat()”方法,结合“grid-template-columns”和“grid-template-rows”属性:



.container {
  position: absolute;
  display: inline-grid;
  grid-template-columns: repeat(20, 42px);
  grid-template-rows: repeat(12, 42px);
}


Enter fullscreen mode Exit fullscreen mode

在上面的代码片段中, repeat() 方法接受两个参数:列/行的数量,以及每个单元格的大小。上面的代码生成一个网格(这次是作为内联元素),该网格有 20 列和 12 行,每个单元格为 42x42 像素。

由于我的项目尺寸非常有限,我大部分的 CSS 代码都是用像素数来编写的。你也可以使用分数来将网格内剩余的空间划分成相等的部分——例如,“3fr”表示占 1/3 的空间,“2fr”表示占 1/2 的空间,“1fr”表示占全部的空间,等等。(设置不同大小的列/行会有一些细微的差别,但我将参考上面的链接。)

使用 grid-column-start/end + grid-row-start/end 设置位置

CSS Grid 有一种方便的方法可以将其他元素附加到自身:指定要放置的起始和结束列和行:



.item1 {
  grid-column-start: 1;
  grid-column-end: 3;
}


Enter fullscreen mode Exit fullscreen mode

(摘自 W3 学校)

使用代表从左到右的列号的整数,这将拉伸您的元素以覆盖从 grid-column-start 左上角开始到 grid-column-end 左上角的所有列。(grid-row-start 和 grid-row-end 也是如此。)上面的代码片段将拉伸带有 class 'item1' 的元素以覆盖第 1 列和第 2 列,并在第 3 列之前停止。

项目特定的实用性

所以,CSS 是个很棒的工具,但就我的游戏引擎而言,它并非完美之选。最终,为了游戏的最终视觉效果,需要消除行列之间的间隙;而对于网格层级上的元素,我只能将它们附加到特定的网格单元上,而不是浮动在它们之间。因此,我最终只将地形图像放在网格层上,因为它们是静态的,并且(目前)不会与单位或物品进行交互。

JavaScript 类

我一直犹豫着是否要全力投入 JavaScript 类,但这个项目让我看到了它们提供的实用性。我的项目部分需求涉及持久化数据,所以我想跟踪游戏板上单位和物品的位置。这样,只要位置足够频繁地保存到数据库中,我就可以随着浏览器的刷新重新加载游戏。

考虑到位置记忆在创建碰撞箱和碰撞检测方面至关重要,我决定重构所有代码(当时,我花了两三天时间处理 DOM 操作),以便游戏中绘制的所有元素——棋盘、图层容器、地形图像、单位和物品的单元格——都变成类实例。这个下午过得很充实,因为之后我获得了几个好处:

我的游戏类实例记住了它们的 div,反之亦然

查看 Cell 类的以下代码,该类被扩展用于创建 Units 和 Items:



class Cell {
  constructor(containerQuery, position, onMap = true) {
    this.position = position;
    this.onMap = onMap

    this.div = div
    div.cell = this

    this.div.setAttribute('style', `left: ${this.position.left}px; top: ${this.position.top}px`)
  }
}


Enter fullscreen mode Exit fullscreen mode

注意到每个单元格都有一个 .div 属性,每个 div 又都有一个 .cell 属性了吗?我仍然不确定是否有更简单的方法来建立这种联系,但对我来说,能够灵活地通过类实例或 div 来获取单位和物品变得非常重要,因此能够调用 (div.cell) 和 (this.div) 来获取正确的数据非常方便。例如,以下是来自终局事件的片段,其中物品“树”的类列表被修改了:



      treeCell.div.classList.add('slow-fadeout')
      treeCell.div.classList.add('special-effect')
      treeCell.div.classList.remove('item')


Enter fullscreen mode Exit fullscreen mode

类实例记住了它们的 div 在板上的位置

我创建了一个“position”属性,该属性指向一个可以在 HTML 样式中使用的具有位置的对象,并构建了一个辅助方法将 div 的位置转换为该“position”属性:



class Cell {
  constructor(containerQuery, position, onMap = true) {
    this.position = position;
  }
}

function positionCreator(div) {
  return {
    left: div.getBoundingClientRect().left,
    top: div.getBoundingClientRect().top,
    width: div.getBoundingClientRect().width,
    height: div.getBoundingClientRect().height
  }
}


Enter fullscreen mode Exit fullscreen mode

positionCreator() 方法由 JR 在 JSFiddle 上提供的这段令人惊叹的代码提供

然后,当我添加允许单位移动的功能时,我包含了根据类实例的 div 当前位置更新类实例的位置属性的代码,每秒重新计算 20 次(每 50 毫秒):



while (transitionOn) {
      let hitboxUpdater = setInterval(()=>{

        if (transitionOn === false) {
          clearInterval(hitboxUpdater);
          updateCells()
        }

        selectedUnit.cell.hitboxPosition = positionCreator(selectedUnit.cell.hitbox())

        let containerX = unitContainer.div.getBoundingClientRect().x
        let containerY = unitContainer.div.getBoundingClientRect().y
        selectedUnit.cell.position = positionCreator(selectedUnit)
        selectedUnit.cell.position.left -= containerX
        selectedUnit.cell.position.top -= containerY

        collider.checkContainerUnitCollision(selectedUnit, boardContainer)
        collider.checkItemUnitCollision(selectedUnit)
    }, 50)
    break;
 }


Enter fullscreen mode Exit fullscreen mode

当 transitionOn 变量为“true”时,此 setInterval() 函数会根据 selectedUnit 在游戏 div 中的位置更新其单元格位置,然后检查与游戏边框和其他单元格的碰撞。

最后,将 console.logs 添加到函数中(目前大部分函数都已消失或被注释掉)让我可以方便地读取 Chrome 开发者工具中的 div 位置,这有助于我在创建命中框和碰撞检测时进行调试。

继承使得创建和定制不同的游戏类(例如物品和单位)变得容易

好吧好吧,我知道原型设计是 Javascript 的特色并且继承与组合是一个很大的话题,但是有几个小例子表明继承确实有帮助!

在我决定将单位和物品设为“单元格”类型后,我创建了“单位”和“物品”类,它们都继承自单元格。这样我就可以在不影响其他类的情况下调试和调整其中一个类。最终,它们之间只有一些区别,但这在 DRY 编程中是一个很好的实践——毕竟,只有单位需要库存,物品不需要!



class Unit extends Cell {
  constructor(name, container, position, onMap) {
    super(container, position, onMap)
    this.name = name
    this.cellType = "unit"
    this.gameSessionId = currentGameSession.id

    this.inventory = []
  }


Enter fullscreen mode Exit fullscreen mode

不过,我要说的是,下次有机会时,我很高兴尝试以组合为中心的方法,而不是以继承为中心的方法!

碰撞箱、碰撞检测和碰撞事件

《反恐精英》中的攻击范围
《反恐精英》中的命中框示例

这是该项目的巅峰之作:创建一个允许对象通过碰撞进行交互的游戏引擎。实现这一目标的方法是为每个交互元素赋予一个碰撞盒,并设置函数在元素运动时(因此可能产生碰撞)持续检查碰撞盒的碰撞。

Hitboxes - 使用 CSS 和辅助函数快速添加它们

交互元素的构造函数的一部分是创建一个具有“hitbox”类的子 div,从而为它们提供一个较小的内部 div 作为它们的 hitbox:



.cell > .hitbox {
  position: absolute;
  border-style: solid;
  border-width: 1px;
  /* border-color normally set to yellow to add visibility */
  border-color: transparent;
  width: 85%;
  height: 85%;
  left: 5%;
  top: 5.5%;
}


Enter fullscreen mode Exit fullscreen mode

当元素移动并且其位置每秒更新 20 次时,它们的命中框位置也会更新。

碰撞检测和碰撞事件

我之前已经在 J​​SFiddle 中包含过这个链接,但我还是再重复一遍:https://jsfiddle.net/jlr7245/217jrozd/3/(感谢 JR!!!)

这成了我事实上的目标:练习足够多的 JavaScript,以便直观地理解并在我的游戏中重现这一点。这段代码是一段优雅的原生 JS 代码,它移动 div,并在检测到碰撞时改变它们的颜色。碰撞是通过测量每个 div 相对于彼此的位置来检测的。这段代码有几个关键点:

1. this.position 和positionCreator()

JR 的代码最终说服我将所有内容重构为 JavaScript 类。这个类和这个函数的优雅让我知道自己想要复制它:



class BaseDiv {
  constructor(position) {
    this.position = position;
  }
}

function positionCreator(currentDiv) {
  return {
    left: currentDiv.getBoundingClientRect().left,
    top: currentDiv.getBoundingClientRect().top,
    height: currentDiv.getBoundingClientRect().height,
    width: currentDiv.getBoundingClientRect().width
  };
}


Enter fullscreen mode Exit fullscreen mode

2. 用四个位置条件测量碰撞

这段代码展示了检查 div 是否重叠的条件语句。它们合在一起,判断两个矩形 div 是否接触:



if (currentDiv.position.left < this.moveableDiv.position.left + this.moveableDiv.position.width &&
currentDiv.position.left + currentDiv.position.width > this.moveableDiv.position.left &&
currentDiv.position.top < this.moveableDiv.position.top + this.moveableDiv.position.height &&
currentDiv.position.height + currentDiv.position.top > this.moveableDiv.position.top) {
    hasJustCollided = true;


Enter fullscreen mode Exit fullscreen mode

3. 将所有条件和逻辑/控制流存储在“碰撞器”变量中

这是最后的天才之举:创建一个变量,其中包含检测碰撞所需的所有逻辑,并适当地触发正确的碰撞事件:



const collider = {
  moveableDiv: null,
  staticDivs: [],
  checkCollision: function() {
    let hasJustCollided = false;
    for (let i = 0; i < this.staticDivs.length; i++) {
      const currentDiv = this.staticDivs[i];
      if (currentDiv.position.left < this.moveableDiv.position.left + this.moveableDiv.position.width &&
      currentDiv.position.left + currentDiv.position.width > this.moveableDiv.position.left &&
      currentDiv.position.top < this.moveableDiv.position.top + this.moveableDiv.position.height &&
      currentDiv.position.height + currentDiv.position.top > this.moveableDiv.position.top) {
        hasJustCollided = true;
        if (!this.moveableDiv.ref.classList.contains('collision-state')) {
          this.moveableDiv.ref.classList.add('collision-state');
        }
      } else if (this.moveableDiv.ref.classList.contains('collision-state') && !hasJustCollided) {
          this.moveableDiv.ref.classList.remove('collision-state');
        }
    }
  },
};


Enter fullscreen mode Exit fullscreen mode

我的挑战

以这段优美的代码为指导,我开始在自己的代码上一点一点地构建类似的功能。当然,在将这段代码改编成我自己的代码时,我遇到了一些挑战:

单位边界碰撞和单位物品碰撞非常不同!

除了不同大小和类型的碰撞(毕竟,单位始终位于游戏边界内,因此根据上述代码始终会发生碰撞)之外,边界碰撞还需要非常不同的结果 - 即阻止移动而不是触发新事件。

当一个单位与游戏边界发生碰撞时,我希望阻止该单位进一步移动,以便它们留在游戏内。然而,仅仅停止单位的移动会导致它卡住——最终,我的解决方案是将碰撞的单位“弹回”边界,使其远离边界几个像素,这样它们就可以分配新的移动目标而不会被卡住:



      let unitHitboxPosition = selectedUnit.cell.hitboxPosition
      let containerHitboxPosition = container.hitboxPosition

      // left side - extra-padding 8px, rebound 3px
      if (unitHitboxPosition.left <= containerHitboxPosition.left + 8) {
          console.log("BORDER COLLISION DETECTED!! (left)")
          selectedUnit.style.left = parseInt(getComputedStyle(selectedUnit).left.replace("px", "")) + 3 + "px"
        }
      // top side - extra-padding 10px, rebound 3px
      if (unitHitboxPosition.top <= containerHitboxPosition.top + 10) {
          console.log("BORDER COLLISION DETECTED!! (top)")
          selectedUnit.style.top = parseInt(getComputedStyle(selectedUnit).top.replace("px", "")) + 3 + "px"
        }
      // right side - extra-padding 7px, rebound -1px
      if (unitHitboxPosition.left + unitHitboxPosition.width >= containerHitboxPosition.left + containerHitboxPosition.width - 7) {
          console.log("BORDER COLLISION DETECTED!! (right)")
          selectedUnit.style.left = parseInt(getComputedStyle(selectedUnit).left.replace("px", "")) - 1 + "px"
        }
      // bottom side - extra-padding 10px, rebound -1px
      if (unitHitboxPosition.top + unitHitboxPosition.height >= containerHitboxPosition.top + containerHitboxPosition.height - 10) {
          console.log("BORDER COLLISION DETECTED!! (bottom)")
          selectedUnit.style.top = parseInt(getComputedStyle(selectedUnit).top.replace("px", "")) - 1 + "px"
        }


Enter fullscreen mode Exit fullscreen mode

计算离散运动与流畅运动的碰撞需要不同的监听器

我已经提到过这一点,但我之所以必须每秒 20 次重新计算单位位置并检查碰撞检测,是因为单位的移动非常流畅,而不是像原始代码片段中那样谨慎地跳跃(按下箭头键 => 移动 5 个像素)。通过每秒 20 次重新检查碰撞,碰撞很可能被足够快地捕捉到,从而在用户注意到单位远离碰撞之前触发事件。

如果碰撞事件发生后某些元素从板上消失会怎样?

JavaScript 类的另一个用武之地是“onMap”属性,它让我可以决定是否在棋盘上渲染某个单元格。为了让用户体验更自然,我为这些碰撞事件添加了一些 setTimeout() 和 CSS 动画——这样,在类属性更新以及单元格从棋盘上移除时,用户就能看到炫酷的动画。



function itemCollisionEvent(unitCell, itemCell) {

  if (itemCell === axeCell && unitCell === mageCell) {
    itemCell.onMap = false
    addItemToInventory(unitCell, axeCell.name)
    updateCells()
    displayTextMessage("Axe gained to your inventory!")

    itemCell.div.classList.remove('item')
    itemCell.div.classList.add('fadeout', 'special-effect')

  }
}


Enter fullscreen mode Exit fullscreen mode

我非常珍惜这次练习制作 CSS 动画和过渡的机会,它们不仅能与底层代码完美结合,还能提供更好的用户体验,而不是仅仅停留在代码层面!(此外,它还让我更加深刻地体会到游戏加载画面中发生的各种变化……)

数据库和效率

对此我没什么好说的,除了我特意设计了一些尽可能糟糕的方面来说明效率问题(并最终练习如何解决这些问题)。我希望我的游戏引擎不仅能在刷新时记住单位和物品的位置,还能记住随机生成的地形(具体来说,就是 .png 文件名末尾的整数)。

现在回想起来,我本来可以将这些数据存储为一串整数——但在用 Rails 创建后端时,我意识到可以尝试进行低效的数据库调用带来的时间延迟。因此,我改成了这样:每个新游戏都会立即将 240 行数据保存到 Terrains 表中。每行数据仅包含一个图像源 URL 和一个用于查找的游戏会话 ID——这绝对低效!

尽管如此,我还是给自己提出了两个问题需要解决,我认为这两个问题是更大效率问题的缩影:

a. 我该如何设计一种用户体验,让查询数据库和渲染棋盘时感觉流畅?

当游戏会话重新加载时,需要从数据库中检索 240 行数据,并在游戏开始前用于重新绘制地图。最终,我围绕此问题构建了主菜单的过渡时间,以便在查询数据库时菜单隐藏未完成的棋盘。这虽然不能解决问题,但提供了更流畅的用户体验,即使底层问题得到解决,体验仍然良好。

b. 游戏结束后,如何有效地销毁数据库中未使用的数据?

坦白说,这还不是我内置的功能。我之所以没有在 Heroku 上部署这个功能,是因为数据库的限制——我的数据库一度仅在 Terrains 表中就有超过 12 万行数据!在我无休止地等待种子文件删除所有当前记录(“无休止”指的是整整四分钟)之后,高效清理这个问题的必要性就变得显而易见了。这完美地诠释了我在上一篇博客中开始研究的效率问题:在运行的操作达到一定阈值后,时间的增长变得难以控制。真的,在电子游戏中,让玩家为任何事情等待整整四分钟都是不合适的

这是另一个 Javascript 类发挥作用的案例。游戏结束事件的一部分是将游戏会话的“complete”属性设置为“true”,这样可以轻松识别并定期执行查询以清理数据库。(我认为游戏结束动画是将其在后台运行的最佳时机。)对于被放弃的游戏,我计划使用数据库时间戳来清理所有已过期的游戏会话,这些会话很可能在创建后 10 分钟内过期。我预计这种伪垃圾收集机制可以让玩家免去那些令人厌烦的四分钟等待时间。

骷髅敲击手指,配图“多久”

后续步骤

我不认为这个项目已经完成了!虽然这只是一个为期一周的任务,但Brian Pak一直鼓励我清理并开源这个引擎。以下是我的目标以及相关的下一步计划:

为开源做好准备:

  1. 清理代码,添加注释以使其更清晰,并恢复生成有用调试信息(例如点击事件位置)的 console.log。
  2. 最后写一个自述文件,描述如何创建单位、物品、地形和碰撞事件。
  3. 创建一个非特定于游戏的引擎版本——目前,该引擎与我为其制作的概念验证游戏“火法师”密不可分。

扩展引擎:

  1. 在数据库中添加已完成和已过期的游戏会话的伪垃圾收集。
  2. 更改地形数据保存到数据库的方式。
  3. 在 Heroku 上部署可测试的版本,并在其他浏览器中进行测试。
  4. (延伸目标)使用 Rails 的 Action Cable 允许多个浏览器访问和更新同一个游戏会话,从而实现多人游戏。
  5. (延伸目标)添加基本的攻击/战斗功能,采用原版塞尔达的风格(选择库存中的物品,触发攻击,渲染攻击动画和碰撞事件)

希望您能从中找到一些关于 JavaScript 和 CSS 的实用技巧!请持续关注后续关于该引擎开源的文章,也欢迎您在这里或直接在 Github 上提供反馈和建议!再次强调,这是我的 JavaScript 前端 Github 链接这是 Rails 后端 Github 链接

鏂囩珷鏉ユ簮锛�https://dev.to/isalevine/how-i-accidentally-made-a-game-engine-from-scratch-with-vanilla-js-4m80
PREV
拥有超过 250 个 Web 开发资源的存储库
NEXT
Git + GitHub 初学者指南