30分で作る「一筆書き」判定:線をなぞって判定する
遊びをつくる執筆にClaudeを使用しています。

画面に薄いお手本の線があり、指やマウスでなぞると「成功です」「3番目の点を通っていません」と返ってくる。この仕組みを作るとき、最初に考えるのは「なぞれたかどうかを、どうやって数字にするか」でしょう。ブラウザが渡してくるのは、座標がバラバラに並んだイベントの列だけです。線という連続したものは、どこにもありません。 この記事では、マウスの軌跡を点の列として記録し、お手本の各ポイントを順番どおりに通ったかを判定する小さな試作を作ります。使うのはHTMLとJavaScriptだけで、外部ライブラリもサーバーも不要です。コードは全部で50行台に収まります。 ここでいう「一筆書き」は、ボタンを押してから離すまでの1本のストロークで、お手本をたどり切ることを指します。途中で離したら、その時点で判定が走ります。数学のグラフ理論の一筆書きとは違い、「決まった線を、途切れずなぞれたか」を調べる判定です。
この記事の目次
判定を3つの問いに分ける
「なぞれたか」を1つの条件にすると、うまくいきません。次の3つに分けて考えます。
- 通ったか:お手本の各チェックポイントの近くを、軌跡が通過したか
- 順番どおりか:チェックポイントを1番から最後まで、この順で通ったか
- はみ出していないか:軌跡のどれだけの割合が、お手本の線から離れていたか
1だけだと、画面を塗りつぶすように乱暴に動かしても成功してしまいます。2を足すと、行ったり来たりするお手本でも向きを区別できます。それでも、チェックポイント間を大回りしてもよいことになるので、3で補います。
3つを別々の値として持っておく利点は、メッセージを出し分けられることです。「3番目の点を通っていません」と「はみ出しが多めです」は、プレイヤーへの教え方がまったく違います。
手順1:土台とお手本を用意する(目安5分)
新しいテキストファイルに index.html という名前を付け、次のコードを貼ります。ここから先のコードは、すべて同じファイルの続きに書いていきます。
<canvas id="c" width="480" height="320" style="border:1px solid #888;touch-action:none"></canvas>
<p id="msg">お手本をなぞってください</p>
<script>
const c = document.getElementById('c'), g = c.getContext('2d');
const msg = document.getElementById('msg');
const guide = [[60,260],[160,60],[260,260],[360,60],[430,200]]; // お手本の頂点
const R = 24; // 許容半径(お手本の線の太さの半分)
let trail = [], drawing = false;
function distSeg(p, a, b) {
const dx = b[0]-a[0], dy = b[1]-a[1];
const t = Math.max(0, Math.min(1, ((p[0]-a[0])*dx + (p[1]-a[1])*dy) / (dx*dx+dy*dy)));
return Math.hypot(p[0]-(a[0]+t*dx), p[1]-(a[1]+t*dy));
}
function distGuide(p) {
let d = Infinity;
for (let i = 0; i < guide.length-1; i++) d = Math.min(d, distSeg(p, guide[i], guide[i+1]));
return d;
}ポイントは3つあります。
- touch-action:none は、スマートフォンでなぞったときに画面がスクロールしてしまうのを防ぎます。これを忘れると、タッチ操作だけ途中で軌跡が途切れます。
- guide はお手本の頂点の配列です。この頂点が、そのままチェックポイントになります。
- distSeg は「点と線分の最短距離」を返す関数です。点を線分に射影して、はみ出した分を端に丸めています。distGuide は、お手本の全線分のうち一番近いものとの距離を返します。
手順2:軌跡を記録して描画する(目安8分)
次に、ポインタイベントで軌跡を集めます。マウスとタッチを別々に書かなくて済むよう、pointerdown、pointermove、pointerup を使います。
function pos(e){ const r=c.getBoundingClientRect(); return [(e.clientX-r.left)*c.width/r.width,(e.clientY-r.top)*c.height/r.height]; }
function add(p){
const q = trail[trail.length-1];
if (q) { const n = Math.ceil(Math.hypot(p[0]-q[0],p[1]-q[1])/4);
for (let i=1;i<=n;i++) trail.push([q[0]+(p[0]-q[0])*i/n, q[1]+(p[1]-q[1])*i/n]); }
else trail.push(p);
}
function draw() {
g.clearRect(0,0,480,320);
g.lineWidth = R*2; g.strokeStyle = '#cfe8ff'; g.lineJoin = 'round'; g.lineCap = 'round';
g.beginPath(); guide.forEach((p,i)=> i ? g.lineTo(...p) : g.moveTo(...p)); g.stroke();
g.lineWidth = 3; g.strokeStyle = '#e33';
g.beginPath(); trail.forEach((p,i)=> i ? g.lineTo(...p) : g.moveTo(...p)); g.stroke();
}
c.onpointerdown = e => { trail=[]; drawing=true; c.setPointerCapture(e.pointerId); add(pos(e)); draw(); };
c.onpointermove = e => { if(drawing){ add(pos(e)); draw(); } };
c.onpointerup = () => { drawing=false; msg.textContent = judge(); };
draw();ここで大切なのは add 関数です。pointermove は、動かしている間じゅう等間隔で発火するわけではありません。素早く動かすと、イベント間の距離が数十ピクセルに開きます。その間にチェックポイントがあっても、記録された点が通過していないことになり、正しくなぞったのに失敗する原因になります。
そこで add は、前の点との間を約4ピクセル間隔で補間し、点を埋めています。間隔の4は、許容半径24よりずっと小さければ十分です。
もう一つの要点は setPointerCapture です。ボタンを押したままキャンバスの外へ出てしまっても、pointerup を受け取れるようにします。これがないと、枠の外で指を離したときに判定が走らず、drawing が true のまま残ります。
お手本は、太い淡色の線(幅は R*2)として描いています。プレイヤーに見える帯の幅と、判定の許容半径を同じ値で結んでおくと、「帯の中ならOK」という見た目と判定が一致します。
手順3:判定関数を書く(目安8分)
最後に judge を足し、</script> で閉じます。
function judge() {
if (trail.length < 2) return '線が短すぎます';
const out = trail.filter(p => distGuide(p) > R).length / trail.length;
let last = -1;
for (let k = 0; k < guide.length; k++) {
const idx = trail.findIndex((p, i) => i > last && Math.hypot(p[0]-guide[k][0], p[1]-guide[k][1]) <= R);
if (idx < 0) return `${k+1}番目の点を順番通りに通っていません`;
last = idx;
}
return out > 0.1 ? `通過はOKですが、はみ出しが${Math.round(out*100)}%あります` : '成功です';
}
</script>読み方を整理します。
- out は、軌跡の点のうち、お手本の線から R より離れた点の割合です。これが3つ目の問い「はみ出し」にあたります。
- for のループが、1つ目と2つ目の問いを同時に調べています。last は「直前のチェックポイントを通った軌跡上の位置」で、i > last の条件により、次のチェックポイントは必ずそれより後の点から探します。
- チェックポイントごとに、条件を満たす最初の点を採用しています。早く通ったものを選ぶほうが、後続の探索の余地が広く残るので、順序があるかどうかの判定として正しく働きます。
- 閾値の 0.1(はみ出し10%)は仮の値です。後の調整で見直します。
保存してブラウザで開けば、動くはずです。うまく動かない場合は、開発者ツールのコンソールでエラーを確認してください。多くは括弧の閉じ忘れです。
手順4:自分で確かめる(目安9分)
判定は、何度か触って初めて「ちょうどいい」かどうかが分かります。結果はここでは書きません。次の表の4パターンを、それぞれ数回ずつ試してください。
- お手本の帯の中央を、ゆっくりなぞる、見たいこと:成功になるか
- 頂点の角を内側へ切り込むようになぞる、見たいこと:どのくらい切り込むと失敗になるか
- 終点から始点へ、逆向きになぞる、見たいこと:順序の判定が働くか
- 画面全体を塗りつぶすように乱暴に動かす、見たいこと:はみ出し率が閾値を超えるか
記録は、1回ごとに「なぞり方」「表示されたメッセージ」「自分の感覚(簡単すぎた・ちょうどいい・厳しすぎた)」の3つをメモに残します。同じなぞり方を最低3回繰り返すと、判定が安定しているのか、なぞり方のばらつきで揺れているのかが見えてきます。
結果の読み方は、次の対応で考えます。
- ていねいになぞったのに失敗が続く:R が小さすぎる可能性があります。R を数ピクセル増やし、同じなぞり方で再確認します。
- 雑なのに成功が多い:R が大きすぎるか、はみ出し率の閾値が緩い可能性があります。先に閾値の 0.1 を下げてみます。
- 逆向きでも成功する:順序判定が効いていません。findIndex の i > last の条件が残っているか見直します。
- 速くなぞると失敗する:補間が働いていない可能性があります。add の n の計算を確認します。
調整は、一度に1つの値だけ変えるのが原則です。R と閾値を同時に動かすと、どちらが効いたのか分からなくなります。
起こりやすい誤りと広げ方
つまずきやすい点を挙げておきます。
- キャンバスのCSS上の大きさを変える:pos でスケールを補正しているので、拡大縮小しても座標は合います。補正を消すと、画面が小さい端末でずれます。
- 始点と終点が同じ場所の図形:円や三角形のように、始点と終点が重なるお手本では、最後のチェックポイントが一番最初の時点で通過済みに見えることがあります。findIndex に i > last を付けているため、始点より後の点でもう一度通ることが必要になりますが、短い周回では判定がきわどくなるので注意してください。
- 自己交差するお手本:星形のように線が交差する図形では、交差点の近くで「次の頂点を通った」と誤認される場合があります。頂点を増やして間隔を詰めると安定しやすくなります。
- 途中で離した場合:現在の実装では、離した時点で判定が走り、続きはできません。「一筆」を厳密に守る設計ですが、中断を許したいゲームなら、pointerup で trail を消さずに持ち越す設計も考えられます。
広げ方としては、guide の配列を差し替えるだけで別の図形になります。複数のお手本を配列にして、成功したら次の図形へ進めれば、ステージ制の小さなゲームになります。
次の行動
まず、上のコードをそのまま index.html として保存し、表の4パターンを数回ずつ試して、自分の R と閾値を決めてください。次に、guide の座標を自分の好きな形に書き換えて、判定が妥当か確かめます。
アソバースに投稿する場合は、直下に index.html を置いたHTMLまたはZIPをアップロードします。このコードは外部通信を使わず、1ファイルで動くので、その条件に合います。投稿画面の入力、表示の確認、公開の3段階で進めてください。

