マガジン

30分で作る「道をつなぐ」パズル:タイルを回して線を通す

遊びをつくる

執筆にClaudeを使用しています。

色紙の試作コース、小さな駒、ノートを並べたゲーム制作の作業机。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

タイルをクリックすると90度回り、左上の入口から右下の出口まで一本の線がつながればクリア。この「道をつなぐ」パズルの面白さは、回すたびに道が伸びたり途切れたりする手応えにあります。この手応えは、タイルを回す処理と、つながっているかを調べる処理の2つだけで作れます。 画像素材もゲームエンジンも要りません。1つのHTMLファイルに約45行のJavaScriptを書けば遊べます。この記事で答えを出したい問いは次の2点です。 答えは、辺の接続を4ビットの数字で持つこと、そして入口から一筆書きのように探索することです。

この記事の目次
  1. 先に決める:タイルは4ビットの数字1つで表す
  2. 手順:全体を書いて動かす
  3. 接続判定を読む:入口から歩いて、つながる先だけ数える
  4. 動作確認:自分で答えを出す4つの試し方
  5. よくある失敗と、次に広げる方向
  6. 次の行動

先に決める:タイルは4ビットの数字1つで表す

画像や図形でタイルを管理しようとすると、回転のたびに向きの分岐が増えます。そこで、タイルが持つ道を「上・右・下・左のどの辺に口が開いているか」という4つの有無で表します。4つの有無は、2進数の4桁に収まります。

  • 上、ビットの値:1、進む向き(行, 列):(-1, 0)、反対側の辺:下(4)
  • 右、ビットの値:2、進む向き(行, 列):(0, +1)、反対側の辺:左(8)
  • 下、ビットの値:4、進む向き(行, 列):(+1, 0)、反対側の辺:上(1)
  • 左、ビットの値:8、進む向き(行, 列):(0, -1)、反対側の辺:右(2)

たとえば左右に抜ける横の直線は、左(8)と右(2)を足して10です。上と右に開いた曲がり角は、1と2を足して3です。主なタイルの値は次のとおりです。

  • 10:左右の直線
  • 5:上下の直線
  • 3:上と右の曲がり
  • 12:下と左の曲がり
  • 7:上・右・下のT字
  • 1:上だけの行き止まり

この表現の利点は、回転が1行で書けることです。時計回りに90度回すと、上は右へ、右は下へ、下は左へ、左は上へ移ります。これは「ビットを1つ左にずらし、あふれた8を1に戻す」操作と同じです。

js
const rot = v => ((v << 1) | (v >> 3)) & 15;

v << 1 で全体を左にずらし、v >> 3 で元の8(左)を1(上)の位置に戻します。最後の & 15 で5ビット目を落とします。4回呼ぶと元に戻るので、自分で数字を入れて確かめてみてください。

手順:全体を書いて動かす

目安の時間割は次のとおりです。個人差があるので、あくまで目安として使ってください。

  1. 0〜5分:空のフォルダに index.html を作り、下のコードをすべて貼る。
  2. 5〜10分:ブラウザで開き、タイルをクリックして回ることを確かめる。
  3. 10〜20分:reach 関数の中身を1行ずつ読み、接続判定の仕組みを理解する。
  4. 20〜30分:確認項目を試し、盤面を自分の配置に差し替える。

コードは次のとおりです。外部ライブラリは使わず、このファイル単独で動きます。

html
<canvas id="c" width="320" height="320"></canvas>
<p id="m"></p>
<script>
const N=4,S=80;
const B=[[10,12,5,10],[5,3,12,7],[10,5,3,12],[7,10,5,3]];
const D=[[1,-1,0,4],[2,0,1,8],[4,1,0,1],[8,0,-1,2]];
const rot=v=>((v<<1)|(v>>3))&15;
const each=f=>{for(let y=0;y<N;y++)for(let x=0;x<N;x++)f(y,x)};
const g=B.map(r=>r.map(v=>{for(let i=Math.random()*4|0;i>0;i--)v=rot(v);return v}));
const ctx=c.getContext("2d");
function reach(){
  const s=new Set(),st=[];
  if(g[0][0]&8){s.add(0);st.push([0,0])}
  while(st.length){
    const[y,x]=st.pop();
    for(const[b,dy,dx,o]of D){
      const ny=y+dy,nx=x+dx;
      if(g[y][x]&b&&g[ny]?.[nx]&o&&!s.has(ny*N+nx)){s.add(ny*N+nx);st.push([ny,nx])}
    }
  }
  return s;
}
function draw(){
  const s=reach();
  ctx.lineWidth=10;ctx.lineCap="round";
  each((y,x)=>{ctx.fillStyle=(x+y)%2?"#2a2a40":"#222233";ctx.fillRect(x*S,y*S,S,S)});
  each((y,x)=>{
    ctx.strokeStyle=s.has(y*N+x)?"#e8a317":"#889";
    const cx=x*S+40,cy=y*S+40;
    for(const[b,dy,dx]of D)if(g[y][x]&b){
      ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(cx+dx*40,cy+dy*40);ctx.stroke();
    }
  });
  ctx.fillStyle="#e8a317";ctx.fillRect(0,36,6,8);
  m.textContent=s.has(15)&&g[3][3]&2?"つながりました":"タイルをクリックで回転";
}
c.onclick=e=>{
  const r=c.getBoundingClientRect();
  const x=(e.clientX-r.left)/r.width*N|0,y=(e.clientY-r.top)/r.height*N|0;
  g[y][x]=rot(g[y][x]);draw();
};
draw();
</script>

配列 B は「正解の向き」の盤面です。左上のタイルから右下のタイルまでの道が、1本通る配置にしてあります。道筋は次のとおりです。

  • (0,0) の左辺から入る。
  • 右へ進み、(0,1) で下へ折れる。
  • (1,1) で右へ、(1,2) で下へ、(2,2) で右へ、(2,3) で下へと進む。
  • (3,3) の右辺から出る。

残りのタイルは、道を惑わせる飾りです。g は B の各タイルを0〜3回ランダムに回した、実際の盤面です。

接続判定を読む:入口から歩いて、つながる先だけ数える

判定の核は reach 関数です。やっていることは、入口から歩いて行ける場所に印を付ける作業にすぎません。流れは次の3段階です。

  1. 入口を確かめる:左上タイル g[0][0] の左(8)が開いていなければ、入口から一歩も進めません。何も印を付けずに終わります。
  2. 隣へ進めるかを調べる:いま立っているタイルが方向 b に口を開けていて、かつ隣のタイルが反対側 o に口を開けていれば、道がつながっています。片方だけ開いている場合は、つながっていません。
  3. 行った場所を覚える:s(訪問済みの集合)に入れた場所には、二度入りません。これを忘れると、輪になった道で無限に歩き続けます。

g[ny]?.[nx] の ?. は、盤の外を指したときに undefined を返すための書き方です。undefined & o は0になるため、盤の外には進めないという扱いになります。壁の判定を別に書かなくて済みます。

クリア条件は、出口のタイル (3,3) が訪問済み(番号は 3×4+3=15)で、かつその右辺(2)が開いていることです。入口も出口も「盤の外へ口が開いているか」で決まるので、辺の口の考え方が盤面の中と外で共通になります。

訪問済みのタイルは金色で描かれます。入口から届いている範囲が見えるので、どこで道が切れているかをプレイヤーが目で追えます。この色分けは、見た目だけでなく、遊びの手がかりとしても効きます。

動作確認:自分で答えを出す4つの試し方

結果はプログラムを動かして確かめます。次の4つを順に行ってください。それぞれ「こうなればこう判断する」を添えます。

  1. 正解の盤面をそのまま表示する
  • const g=B.map(...) の行の代わりに const g=B.map(r=>r.slice()); と書き換えて開きます。
  • 「つながりました」と表示され、道が金色になれば、判定は正しく動いています。表示されない場合は、B の数字か D の表の転記ミスを疑います。
  1. 入口を塞ぐ
  • 正解の盤面のまま g[0][0] を、左が開かない値に書き換えます。たとえば左右の直線の10を、上下の直線の5にします。
  • 金色のタイルが1つも出なければ、入口の判定が働いています。
  1. 一方向だけ開いた隣を作る
  • 隣り合う2枚のうち片方だけが口を開いている状態を作ります。
  • 道が途切れて見えるはずです。つながって見えるなら、g[y][x]&b と g[ny]?.[nx]&o の両方を条件にしているか見直します。
  1. 回して戻す
  • 1枚のタイルを4回クリックして、元の見た目に戻るかを確かめます。
  • 戻らなければ、rot の式を見直します。

ランダムに回しているため、読み込んだ直後にたまたまクリアの状態になる場合があります。その場合は再読み込みして新しい盤面にしてください。

よくある失敗と、次に広げる方向

作っている途中で起こりやすい誤りをまとめます。

  • 反対側のビットを取り違える:上の反対は下です。右の反対は左です。表の「反対側の辺」の列を見ながら D を書きます。
  • 行と列を逆にする:配列は g[行][列] です。画面の座標は x(列)と y(行)なので、混ぜると回るタイルがずれます。
  • 入口の見た目と判定が一致しない:画面の左端に入口の印を描いても、g[0][0]&8 の条件がなければ、見た目だけの入口になります。
  • クリアの後も回せる:この最小構成では、クリア後もタイルを回せます。回せなくするには、クリック処理の先頭で勝ちの状態を調べて戻ります。

ここから先は、足したい機能に応じて選べます。

  • 手数の表示:クリックのたびに数を増やし、m に表示します。
  • 盤面の差し替え:B を別の配列にすると、別のステージになります。T字の7や行き止まりの1を混ぜると、道を分岐させたり、わざと外したりできます。
  • ランダム生成:経路を先に作り、残りを飾りで埋め、最後に回します。この順で作れば、必ず解のある盤面になります。
  • 回転の動き:回転アニメーションを付けるなら、OSの「動きを抑える」設定を matchMedia("(prefers-reduced-motion: reduce)") で読み、有効なときはアニメーションを省くと親切です。

次の行動

まず上のコードを index.html として保存し、ブラウザで開いてクリックしてみてください。次に「動作確認」の4つを順に試し、B を自分の道に書き換えて、1ステージを完成させます。

アソバースへ投稿する場合は、このHTMLを直下の index.html としてアップロードできます。公開の前に、「入力→表示の確認→公開」の流れで表示を確かめてください。公開にはメールアドレスの確認が必要です。