スライドパズルを埋め込みたい

ブログにスライドパズルを埋め込みたい。

色々なアプローチがあるだろうが、今回は、ブログのHTMLに簡単な構文でスライドパズルを表現したら、すぐに遊べる形にしてくれるものを考えてみる。 一言にスライドパズルと言っても、様々な形態のものが存在するが、今回はまずはいったん簡単なもの、いわゆる箱入り娘のようなタイプ、のみを対象とする。 目的を明確にするために、3問ほど簡単なサンプル問題を作成した(図1~図3)。

f:id:pzdc:20210630214505p:plain
図1 1を右下へ
f:id:pzdc:20210630214526p:plain
図2 1を右下へ
f:id:pzdc:20210630214542p:plain
図3 1を左上へ

1問目と2問目は見ればわかるだろう。3問目は、同じ数字が同期することに注意する。

スライドパズルの表現として、今回は、単純なものを採用する。例えば1問目は次のように表現する。

1 0 3 | * * *
0 0 3 | * * *
2 2 0 | * * 1

垂直バー(|)より左側が問題の表現で、半角スペースで区切られた数字の配列とする。垂直バーの右側はゴール条件で、この場合は1と書かれた箇所(右下)にブロック1が到着するとゴールとみなす。なお、「*」はワイルドカードを意図した表現である。ゴール条件の記法は汎用性には乏しいが、今回は単純な実装を目指す。

これを「slidingpuzzlesimple」クラスのdiv要素の中に記述する。 実際のHTMLの例を下に記す。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex,nofollow">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="./css_sliding.css" type="text/css">
<title>simple sliding puzzle template HTML</title>
</head>
<body>
<h6>例題 その1 想定手数: 5</h6>
<div class="slidingpuzzlesimple" id="ex1">
1 0 3 | * * *
0 0 3 | * * *
2 2 0 | * * 1
</div>
<h6>例題 その2</h6>
<div class="slidingpuzzlesimple" id="ex2">
1 1 5 5 | * * * *
0 1 0 0 | * * * *
4 0 6 6 | * * * *
4 3 3 2 | * * * 1
</div>
まあまあ
<h6>例題 その3 想定手数: 5</h6>
<div class="slidingpuzzlesimple" id="ex3">
2 0 0 3 | 1 * * *
0 3 0 0 | * * * *
0 2 2 1 | * * * *
</div>
<script type="text/javascript" src="./js_simpleslidingpuzzler.js"></script>
</body>
</html>

上のサンプルHTMLでは、「js_simpleslidingpuzzler.js」ファイルを読み込んでいる。このJavaScriptで各種の実装を行っていく。 また、上のサンプルHTMLでは、「css_sliding.css」ファイルも読み込んでいる。スタイル関係はこのCSSでまとめて行うこととする。

さて、今回は盤面を表示するところまでを進めてみることにする。

まず、表示の基本的な方針を決めておく。

var cellsize_default = 48;
var padding_default = 4;

グローバル変数に格子のセルのサイズ(cellsize_default)と、パディングの値(padding_default)を入れておく。描画はこれをもとに行うことにする。 ページの読み込みが終わった段階で、(この後で定義する)main関数を呼び出す、という形にする。

window.onload = function() {
  "use strict";
  main();
}

main関数内では、クラス「slidingpuzzlesimple」のDOM要素を全取得し、それぞれについて、(この後で定義する)setboard関数を実行する、ということにする。

function main() {
  "use strict";
  let targets = Array.from(document.getElementsByClassName('slidingpuzzlesimple'));
  targets.forEach(target => setboard(target));
}

上で、document.getElementByClassName関数の返り値の型はHTMLCollectionなので、便宜のために配列型に変換している。

setboard関数は次のように書いてみた。

function setboard(elem) {
  "use strict";
  let board = new Board(elem);
  board.make_canvas();
  board.draw();
}

盤面に関するこまごまとしたことは全部クラスBoardに任せてしまうことにする。クラスの設計の仕方としてはあまり良くないかもしれないが、今は気にしない。

今回は盤面の描画までなので、処理の内容は、下記の通りとなる。

  1. DOM要素のinnerHTMLのテキストをパースして、保存しておく
  2. キャンバスを用意して、DOM要素のinnerHTMLを置き換える
  3. 作成したキャンバスに、事前に保存しておいた盤面情報をもとに描画を行う

この内、1番の処理はBoardクラスのコンストラクタで実行し、2番と3番の処理はそれぞれ、make_canvas()メソッドとdraw()メソッドで実行することにする。

ここからは、Boardクラスの記述に入っていく。

class Board {
  // ここを書いていく!
}

まず1番の、「DOM要素のinnerHTMLのテキストをパースして、保存しておく」の部分を実装する。

  constructor(elem) {
    this.elem = elem;
    this.txt = elem.innerHTML;
    this.board_id = elem.id;
    let lines = this.txt.trim().split('\n')
    this.q_array = lines.map(line => line.trim().split('|')[0].trim().split(' '));
    this.a_array = lines.map(line => line.trim().split('|')[1].trim().split(' '));
    this.cellsize = cellsize_default;
    this.padding = padding_default;
    // console.log(this.q_array);
    // console.log(this.a_array);
  }

今回はこまごまとしたメンバ変数の設定と併せて、コンストラクタに直接書いてしまう。初期盤面の情報をthis.q_arrayに、ゴール条件の情報をthis.a_arrayに保存している。将来的には、初期盤面の情報とゴール条件の情報のほかに、「現在の盤面」の情報も保持する必要があるが、今は考えていない。

次に、2番の「キャンバスを用意して、DOM要素のinnerHTMLを置き換える」を実装する。

  make_canvas() {
    this.x_size = this.q_array[0].length;
    this.y_size = this.q_array.length;
    this.width = this.x_size * this.cellsize + 2 * this.padding;
    this.height = this.y_size * this.cellsize + 2 * this.padding;
    let canvas = '<canvas id="canvas_' +
      (this.board_id.toString(10)) +
      '" width="' + (this.width.toString(10)) +
      '" height="' + (this.height.toString(10)) + '"' +
      '></canvas>'
    this.elem.innerHTML = canvas;
    this.canvas = document.getElementById('canvas_' + this.board_id);
  }

盤面を描画するには、SVGを使用する方法もあるが、今回はcanvasを使用した。 canvasの作成は、あまり美しい実装ではないが、文字列の結合で実現している。 作成したcanvasをDOM要素のinnerHTMLに放り込んで、そのあとでそのcanvasに対する参照をgetElementByIdで取ってきている。

最後に3番の「作成したキャンバスに、事前に保存しておいた盤面情報をもとに描画を行う」の実装を行う。

  draw() {
    this.drawgrid();
    this.drawblock();
  }

格子のグリッドを描画する処理(drawgrid)と、ブロックを描画する処理(drawblock)とに分けている。 canvasは複数重ねてレイヤーのようにすることもできるが、今回のように直接一枚のcanvasに描いていく場合は、基本的に後ろのレイヤーから順番に描く必要がある。パズルの描画を考えるときに、こういったレイヤーの順序は、意外と重要である。このため、順序を簡単に変更できるために、処理を関数に分けている。

残すは描画処理のみとなった。おそらく探せば便利なライブラリやフレームワークがあるのだろうが、今はゴリゴリと書いていく。

  drawgrid() {
    let context = this.canvas.getContext('2d');
    context.lineWidth = 1;
    context.lineCap = "round";
    context.fillStyle = 'rgb( 0, 0, 0 )';
    context.strokeStyle = 'rgb( 0, 0, 0 )';
    for (let ix = 0; ix <= this.x_size; ix ++) {
      context.moveTo(this.padding + ix * this.cellsize, this.padding);
      context.lineTo(this.padding + ix * this.cellsize, this.padding + this.y_size * this.cellsize);
    }
    context.stroke();
    context.beginPath();
    for (let iy = 0; iy <= this.y_size; iy ++) {
      context.moveTo(this.padding, this.padding + iy * this.cellsize);
      context.lineTo(this.padding + this.x_size * this.cellsize, this.padding + iy * this.cellsize);
    }
    context.stroke();
    context.beginPath();
  }

JavaScriptCanvasのコマンドはPostScriptやWinAPIに似ているが、細かいところで違いがあるので注意が必要である。例えば、PostScriptではstrokeやfillをすると自動でカレントパスを初期化する(再利用する際には例えばgsave/grestoreを使用する)が、JavaScriptではstrokeやfillをしてもパスが消えないので、明示的にbeginPathで消さなくてはいけない。また、最近のブラウザの仕様は確認していないが、私が熱心にcanvasを触っていた数年前時点では、canvasの描画のされ方はブラウザによって若干異なっていた。したがって、こだわるなら、ブラウザごとの挙動の違いも確認しておいた方が良いだろう。

  drawblock() {
    let context = this.canvas.getContext('2d');
    context.font = (this.cellsize).toString(10) + "px 'Helvetica'";
    context.textAlign = "center";
    context.textBaseline = "middle";
    for (let ix = 0; ix < this.x_size; ix ++) {
    for (let iy = 0; iy < this.y_size; iy ++) {
      if (this.q_array[iy][ix] != 0) {
        let text = this.q_array[iy][ix];
        let x = this.padding + (ix + 0.5) * this.cellsize;
        let y = this.padding + (iy + 0.5) * this.cellsize;
        let textmetrics = context.measureText(text);
        let ascent = textmetrics.actualBoundingBoxAscent;
        let descent = -textmetrics.actualBoundingBoxDescent;
        let avgheight = (ascent + descent) / 2;
        y += avgheight;
        context.fillText(text, x, y);
      }
    }
    }
  }

文字列をマスの中央に表示する、というのは嵌りどころだが、幸いなことに、textmetricsというものが最近主要ブラウザに実装され、文字のバウンディングボックスを正確に知ることができるようになった。上のコードではそれを使用してブロックを区別する数字の表示を実装している。

描画が完成した。Google Chrome(バージョン91)での表示を図4に示す。

f:id:pzdc:20210630232650p:plain
図4 ブラウザ上での表示

美しくはないが、最低限の描画はできている。

今回は以上。動かす機能などはまた気が向いた時に書くことにする。