💭

CSSだけでマルバツゲーム(三目並べ)を実装する

2021/12/09に公開

本記事の内容は、実務では役に立たないもしくは役に立たない可能性が高いです。
あくまで遊びの延長として捉えていただけると幸いです。

完成形

まずは最初に完成形から。
マルバツゲームなので勝敗がつくか引き分けになったらお終いです。
勝敗と引き分けの判定もCSSで行っています。

※CodePenに全体のコードを載せている時は重要なコードだけを抜粋しつつ説明します。全体のコードが必要な時はCodePenを参照してください。

実装

では、さっそくですが実装を行っていきましょう。

最初から9つのマスに対し処理を考えるのは大変なので、まずは1つのマスについて考えたいと思います。

マルの描写

マスをクリックするとマルを描写するようにします。
通常クリックによってスタイルを変更する際にはJavaScriptを使用して、クラスや属性を書き換えることが多いと思いますが、今回はHTMLとCSSのみで状態を持つようにします。
HTMLで状態を持つ方法といえば皆さんご存知ですね。そうです、inputを使いましょう。

pug
input(
  type='checkbox'
  name='cell-o'
  id='cell-o'
)
.game
  .board
    span.cell
      label.label(for='cell-o')
scss
// マルの描写
#cell-o:checked ~ .game .cell {
  &::before {
    content: "O";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}

checkbox:checkedがついた時だけ、擬似要素を使用して.cellにマルを描写しています。

マルかバツを描写する

次にマルもしくはバツを描写するようにします。
マスがマルバツ何もないの3つの状態を持つようになるので、checkboxではなくradioを使いましょう。
ケースバイケースではありますが、基本的に3つ以上の状態を管理する時はcheckboxよりradioを使うことで管理が楽になると思います。

pug
//- checkbox から radio に変更
input(
  type='radio'
  name='cell'
  id='cell-o'
)
input(
  type='radio'
  name='cell'
  id='cell-x'
)
.game
  .board
    span.cell
      label.label.__o(for='cell-o')
      label.label.__x(for='cell-x')
scss
// マルの描写
#cell-o:checked ~ .game .cell {
  &::before {
    content: "O";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}
// バツの描写
#cell-x:checked ~ .game .cell {
  &::before {
    content: "X";
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font-size: 50px;
    font-weight: 600;
  }
}

わかりやすく赤と青を色付けしました。赤をクリックすればマルが、青をクリックすればバツが描写されます。
HTMLの要素がcheckboxからradioに変わっただけであり、基本的にはマルだけを描写するときと同じですね。

ここまでできたら9マスに増やしましょう。inputは実際の描画には不必要なのでvisibility: hiddenで隠しておきます。
PugとSassを使っているのでループをうまく使いながら実装すると、すっきり記述できると思います。

ターン制の実装

次に実装したいのがターン制です。
現状は1つのマスを左右に分割してマルとバツを出し分けています。
ターン制を実装することで1つのマスを左右に分割するのではなく、クリックしたらマルとバツが交互に描写されるようにコントロールしたいと思います。

具体的な方法ですが、input:checkedの数を数えて、labelz-indexを調整します。

scss
/// ターン制の実現

// radio ボタンが 1 個選択されている → X の z-index を大きく
input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 2;
}
// radio ボタンが 2 個選択されている → O の z-index を大きく
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 3;
}

//以降も :checked の数に応じて z-index を交互に増やしていく
// ...

上記CodePenではターンが変わる度に、わかりやすくボーダーの色を変えています。
labelposition:absoluteで重ねて、radio:checkedの数に応じてz-indexを交互に入れ替えていくことで、マルとバツのターン制を実現できます。

scss
// 印をつけたマスにはもうクリックできないようにする
@for $i from 1 through 9 {
  #cell-#{$i}-o:checked ~ .game .cell.__#{$i} {
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 100;
      visibility: visible;
    }
  }
}

また印をつけた後のマスを再度クリックされると面倒なので、:checkedがついた.cellの上を擬似要素で覆います。
こうすることで1つのマスを複数回クリックされたとしてもマルとバツが変動しないようにできます。

勝敗判定

勝敗がついたらその時点で「勝利:X」、「勝利:O」の文字を表示させたいと思います。

勝敗判定に関しては泥臭くやるしかありません。
パターン数は下記の通り8通りです。全パターンを書き出してCSSに反映します。
.resultの要素を仕込んでおいて、勝敗がついた時に表示します。

勝利条件

scss
// 1,2,3 に :checked がついたとき
#cell-1-x:checked ~ #cell-2-x:checked ~ #cell-3-x:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "勝利: X";
    }
  }
}

// 4,5,6 に :checked がついたとき
// ...
// 7,8,9 に :checked がついたとき
// ...
// と全パターンについて記述する

ただこれを全部書いていくのは辛いのでSassの配列を使いましょう。
Sassでは2次元配列も扱えるようなので下記のように記述します。

scss
// どちらかが勝利した時に .result を表示する
// 勝利パターン総当たり
$pattern: ([1,2,3], [4,5,6], [7,8,9],[1,4,7],[2,5,8],[3,6,9],[1,5,9],[3,5,7]);
@each $cells in $pattern {
  // xの勝利
  #cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "勝利: X";
      }
    }
  }
  // oの勝利
  #cell-#{nth($cells, 1)}-o:checked ~ #cell-#{nth($cells, 2)}-o:checked ~ #cell-#{nth($cells, 3)}-o:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "勝利: O";
      }
    }
  }
}

引き分けの判定

最後に引き分けの判定を実装します。
引き分けの判定はシンプルで

  • マスが9個埋まった、つまりinput:checkedが9個ついた
  • 勝敗がついていない

上記の2つの要素を満たす時になります。
勝敗がついたか否かを考えるのは面倒なので、いったんマスが9個埋まった場合だけを考えましょう。

scss
// :checked が 9 個ついた時
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "引き分け"
    }
  }
}

上記の通り:checkedが9個ついた時は擬似要素で引き分けを表示します。
次に2つ目の要素である、勝敗がついたか否かを考えたいと思うのですが、実はもうこれ以上作業をする必要はありません。

理由を説明します。
まず:chekcedが8個以内の時点で勝敗がついてる時は、すでに.resultが表示されているはずなので考える必要がありませんね。
考えるべきはちょうど9マス目が埋まったと同時に、勝敗がつくパターンですが、ここでCSSを見直してみます。

scss
// 勝敗がついた時
#cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
  &__text {
    &::after {
      content: "勝利: X";
    }
  }
}
// 引き分けの時
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  &__text {
    &::after {
      content: "引き分け"
    }
  }
}

上記をブラウザで確認しみるとわかりますが、勝敗がついた時のセレクターの方が、詳細度が大きいんですね。
したがって引き分けと勝敗がついた時の状態が重なった時は勝敗がついた時の擬似要素が優先されるようになっています。
結果として勝敗がついてない時だけ「引き分け」の文字が表示されるようになるということです。

ここまでお付き合いいただきありがとうございました。以上で実装の完成となります!

完成コード

冒頭のCodePenと同じ内容になりますが、こちらにも完成コードだけ掲載しておきます。

完成コード
pug
.wrapper
  - for(var i = 1; i < 10; i++)
    input(
      type='radio'
      name='cell-' + i
      id='cell-'+i+'-o'
    )
    input(
      type='radio'
      name='cell-' + i
      id='cell-'+i+'-x'
    )
  .game
    .board
      - for(var i = 1; i < 10; i++)
        span.cell(class='__' + i)
          label.label.__o(for='cell-'+i+'-o' class='__'+i)
          label.label.__x(for='cell-'+i+'-x' class='__'+i)
    .result
      p.result__text
scss
$board-w:300;
$board-h:300;
.wrapper {
  position:relative;
  background-color: skyblue;
}
input[name^="cell-"] {
  position: absolute;
  visibility: hidden;
}
.game {
  position:relative;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}
.board {
  display: flex;
  flex-wrap: wrap;
  width: $board-w + px;
  height: $board-h + px;
  background-color: #fff;
}
.result {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,.8);
  opacity: 0;
  visibility: hidden;
  z-index: 100;
  &__text {
    color: #fff;
    font-size: 30px;
    font-weight: 600;
    &::after {
      content: "";
    }
  }
}
.cell {
  position: relative;
  display: inline-block;
  width: ($board-w/3) + px;
  height: ($board-h/3) + px;
  box-sizing: border-box;
  &:nth-child(3n),
  &:nth-child(3n - 1){
    border-left: 2px solid rgba(0,0,0,.5);
  }
  &:nth-child(n + 4){
    border-top: 2px solid rgba(0,0,0,.5);
  }
}
.label {
  position:absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  &.__o {
    z-index: 1
  }
  &.__x {
    z-index: 0
  }
}

// ターン制の実装
input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 2;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 3;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 4;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 5;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 6;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 7;
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__x {
  z-index: 8
}
input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ input[name^='cell-']:checked ~ .game .cell .label.__o {
  z-index: 9
}

@for $i from 1 through 9 {
  // マルの描写
  #cell-#{$i}-o:checked ~ .game .cell.__#{$i} {
    &::before {
      content: "O";
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      font-size: 50px;
      font-weight: 600;
    }
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      display: block;
      width: 100%;
      height: 100%;
      z-index: 100;
    }
  }
  // バツの描写
  #cell-#{$i}-x:checked ~ .game .cell.__#{$i} {
    &::before {
      content: "X";
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      font-size: 50px;
      font-weight: 600;
    }
    &::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      display: block;
      width: 100%;
      height: 100%;
      z-index: 100;
    }
  }
}

// 勝利パターン総当たり
$pattern: ([1,2,3], [4,5,6], [7,8,9],[1,4,7],[2,5,8],[3,6,9],[1,5,9],[3,5,7]);
@each $cells in $pattern {
  // xの勝利
  #cell-#{nth($cells, 1)}-x:checked ~ #cell-#{nth($cells, 2)}-x:checked ~ #cell-#{nth($cells, 3)}-x:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "勝利: X";
      }
    }
  }
  // oの勝利
  #cell-#{nth($cells, 1)}-o:checked ~ #cell-#{nth($cells, 2)}-o:checked ~ #cell-#{nth($cells, 3)}-o:checked ~ .game .result {
    opacity: 1;
    visibility: visible;
    &__text {
      &::after {
        content: "勝利: O";
      }
    }
  }
}

// 引き分け
input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ input[name^="cell-"]:checked ~ .game .result {
  opacity: 1;
  visibility: visible;
  &__text {
    &::after {
      content: "引き分け"
    }
  }
}

終わりに

HTMLとCSSだけでマルバツゲームを作る方法でした!
inputを上手く使うことでゲームロジックを作ることができましたね。
前述の通り実際の現場で本記事の内容が役に立つことは少ないと思います。
けれどもCSSの可能性を模索する中で新たな発見があったりするので、これからも役に立たない技術を隙あらば勉強していきたいなと思います。

Discussion