在 CSS 中构建战舰游戏

Avatar of Daniel Schulz
Daniel Schulz

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费信用额度!

这是一个实验,看看我使用 CSS 可以进行到何种程度的交互式体验。什么项目比游戏更适合尝试呢?战舰 似乎是一个很好的挑战,也是我目前为止看到的 CSS 游戏的升级,因为它拥有多个需要与两个玩家交互的区域的复杂性。

想要查看完整的游戏吗?

查看代码库 查看演示

哦,你想了解它的工作原理?让我们深入研究一下。

我立即意识到,将会有大量的重复 HTML 和非常长的 CSS 选择器,因此我设置了 Pug 来编译 HTML 和 Less 来编译 CSS。从现在开始,所有代码都将使用这两种语言编写。

CSS 中的交互式元素

为了使游戏机制正常工作,我们需要一些交互式元素。我们将逐步介绍每个元素。

HTML 复选框和 :checked

战舰游戏涉及大量检查以确定某个区域是否包含船只,因此我们将使用大量复选框

[type*='checkbox'] {
  // inactive style

  &:checked {
    // active style
  }
}

要设置复选框的样式,我们首先需要使用appearance: none;来重置它们,但这种方法目前支持程度很差,需要浏览器前缀。更好的解决方案是添加辅助元素。<input>标签不能包含子元素,包括伪元素(即使 Chrome 会渲染它们),因此我们需要使用相邻兄弟选择器来解决这个问题。

[type*='checkbox'] {
  position: relative;
  opacity: none;

  + .check-helper {
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
    // further inactive styles
  }

  &:checked {
    + .check-helper {
      // active styles
    }
  }
}

如果使用<label>作为辅助元素,您还可以将复选框的点击区域扩展到辅助元素上,从而可以更自由地进行定位。此外,您可以为同一个复选框使用多个标签。但是,不支持同一个标签使用多个复选框,因为您必须为每个复选框分配相同的 ID。

目标

我们正在制作一个本地多人游戏,因此我们需要将一个玩家的战场隐藏在另一个玩家面前,并且需要一种暂停模式,允许玩家在不偷看对方船只的情况下切换。一个解释规则的开始屏幕也很不错。

HTML 已经为我们提供了链接到文档中给定 ID 的选项。使用:target,我们可以选择刚刚跳转到的元素。这使我们能够在完全静态的文档中(甚至不会破坏后退按钮)创建类似于单页应用程序的行为。

- var screens = ['screen1', 'screen2', 'screen3'];
body
  nav
    each screen in screens
      a(href='#' + screen)

  each screen in screens
    .screen(id=screen)
      p #{screen}
.screen {
  display: none;

  &:target {
    display: block;
  }
}

可见性和指针事件

渲染元素处于非活动状态通常通过使用pointer-events: none;来实现。pointer-events的妙处在于您可以将其在子元素上反转。这将只保留所选子元素的可点击性,而父元素保持可点击状态。这在后面与复选框辅助元素结合使用时会很有用。

visibility: hidden;也是如此。虽然display: none;opacity: 0;使元素及其所有子元素消失,但可见性可以反转。

请注意,隐藏的可见性还会禁用任何指针事件,这与opacity: 0;不同,但会保留在文档流中,这与display: none;不同。

.foo {
  display: none; // invisible and unclickable
  .bar {
    display: block; // invisible and unclickable
  }
}

.foo {
  visibility: hidden; // invisible and unclickable
  .bar {
    visibility: visible; // visible and clickable
  }
}

.foo {
  opacity: 0;
  pointer-evens: none; // invisible and unclickable
  .bar {
    opacity: 1;
    pointer-events: all; // still invisible, but clickable
  }
}
CSS 规则 可反转的透明度 可反转的指针事件
display: none;
visibility: hidden;
opacity: 0;
pointer-events: none;

好了,现在我们已经确定了交互式元素的策略,接下来让我们转向游戏本身的设置。

设置

在实际开始之前,我们需要定义一些全局静态变量和战场的大小

@gridSize: 12;
@zSea: 1;
@zShips: 1000;
@zAbove: 2000;
@seaColor: #123;
@enemyColor: #f0a;
@playerColor: #0c8;
@hitColor: #f27;

body {
  --grid-measurements: 70vw;
  @media (min-aspect-ratio: 1/2) {
    --grid-measurements: 35vh;
  }
}

网格大小是战场的大小:在本例中为 12×12 个区域。接下来,我们定义一些 z-index 和颜色。

这是 Pug 的骨架

doctype html

head
  title Ships!
  link(rel="stylesheet", href="style.css")
  meta(charset="UTF-8")
  meta(name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no")
  meta(name="theme-color" content="#000000")

body

从现在开始的所有 HTML 代码都将位于body中。

实现状态

我们需要为玩家 1、玩家 2、暂停和开始屏幕构建状态。我们将按照上面介绍的方法使用目标选择器来实现。这是一个我们想要实现的小草图

我们有几种模式,每种模式都有自己的容器和 ID。视口中只显示一种模式,其他模式隐藏在display: none;中,除了玩家模式。如果一个玩家处于活动状态,另一个玩家需要在视口之外,但仍然具有指针事件,以便玩家可以相互交互。

.mode#pause

each party in ['p1', 'p2']
  .mode(id=party)

.mode#start

.status
  each party  in ['p1', 'p2']
    a.player-link.switch(href='#' + party)
  a.status-link.playpause(href='#pause') End Turn

h1
  Ships!

.status div 包含主导航。它的条目会根据活动模式而改变,因此为了正确选择它,我们需要将它放在.mode元素之后。<h1>也是如此,因此它最终会出现在文档的末尾(不要告诉 SEO 人员)。

.mode {
  opacity: 0;
  pointer-events: none;

  &:target,
  &#start {
    opacity: 1;
    pointer-events: all;
    z-index: 1;
  }

  &#p1, &#p2 {
    position: absolute;
    transform: translateX(0);
    opacity: 1;
    z-index: 2;
  }

  &#p1:target {
    transform: translateX(50vw);

    +#p2 {
      transform: translateX(50vw);
      z-index: 2;
    }
  }

  &#p2 {
    transform: translateX(50vw);
    z-index: 1;
  }

&#pause:target {
    ~ #p1, ~ #p2 {
      opacity: 0;
    }
  }
}

#start {
  .mode:target ~ & {
    display: none;
  }
}

.mode div 从不具有指针事件,并且始终完全透明(即处于非活动状态),除了开始屏幕,它默认情况下是启用的,以及当前目标屏幕。我没有简单地将其设置为display: none;,因为我仍然需要它位于文档流中。隐藏可见性不起作用,因为我需要在后面击中敌方船只时单独激活指针事件。

我需要#p1#p2彼此相邻,因为这将启用一个玩家的击中和另一个玩家的船只之间的交互。

实现战场

我们需要两组两块战场,总共四块战场。每组包含一块当前玩家的战场和一块对方玩家的战场。一组将在#p1中,另一组将在#p2中。只有一位玩家会在视口中,但两者都会保留它们的指针事件和文档流。这是一个小的草图

现在我们需要大量 HTML 代码。每个玩家都需要两个战场,战场需要有 12×12 个区域。总共有 576 个区域,因此我们需要进行一些循环。

这些区域将具有自己的类,声明它们在网格中的位置。此外,第一行或第一列的区域将具有一个位置指示器,因此您可以说出一些很酷的话,比如“向 C6 发射”。

each party in 'p1', 'p2']
  .mode(id=party)
    each faction in 'enemy', 'player']
      .battlefield(class=faction, class=party)
        each line in 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
          each col, colI in 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
            div(class='field-x' + (colI+1) + '-y' + line)
              if (col === 'A')
                .indicator-col #{line}
              if (line === 1)
                .indicator-line #{col}

战场本身将被设置在 CSS 网格 中,其模板和测量来自我们之前设置的变量。我们将在.mode div 中绝对定位它们,并将敌人位置与玩家位置切换。在实际的棋盘游戏中,您也会在底部放置自己的船只。请注意,我们需要对calc的顶部值进行转义,否则 Less 会尝试为您计算它并失败。

.battlefield {
  position: absolute;
  display: grid;
  grid-template-columns: repeat(@gridSize, 1fr);
  width: var(--grid-measurements);
  height: var(--grid-measurements);
  margin: 0 auto 5vw;
  border: 2px solid;
  transform: translate(-50%, 0);
  z-index: @zSea;

  &.player {
    top: calc(var(--grid-measurements) ~"+" 150px);
    border-color: transparent;

    :target & {
      border-color: @playerColor;
    }
  }

  &.enemy {
    top: 100px;
    border-color: transparent;

    :target & {
      border-color: @enemyColor;
    }
  }
}

我们希望战场的方格呈现出漂亮的棋盘图案。我写了一个混合器来计算颜色,并且由于我喜欢将混合器与其他代码分开,因此它将被放在components.less文件中。

.checkerboard(@counter) when (@counter > 0) {
  .checkerboard(@counter - 2);

  &[class^='field-'][class$='-y@{counter}'] {
    &:nth-of-type(odd) {
      background-color: transparent;

      :target & {
      background-color: darken(@seaColor, 3%);
    }
  }

  &:nth-of-type(even) {
    background-color: transparent;

    :target & {
        background-color: darken(@seaColor, 4%);
      }
    }
  }
}

当我们使用.checkerboard(@gridSize);调用它时,它将遍历网格的每第二行,并为当前元素的奇数和偶数实例设置背景颜色。我们可以使用普通的:odd:even来对剩余的区域进行着色。

接下来,我们将指示器放在战场之外。

[class^='field-'] {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: transparent;
  
  .checkerboard(@gridSize);
  :target &:nth-of-type(even) {
    background-color: darken(@seaColor, 2%);
  }

  :target &:nth-of-type(odd) {
    background-color: darken(@seaColor, 1%);
  }

  [class^='indicator-'] {
    display: none;

    :target & {
      position: absolute;
      display: flex;
      justify-content: center;
      width: calc(var(--grid-measurements)~"/"@gridSize);
      height: calc(var(--grid-measurements)~"/"@gridSize);
      color: lighten(@seaColor, 10%);
      pointer-events: none;
    }

    &.indicator-line {
      top: -1.5em;
      align-items: flex-start;
    }

    &.indicator-col {
      left: -2.3em;
      align-items: center;
    }
  }
}

放置战舰

现在让我们进入棘手的部分,放置一些战舰。这些战舰需要可点击和交互,因此它们将是复选框。实际上,我们需要两个复选框来代表一艘战舰:miss(未命中)和hit(命中)。

  • Miss(未命中)位于底部。如果该区域没有其他目标,你的射击会击中水面,触发未命中动画。唯一的例外是玩家点击自己的战场。在这种情况下,战舰动画会播放。
  • 当己方战舰出现时,它会激活一个新的复选框。这个复选框叫做 hit(命中)。它位于与对应战舰完全相同的坐标,但在另一个玩家的攻击区域,并且位于 miss 的复选框辅助元素上方。如果命中被激活,它会在当前玩家的攻击区域以及对手的战舰上显示命中动画。

这就是为什么我们需要将我们的战场绝对地放置在一起。我们需要它们始终对齐,以便它们能够相互交互。

首先,我们将设置一些适用于这两个复选框的样式。我们仍然需要指针事件,但想要在视觉上隐藏复选框,并使用辅助元素来代替。

.check {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  
  + .check-helper {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }
}

我们现在也将为我们的事件编写一些类,以便以后使用。这些也会写入 components.less

.hit-obj {
  position: absolute;
  visibility: visible;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  animation: hit 1s forwards;
}

.ship-obj {
  position: absolute;
  left: 0;
  top: 0;
  width: 90%;
  height: 90%;
  border-radius: 15%;
  animation: setShip 0.5s forwards;
}

.miss-obj {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  animation: miss 1s forwards;
}

战舰出现和未命中

这两个事件基本上是一样的。如果你在自己的战场上击中大海,你就会创造一艘战舰。如果你在敌人的战场上击中大海,你就会触发一个未命中。这可以通过在辅助类的伪元素中调用我们 components.less 文件中的相应类来实现。我们在这里使用伪元素,因为我们需要稍后在一个辅助元素中放置两个对象。

如果你创造了一艘战舰,你就不应该能够取消创造它,因此在它被选中后,它会失去指针事件。然而,下一个命中复选框会获得指针事件,使敌人能够击中已出现的战舰。

.check {
  &.ship {
    &:checked {
      pointer-events: none;
    }

    &:checked + .check-helper {
      :target .player & {
        &::after {
          content: "";
          .ship-obj; // set own ship
        }
      }

      :target .enemy & {
        &::after {
          content: "";
          .miss-obj; // miss enemy ship
        }
      }
    }        

    &:checked ~ .hit {
      pointer-events: all;
    }
  }
}

击中战舰

这个新的命中复选框被绝对地定位在另一个玩家的攻击区域之上。对于玩家 1 来说,这意味着它位于右侧 50vw,并且位于网格高度 + 50px 边距之上。它默认没有指针事件,这些事件将被 .ship:check ~ .hit 中设置的事件覆盖,因此只有实际设置的战舰才能被击中。

为了显示命中事件,我们需要两个伪元素:一个在攻击区域确认命中;另一个显示受害者被击中的位置。:checked + .check-helper::aftercomponents.less 中的 .hit-obj 类调用到攻击者的区域,并且相应的 ::before 伪元素被翻译回受害者的战场。

由于命中事件的显示不受限于活动玩家,我们需要手动使用 display: none; 删除所有不必要的实例。

.check {
  &.hit {
    position: absolute;
    top: ~"calc(-1 * (var(--grid-measurements) + 50px))";
    left: 50vw;
    width: 100%;
    height: 100%;
    pointer-events: none;

    #p2 &,
    #p1:target & {
      left: 0;
    }

    #p1:not(:target) & + .check-helper::before {
      left: 50vw;
    }

    &:checked {
      opacity: 1;
      visibility: hidden;
      pointer-events: none;

      + .check-helper {
        &::before {
          content: "";
          .hit-obj; // hit enemy ships
          top: ~"calc(-1 * (var(--grid-measurements) + 50px))";
      }

        &::after {
          content: "";
          .hit-obj; // hit own ships
          top: -2px;
          left: -2px;
        }

        #p1:target &::before,
        #p1:target ~ #p2 &::after,
        #p1:not(:target) &::after,
        #p2:target &::before {
          display: none;
        }
      }
    }

    #p1:target .battlefield.p1 &,
    #p2:target .battlefield.p2 & {
      display: none;
    }
  }
}

事件动画

虽然我们已经对 miss、战舰和 hit 对象进行了样式设置,但现在还没有看到任何东西。这是因为我们仍然缺少使这些对象可见的动画。这些是简单的关键帧动画,我将其放入一个名为 animations.less 的新 Less 文件中。

@keyframes setShip {
  0% {
    transform: scale(0, 0);
    background-color: transparent;
  }

  100% {
    transform: scale(1, 1);
    background-color: @playerColor;
  }
}

@keyframes hit {
  0% {
    transform: scale(0, 0);
    opacity: 0;
    background-color: transparent;
  }

  10% {
    transform: scale(1.2, 1.2);
    opacity: 1;
    background-color: spin(@hitColor, 40);
    box-shadow: 0 0 0 0.5em var(--shadowColor);
  }

  100% {
    transform: scale(.7, .7);
    opacity: .7;
    background-color: @hitColor;
    box-shadow: 0 0 0 0.5em var(--shadowColor);
  }
}

@keyframes miss {
  0% {
    transform: scale(0, 0);
    opacity: 1;
    background-color: lighten(@seaColor, 50);
  }

  100% {
    transform: scale(1, 1);
    opacity: .8;
    background-color: lighten(@seaColor, 10);
  }
}

添加可自定义的玩家姓名

这对于功能来说并不是必需的,但它是一个不错的额外功能。玩家不再被叫做“玩家 1”和“玩家 2”,他们可以输入自己的姓名。我们通过在 .status 中添加两个 <input type="text"> 来实现,每个玩家一个。它们有占位符,以防玩家不想输入姓名,并想要立即开始游戏。

.status
  input(type="text" placeholder="1st Player").player-name#name1
  input(type="text" placeholder="2nd Player").player-name#name2
  each party  in ['p1', 'p2']
      a.player-link.switch(href='#' + party)
  a.status-link.playpause(href='#pause') End Turn

因为我们把它们放在 .status 中,所以我们可以在每个屏幕上显示它们。在开始屏幕上,我们将其保留为普通的输入框,供玩家输入姓名。我们对它们的占位符进行样式设置,使其看起来像实际的文本输入,因此玩家是否输入姓名实际上并不重要。

.status {
  .player-name {
    position: relative;
    padding: 3px;
    border: 1px solid @enemyColor;
    background: transparent;
    color: @playerColor;

    &::placeholder {
      color: @playerColor;
      opacity: 1; // Reset Firefox user agent styles
    }
  }
}

在其他屏幕上,我们删除了它们典型的输入框样式以及指针事件,使其看起来像普通的、不可更改的文本。.status 还包含指向选择玩家的空链接。我们对这些链接进行样式设置,使其具有实际的尺寸,并在它们之上显示没有指针事件的姓名输入。现在,点击一个姓名会触发该链接,并定位到相应的模式。

.status {
  .mode#pause:target ~ & {
    top: 40vh;
    width: calc(100% ~"-" 40px);
    padding: 0 20px;
    text-align: center;
    z-index: @zAbove;

    .player-name,
    .player-link {
      position: absolute;
      display: block;
      width: 80%;
      max-width: 500px;
      height: 40px;
      margin: 0;
      padding: 0;

      &:nth-of-type(even) {
        top: 60px;
      }
    }

    .player-name {
      border: 0;
      text-align: center;
      pointer-events: none;
    }
  }
}

玩家屏幕只需要显示活动玩家,所以我们删除了另一个玩家。

.status {
  .mode#p1:target ~ & #name2 {
    display: none;
  }
  
  .mode#p2:target ~ & #name1 {
    display: none;
  }
}

关于 Internet Explorer 和 Edge 的一些说明:微软浏览器 尚未实现 ::placeholder 伪元素。虽然它们支持 IE 的 :-ms-input-placeholder 和 Edge 的 ::-ms-input-placeholder,以及 Edge 的 webkit 前缀,但这些前缀只有在没有设置 ::placeholder 时才有效。在我对占位符进行的一些测试中,我只设法在微软浏览器或所有其他浏览器中正确地对其进行样式设置。如果其他人有解决方法,请分享!

整合所有内容

我们到目前为止获得的是一个功能性的游戏,但不是很好看。我使用开始屏幕来阐明一些基本规则。由于我们没有硬编码的获胜条件,也没有任何东西可以阻止玩家将他们的战舰随意地放置在任何地方,所以我创建了一个“公平游戏”的说明,鼓励玩家遵循良好的道德准则。

.mode#start
  .battlefield.enemy
    ol
      li
        span You are this color.
      li
        span Your enemy is
        span this
        span color
      li
        span You may place your ships as follows:
        ul
          li 1 x 5 blocks
          li 2 x 4 blocks
          li 3 x 3 blocks
          li 4 x 2 blocks

我不会详细介绍我是如何将事物精确地设置为我喜欢的,因为其中大部分都是非常基本的 CSS。你可以浏览最终结果来挑选它们。

当我们最终将所有部分连接起来时,我们将得到以下结果

查看 CodePen 上 Daniel Schulz (@iamschulz) 的 CSS 游戏:战舰

总结

让我们回顾一下我们已经完成的事情。

HTML 和 CSS 可能不是编程语言,但它们是其自身领域中的强大工具。我们可以使用伪类来管理状态,并使用伪元素来操作 DOM。

虽然我们大多数人一直在使用 :hover:focus,但 :checked 却鲜为人知,充其量只是用来对实际的复选框和单选按钮进行样式设置。复选框是一种方便的小工具,可以帮助我们摆脱前端功能中不必要的 JavaScript。在实际项目中,只要需求不太复杂,我不会犹豫使用纯 CSS 来构建 下拉菜单或隐藏式菜单

在使用 :target 选择器时,我会更加谨慎。由于它使用 URL 哈希值,因此它只能用于全局值。我认为我会将其用于,比如,在一个内容页面上突出显示当前段落,但不会用于可重复使用的元素,比如滑块或手风琴菜单。在大型项目中,它也可能很快变得混乱,特别是当其他部分开始控制哈希值时。

构建这款游戏对我来说是一次学习经历,我需要处理相互交互的伪选择器,并使用大量指针事件进行操作。如果我不得不重新构建它,我肯定会选择另一条路,这对我来说是一个好结果。我绝对不会将其视为一个可用于生产环境的解决方案,甚至不是一个干净的解决方案,这些超具体的选择器对于维护来说简直是噩梦,但它有一些我可以移植到实际项目中的优点。

最重要的是,这是一件很有趣的事情。