記事一覧に戻る

マインスイーパーを作り直しました。

はじめに

先日、canvasを使ったMinesweeperをリリースしました。そして、こちらの記事で、

  • ゲームのレンダリングをcanvasの2Dcontextで行っていて、Reactっぽくない
  • 地雷版のstateがオブジェクトのため、プロパティの一部が変わっても参照値が変わらずレンダリングされない

ことを不満点として述べました。そして最後に、スプレッド構文を使ってstateのオブジェクトを新しいオブジェクトに展開すれば、useStateで再レンダリングされる手法があるらしい、ということも記載しました。

スプレッド構文:

const objA = {a:1,b:2}
// これがスプレッド構文。配列にも使えて便利。
const objB = {...objA}
console.log(objA===objB) // -> false 

ここまで分かれば、作り直さない訳にはいきません。

自ら課した制約

  1. canvasで描写しないこと
  2. 手動DOM操作は行わないこと
  3. 新機能を追加すること

あんまり無いですが、1.が手直しが多くなりそうですね。前作はcanvasのレンダリングに頼っていたので、タグが変わればつられてReactのレンダリングのされ方も変わります。描写元データのstate管理から考え直す必要がありそうです。 canvasは馴染みのあるタグで好きですが、封印します。3.は、ただの作り直しだとつまらないので、おまけ。

以上3点を意識して作り直す。

描写の課題

■ 前作の仕組み

ここで、少し前作の描写の仕組みを説明します。次の画像を見てください。

minesweeper

これがマインスイーパーのゲームに表示する画像です。各パーツが1つの画像のファイルにまとめられています。いわゆるspritesheetです。この画像を必要なエリアだけ切取、canvasに描写しています。

例えば、赤色の爆弾のマスは、画像の左から32px、上から39px、 幅16px、高さ16pxの位置にあります。これを描写するために以下のように2DcontextのdrawImageを使っています。

// ctxは2Dcontextとします。
// imgはspritesheetをsrcに設定したimgタグとします。
// x,y -> canvasの描写位置
// w,h -> canvasに描写する幅と高さ
const [x,y,w,h] = [0,0,16,16]
ctx.drawImage(img,32,39,16,16,x,y,w,h);

位置情報が多くややこしいですが、mdnに詳しく記載されています。日本語ページは残念ながら無いようです。

要は、spritesheetをimgタグに設定し、該当部分のみをcanvasに描写しているのが、前作のやり方です。

描写元になるオブジェクトは以下のような構成です。

// ゲーム版クラス
export class Board {
    tiles: number[][];    // 地面を覆ってるマス
    ground: number[][];   // 地面のマス。爆弾か数字か空白
    isGroundSet: boolean; // 地面が設定されているかのフラグ
    smileState: number;   // smiley faceの状態
    gameState: number;    // gameの状態
    time: number;         // 最初のクリックからの経過秒数:0~999
    timerID: number;      // setIntervalのidだけど、使ってない!
    cols: number;         // tiles,groundの列数
    rows: number;         // tiles,groundの行数
    width: number;        // ゲーム版全体の幅
    height: number;       // ゲーム版全体の高さ
    guess: number;        // 残地雷数(地雷数-旗の数)
    bombs: number;        // 爆弾数:0~9
    /*offsetは座標軸からtilesの添え字に変換するために必要*/
    offsetTop: number;    // 地雷版の上からのoffset
    offsetLeft: number;   // 地雷版の左からのoffset
    // 以下略。method大量
}

ゲーム版はtiles、groundにspriteのインデックスが入っていて、その値に応じてcanvasに画像を描写しています。smileStateが😊マークのspriteのインデックスが入っています。timeには経過秒数、guessにはの残爆弾数を設定し、数字に応じた画像を描写しています。

こうやって見ると、かなり肥大化していますね。。。そして、class構文を使っています。useState(new Board()); setState(new Board())といった感じに、レベルが変わった時、ニコニコマークをクリックして再プレイになる時、Boardごと初期化してReactにレンダリングしてもらっています。

■ canvasの代替をどうするか

描写元のデータの修正は後で述べますが、canvasの代替をまず考える必要があります。spritesheetを利用するので、 imgタグを使うしか選択肢はなさそうです。spritesheetをパーツごとにトリミングして保存して、ロード時にimgタグにセットする、、、というのが一番ストレートなやり方に思えます。 しかし、全ての画像の読み取りが終わってからゲーム版の描写をしなければならないので、ファイルが多くなると制御が面倒になりそうです。1つの画像ファイルの読み取りをPromiseにして、Promise.allで全て解決するまで待つ、、、みたいな処理になるかと思いますが、あまり自信がありません。

spread構文の課題

はじめにで「スプレッド構文こそ鍵である」と言いました。しかし、前作のBoardクラスのインスタンスをそのままスプレッドするのは、いくつか問題があります:

  1. オブジェクトの中にオブジェクト(配列)。
  2. prototypeから継承されない。
  3. 2次元配列

何より、spritesheetを切り取るのが面倒です。

「オブジェクトの中にオブジェクト」問題

例えばオブジェクトが入れ子になったオブジェクトをスプレッドすると、以下のような動作をします。

const data = {
   arr:[1,2,3]
}
const copyData = {...data}
// これは当然falseです。
console.log(data === copyData )
// これはtrueになります。
console.log(data.arr === copyData.arr);

これは、スプレッド構文はshallow copyでdeep copyでは無いからです。 オブジェクトの要素をコピーするものの、要素がさらにオブジェクトだった場合は、コピーされるのはあくまでそのreferenceになるからです。

shallow copyの詳しい説明は、やはりmdnを参照すべきでしょう。

「prototypeから継承されない」問題

Boardクラスにはクリックした時の処理等、メソッドがいくつか定義されています。 しかし、そのインスタンスを{...board}とすると、メソッドは全て無くなってしまいます 。 JSでもES6からclass構文が導入されましたが、これはあくまで糖衣構文で、JS特有のprototypeチェーンを使った継承が行われます。class構文で定義したメソッドは。全てconstructorのprototypeプロパティに定義されたものとして処理されます。つまり。そのインスタンスでは、そのインスタンス自体が保持するメソッドではなく、__proto__という継承元のデータを格納するプロパティ上に保持されます。スプレッド構文では、この__proto__の中身はコピーしてくれません。

もしかすると、ES6以降のモダンなJSしか触れていない人はピンと来ないかもしれませんね。

何はともあれ、boardを更新する時にメソッドが無くなってしまっては、必要な処理が呼び出せません。

「2次元配列」問題

二次元配列の何が悪いんじゃい!と思いますが、、、1つ目の「オブジェクトの中にオブジェクト」と同じです。「配列の中に配列」が入っている訳ですから。

const data = [
    [1,2],
    [3.4]
];
// 配列のスプレッドは[]
const copyData = [...data];
// これは当然false
console.log(data === copyData);
// これはshallow copyなのでtrueになります。
console.log(data[0] === copyData[0]);

解決-描写の課題

spritesheetをパーツごとにファイルを分けるのは面倒です。分けたとて読み取りが大変そう。何か良い方無いなぁ~~と思っていたところ、プログラム内でspritesheetをパーツ単位に分ける方法を発見。

// 地雷版のSprite情報:[[x,y,width,height],[x,y,width,height],...]
const TILE_SP = [
  [0, 23, 16, 16], // EMPTY
  [16, 23, 16, 16], // 1
  [32, 23, 16, 16], // 2
  [48, 23, 16, 16], // 3
  [64, 23, 16, 16], // 4
  [80, 23, 16, 16], // 5
  [96, 23, 16, 16], // 6
  [112, 23, 16, 16],// 7
  [128, 23, 16, 16],// 8
  [0, 39, 16, 16],  // COVERED
  [16, 39, 16, 16], // FLAG
  [32, 39, 16, 16], // REDBOMB
  [48, 39, 16, 16], // NG_BOMB
  [64, 39, 16, 16]  // BOMB
];


// sprite制御クラス
class SpriteHandler {

  sprite: SpriteInfo;

  constructor() {
    this.sprite = {};
  }

  /**
   * keyでval(画像url)を紐づける
   * @param key 画像urlを紐づけるキー
   * @param val 画像url
   */
  addInfo(key: string, val: string) {
    this.sprite[key] = { url: val };
  }

  /**
   * indexに対応するspriteのurlを返す
   * @param index 
   * @returns indexに対応する画像url
   */
  getUrl(index: number) {
    return this.sprite[index.toString()].url;
  }
}

/**
 * spHandlerを初期化。spPosを走査し、要素分以下を繰り返す:  
 * - [x,y,w,h]でspritesheetを切取り、spPosの大きさのcanvasに描写する
 * - canvas.toDataURL()で画像化し、urlを取得
 * - spHandlerのaddInfoを使って、走査のindexをキーにurlをマッピング
 * @param img   imgタグ
 * @param spPos 切り取る位置情報[x,y,w,h]の一覧を保持した2次元配列
 * @param spSize 切り取った画像の大きさ[w,h]
 * @returns SpriteHandler
 */
function newHandler(img: HTMLImageElement, spPos: number[][], spSize: number[]) {

  const spHandler = new SpriteHandler();
  const cvs = document.createElement("canvas");
  const ctx = cvs.getContext("2d");
  const [sw, sh] = spSize;
  cvs.width = sw;
  cvs.height = sh;

  // spPosが保持している[x,y,w,h]の分画像urlを生成し、iをキーにspHandlerにマッピング
  spPos.forEach((data, i) => {
    const [dx, dy, w, h] = data;
    // canvasに画像のパーツを描写
    ctx!.drawImage(img, dx, dy, w, h, 0, 0, sw, sh);
    // canvasをURL化。
    const url = cvs.toDataURL();
    // 制御用オブジェクトに追加
    spHandler.addInfo(i.toString(), url);
  });
  return spHandler;
}

spritesheetの各パーツと同じ大きさのcanvasを生成し、パーツをcanvasに描写します。そしてcanvasをtoDataURL()で画像形式にフォーマットし、そのURLを取得します。 上の処理はロード時に呼び出しているので、後続で使いやすいように専用のクラスspHandlerに入れています。

これでパーツごとに画像のurlが出来たので、最終的には次のようにimgタグのsrcにぶち込めばOKです。

function SomeSprite(props){
    return <img src={props.src} />
}

ちなみにcanvasはHTMLのbodyには差し込まないので、ブラウザには表示されません。

結局canvas使ってんじゃん、、、、と思わなくもないですが、spritesheetをバラすだけなのでいいでしょう。

解決-spread構文の問題

ここに書いた問題ですが、同時に解決できそうです。 まず、boardクラスで実装していた描写もとのデータですが、残念ながらメソッドの実行が出来ないのでメソッドの部分はすべて普通の関数に書き換えます。そして、普通のオブジェクトとして実装してReactでstate管理していきます。

Reactのstateとして使う場合、classはあまり使わないほうが良いみたいです。ソースが見つけられませんでしたが、何かのサイトで見た気がします。公式ドキュメントでは無かったと思いますが、、、

そして、二次元配列にしていたプロパティは、一次元配列にします。

// Gameの型
export interface Game {
    tiles: TileType[],     // groundの上のタイル。2次元配列にすると{...tiles}でdeep copyされないので1次元に。
    ground: GroundType[],  // ground(地雷版)。同上で1次元
    smileState: SmileType, // 😊の状態
    gameState: GameType,   // ゲームの状態
    isGroundSet: boolean,  // ground設置がされているか
    level: LevelKeyType,   // level値
    rows: number,          // 行数
    cols: number,          // 列数
    bombs: number,         // 地雷数
    remains: number,       // 残り地雷数。旗を立てると(間違えていても)減る

結局tilesとgroundは配列なので、「オブジェクトの中にオブジェクト」はまだ残ってしまいますが、一応以下のようにコピーをとって更新時に被せれば回避できます。

// コピーする
const tiles = [...game.tiles];
// 処理に応じてコピーを更新
updateTiles(tiles)
// Reactのstate更新関数とします。
// gameのコピーを展開し、tilesプロパティはcopyTilesで上書き
setBoard({...game,tiles})

{...game,tiles}の部分ですが、ドキュメント等でよく見かけるようになった記法です。ES6で新たに導入されたようです。 ちなみに...gameはgameオブジェクトのコピーを展開しているだけです。tilesはキー名がないやんけ!と思ってしまいますが、 キー名を省略すると変数名がそのままキー名になります。{tiles:tiles}の略記ですね。この例だと、tilesのキー名が重複しますので、 後に出てきた値で上書きされます。{tiles,...game}と順番を逆にすると上書きされないので注意です。

ちなみに、上の例ではuseStateを使用していますが、Reactではstate管理にもう一つuseReducerがあります。

公式ドキュメント:

useReducer が useState より好ましいのは、複数の値にまたがる複雑な state ロジックがある場合や、前の state に基づいて次の state を決める必要がある場合です。

今回のGameオブジェクトが該当するかは微妙ですが、オブジェクトでstate管理するのも初めてなので、useReducerを使ってみます。 仕様は公式ドキュメントを見るのが手っ取り早いと思います。

ゲーム版を左クリック、右クリック、ダブルクリック、もしくはニコニコマークをクリックした時の処理を、useReducerの第一引数に指定しています。

function reducer(state: Game, action: Action): Game {
  // payload部分。undefinedの可能性あり。
  const { i, level } = action.payload;
  // Gameのプロパティで変更の可能性のあるものをここで抜き出しておく
  // tilesとgroundは配列なので、Reactが変更を把握できるようcopyを取得
  let [tiles, ground] = copy(state);
  let { smileState, gameState, remains } = state;
  // Actionに応じて処理
  switch (action.type) {
    case ACTION.INIT:
      // 初期化処理。levelに応じたGameを返す
      if (level === undefined) break;
      return NewGame(LEVELS[level]);
    case ACTION.CLICK:
      // 左クリック
      if (i === undefined) break;
      // 開いていないtileでなければ、処理なし
      if (tiles[i] !== NOT_OPEN) break;
      // groundが初期化されていなければ、セット
      if (!state.isGroundSet) {
        ground = genGround(i, state);
      }
      // tileを開く
      openTile(i, tiles, ground, state);
      // 勝ち負け判定し、smileStateとgameStateを受け取る
      [smileState, gameState] = calcState(i, tiles, ground, state);
      // reactが変更を把握できるよう、spread構文を使う
      // 変更があるものは、stateと同じプロパティ名で被せて上書き
      return {
        ...state,
        tiles,
        ground,
        isGroundSet: true,
        smileState,
        gameState,
      };
      // ~~~略
}

クリック等、gameの更新をする操作でこの関数を呼び出す形です。処理を分岐させるために適宜actionのpayloadプロパティに値を設定して呼び出します。 githubにこの部分のみ載せたので、詳細はこちらで。

作り直しのおまけとして、新レベル「極み(KI-WA-MI)」を追加しています。クリアするには2択を異次元レベルで正解しないといけません。もしクリア出来たら画面キャプチャをとってツイッターで連絡くださいv(^^)v

最後に

僅か1週間程度で作り直すことになりましたが、納得の行くものが出来ました。React面白いです。 とはいえ、canvasバージョンも問題無く動きます。いくつかReact的にはBad Practiceを踏んでる可能性はありますが、それを言ったら新バージョンも同じですかね。

canvasの2Dcontext使ってもこうやりゃできるよ!とかあればTwitterで教えてください。

記事一覧に戻る