30分で作る「道をつなぐ」パズル:タイルを回して線を通す
遊びをつくる執筆にClaudeを使用しています。

タイルをクリックすると90度回り、左上の入口から右下の出口まで一本の線がつながればクリア。この「道をつなぐ」パズルの面白さは、回すたびに道が伸びたり途切れたりする手応えにあります。この手応えは、タイルを回す処理と、つながっているかを調べる処理の2つだけで作れます。 画像素材もゲームエンジンも要りません。1つのHTMLファイルに約45行のJavaScriptを書けば遊べます。この記事で答えを出したい問いは次の2点です。 答えは、辺の接続を4ビットの数字で持つこと、そして入口から一筆書きのように探索することです。
この記事の目次
先に決める:タイルは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に戻す」操作と同じです。
const rot = v => ((v << 1) | (v >> 3)) & 15;v << 1 で全体を左にずらし、v >> 3 で元の8(左)を1(上)の位置に戻します。最後の & 15 で5ビット目を落とします。4回呼ぶと元に戻るので、自分で数字を入れて確かめてみてください。
手順:全体を書いて動かす
目安の時間割は次のとおりです。個人差があるので、あくまで目安として使ってください。
- 0〜5分:空のフォルダに index.html を作り、下のコードをすべて貼る。
- 5〜10分:ブラウザで開き、タイルをクリックして回ることを確かめる。
- 10〜20分:reach 関数の中身を1行ずつ読み、接続判定の仕組みを理解する。
- 20〜30分:確認項目を試し、盤面を自分の配置に差し替える。
コードは次のとおりです。外部ライブラリは使わず、このファイル単独で動きます。
<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段階です。
- 入口を確かめる:左上タイル g[0][0] の左(8)が開いていなければ、入口から一歩も進めません。何も印を付けずに終わります。
- 隣へ進めるかを調べる:いま立っているタイルが方向 b に口を開けていて、かつ隣のタイルが反対側 o に口を開けていれば、道がつながっています。片方だけ開いている場合は、つながっていません。
- 行った場所を覚える:s(訪問済みの集合)に入れた場所には、二度入りません。これを忘れると、輪になった道で無限に歩き続けます。
g[ny]?.[nx] の ?. は、盤の外を指したときに undefined を返すための書き方です。undefined & o は0になるため、盤の外には進めないという扱いになります。壁の判定を別に書かなくて済みます。
クリア条件は、出口のタイル (3,3) が訪問済み(番号は 3×4+3=15)で、かつその右辺(2)が開いていることです。入口も出口も「盤の外へ口が開いているか」で決まるので、辺の口の考え方が盤面の中と外で共通になります。
訪問済みのタイルは金色で描かれます。入口から届いている範囲が見えるので、どこで道が切れているかをプレイヤーが目で追えます。この色分けは、見た目だけでなく、遊びの手がかりとしても効きます。
動作確認:自分で答えを出す4つの試し方
結果はプログラムを動かして確かめます。次の4つを順に行ってください。それぞれ「こうなればこう判断する」を添えます。
- 正解の盤面をそのまま表示する
- const g=B.map(...) の行の代わりに const g=B.map(r=>r.slice()); と書き換えて開きます。
- 「つながりました」と表示され、道が金色になれば、判定は正しく動いています。表示されない場合は、B の数字か D の表の転記ミスを疑います。
- 入口を塞ぐ
- 正解の盤面のまま g[0][0] を、左が開かない値に書き換えます。たとえば左右の直線の10を、上下の直線の5にします。
- 金色のタイルが1つも出なければ、入口の判定が働いています。
- 一方向だけ開いた隣を作る
- 隣り合う2枚のうち片方だけが口を開いている状態を作ります。
- 道が途切れて見えるはずです。つながって見えるなら、g[y][x]&b と g[ny]?.[nx]&o の両方を条件にしているか見直します。
- 回して戻す
- 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 としてアップロードできます。公開の前に、「入力→表示の確認→公開」の流れで表示を確かめてください。公開にはメールアドレスの確認が必要です。

