绘图

我们通过定义一个“显示器”对象来封装绘图代码,该对象显示指定关卡,以及状态。本章定义的显示器类型名为DOMDisplay,因为该类型使用简单的 DOM 元素来显示关卡。

我们会使用样式表来设定实际的颜色以及其他构建游戏中所需的固定的属性。创建这些属性时,我们可以直接对元素的style属性进行赋值,但这会使得游戏代码变得冗长。

下面的帮助函数提供了一种简洁的方法,来创建元素并赋予它一些属性和子节点:

  1. function elt(name, attrs, ...children) {
  2. let dom = document.createElement(name);
  3. for (let attr of Object.keys(attrs)) {
  4. dom.setAttribute(attr, attrs[attr]);
  5. }
  6. for (let child of children) {
  7. dom.appendChild(child);
  8. }
  9. return dom;
  10. }

我们创建显示器对象时需要指定其父元素,显示器将会创建在该父元素上,同时还需指定一个关卡对象。

  1. class DOMDisplay {
  2. constructor(parent, level) {
  3. this.dom = elt("div", {class: "game"}, drawGrid(level));
  4. this.actorLayer = null;
  5. parent.appendChild(this.dom);
  6. }
  7. clear() { this.dom.remove(); }
  8. }

由于关卡的背景网格不会改变,因此只需要绘制一次即可。角色则需要在每次刷新显示时进行重绘。drawFame需要使用actorLayer属性来跟踪已保存角色的动作,因此我们可以轻松移除或替换这些角色。

我们的坐标和尺寸以网格单元为单位跟踪,也就是说尺寸或距离中的 1 单元表示一个单元格。在设置像素级尺寸时,我们需要将坐标按比例放大,如果游戏中的所有元素只占据一个方格中的一个像素,那将是多么可笑。而scale绑定会给出一个单元格在屏幕上实际占据的像素数目。

  1. const scale = 20;
  2. function drawGrid(level) {
  3. return elt("table", {
  4. class: "background",
  5. style: `width: ${level.width * scale}px`
  6. }, ...level.rows.map(row =>
  7. elt("tr", {style: `height: ${scale}px`},
  8. ...row.map(type => elt("td", {class: type})))
  9. ));
  10. }

前文提及过,我们使用<table>元素来绘制背景。这非常符合关卡中grid属性的结构。网格中的每一行对应表格中的一行(<tr>元素)。网格中的每个字符串对应表格单元格(<td>)元素的类型名。扩展(三点)运算符用于将子节点数组作为单独的参数传给elt

下面的 CSS 使表格看起来像我们想要的背景:

  1. .background { background: rgb(52, 166, 251);
  2. table-layout: fixed;
  3. border-spacing: 0; }
  4. .background td { padding: 0; }
  5. .lava { background: rgb(255, 100, 100); }
  6. .wall { background: white; }

其中某些属性(border-spacing和padding)用于取消一些我们不想保留的表格默认行为。我们不希望在单元格之间或单元格内部填充多余的空白。

其中background规则用于设置背景颜色。CSS中可以使用两种方式来指定颜色,一种方法是使用单词(white),另一种方法是使用形如rgb(R,G,B)的格式,其中R表示颜色中的红色成分,G表示绿色成分,B表示蓝色成分,每个数字范围均为 0 到 255。因此在rgb(52,166,251)中,红色成分为 52,绿色为 166,而蓝色是 251。由于蓝色成分数值最大,因此最后的颜色会偏向蓝色。而你可以看到.lava规则中,第一个数字(红色)是最大的。

我们绘制每个角色时需要创建其对应的 DOM 元素,并根据角色属性来设置元素坐标与尺寸。这些值都需要与scale相乘,以将游戏中的尺寸单位转换为像素。

  1. function drawActors(actors) {
  2. return elt("div", {}, ...actors.map(actor => {
  3. let rect = elt("div", {class: `actor ${actor.type}`});
  4. rect.style.width = `${actor.size.x * scale}px`;
  5. rect.style.height = `${actor.size.y * scale}px`;
  6. rect.style.left = `${actor.pos.x * scale}px`;
  7. rect.style.top = `${actor.pos.y * scale}px`;
  8. return rect;
  9. }));
  10. }

为了赋予一个元素多个类别,我们使用空格来分隔类名。在下面展示的 CSS 代码中,actor类会赋予角色一个绝对坐标。我们将角色的类型名称作为额外的 CSS 类来设置这些元素的颜色。我们并没有再次定义lava类,因为我们可以直接复用前文为岩浆单元格定义的规则。

  1. .actor { position: absolute; }
  2. .coin { background: rgb(241, 229, 89); }
  3. .player { background: rgb(64, 64, 64); }

setState方法用于使显示器显示给定的状态。它首先删除旧角色的图形,如果有的话,然后在他们的新位置上重新绘制角色。试图将 DOM 元素重用于角色,可能很吸引人,但是为了使它有效,我们需要大量的附加记录,来关联角色和 DOM 元素,并确保在角色消失时删除元素。因为游戏中通常只有少数角色,重新绘制它们开销并不大。

  1. DOMDisplay.prototype.setState = function(state) {
  2. if (this.actorLayer) this.actorLayer.remove();
  3. this.actorLayer = drawActors(state.actors);
  4. this.dom.appendChild(this.actorLayer);
  5. this.dom.className = `game ${state.status}`;
  6. this.scrollPlayerIntoView(state);
  7. };

我们可以将关卡的当前状态作为类名添加到包装器中,这样可以根据游戏胜负与否来改变玩家角色的样式。我们只需要添加 CSS 规则,指定祖先节点包含特定类的player元素的样式即可。

  1. .lost .player {
  2. background: rgb(160, 64, 64);
  3. }
  4. .won .player {
  5. box-shadow: -4px -7px 8px white, 4px -7px 8px white;
  6. }

在遇到岩浆之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们添加两个模糊的白色阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。

我们无法假定关卡总是符合视口尺寸,它是我们在其中绘制游戏的元素。所以我们需要调用scrollPlayerIntoView来确保如果关卡在视口范围之外,我们可以滚动视口,确保玩家靠近视口的中央位置。下面的 CSS 样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative,因此该元素中的角色总是相对于关卡的左上角进行定位。

  1. .game {
  2. overflow: hidden;
  3. max-width: 600px;
  4. max-height: 450px;
  5. position: relative;
  6. }

scrollPlayerIntoView方法中,我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的scrollLeftscrollTop属性,当玩家接近视口边界时修改滚动坐标。

  1. DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
  2. let width = this.dom.clientWidth;
  3. let height = this.dom.clientHeight;
  4. let margin = width / 3;
  5. // The viewport
  6. let left = this.dom.scrollLeft, right = left + width;
  7. let top = this.dom.scrollTop, bottom = top + height;
  8. let player = state.player;
  9. let center = player.pos.plus(player.size.times(0.5))
  10. .times(scale);
  11. if (center.x < left + margin) {
  12. this.dom.scrollLeft = center.x - margin;
  13. } else if (center.x > right - margin) {
  14. this.dom.scrollLeft = center.x + margin - width;
  15. }
  16. if (center.y < top + margin) {
  17. this.dom.scrollTop = center.y - margin;
  18. } else if (center.y > bottom - margin) {
  19. this.dom.scrollTop = center.y + margin - height;
  20. }
  21. };

找出玩家中心位置的代码展示了,我们如何使用Vec类型来写出相对可读的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。

接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于 0 的值或超出元素滚动区域的值。这是没问题的。DOM 会将其修改为可接受的值。如果我们将scrollLeft设置为–10,DOM 会将其修改为 0。

最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。

我们现在能够显示小型关卡。

  1. <link rel="stylesheet" href="css/game.css">
  2. <script>
  3. let simpleLevel = new Level(simpleLevelPlan);
  4. let display = new DOMDisplay(document.body, simpleLevel);
  5. display.setState(State.start(simpleLevel));
  6. </script>

我们可以在link标签中使用rel="stylesheet",将一个 CSS 文件加载到页面中。文件game.css包含了我们的游戏所需的样式。