ハッカソンイベントで、React Konva製のジグソーパズルっぽいパズルを作ってみた

個人開発って何作るか悩んだり、モチベを保つのが難しかったりしますよね。
そんな自分が、先日web1weekというイベントで個人開発にチャレンジしました。
何とかリリースまでできたので、使用した技術やどんなことをやったのかといった内容を残しておきます。

はじめに #

web1weekって? #

簡単に言うと「1週間でお題に沿ったWebサービスを作ってみよう」というイベントです。
プログラマー、クリエイターが記事投稿や議論ができるコミュニティ、Crieitの運営者である、だらさん主催で行われました。

参加しようと思ったきっかけ #

主な背景としては

  • 個人開発に使える時間があった
  • 前回の開催時も面白そうと気になっていた(今回は2回目の開催)
  • 個人開発をやってみたかった(+こういったイベントならモチベも保てるかなと思った)
    といった感じです。

個人開発は以前からやりたいと思うことはありつつ、結局モチベが続かなくて止めてしまったりということが多かったのです…。
もし何か作ることができたら、1つの実績にできて自分の財産になるかなーと思いました。

これは…やるしかない。

ということで参加。

自分が作ったもの #

Jigsaw Like Puzzle

ちょっとしたパズルを作りました。
PC向け。スマホ対応は現状してません。

パズルのプレイ画面GIF

なんでパズル? #

イベントボードへの投稿にも書いていたので引用します。

「Like」ということで
好きなもの → 趣味とかかな? → 絵を描くこと(最近全然描いてないけど) → Canvasでお絵描き実装ができるらしい → でも、ただ絵を描くだけじゃつまらない → もしかしてジグソーパズル作れそう? といった感じで行きつきました。

ジグソーパズル特有の形は再現できてないので、あくまで「ジグソーパズルっぽいもの」ですね。
完全に後付け理由ですが、Like って「~ようなもの」って意味もありますし、意図せずテーマに沿ったものになりました(笑)
こんなことあるんですねー。

構成 #

構成図描こうかなとも思ったんですが、大した構成でもないのでざっくり文面で書きます。

開発はDockerのNode.jsコンテナで。
本番はFirebase Hostingでホスティングしています。

使用した主なライブラリはこちら(正確には他にもあります)

  • React:言わずと知れたUI構築ライブラリ(create-react-appで導入)
  • React Konva:Canvasを扱うライブラリであるKonva.jsのReact版
  • React Router:ルーティング
  • Material UI:UIコンポーネント集
  • PropTypes:propsのバリデーション
  • ESLint:静的解析
  • Prettier:コードフォーマッター

基本的なUIはMaterial UIで構築しました。
Boxコンポーネントがすごい便利でしたね。おかげであまりCSSを書かずにすみました。divがその分増えましたが。
パズル部分はReact Konvaで構築しています。

React KonvaとFirebaseについては初めて使ったので、まだまだちゃんとわかってないことも多いです。

どんな感じで開発してたのか #

ざっくりこんな感じでした。

1日目:5/18(月) #

お題に沿って何を作るか案がなかなか出てこなかったです。
React Konvaに行きついてからドキュメントをひたすら読んで、夕方くらいにやっとパズルいけるかも?ってなりました。

2日目:5/19(火) #

午後からやっと手元の環境で動かしてみました。
色々試してみて、なんとなくいけそうかな?という手ごたえがありました。

ただ、そこからパズルの元となる画像の適切なサイズ割り出しに時間かかりました…。3パターンできれいに割れて、適度な大きさのサイズがいいよねってなりまして。

3日目:5/20(水) #

ようやく画像サイズを決定。

720 * 480
初級:120 * 120 → 6 * 4 = 24
中級:80 * 80 → 9 * 6 = 54
上級:60 * 60 → 12 * 8 = 96

その後、ストップウォッチの実装をどうやるかめっちゃ悩みました…。
記事を参考にしながら試すも、うまくいかずドはまり。
時間かかりつつも一応実装できました。

この時点でいまだにリポジトリを作っていなかったので、とりあえず作成だけ。

4日目:5/21(木) #

ルーティングやOGP、画像の取り扱いをどうしようか悩みました。
結果的には、最低限遊べるレベルのリリースができればいいやということで、一旦は妥協することにしました。

午後からやっとプロジェクトのセットアップ。
Issueやプルリクのテンプレ、Dockerで開発環境構築ともろもろ必要な準備を整えました。

ピース位置チェック(正解位置に置かれたらはまる)のやり方がなんとなくわかって、よーし作っていくぞーという流れへ。

5日目:5/22(金) #

パズル画面に背景テクスチャをいれようとしましたが、迷ったので結局止めました。

黙々と進めて、ぼんやりとしたイメージで作っていった画面のモックがおおよそできました。

モックに続いて、難易度選択モーダル、ピース生成ロジックを作成。

6日目:5/23(土) #

ポーズモーダル、クリアモーダルを作成。

ここまでで、とりあえず最低限遊べることは確認できたのでリリースすることに。
コンポーネントを多少分けてはいましたが、ファイル分割とか全然できてなかったので、そこまでリファクタやってからリリースするかとも考えたのですが、もうやっちゃえとなりました。

ホスティングに関しては、多少慣れてるNetlifyでやる手もあるなと思いました。
ただ、今後機能拡張していくとしたらFirebaseの方が色々やれてよさそうと思い、こちらにすることにしました。

リリース(v0.1.0)して投稿。
リリースと言いつつ、色々と足りてないものがまだあるのでプレリリースみたいなものですね。

おおよそどんなことをやっているのか #

ソースコードはリファクタやったりして変わる可能性があるので、リポジトリ見ていただいた方が確実かもしれません(あまりきれいなコードではないですが…)
一応、Issue書いたりしながら進めました。

以下の内容は執筆時点(v0.1.4)での実装のものとなります。
有識者の方からすると、この実装イケてないとかあると思いますがご容赦ください。

ストップウォッチ #

パズル画面のストップウォッチ部分画像

概要 #

setInterval()clearInterval()を使って実装。
恥ずかしながら自分はこの関数を使用したことがなかったこともあり、最初はストップウォッチってどうやって実装したらいいんだろう?という状態でした。

実装のやり方についてはこちらの記事をとても参考にさせていただきました。

1秒ごとに秒数カウントを+1して、その秒数カウントをもとに時、分、秒を計算して更新していくというものです。

問題点 #

同画面で開始と停止を行う上では問題なかったのですが、今回の場合は

  • パズル画面の「一時停止」ボタンを押す → ストップウォッチを停止してポーズモーダルを開く
  • ポーズモーダルの「復帰」ボタンを押す → ポーズモーダルを閉じて、ストップウォッチ再開

という仕様だったので、再開時に時間が最初からになってしまう問題が起きました。

秒数カウントはstateで管理してない変数だったので、再レンダリング時に値がリセットされていたんだろうなと。
そのため、秒数カウントの値をバックアップを取っておく感じでstateでも保持するようにしました。
1秒ごとの更新処理の際に、秒数カウント(変数)が0 だったらstateを確認して、バックアップがあればそこから復元するイメージです。

秒数カウントを最初からstateで管理すればいいのでは?となるかもしれませんが、stateでやるとうまく動いてくれなかったので、こういった形をとりました。

パズル #

パズル画面のパズル部分画像

概要 #

React Konvaで実装していますが、その実体はCanvasです。
Canvasを扱うKonva.jsというライブラリがあり、そのReact版だそうです。

Canvasを扱うにはKonva.jsが便利らしいみたいな記事は複数見かけたんですが、自分はCanvas自体を使ったことがなかったため、いまいちピンとこず…。
なので、最初はひたすらドキュメントを読んで、おおよそどんなことができるものなのかを見ていきました。
その結果、パズルいけそうだなという目途がついたので使ってみたという背景があります。

Konva.jsの構造としては、以下のようになっています(公式より引用)

Konva.jsの構造画像

Shapeは複数種類があり

  • Rect(長方形)
  • Circle(円)
  • Ellipse(楕円)
  • Line(線)
  • Image(画像)
  • Text(テキスト)
  • Star(星)

などがあてはまります。

これらは上位の要素を基準とした x、y座標であったり、横幅、縦幅、色、影などを指定することで、Canvasに描画をしていけるようになっています。

パズルの元画像 #

Imageコンポーネントを使用。
普通に画像パスを渡すのではダメらしく、use-imageライブラリのuseImageフックを使って生成した、DOM画像を渡すようにしています。

ちなみにピース数の計算との兼ね合いで、画像サイズおよびこのImageコンポーネントのサイズは 720 * 480 で固定しています。

パズルの額縁 #

Lineコンポーネントを使用。
4つの点の座標を指定して繋ぐことで図形を描画。これを上下左右で4つ作成しています。
ただの塗りつぶしだと安っぽくなるので、グラデーション指定にしてみました。

パズルのピース #

Imageコンポーネントを使用。
useImageフックによる DOM 画像を渡しているのは同様ですが、cropを指定することで画像の切り抜きをしています。

例として初級の場合であれば、ピースサイズは 120 * 120 なので
1行目

  • {x:0 y:0 width:120 height:120}
  • {x:120 y:0 width:120 height:120}
  • {x:240 y:0 width:120 height:120}
    .
    .
    .

2行目

  • {x:0 y:120 width:120 height:120}
  • {x:120 y:120 width:120 height:120}
  • {x:240 y:120 width:120 height:120}
    .
    .
    .

みたいな感じです。
合わせてコンポーネント自体のサイズも 120 * 120 を指定になります。

draggableを有効化してドラッグアンドドロップができるように。
そのうえ、onDragStartとonDragEndでイベント処理を実装しています。

ピースドラッグ時の挙動 #

scaleを変えて、少しだけピースが大きくなるようになっています。
(公式デモのコードそのまま持ってきた感じです)

それに加えて、ドラッグしているピースが必ず最前面に来るような処理をしています。
Canvas要素は、あとに定義したものが前面に来るようになっているようです。
そのため、この処理をやらないと場合によっては、はめ込まれたピースの背面にドラッグ中のピースが隠れてしまい操作不能になってしまうことがあります。
そんなことなったら一気に萎えちゃいますよね。

ピースドロップ時の挙動 #

scaleを元に戻します。durationも設定してるので、ポヨンと大きさが戻るような見た目になってます。
(これも公式デモのコードをそのまま持ってきた感じです)

加えて、ドロップされた座標と正解位置の座標を比較。
誤差の範囲内であれば、draggableを無効 + ピースの座標を正解位置の座標に更新 することで、ピースがはめこまれるような挙動を実現しています。
この処理はこちらの公式デモを参考にしました。

ゲームの流れ #

これまでの内容を踏まえて、ゲームの流れとしてはおおまかにこんな感じです。
(並列で処理しているところもあります)

難易度選択

難易度に応じたピース数(縦、横)、ピースサイズの値をセット

この3つの値の変更を検知して、初期化ロジック実行
ピースの情報を持ったオブジェクトの配列を生成後、その順番をシャッフル

ピースの情報を持ったオブジェクトの配列をもとにピースのコンポーネントが描画される

ゲーム開始(ストップウォッチ開始)

ピースのドラッグアンドドロップ
ドロップ座標が正解位置の座標の誤差範囲であればはめこまれ、正解ピース数が+1される
(これを全てのピースがはめ込まれるまで繰り返す)

正解ピース数の値の変更を検知して、総ピース数と一致すればクリア(ストップウォッチ停止)

今回参加してみてどうだったか #

楽しかったです!ただ、疲れました(笑)
お題があったとはいえ、ほぼ一から自分で考えて作る必要があったので、普段よりもいっぱい頭使ったからかなと思います。

とはいえ、個人開発として無事にリリースまでできたのはこれが初めてなので素直に嬉しいですね。
他の方の投稿を見るのも楽しいですし、学ばせていただく機会にもなりました。
こういった機会を設けてくださり、ありがとうございました!


ちょっとしたレポート記事を書くつもりがすっかり長くなってしまいました…。
ここまで読んでくださった方、ありがとうございます!

パズルの方は今後も合間を見つけて改修していこうかなと思ってます。
ちなみに最初に導入したGoogle Analyticsがちゃんと動いておらず、投稿時のアクセス数を見られなかったというヘマをやらかしていましたが、修正して現在は無事に動いてます(冷や汗)

もしお暇な時があれば、パズル部屋を覗いてみてください。

参考リンクまとめ #