30分で作る塗り絵:クリックで色が広がる
遊びをつくる執筆にClaudeを使用しています。

クリックした場所から色がじわっと広がり、線の手前でぴたりと止まります。それだけで、ゴールのない遊びは意外なほど成り立ちます。スコアも制限時間も失敗もなく、あるのは「ここを塗ったらどうなるか」という小さな問いだけです。その問いの答えが、クリックの直後に目の前で返ってきます。 この記事の問いは、30分で「クリックで色が広がる塗り絵」を単独のHTML1枚で作れるか、です。作れます。ただし、画像を読み込んで画素を塗る方式にはせず、画面を40×30のマス目に分けて、壁のマスと色のマスだけで塗り絵を表します。この割り切りで、塗りつぶしのアルゴリズムが20行ほどで収まります。 手順は4つです。時間配分は目安で、コードをそのまま貼れば10分もかかりません。残りの時間は、記事の後半にある「手触りを調整する」ことに使ってください。
この記事の目次
塗りつぶしの考え方:「同じ色でつながっている範囲」を探す
塗りつぶし(フラッドフィル)は、クリックしたマスの色を覚え、その色と同じで隣り合っているマスを順に新しい色へ変えていく処理です。隣は上下左右の4方向だけを見ます。壁のマスは塗らず、そこで広がりが止まります。
マス目方式にする理由は2つあります。
- 境界がはっきりします。 画像の線画を画素ごとに塗ると、線の縁の中間色(アンチエイリアス)が原因で、塗り残しや色の縁取りが出やすくなります。マス目なら、壁か壁でないかの二択です。
- 隙間の扱いが単純です。 4方向だけを見る塗りつぶしは、斜めにつながった壁を越えられません。円のように斜めに続く線を引いても、内側の色が外へ漏れません。上下左右で見るという選択は、この性質を目当てにしています。
広がる様子を見せるために、塗りは一度に済ませず、1フレームあたり一定数のマスずつ進めます。待ち行列(キュー)に「次に隣を調べるマス」を入れておき、毎フレーム先頭から取り出して処理します。この「少しずつ処理する」仕掛けが、クリックの手応えそのものになります。
手順1:キャンバスと変数を用意する(約3分)
作業用のフォルダに index.html を作り、以下を貼ります。まだ <script> を閉じないでください。手順4まで続けて1つのファイルにします。
<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="640" height="480" style="border:1px solid #888;touch-action:none"></canvas>
<div id="p"></div>
<script>
const W=40,H=30,S=16,g=c.getContext('2d');
const N=matchMedia('(prefers-reduced-motion: reduce)').matches?1e5:40;
const wall=new Uint8Array(W*H),col=Array(W*H).fill('#fff');
let cur='#e4572e',q=[],hist=[];各変数の役割は次のとおりです。
- W、H、S は、横のマス数、縦のマス数、1マスの画面上の大きさ(16px)です。
- wall は、壁なら1、そうでなければ0を入れる配列です。
- col は、各マスの現在の色です。最初はすべて白です。
- q は塗りの待ち行列、hist は「戻す」ための履歴です。
- N は1フレームで進めるマス数です。OSの「動きを抑える」設定がオンのときは、広がりを見せず一度に塗り終えるよう大きな値にしています。
手順2:線画を壁として描く(約5分)
壁を置く3つの道具(点、四角の枠、円の輪郭)を作り、家と太陽らしきものを描きます。
const put=(x,y)=>{if(x>=0&&y>=0&&x<W&&y<H)wall[y*W+x]=1};
const box=(a,b,d,e)=>{for(let x=a;x<=d;x++)for(let y=b;y<=e;y++)
if(x==a||x==d||y==b||y==e)put(x,y)};
const ring=(cx,cy,r)=>{for(let t=0;t<360;t++)
put(Math.round(cx+r*Math.cos(t*Math.PI/180)),Math.round(cy+r*Math.sin(t*Math.PI/180)))};
box(0,0,W-1,H-1);box(4,12,20,27);box(8,16,12,21);box(15,19,18,27);
ring(30,8,5);ring(30,8,2);box(23,16,36,27);最後の2行が絵の中身です。外枠、家の本体、窓、ドア、二重の円、右下の大きな箱を置いています。ここを書き換えれば、別の塗り絵になります。
円は1度刻みで点を打ち、座標を四捨五入しています。隣り合う点は斜めにつながることがありますが、前述のとおり4方向の塗りつぶしは斜めの壁を越えないので、そのままで使えます。
手順3:描画と「広がる処理」を書く(約7分)
この記事の中心になる部分です。
function draw(){for(let i=0;i<W*H;i++){g.fillStyle=wall[i]?'#333':col[i];
g.fillRect(i%W*S,(i/W|0)*S,S,S)}}
function step(){
for(let n=0;n<N&&q.length;n++){
const [i,t,k]=q.shift(),x=i%W,y=i/W|0;
for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
const a=x+dx,b=y+dy,j=b*W+a;
if(a>=0&&b>=0&&a<W&&b<H&&!wall[j]&&col[j]===t){col[j]=k;q.push([j,t,k])}}}
draw();requestAnimationFrame(step)}step は毎フレーム呼ばれ、最大 N 個のマスを処理します。待ち行列の要素は「マスの番号 i、塗り替える前の色 t、新しい色 k」の3つ組です。取り出したマスの上下左右を見て、壁でなく、色が t のままのマスがあれば、k に塗って行列の末尾に加えます。
注意したいのは、行列に入れた時点で色を変えている点です。こうすると、同じマスが二重に行列へ入りません。見た目としては、処理中のマスの1つ外側まで色が先行しますが、N が小さければ気になりません。
手順4:クリック、パレット、戻すを付ける(約5分)
c.onpointerdown=e=>{const r=c.getBoundingClientRect(),
x=(e.clientX-r.left)*W/r.width|0,y=(e.clientY-r.top)*H/r.height|0,i=y*W+x;
if(wall[i]||col[i]===cur)return;
hist.push(col.slice());q.push([i,col[i],cur]);col[i]=cur};
['#e4572e','#f3a712','#a8c686','#29a0b1','#6b5b95','#fff'].forEach(k=>{
const b=document.createElement('button');b.style.cssText='width:36px;height:36px;background:'+k;
b.onclick=()=>cur=k;p.append(b)});
onkeydown=e=>{if(e.key=='z'&&hist.length){q=[];col.splice(0,W*H,...hist.pop())}};
step();
</script>クリック位置は、キャンバスの表示サイズに合わせてマス番号へ換算しています。画面が縮んでも、クリックしたマスがずれません。壁のマスや、すでに選択中の色と同じマスは何もしません。
パレットの最後の白は消しゴムです。z キーで直前のクリックの前の状態に戻ります。戻すときは待ち行列を空にしているので、広がっている途中でも安全に戻れます。
保存したファイルをブラウザで開き、次の4点を確かめてください。
- 家の外側の背景をクリックすると、家や円の線の手前まで色が広がります。
- 窓やドアの内側は、外側とは別の領域として個別に塗れます。
- 円の内側を塗っても、外へ漏れません。
- z で戻せます。
手触りを調整する:ゴールのない遊びが続く条件
動いたら、残りの時間は調整に使います。ゴールのない遊びは、仕組みよりも手触りで続くかどうかが決まります。見る場所は3つです。
広がる速さ(`N`)
N は1フレームで処理するマスの数です。画面の更新が毎秒60回前後の環境なら、領域のマス数を N で割ったフレーム数だけ広がりが見えます。大きな背景は、小さな窓より長く動きます。実際の速さは環境で変わるので、数値で決めず、自分の目で確かめてください。
- N を5、40、200と変えて、同じ背景を塗ってみます。
- 「次のクリックをしたくなるまでの間」が気にならない値を探します。広がりを待たされる感じがあれば大きすぎる側、広がりが見えなければ小さすぎる側です。
色の数
パレットは6色(消しゴム込み)です。色を増やすほど選ぶ時間が増え、手が止まりやすくなります。減らすと、配色の遊びとして単調になります。3色、6色、12色の3通りを用意して、同じ線画を塗ったとき、どの数がいちばん長く手が動くかを比べてください。比べる方法は、タイマーで5分を測り、その間に何回クリックしたかを数えるだけです。1つの条件につき数回行い、クリック数の傾向を見ます。ただし、自分で何度も遊ぶと慣れが入ります。可能なら、見ていない人に触ってもらい、迷って手が止まる場所だけをメモしてください。
取り返しのつきやすさ
ゴールのない遊びでは、失敗が存在しないことが安心感につながります。その安心感を支えているのが「戻す」です。z キーを知らない人は戻せません。画面に「戻す」ボタンを置いたときと置かないときで、触っている人の手の止まり方が変わるかを見てください。
なお、時間制限なしで置いて眺める遊びには、公式作品の「余白の庭」(木・石・草・花・池・灯りを置く庭づくり)があります。この作品が、置くだけで目的を決めない遊びとして、どこに戻す操作や保存を置いているかを開いて確かめると、自作の足りない部分が見つかります。
つまずきやすい点
壁に1マスでも隙間があると、全体に漏れます。 家の内側だけを塗ったつもりが、背景まで同じ色になるときは、枠の座標を見直します。box の端の座標が1つずれているのが典型的な原因です。
同じ領域を素早く2回クリックすると、色が混ざることがあります。 待ち行列の3つ組は「塗り替える前の色」を持っています。先の広がりが通過したマスは、すでに前の色ではなくなっているため、後の広がりはそのマスを塗りません。同じ背景に別々の色を素早く2回クリックして、何が起きるかを観察してください。意図した挙動として残すか、広がりの途中は次のクリックを無視するよう変えるかは、遊びの好みで決まります。
q.shift() は遅くなる操作ですが、この規模では問題になりません。 全体で1,200マスなので、そのままで足ります。マス数を数万に増やすなら、配列の先頭を取り出す代わりに、位置を示す番号を進める方式に変えてください。
保存は付いていません。 ページを閉じると塗った絵は消えます。アソバースにアップロードした作品内の localStorage は一時メモリーの互換処理なので、保存を前提にしないでください。残したい場合は、クラウドセーブ(作品ごとにJSONで128KiBまで)の利用を検討します。1,200マスの色なら、容量はおそらく問題になりません。
次の行動
まず index.html をそのまま動かし、N と色の数を変えて、5分間手が止まらない組み合わせを探してください。次に、手順2の最後の2行を書き換えて、自分の線画に差し替えます。公開するときは、index.html を直下に置いた単独のHTML(またはZIP)として、アソバースの投稿画面からアップロードできます。公開にはメールアドレスの確認が必要です。

