目次を開く(全 6 章)
01
アナログ時計
座標変換とアニメーションの総復習です。針は「毎回まっすぐ上向きに描いて、座標系のほうを回す」だけ。三角関数の計算はほとんど要りません。
まず、時刻どおりに針を動かす
文字盤・目盛り・3 本の針・デジタル表示。秒針をなめらかに動かすため、秒にミリ秒を足しています。
const R = 96; // 文字盤の半径
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// 針を 1 本描く(いつも真上へ向かって描き、座標系だけ回す)
function hand(angle, length, width, color) {
ctx.save();
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 12);
ctx.lineTo(0, -length);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = color;
ctx.stroke();
ctx.restore();
}
function draw() {
const now = new Date();
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(cx, cy); // 原点を時計の中心へ
// 文字盤
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.fillStyle = '#f4f9fc';
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#1b4e77';
ctx.stroke();
// 60 本の目盛り(5 本ごとに長く太く)
for (let i = 0; i < 60; i++) {
const long = i % 5 === 0;
ctx.save();
ctx.rotate((Math.PI * 2 / 60) * i);
ctx.beginPath();
ctx.moveTo(0, -R + (long ? 15 : 8));
ctx.lineTo(0, -R + 3);
ctx.lineWidth = long ? 3 : 1;
ctx.strokeStyle = long ? '#1b4e77' : '#9dc6e2';
ctx.stroke();
ctx.restore();
}
// 文字盤の数字
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let h = 1; h <= 12; h++) {
const a = (Math.PI * 2 / 12) * h - Math.PI / 2;
ctx.fillText(String(h), Math.cos(a) * (R - 26), Math.sin(a) * (R - 26));
}
// 針の角度(ミリ秒まで使うと秒針がなめらかに動く)
const sec = now.getSeconds() + now.getMilliseconds() / 1000;
const min = now.getMinutes() + sec / 60;
const hour = (now.getHours() % 12) + min / 60;
hand((Math.PI * 2 / 12) * hour, R * 0.52, 7, '#0d2338');
hand((Math.PI * 2 / 60) * min, R * 0.74, 5, '#1b4e77');
hand((Math.PI * 2 / 60) * sec, R * 0.84, 2, '#ff6a3d');
ctx.beginPath();
ctx.arc(0, 0, 5, 0, Math.PI * 2);
ctx.fillStyle = '#ff6a3d';
ctx.fill();
ctx.restore();
// デジタル表示(2 桁になるよう padStart で 0 を足す)
const pad = (n) => String(n).padStart(2, '0');
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 16px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(
pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds()),
58, cy
);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
save() と restore() を目盛り 1 本ごとに挟むことで、回転が積み重ならないようにしています。
ミニマル文字盤・日付・ストップウォッチまで
基本形に、次の 3 つを足しました。上のタブをクリックするとモードが切り替わります。
- ミニマルな文字盤 — 数字は 12 / 3 / 6 / 9 だけ、残りは点。外周に秒の進捗リングを描いています。
- 日付と曜日 —
Dateから取り出して右側のパネルに表示。 - ストップウォッチ — タブでモードを切り替え、スタート/ストップとリセットができます。
const TABS = [
{ id: 'clock', label: '時計', x: 16, w: 74 },
{ id: 'watch', label: 'ストップウォッチ', x: 96, w: 128 }
];
const KEYS = [
{ id: 'toggle', x: 112, w: 92 },
{ id: 'reset', x: 216, w: 92 }
];
const WEEK = ['日', '月', '火', '水', '木', '金', '土'];
const CX = 132;
const CY = 152;
const R = 74;
let mode = 'clock';
let running = false;
let elapsed = 0; // 停止までに貯めた時間(ミリ秒)
let startedAt = 0; // 走り始めた時刻
const pad = (n) => String(n).padStart(2, '0');
/* ---------- 部品 ---------- */
function button(x, y, w, h, label, active) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.fillStyle = active ? '#2f80b8' : '#ffffff';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#c3d4e1';
ctx.stroke();
ctx.fillStyle = active ? '#ffffff' : '#33475a';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, x + w / 2, y + h / 2);
}
function hand(angle, length, width, color) {
ctx.save();
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 10);
ctx.lineTo(0, -length);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = color;
ctx.stroke();
ctx.restore();
}
// 円弧の進捗リング(12 時から時計回り)
function ring(x, y, radius, ratio, width, color) {
ctx.beginPath();
ctx.arc(x, y, radius, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * ratio);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = color;
ctx.stroke();
}
/* ---------- 時計モード ---------- */
function drawClock() {
const now = new Date();
const sec = now.getSeconds() + now.getMilliseconds() / 1000;
const min = now.getMinutes() + sec / 60;
const hour = (now.getHours() % 12) + min / 60;
ctx.save();
ctx.translate(CX, CY);
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.fillStyle = '#f4f9fc';
ctx.fill();
ring(0, 0, R + 9, sec / 60, 4, '#ff6a3d'); // 秒の進捗
// 12 / 3 / 6 / 9 は数字、それ以外は点
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < 12; i++) {
const a = (Math.PI * 2 / 12) * i - Math.PI / 2;
const x = Math.cos(a) * (R - 17);
const y = Math.sin(a) * (R - 17);
if (i % 3 === 0) {
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 14px sans-serif';
ctx.fillText(String(i === 0 ? 12 : i), x, y);
} else {
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fillStyle = '#9dc6e2';
ctx.fill();
}
}
hand((Math.PI * 2 / 12) * hour, R * 0.5, 6, '#0d2338');
hand((Math.PI * 2 / 60) * min, R * 0.72, 4, '#1b4e77');
hand((Math.PI * 2 / 60) * sec, R * 0.8, 2, '#ff6a3d');
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ff6a3d';
ctx.fill();
ctx.restore();
// 右のパネル
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 32px ui-monospace, monospace';
ctx.fillText(pad(now.getHours()) + ':' + pad(now.getMinutes()), 244, 148);
ctx.fillStyle = '#ff6a3d';
ctx.font = 'bold 15px ui-monospace, monospace';
ctx.fillText(':' + pad(now.getSeconds()), 244, 172);
ctx.fillStyle = '#5b6f80';
ctx.font = '13px sans-serif';
ctx.fillText(
(now.getMonth() + 1) + ' 月 ' + now.getDate() + ' 日(' + WEEK[now.getDay()] + ')',
244, 200);
}
/* ---------- ストップウォッチモード ---------- */
function drawWatch() {
const total = elapsed + (running ? performance.now() - startedAt : 0);
const seconds = total / 1000;
ctx.save();
ctx.translate(210, 128);
ctx.beginPath();
ctx.arc(0, 0, 60, 0, Math.PI * 2);
ctx.lineWidth = 8;
ctx.strokeStyle = '#e4edf4';
ctx.stroke();
ring(0, 0, 60, (seconds % 60) / 60, 8, running ? '#ff6a3d' : '#9dc6e2');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 26px ui-monospace, monospace';
ctx.fillText(pad(Math.floor(seconds / 60)) + ':' + pad(Math.floor(seconds % 60)), 0, -6);
ctx.fillStyle = '#5b6f80';
ctx.font = 'bold 15px ui-monospace, monospace';
ctx.fillText('.' + pad(Math.floor((total % 1000) / 10)), 0, 20);
ctx.restore();
button(KEYS[0].x, 210, KEYS[0].w, 30, running ? 'ストップ' : 'スタート', running);
button(KEYS[1].x, 210, KEYS[1].w, 30, 'リセット', false);
}
/* ---------- 全体 ---------- */
function draw() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const tab of TABS) button(tab.x, 10, tab.w, 26, tab.label, mode === tab.id);
if (mode === 'clock') {
drawClock();
} else {
drawWatch();
}
requestAnimationFrame(draw);
}
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
// タブ
if (y >= 10 && y <= 36) {
for (const tab of TABS) {
if (x >= tab.x && x <= tab.x + tab.w) mode = tab.id;
}
return;
}
// ストップウォッチのボタン
if (mode !== 'watch' || y < 210 || y > 240) return;
if (x >= KEYS[0].x && x <= KEYS[0].x + KEYS[0].w) {
if (running) {
elapsed += performance.now() - startedAt; // 貯めてから止める
running = false;
} else {
startedAt = performance.now();
running = true;
}
} else if (x >= KEYS[1].x && x <= KEYS[1].x + KEYS[1].w) {
elapsed = 0;
running = false;
}
});
requestAnimationFrame(draw);
ストップウォッチは「貯めた時間 + 走っている間の差分」で数えています。この形なら、止めても数字がずれません。
02
棒グラフ
配列から fillRect() を並べるだけで、グラフは作れます。大事なのは「値」を「ピクセル」に変換する式を 1 か所に決めておくこと。
まず、値のとおりに棒を並べる
目盛り線・軸・棒・ラベル。棒は 0.7 秒かけて伸びます。
const data = [
{ label: '月', value: 42 },
{ label: '火', value: 58 },
{ label: '水', value: 31 },
{ label: '木', value: 76 },
{ label: '金', value: 64 },
{ label: '土', value: 88 },
{ label: '日', value: 25 }
];
const MAX = 100; // 目盛りの上限
const padL = 42, padR = 14, padT = 30, padB = 34;
const areaW = canvas.width - padL - padR;
const areaH = canvas.height - padT - padB;
const slot = areaW / data.length;
const barW = slot * 0.56;
// 値をグラフ内の高さ(ピクセル)に変える
function toHeight(value) {
return (value / MAX) * areaH;
}
function draw(progress) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 見出し
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText('1 週間の練習時間(分)', 10, 18);
// 横の目盛り線
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let v = 0; v <= MAX; v += 25) {
const y = padT + areaH - toHeight(v);
ctx.strokeStyle = '#e4edf4';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, y + 0.5);
ctx.lineTo(padL + areaW, y + 0.5);
ctx.stroke();
ctx.fillStyle = '#8fa3b4';
ctx.fillText(String(v), padL - 8, y);
}
// 棒
ctx.textAlign = 'center';
data.forEach((d, i) => {
const barH = toHeight(d.value) * progress;
const x = padL + slot * i + (slot - barW) / 2;
const y = padT + areaH - barH;
const g = ctx.createLinearGradient(0, y, 0, padT + areaH);
g.addColorStop(0, '#2f80b8');
g.addColorStop(1, '#9dc6e2');
ctx.fillStyle = g;
ctx.fillRect(x, y, barW, barH);
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 11px sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillText(String(Math.round(d.value * progress)), x + barW / 2, y - 4);
ctx.fillStyle = '#5b6f80';
ctx.font = '12px sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(d.label, x + barW / 2, padT + areaH + 8);
});
// 軸
ctx.strokeStyle = '#0d2338';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(padL + 0.5, padT);
ctx.lineTo(padL + 0.5, padT + areaH);
ctx.lineTo(padL + areaW, padT + areaH);
ctx.stroke();
}
// 0 → 1 へ、だんだんゆっくりになるように伸ばす
const start = performance.now();
function frame(now) {
const t = Math.min((now - start) / 700, 1);
draw(1 - Math.pow(1 - t, 3));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
ホバーで値を出し、向きも変えられる
基本形に、次の 3 つを足しました。棒にマウスを乗せて、グラフの中をクリックしてみてください。
- ホバーの強調とツールチップ —
pointermoveで位置を拾い、当たっている棒を明るくして値を吹き出しで出します。 - 縦棒 ⇄ 横棒の切り替え — 棒の位置を返す関数を 1 つにまとめてあるので、向きの分岐はそこだけで済みます。
- 平均値の点線 —
setLineDash()で目安の線を引いています。
const data = [
{ label: '月', value: 42 },
{ label: '火', value: 58 },
{ label: '水', value: 31 },
{ label: '木', value: 76 },
{ label: '金', value: 64 },
{ label: '土', value: 88 },
{ label: '日', value: 25 }
];
const MAX = 100;
const padL = 44, padR = 20, padT = 46, padB = 34;
const average = data.reduce((sum, d) => sum + d.value, 0) / data.length;
let horizontal = false; // 縦棒か横棒か
let hover = -1; // マウスが乗っている棒
let grow = 0; // 伸びぐあい 0〜1
const areaW = () => canvas.width - padL - padR;
const areaH = () => canvas.height - padT - padB;
// 棒 1 本の位置と大きさ。向きの分岐はここだけ
function barBox(i) {
const value = data[i].value * grow;
if (horizontal) {
const slot = areaH() / data.length;
const thick = slot * 0.58;
return {
x: padL,
y: padT + slot * i + (slot - thick) / 2,
w: (value / MAX) * areaW(),
h: thick
};
}
const slot = areaW() / data.length;
const thick = slot * 0.56;
const length = (value / MAX) * areaH();
return {
x: padL + slot * i + (slot - thick) / 2,
y: padT + areaH() - length,
w: thick,
h: length
};
}
// クリック/ホバー位置が何番目の棒か
function hitTest(x, y) {
if (horizontal) {
if (x < padL || x > padL + areaW()) return -1;
const i = Math.floor((y - padT) / (areaH() / data.length));
return i >= 0 && i < data.length ? i : -1;
}
if (y < padT || y > padT + areaH()) return -1;
const i = Math.floor((x - padL) / (areaW() / data.length));
return i >= 0 && i < data.length ? i : -1;
}
function drawTooltip(i) {
const box = barBox(i);
const text = data[i].label + '曜日: ' + data[i].value + ' 分';
ctx.font = 'bold 12px sans-serif';
const w = ctx.measureText(text).width + 18;
const h = 26;
const x = Math.min(Math.max(box.x + box.w / 2 - w / 2, 6), canvas.width - w - 6);
const y = Math.max(box.y - h - 8, 6);
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.fillStyle = '#0d2338';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + w / 2, y + h / 2 + 1);
}
function draw() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText('1 週間の練習時間(分)', 12, 20);
ctx.fillStyle = '#8fa3b4';
ctx.font = '11px sans-serif';
ctx.fillText('棒にマウスを乗せる / クリックで縦横が入れ替わる', 12, 36);
// 目盛り
ctx.font = '11px sans-serif';
for (let v = 0; v <= MAX; v += 25) {
ctx.strokeStyle = '#e4edf4';
ctx.lineWidth = 1;
ctx.fillStyle = '#8fa3b4';
ctx.beginPath();
if (horizontal) {
const x = padL + (v / MAX) * areaW();
ctx.moveTo(x + 0.5, padT);
ctx.lineTo(x + 0.5, padT + areaH());
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(String(v), x, padT + areaH() + 8);
} else {
const y = padT + areaH() - (v / MAX) * areaH();
ctx.moveTo(padL, y + 0.5);
ctx.lineTo(padL + areaW(), y + 0.5);
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(v), padL - 8, y);
}
ctx.stroke();
}
// 棒
data.forEach((d, i) => {
const box = barBox(i);
const active = i === hover;
const g = horizontal
? ctx.createLinearGradient(box.x, 0, box.x + Math.max(box.w, 1), 0)
: ctx.createLinearGradient(0, box.y, 0, box.y + Math.max(box.h, 1));
g.addColorStop(0, active ? '#ff6a3d' : '#2f80b8');
g.addColorStop(1, active ? '#ffb199' : '#9dc6e2');
ctx.fillStyle = g;
ctx.fillRect(box.x, box.y, box.w, box.h);
ctx.fillStyle = '#5b6f80';
ctx.font = '12px sans-serif';
if (horizontal) {
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(d.label, padL - 8, box.y + box.h / 2);
} else {
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(d.label, box.x + box.w / 2, padT + areaH() + 8);
}
});
// 平均値の点線
ctx.setLineDash([6, 4]);
ctx.strokeStyle = '#ff6a3d';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (horizontal) {
const x = padL + (average / MAX) * areaW();
ctx.moveTo(x, padT);
ctx.lineTo(x, padT + areaH());
} else {
const y = padT + areaH() - (average / MAX) * areaH();
ctx.moveTo(padL, y);
ctx.lineTo(padL + areaW(), y);
}
ctx.stroke();
ctx.setLineDash([]);
// 軸
ctx.strokeStyle = '#0d2338';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(padL + 0.5, padT);
ctx.lineTo(padL + 0.5, padT + areaH());
ctx.lineTo(padL + areaW(), padT + areaH());
ctx.stroke();
if (hover !== -1) drawTooltip(hover);
}
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
const found = hitTest(x, y);
if (found === hover) return; // 変わったときだけ描き直す
hover = found;
draw();
});
canvas.addEventListener('pointerleave', () => {
hover = -1;
draw();
});
canvas.addEventListener('pointerdown', () => {
horizontal = !horizontal;
draw();
});
// 立ち上がりのアニメーション(終わったら描き直しはイベント任せ)
const start = performance.now();
function frame(now) {
const t = Math.min((now - start) / 700, 1);
grow = 1 - Math.pow(1 - t, 3);
draw();
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
03
お絵かきツール
マウス操作の応用です。ボタンも canvas に絵として描いて、押されたかどうかはクリック座標がその矩形や円の中に入っているかで判定します。
まず、色と太さを選んで描けるようにする
色 8 種・太さ 3 段・消しゴム・全消し・PNG 保存。道具箱は上部 40px に描いています。
const TOOLBAR = 40; // 上部の道具箱の高さ
const colors = ['#0d2338', '#1b4e77', '#2f80b8', '#9dc6e2',
'#ff6a3d', '#f2b705', '#3aa76d', '#c0392b'];
const sizes = [3, 8, 16];
const buttons = [
{ id: 'eraser', label: '消しゴム', x: 246, w: 56 },
{ id: 'clear', label: '全消し', x: 306, w: 44 },
{ id: 'save', label: '保存', x: 354, w: 40 }
];
let color = colors[0];
let size = sizes[1];
let erasing = false;
let drawing = false;
let last = null;
/* ---------- 描画 ---------- */
function clearBoard() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, TOOLBAR, canvas.width, canvas.height - TOOLBAR);
}
function drawToolbar() {
ctx.fillStyle = '#eef3f7';
ctx.fillRect(0, 0, canvas.width, TOOLBAR);
ctx.strokeStyle = '#cfdde8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, TOOLBAR - 0.5);
ctx.lineTo(canvas.width, TOOLBAR - 0.5);
ctx.stroke();
// 色の丸
colors.forEach((c, i) => {
const x = 16 + i * 20;
ctx.beginPath();
ctx.arc(x, 20, 8, 0, Math.PI * 2);
ctx.fillStyle = c;
ctx.fill();
if (!erasing && c === color) {
ctx.beginPath();
ctx.arc(x, 20, 11, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = '#0d2338';
ctx.stroke();
}
});
// 太さの丸
sizes.forEach((s, i) => {
const x = 182 + i * 22;
ctx.beginPath();
ctx.arc(x, 20, s / 2 + 1, 0, Math.PI * 2);
ctx.fillStyle = '#5b6f80';
ctx.fill();
if (s === size) {
ctx.beginPath();
ctx.arc(x, 20, 11, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff6a3d';
ctx.stroke();
}
});
// 文字ボタン
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
buttons.forEach((b) => {
const active = b.id === 'eraser' && erasing;
ctx.beginPath();
ctx.roundRect(b.x, 8, b.w, 24, 6);
ctx.fillStyle = active ? '#ff6a3d' : '#ffffff';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#b9cbd9';
ctx.stroke();
ctx.fillStyle = active ? '#ffffff' : '#33475a';
ctx.fillText(b.label, b.x + b.w / 2, 21);
});
}
/* ---------- 操作 ---------- */
function positionOf(event) {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height)
};
}
// 1 区間ずつ線を引く。道具箱にはみ出さないよう clip で囲う
function lineTo(p) {
ctx.save();
ctx.beginPath();
ctx.rect(0, TOOLBAR, canvas.width, canvas.height - TOOLBAR);
ctx.clip();
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(p.x, p.y);
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = erasing ? '#ffffff' : color;
ctx.stroke();
ctx.restore();
last = p;
}
function savePng() {
// 道具箱を除いた「絵の部分」だけを切り出して保存する
const out = document.createElement('canvas');
out.width = canvas.width;
out.height = canvas.height - TOOLBAR;
out.getContext('2d').drawImage(
canvas, 0, TOOLBAR, out.width, out.height, 0, 0, out.width, out.height);
const link = document.createElement('a');
link.href = out.toDataURL('image/png');
link.download = 'my-drawing.png';
link.click();
}
function hitToolbar(x) {
for (let i = 0; i < colors.length; i++) {
if (Math.abs(x - (16 + i * 20)) <= 10) {
color = colors[i];
erasing = false;
return;
}
}
for (let i = 0; i < sizes.length; i++) {
if (Math.abs(x - (182 + i * 22)) <= 11) {
size = sizes[i];
return;
}
}
for (const b of buttons) {
if (x >= b.x && x <= b.x + b.w) {
if (b.id === 'eraser') erasing = !erasing;
if (b.id === 'clear') clearBoard();
if (b.id === 'save') savePng();
return;
}
}
}
canvas.addEventListener('pointerdown', (event) => {
const p = positionOf(event);
if (p.y < TOOLBAR) { // 道具箱を押した
hitToolbar(p.x);
drawToolbar();
return;
}
drawing = true;
last = p;
canvas.setPointerCapture(event.pointerId);
lineTo({ x: p.x + 0.01, y: p.y }); // 点をひとつ打つ
});
canvas.addEventListener('pointermove', (event) => {
if (!drawing) return;
lineTo(positionOf(event));
});
canvas.addEventListener('pointerup', () => {
drawing = false;
});
clearBoard();
drawToolbar();
「保存」を押すと、描いた絵が PNG ファイルとしてダウンロードされます。
取り消し(Undo)が効くようにする
お絵かきツールでいちばん欲しくなるのは「ひとつ前に戻る」です。基本形に次の 2 つを足しました。
- 取り消し — 線を描き始める直前に
getImageData()で絵を控えておき、putImageData()で書き戻します。20 手ぶん覚えます。 - 戻れないときは押せない見た目に — 控えが 1 つも無いときは「取消」ボタンを薄く描き、押しても何も起きません。
const TOOLBAR = 40;
const LIMIT = 20; // 覚えておく手数
const colors = ['#0d2338', '#2f80b8', '#3aa76d', '#f2b705', '#ff6a3d', '#c0392b'];
const sizes = [3, 8, 16];
const buttons = [
{ id: 'eraser', label: '消しゴム', x: 200, w: 56 },
{ id: 'undo', label: '取消', x: 260, w: 44 },
{ id: 'clear', label: '全消し', x: 308, w: 44 },
{ id: 'save', label: '保存', x: 356, w: 40 }
];
const history = []; // 描く前の絵をためておく
let color = colors[0];
let size = sizes[1];
let erasing = false;
let drawing = false;
let last = null;
/* ---------- 取り消しのしくみ ---------- */
function remember() {
history.push(ctx.getImageData(0, TOOLBAR, canvas.width, canvas.height - TOOLBAR));
if (history.length > LIMIT) history.shift();
}
function undo() {
const previous = history.pop();
if (!previous) return;
ctx.putImageData(previous, 0, TOOLBAR); // 道具箱には触らない
}
/* ---------- 描画 ---------- */
function clearBoard() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, TOOLBAR, canvas.width, canvas.height - TOOLBAR);
}
function drawToolbar() {
ctx.fillStyle = '#eef3f7';
ctx.fillRect(0, 0, canvas.width, TOOLBAR);
ctx.strokeStyle = '#cfdde8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, TOOLBAR - 0.5);
ctx.lineTo(canvas.width, TOOLBAR - 0.5);
ctx.stroke();
colors.forEach((c, i) => {
const x = 16 + i * 20;
ctx.beginPath();
ctx.arc(x, 20, 8, 0, Math.PI * 2);
ctx.fillStyle = c;
ctx.fill();
if (!erasing && c === color) {
ctx.beginPath();
ctx.arc(x, 20, 11, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = '#0d2338';
ctx.stroke();
}
});
sizes.forEach((s, i) => {
const x = 140 + i * 22;
ctx.beginPath();
ctx.arc(x, 20, s / 2 + 1, 0, Math.PI * 2);
ctx.fillStyle = '#5b6f80';
ctx.fill();
if (s === size) {
ctx.beginPath();
ctx.arc(x, 20, 11, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff6a3d';
ctx.stroke();
}
});
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
buttons.forEach((b) => {
const on = b.id === 'eraser' && erasing;
const dim = b.id === 'undo' && history.length === 0;
ctx.beginPath();
ctx.roundRect(b.x, 8, b.w, 24, 6);
ctx.fillStyle = on ? '#ff6a3d' : '#ffffff';
ctx.globalAlpha = dim ? 0.45 : 1;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#b9cbd9';
ctx.stroke();
ctx.fillStyle = on ? '#ffffff' : '#33475a';
ctx.fillText(b.label, b.x + b.w / 2, 21);
ctx.globalAlpha = 1;
});
}
/* ---------- 操作 ---------- */
function positionOf(event) {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height)
};
}
function lineTo(p) {
ctx.save();
ctx.beginPath();
ctx.rect(0, TOOLBAR, canvas.width, canvas.height - TOOLBAR);
ctx.clip();
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(p.x, p.y);
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = erasing ? '#ffffff' : color;
ctx.stroke();
ctx.restore();
last = p;
}
function savePng() {
const out = document.createElement('canvas');
out.width = canvas.width;
out.height = canvas.height - TOOLBAR;
out.getContext('2d').drawImage(
canvas, 0, TOOLBAR, out.width, out.height, 0, 0, out.width, out.height);
const link = document.createElement('a');
link.href = out.toDataURL('image/png');
link.download = 'my-drawing.png';
link.click();
}
function hitToolbar(x) {
for (let i = 0; i < colors.length; i++) {
if (Math.abs(x - (16 + i * 20)) <= 10) {
color = colors[i];
erasing = false;
return;
}
}
for (let i = 0; i < sizes.length; i++) {
if (Math.abs(x - (140 + i * 22)) <= 11) {
size = sizes[i];
return;
}
}
for (const b of buttons) {
if (x < b.x || x > b.x + b.w) continue;
if (b.id === 'eraser') erasing = !erasing;
if (b.id === 'undo') undo();
if (b.id === 'clear') { remember(); clearBoard(); }
if (b.id === 'save') savePng();
return;
}
}
canvas.addEventListener('pointerdown', (event) => {
const p = positionOf(event);
if (p.y < TOOLBAR) {
hitToolbar(p.x);
drawToolbar();
return;
}
remember(); // 描く前の絵を控えておく
drawing = true;
last = p;
canvas.setPointerCapture(event.pointerId);
lineTo({ x: p.x + 0.01, y: p.y });
drawToolbar(); // 「取消」が押せる見た目に変わる
});
canvas.addEventListener('pointermove', (event) => {
if (!drawing) return;
lineTo(positionOf(event));
});
canvas.addEventListener('pointerup', () => {
drawing = false;
});
clearBoard();
drawToolbar();
何本か描いてから「取消」を押すと、1 本ずつ戻ります。
04
跳ねるボール
アニメーションの応用です。
x y vx vy をバラバラの変数で持つのをやめ、
1 個ぶんの情報をまとめたオブジェクトにして配列に並べます。こうすると、何個に増やしてもコードの長さは変わりません。
まず、12 色のボールを壁で跳ね返らせる
色ごとに 1 個ぶんのオブジェクトを作り、配列にして回すだけです。
const palette = [
'#e6194b', '#f58231', '#ffcc00', '#3cb44b',
'#008080', '#42d4f4', '#4363d8', '#911eb4',
'#f032e6', '#ff9ec4', '#9a6324', '#ffffff'
];
// 色ごとに 1 個ぶんの情報をまとめる
const balls = palette.map((color, i) => {
const angle = (Math.PI * 2 / palette.length) * i + 0.4;
const speed = 95 + i * 7;
return {
color: color,
r: 10 + (i % 4) * 4,
x: 60 + (i % 6) * 60,
y: 70 + Math.floor(i / 6) * 90,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed
};
});
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
ctx.fillStyle = '#0d2338';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 中身が違うだけで、やることは 13 章と同じ
for (const b of balls) {
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < b.r) { b.x = b.r; b.vx = -b.vx; }
if (b.x > canvas.width - b.r) { b.x = canvas.width - b.r; b.vx = -b.vx; }
if (b.y < b.r) { b.y = b.r; b.vy = -b.vy; }
if (b.y > canvas.height - b.r) { b.y = canvas.height - b.r; b.vy = -b.vy; }
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
}
ctx.fillStyle = 'rgb(255 255 255 / 65%)';
ctx.font = '13px sans-serif';
ctx.fillText(balls.length + ' 個のボール', 12, 24);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
重力と当たり判定を入れ、クリックで増やす
基本形に、次の 3 つを足しました。キャンバスをクリックするとボールが増えます。
- 重力 — 毎フレーム
vyに下向きの加速度を足すだけ。壁では速度の向きを反転します。 - ボールどうしの当たり判定 — 中心の距離が半径の合計より短ければ衝突。重なりを押し戻してから、ぶつかった向きの速度を交換します(同じ重さの弾性衝突)。
- クリックで追加 — 押した場所に、ランダムな色と速度で 1 個生やします。
const palette = [
'#e6194b', '#f58231', '#ffcc00', '#3cb44b',
'#008080', '#42d4f4', '#4363d8', '#911eb4',
'#f032e6', '#ff9ec4', '#9a6324', '#ffffff'
];
const GRAVITY = 460; // 1 秒あたりの下向きの加速(px/秒/秒)
const balls = [];
function addBall(x, y, color) {
balls.push({
color: color,
r: 9 + Math.random() * 12,
x: x,
y: y,
vx: (Math.random() - 0.5) * 260,
vy: (Math.random() - 0.5) * 200,
flash: 0 // ぶつかった直後だけ光らせる
});
}
palette.forEach((color, i) => {
addBall(50 + (i % 6) * 64, 50 + Math.floor(i / 6) * 70, color);
});
/* ---------- 当たり判定 ---------- */
function collide(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.hypot(dx, dy);
if (distance === 0 || distance >= a.r + b.r) return; // ぶつかっていない
// ぶつかった向きの単位ベクトル
const nx = dx / distance;
const ny = dy / distance;
// めり込んだぶんを半分ずつ押し戻す
const overlap = (a.r + b.r - distance) / 2;
a.x -= nx * overlap;
a.y -= ny * overlap;
b.x += nx * overlap;
b.y += ny * overlap;
// その向きの速度成分だけを交換する
const va = a.vx * nx + a.vy * ny;
const vb = b.vx * nx + b.vy * ny;
const diff = vb - va;
a.vx += diff * nx;
a.vy += diff * ny;
b.vx -= diff * nx;
b.vy -= diff * ny;
a.flash = 1;
b.flash = 1;
}
/* ---------- 1 コマぶんの計算と描画 ---------- */
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.032);
last = now;
for (const b of balls) {
b.vy += GRAVITY * dt; // 重力
b.x += b.vx * dt;
b.y += b.vy * dt;
b.flash = Math.max(0, b.flash - dt * 4);
if (b.x < b.r) { b.x = b.r; b.vx = -b.vx; }
if (b.x > canvas.width - b.r) { b.x = canvas.width - b.r; b.vx = -b.vx; }
if (b.y < b.r) { b.y = b.r; b.vy = -b.vy; }
if (b.y > canvas.height - b.r) { b.y = canvas.height - b.r; b.vy = -b.vy; }
}
// 総当たりで全部の組み合わせを調べる
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) collide(balls[i], balls[j]);
}
ctx.fillStyle = '#0d2338';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const b of balls) {
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
if (b.flash > 0) {
ctx.beginPath();
ctx.arc(b.x, b.y, b.r + 3, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = `rgb(255 255 255 / ${b.flash.toFixed(2)})`;
ctx.stroke();
}
}
ctx.fillStyle = 'rgb(255 255 255 / 70%)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText(balls.length + ' 個 / クリックで増える', 12, 24);
requestAnimationFrame(frame);
}
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
addBall(x, y, palette[Math.floor(Math.random() * palette.length)]);
});
requestAnimationFrame(frame);
総当たりの判定は個数の 2 乗で重くなります。数百個を超えるなら、画面を格子に区切って近所どうしだけ調べる工夫が要ります。
05
15 パズル
ここまでの全部入りです。四角形・文字・当たり判定・アニメーションを組み合わせると、これくらいのものが 100 行ほどで作れます。タイルをクリック(矢印キーでも操作できます)して、1 から 15 まで順番に並べてください。
まず、数字のタイルを滑らせる
盤面は 16 個の配列ひとつ。0 が空きマスです。シャッフルは「そろった状態から正しい手をランダムに戻す」方式なので、必ず解けます。
const N = 4; // 4 x 4
const TILE = 72;
const OX = (canvas.width - TILE * N) / 2;
const OY = 62;
const BUTTON = { x: 300, y: 14, w: 106, h: 28 };
let board = []; // 0 は空きマス
let moves = 0;
let solved = false;
let anim = null; // 動かしている最中のタイル
/* ---------- 盤面の計算 ---------- */
function blank() {
return board.indexOf(0);
}
// そのマスの上下左右にあるマスの番号
function neighbors(i) {
const row = Math.floor(i / N);
const col = i % N;
const list = [];
if (row > 0) list.push(i - N);
if (row < N - 1) list.push(i + N);
if (col > 0) list.push(i - 1);
if (col < N - 1) list.push(i + 1);
return list;
}
// そろった状態から「正しい手」をランダムに 200 回戻す
// = 必ず解ける並びになる
function shuffle() {
board = [];
for (let i = 1; i < N * N; i++) board.push(i);
board.push(0);
let previous = -1;
for (let n = 0; n < 200; n++) {
const b = blank();
const options = neighbors(b).filter((i) => i !== previous);
const pick = options[Math.floor(Math.random() * options.length)];
board[b] = board[pick];
board[pick] = 0;
previous = b;
}
moves = 0;
solved = false;
anim = null;
}
function isSolved() {
for (let i = 0; i < N * N - 1; i++) {
if (board[i] !== i + 1) return false;
}
return true;
}
function cellAt(i) {
return { x: OX + (i % N) * TILE, y: OY + Math.floor(i / N) * TILE };
}
/* ---------- 描画 ---------- */
function drawTile(value, x, y) {
ctx.beginPath();
ctx.roundRect(x + 3, y + 3, TILE - 6, TILE - 6, 10);
ctx.fillStyle = solved ? '#3aa76d' : '#2f80b8';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 26px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(value), x + TILE / 2, y + TILE / 2 + 1);
}
function draw() {
ctx.fillStyle = '#f6f9fc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 盤の下敷き
ctx.beginPath();
ctx.roundRect(OX - 7, OY - 7, TILE * N + 14, TILE * N + 14, 14);
ctx.fillStyle = '#dde8f1';
ctx.fill();
for (let i = 0; i < board.length; i++) {
if (board[i] === 0) continue;
if (anim && anim.index === i) continue; // 移動中のタイルは最後に描く
const p = cellAt(i);
drawTile(board[i], p.x, p.y);
}
if (anim) {
drawTile(
anim.value,
anim.fromX + (anim.toX - anim.fromX) * anim.t,
anim.fromY + (anim.toY - anim.fromY) * anim.t
);
}
// 手数と状態
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 15px sans-serif';
ctx.fillText('手数 ' + moves, 16, 28);
if (solved) {
ctx.fillStyle = '#3aa76d';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('そろいました!', 96, 28);
} else {
ctx.fillStyle = '#8fa3b4';
ctx.font = '12px sans-serif';
ctx.fillText('クリック / 矢印キーで動かす', 96, 28);
}
// シャッフルボタン
ctx.beginPath();
ctx.roundRect(BUTTON.x, BUTTON.y, BUTTON.w, BUTTON.h, 8);
ctx.fillStyle = '#ff6a3d';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('シャッフル', BUTTON.x + BUTTON.w / 2, BUTTON.y + BUTTON.h / 2);
}
/* ---------- 操作 ---------- */
function move(i) {
if (anim || solved) return;
const b = blank();
if (!neighbors(b).includes(i)) return; // 空きマスの隣でなければ動かせない
const from = cellAt(i);
const to = cellAt(b);
const value = board[i];
board[b] = value;
board[i] = 0;
moves++;
anim = {
index: b, value: value, t: 0, start: performance.now(),
fromX: from.x, fromY: from.y, toX: to.x, toY: to.y
};
requestAnimationFrame(step);
}
function step(now) {
const t = Math.min((now - anim.start) / 110, 1);
anim.t = t;
draw();
if (t < 1) {
requestAnimationFrame(step);
} else {
anim = null;
solved = isSolved();
draw();
}
}
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
// シャッフルボタンの当たり判定
if (x >= BUTTON.x && x <= BUTTON.x + BUTTON.w &&
y >= BUTTON.y && y <= BUTTON.y + BUTTON.h) {
shuffle();
draw();
return;
}
// どのマスを押したか
const col = Math.floor((x - OX) / TILE);
const row = Math.floor((y - OY) / TILE);
if (col < 0 || col >= N || row < 0 || row >= N) return;
move(row * N + col);
});
canvas.addEventListener('keydown', (event) => {
const b = blank();
const target = {
ArrowUp: b + N, // 下のタイルが上へ動く
ArrowDown: b - N,
ArrowLeft: b + 1,
ArrowRight: b - 1
}[event.key];
if (target === undefined) return;
if (!neighbors(b).includes(target)) return;
event.preventDefault();
move(target);
});
shuffle();
draw();
キャンバスをクリックしてから矢印キーを押すと、キーボードだけでも遊べます。
絵柄・タイマー・クリア演出をつける
数字を並べるだけのパズルを、絵を組み立てるパズルにしました。基本形に足したのは次の 3 つです。
- 絵柄を 16 分割 — 画面に出さない canvas に風景を描き、
drawImage()の 9 引数版で 1 マスぶんずつ切り出します。 - 手数とタイマー — 最初の 1 手で計り始め、そろった瞬間に止まります。
- クリア演出 — そろうと切れ目のない 1 枚の絵になり、記録が表示されます。「数字」ボタンで番号の表示も切り替えられます。
const N = 4;
const TILE = 76;
const OX = (canvas.width - TILE * N) / 2;
const OY = 64;
const KEYS = [
{ id: 'numbers', label: '数字', x: 236, w: 56 },
{ id: 'shuffle', label: 'シャッフル', x: 300, w: 106 }
];
let board = [];
let moves = 0;
let solved = false;
let showNumbers = true;
let anim = null;
let startedAt = 0; // 0 なら、まだ 1 手も動かしていない
let finishedAt = 0;
/* ---------- 絵柄をコードで描く(画像ファイルの代わり) ---------- */
function makePicture(size) {
const src = document.createElement('canvas');
src.width = size;
src.height = size;
const s = src.getContext('2d');
const sky = s.createLinearGradient(0, 0, 0, size);
sky.addColorStop(0, '#123c5e');
sky.addColorStop(0.55, '#2f80b8');
sky.addColorStop(1, '#a9d3ea');
s.fillStyle = sky;
s.fillRect(0, 0, size, size);
s.beginPath();
s.arc(size * 0.72, size * 0.23, size * 0.11, 0, Math.PI * 2);
s.fillStyle = '#ffcc00';
s.fill();
s.beginPath();
s.moveTo(0, size);
s.lineTo(size * 0.34, size * 0.40);
s.lineTo(size * 0.64, size);
s.closePath();
s.fillStyle = '#0d2338';
s.fill();
s.beginPath();
s.moveTo(size * 0.42, size);
s.lineTo(size * 0.74, size * 0.54);
s.lineTo(size, size);
s.closePath();
s.fillStyle = '#1b4e77';
s.fill();
s.fillStyle = 'rgb(13 35 56 / 40%)';
s.fillRect(0, size * 0.80, size, size * 0.20);
s.strokeStyle = 'rgb(255 255 255 / 40%)';
s.lineWidth = 2;
for (let i = 0; i < 4; i++) {
const y = size * 0.85 + i * 10;
s.beginPath();
s.moveTo(size * 0.08 + i * 8, y);
s.lineTo(size * 0.92 - i * 8, y);
s.stroke();
}
return src;
}
const picture = makePicture(TILE * N);
/* ---------- 盤面 ---------- */
const blank = () => board.indexOf(0);
function neighbors(i) {
const row = Math.floor(i / N);
const col = i % N;
const list = [];
if (row > 0) list.push(i - N);
if (row < N - 1) list.push(i + N);
if (col > 0) list.push(i - 1);
if (col < N - 1) list.push(i + 1);
return list;
}
function shuffle() {
board = [];
for (let i = 1; i < N * N; i++) board.push(i);
board.push(0);
let previous = -1;
for (let n = 0; n < 200; n++) {
const b = blank();
const options = neighbors(b).filter((i) => i !== previous);
const pick = options[Math.floor(Math.random() * options.length)];
board[b] = board[pick];
board[pick] = 0;
previous = b;
}
moves = 0;
solved = false;
anim = null;
startedAt = 0;
finishedAt = 0;
}
function isSolved() {
for (let i = 0; i < N * N - 1; i++) {
if (board[i] !== i + 1) return false;
}
return true;
}
const cellAt = (i) => ({ x: OX + (i % N) * TILE, y: OY + Math.floor(i / N) * TILE });
function elapsed() {
if (!startedAt) return 0;
return (finishedAt || performance.now()) - startedAt;
}
/* ---------- 描画 ---------- */
function drawTile(value, x, y) {
const cell = value - 1;
ctx.save();
ctx.beginPath();
ctx.roundRect(x + 2, y + 2, TILE - 4, TILE - 4, 10);
ctx.clip();
// 元の絵から 1 マスぶんを切り出して置く
ctx.drawImage(
picture,
(cell % N) * TILE, Math.floor(cell / N) * TILE, TILE, TILE,
x + 2, y + 2, TILE - 4, TILE - 4);
ctx.restore();
if (!showNumbers) return;
ctx.beginPath();
ctx.roundRect(x + 8, y + 8, 26, 20, 6);
ctx.fillStyle = 'rgb(13 35 56 / 62%)';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(value), x + 21, y + 19);
}
function drawHeader() {
const seconds = Math.floor(elapsed() / 1000);
const time = String(Math.floor(seconds / 60)).padStart(2, '0')
+ ':' + String(seconds % 60).padStart(2, '0');
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#0d2338';
ctx.font = 'bold 15px sans-serif';
ctx.fillText('手数 ' + moves, 16, 29);
ctx.fillStyle = '#5b6f80';
ctx.font = 'bold 15px ui-monospace, monospace';
ctx.fillText(time, 104, 29);
for (const key of KEYS) {
const active = key.id === 'numbers' ? showNumbers : false;
ctx.beginPath();
ctx.roundRect(key.x, 14, key.w, 30, 8);
ctx.fillStyle = key.id === 'shuffle' ? '#ff6a3d' : (active ? '#2f80b8' : '#ffffff');
ctx.fill();
if (key.id === 'numbers') {
ctx.lineWidth = 1;
ctx.strokeStyle = '#c3d4e1';
ctx.stroke();
}
ctx.fillStyle = key.id === 'shuffle' || active ? '#ffffff' : '#33475a';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(key.label, key.x + key.w / 2, 29);
}
}
function draw() {
ctx.fillStyle = '#f6f9fc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.roundRect(OX - 7, OY - 7, TILE * N + 14, TILE * N + 14, 14);
ctx.fillStyle = '#dde8f1';
ctx.fill();
if (solved) {
// そろったら 1 枚の絵として見せる
ctx.save();
ctx.beginPath();
ctx.roundRect(OX, OY, TILE * N, TILE * N, 10);
ctx.clip();
ctx.drawImage(picture, OX, OY);
ctx.restore();
const seconds = Math.floor(elapsed() / 1000);
ctx.beginPath();
ctx.roundRect(OX, OY + TILE * N - 54, TILE * N, 54, 0);
ctx.fillStyle = 'rgb(13 35 56 / 78%)';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('クリア!', OX + TILE * N / 2, OY + TILE * N - 36);
ctx.font = '13px sans-serif';
ctx.fillText(moves + ' 手 / ' + seconds + ' 秒', OX + TILE * N / 2, OY + TILE * N - 15);
} else {
for (let i = 0; i < board.length; i++) {
if (board[i] === 0) continue;
if (anim && anim.index === i) continue;
const p = cellAt(i);
drawTile(board[i], p.x, p.y);
}
if (anim) {
drawTile(
anim.value,
anim.fromX + (anim.toX - anim.fromX) * anim.t,
anim.fromY + (anim.toY - anim.fromY) * anim.t
);
}
}
drawHeader();
}
/* ---------- 操作 ---------- */
function move(i) {
if (anim || solved) return;
const b = blank();
if (!neighbors(b).includes(i)) return;
const from = cellAt(i);
const to = cellAt(b);
if (!startedAt) startedAt = performance.now(); // 1 手目で計り始める
anim = {
index: b, value: board[i], t: 0, start: performance.now(),
fromX: from.x, fromY: from.y, toX: to.x, toY: to.y
};
board[b] = board[i];
board[i] = 0;
moves++;
}
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
if (y >= 14 && y <= 44) {
for (const key of KEYS) {
if (x < key.x || x > key.x + key.w) continue;
if (key.id === 'numbers') showNumbers = !showNumbers;
if (key.id === 'shuffle') shuffle();
}
return;
}
const col = Math.floor((x - OX) / TILE);
const row = Math.floor((y - OY) / TILE);
if (col < 0 || col >= N || row < 0 || row >= N) return;
move(row * N + col);
});
canvas.addEventListener('keydown', (event) => {
const b = blank();
const target = {
ArrowUp: b + N,
ArrowDown: b - N,
ArrowLeft: b + 1,
ArrowRight: b - 1
}[event.key];
if (target === undefined) return;
if (!neighbors(b).includes(target)) return;
event.preventDefault();
move(target);
});
/* ---------- 毎コマ描き直す(タイマーを進めるため) ---------- */
function loop() {
if (anim) {
anim.t = Math.min((performance.now() - anim.start) / 120, 1);
if (anim.t >= 1) {
anim = null;
solved = isSolved();
if (solved && !finishedAt) finishedAt = performance.now();
}
}
draw();
requestAnimationFrame(loop);
}
shuffle();
requestAnimationFrame(loop);
「数字」を消すと、絵だけを頼りに組み立てることになります。急に難しくなります。
06
次の一歩
5 つとも、基本形から完成形まで通してつくりました。ここから先は「もっと速く」「もっと大きく」の話になります。
この 5 つを、さらに育てるなら
- 時計 — アラーム、複数タイムゾーンの表示、ラップタイムの記録。
- 棒グラフ — 折れ線や積み上げへの拡張、値の並べ替えアニメーション、凡例。
- お絵かき — やり直し(Redo)、レイヤー、図形ツール、筆圧(
event.pressure)。 - ボール — 画面を格子に区切って近所だけ判定する、摩擦や反発係数を足す、マウスで引っぱる。
- 15 パズル — 好きな写真を読み込んで分割する、5×5 に増やす、自動で解く手順を探す。
先にある話題
- 当たり判定 —
isPointInPath()を使うと、クリックした位置がその図形の中かをブラウザに判定させられます。複雑な形ほど有効です。 - ピクセル操作 —
getImageData()/putImageData()で 1 ピクセルずつ加工でき、フィルターが自作できます(お絵かきの取り消しでも使いました)。 - OffscreenCanvas — 重い描画を Web Worker 側に逃がして、画面の反応を保ちます。
- WebGL / WebGPU — 3D や数万個の描画になったら、GPU を直接使うこちらへ。
ctx で使えるメソッドとプロパティの一覧。困ったらここで引きます。
完成形といっても、足したのはどれも「基礎編で出てきた道具」だけです。新しい API を覚えたから作れるようになったのではなく、 描く順番と状態の持ち方を決められるようになったから作れています。次は、自分が作りたいものでこれをやってみてください。
ここまで作ってきたものを、実際に人が見られる場所へ出します。