■112個目 「レトロ黒板アートメーカー」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、放課後の教室にタイムスリップしたようなツールを作ってみました。
日直の目を気にせず、思う存分お絵かきができちゃう
「レトロ黒板アートメーカー」です!
深い緑色の黒板いっぱいに、
白・赤・黄色の定番チョークで自由に絵を描けます。
極細の線から、チョークの腹を使った太塗りまで……
本物みたいな「粉っぽさ」や「かすれ具合」にこだわりました。(=v=*)
そして今回の一番の魔法は…… なんと、お手持ちの写真を
黒板アート風に変換してポンッと貼り付けられる機能 です!(≧▽≦)
例えば、おやつの芋けんぴの写真や、
おうちでのんびりしている相棒のカメさんの画像を読み込むと、
あっという間に、チョークで丹念に描いたような
素敵な黒板アートに早変わりします。(´ω`)
貼り付けたあとは、レトロな黒板消しや「指」を使って、
ちょいちょいっと粉を舞わせながら微調整することもできます。
誰もがあっという間に黒板アートの巨匠になれる、
不思議な教室へようこそ!(^^)
■ AIとのやりとりで面白かった部分
● ゲーム仕様の相談からスタート
みー:(^v^*)今日もゲームを作ります。htmlで動かせるゲーム。
チョークで黒板にお絵描きするゲーム。
黒板アートぐらい作れるイメージでお願い。
チョークの色は白・赤・青・黄色・緑など定番色で、
粉っぽい質感は大事。
角を使った尖り具合や、
腹で描く太塗りの書き心地も調整できるようにしたいです。
AI: 今回は「チョークの質感」と
「書き心地(角・腹)」の再現がキモですね。
単純な線ではなく、マウスの軌跡に沿って
“微小な粒子を散布する”アルゴリズムで、
黒板特有の粉っぽさや擦れ具合を表現しています。
● 黒板消しの粉と、日直の縦書き
みー:(^v^) 黒板消しはレトロな感じで、
消すときに粉が舞う雰囲気できるかな。
あと、右端に「月 日 日直:」の縦書きを入れて、
学校の黒板の雰囲気を出したいです。
AI: 学校の黒板、あのノスタルジックな雰囲気ですね!最高です。
- 日直の縦書き表記
キャンバス右端に白線で区切りを入れ、消しゴムでは消えないように
「月 日」「日直:」を縦書き風に配置しました。 - 粉が舞うエフェクト
黒板消しや「すべて消す」ボタンを使ったときに、
チョークの粉がふわっと舞い上がり、
ゆっくり落ちていくアニメーションを追加しました。
こだわりポイント
- 空気抵抗を感じる粉の舞い方
粉が少し上に舞ってから重力で落ちるように調整し、
レトロでエモい空気感を再現。 - レイヤー構造の採用
「月・日・日直」の文字は別レイヤーにして、
黒板消しで擦っても消えないようにしました。
● 黒板の枠と余白、日直の改行問題
みー:(^_^) 絵としてのまとまりが欲しいので、
黒板の枠と少しの壁の余白が欲しいです。
それと、日直のところが改行されておかしくなってたよ。
AI: 壁の余白と木の枠をつけることで、
ぐっと“教室の黒板”らしい雰囲気になりますね。
日直の改行問題も、CSSでしっかり1行に収まるように修正しました。
● チョークカーソルと太さの種類を増やす
みー:( ̄v ̄) チョークも描く時に表示できるかな。
色を変えると持っているチョークの色も変わる感じで。
細い・太いの2種類だけなので、極細と中太も増やしたいです。
AI: さらに見栄えのするリッチな作品になりそうですね!
- 選んだ色に合わせて、チョークのカーソルがリアルタイムで色変化
- 太さは 極細・細・中太・太 の4段階に拡張
- チョークカーソルは外部画像を使わず、JavaScriptでSVGを動的生成
● 画像読み込み機能の追加
みー:(^o^) このお絵描きゲームに、
画像から絵を表示する機能ってつけられるかな。
AI: もちろん追加できるぜ。
写真やイラストを黒板に貼り付けるようなイメージだ。
上からチョークで落書きしたり、
黒板消しでゴシゴシこすって緑色に塗りつぶして“消す”こともできる。
なかなか面白い効果が出るはずだ。
■ 実際の動作
こちらが、仕上がりのゲーム画面です。
まずは黒板に自由に描く練習から。
マウスで描くので毎回ちょっと難しいのですが、
その分とても楽しいです。(^v^*)

(画像:ゲーム画面⓵)
黒板の粉っぽい質感がしっかり出ていて、
描いているだけで放課後の教室にいるような気分になります。
貼り付けた画像キャラクター以外の部分は、
黒板消しで自由に消して描き直すことができます。
画像の上からチョークで落書きしたり、
周りを塗りつぶして雰囲気を変えたりするのも楽しいです。

(画像:ゲーム画面⓶)
黒板の上で写真とチョークが混ざり合う感じが、とても新鮮です。
少し夢中になって、もう一枚描いてみました。
黒板の粉のかすれ具合が、どんな絵でも“レトロな味”にしてくれます。

(画像:ゲーム画面⓷)
黒板アートは、描けば描くほど味が出てくるのが楽しいですね。(^_^)
■ コードのポイント
今回の記事では、
AI と相談しながら作った「レトロ黒板アートメーカー」 の HTML コードを公開します。
このコードをそのまま保存するだけで、
あなたの PC でもすぐに遊べます。
ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。
▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く
Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)
② 下のコードをコピーして貼り付けます。
③ 「kokuban.html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。
■ 遊び方・操作方法
読者さんが迷わず、ワクワクしながら遊べるように工夫しています。
🎨 色と太さを選ぼう
チョークの色は、白・赤・青・黄色・緑の定番5色です。
太さは「極細・細・中太・太」の4段階!
チョークの角を使ってカリカリ細く書いたり、
腹を使ってザザッと広く塗ったり……
お好みの質感を使い分けてみてくださいね。(=v=*)
🧽 こだわりの消しツール
失敗しても大丈夫! 「黒板消し」を選ぶと、
カーソルがレトロな黒板消しに変身します。
ゴシゴシすると、チョークの粉がフワッと舞い散りますよ〜。(≧▽≦)
「はみ出しちゃった!」という細かいところは、
「👆 指」ツールでちょいちょいっとこすって消すのがおすすめです。(´ω`)
📸 写真をチョークアートに変換!
今回の目玉機能です!
「画像を選ぶ」ボタンからお手持ちの写真やイラストを読み込むと、
自動でチョーク画風に変換されて黒板に貼り付きます。
思い出の写真が、あっという間に黒板アートになっちゃいます。(^^*)
✨ すべて消す
「全消去」ボタンを押すと、
大量の粉を舞わせながら黒板がまっさらにリセットされます。
右端に書いてある「日直」の文字や線は消えないようになっているので、
安心して何度でも描き直せます。(^^)
黒板の粉のかすれ具合や、チョークの質感を楽しみながら、
ぜひあなただけの素敵な黒板アートを作ってみてくださいね!
“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>レトロ黒板アートメーカー(画像変換 修正版)</title> <style> body { margin: 0; background-color: #e4e0d7; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; overflow: hidden; font-family: sans-serif; user-select: none; } .board-frame { background-color: #6d4c33; padding: 15px; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); margin-bottom: 20px; } .board-wrapper { position: relative; width: 90vw; height: 75vh; background-color: #1a3c23; box-shadow: inset 0 0 40px rgba(0,0,0,0.8); overflow: hidden; } canvas { position: absolute; top: 0; left: 0; } #particle-canvas { pointer-events: none; z-index: 10; } .board-text { position: absolute; top: 40px; right: 40px; color: rgba(255, 255, 255, 0.75); font-family: "Yu Mincho", "MS PMincho", serif; font-size: 32px; writing-mode: vertical-rl; pointer-events: none; border-left: 2px solid rgba(255, 255, 255, 0.4); padding-left: 25px; height: calc(100% - 80px); } .text-line { margin-left: 45px; white-space: nowrap; display: inline-block; } .space-for-num { display: inline-block; height: 3.5em; } .toolbar { display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; background: #333; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #fff; z-index: 20; } .panel { display: flex; gap: 8px; align-items: center; border-right: 1px solid #555; padding-right: 15px; } .panel:last-child { border-right: none; padding-right: 0; } button { background: #555; color: white; border: 2px solid transparent; padding: 8px 10px; border-radius: 4px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 0.9em; } button:hover { background: #666; } button.active { border-color: #fff; background: #444; } .color-btn { width: 30px; height: 30px; padding: 0; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.5); } #btn-eraser { position: relative; width: 60px; height: 40px; background: transparent; border: none; padding: 0; margin-top: -5px; overflow: hidden; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.5); } #btn-eraser::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 18px; background: #a6704b; } #btn-eraser::after { content: ''; position: absolute; top: 18px; left: 0; right: 0; bottom: 0; background: #444; border-bottom: 4px solid #ccc; } #btn-eraser.active { outline: 3px solid white; outline-offset: 2px; } #btn-finger { padding: 8px 12px; font-size: 1em; } #image-upload { display: none; } </style></head><body> <div class="board-frame"> <div class="board-wrapper"> <canvas id="chalkboard"></canvas> <canvas id="particle-canvas"></canvas> <div class="board-text"> <div class="text-line"> <span class="space-for-num"></span>月 <span class="space-for-num"></span>日 </div> <div class="text-line"> 日直: </div> </div> </div> </div> <div class="toolbar"> <div class="panel"> <span style="font-size: 0.85em;">色:</span> <button class="color-btn active" style="background: #ffffff;" data-color="255, 255, 255" onclick="setColor(this)"></button> <button class="color-btn" style="background: #ff8888;" data-color="255, 136, 136" onclick="setColor(this)"></button> <button class="color-btn" style="background: #88ccff;" data-color="136, 204, 255" onclick="setColor(this)"></button> <button class="color-btn" style="background: #ffff88;" data-color="255, 255, 136" onclick="setColor(this)"></button> <button class="color-btn" style="background: #88ff88;" data-color="136, 255, 136" onclick="setColor(this)"></button> </div> <div class="panel"> <span style="font-size: 0.85em;">太さ:</span> <button id="btn-super-corner" onclick="setMode('super-corner')">極細</button> <button id="btn-corner" class="active" onclick="setMode('corner')">細</button> <button id="btn-medium-flat" onclick="setMode('medium-flat')">中太</button> <button id="btn-flat" onclick="setMode('flat')">太</button> </div> <div class="panel"> <button id="btn-eraser" onclick="setMode('eraser')" title="黒板消し"></button> <button id="btn-finger" onclick="setMode('finger')" title="指でこする">👆 指</button> </div> <div class="panel"> <button onclick="document.getElementById('image-upload').click()" style="background: #4a6b8c;">🖼️ 画像をチョーク化</button> <input type="file" id="image-upload" accept="image/*"> <button onclick="clearBoard()" style="background: #883333;">全消去</button> </div> </div> <script> const canvas = document.getElementById('chalkboard'); const ctx = canvas.getContext('2d'); const particleCanvas = document.getElementById('particle-canvas'); const pCtx = particleCanvas.getContext('2d'); const wrapper = document.querySelector('.board-wrapper'); let isDrawing = false; let lastX = 0; let lastY = 0; let currentColor = '255, 255, 255'; let currentMode = 'corner'; let particles = []; function resizeCanvas() { const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvas.width || wrapper.clientWidth; tempCanvas.height = canvas.height || wrapper.clientHeight; if (canvas.width) tempCanvas.getContext('2d').drawImage(canvas, 0, 0); canvas.width = wrapper.clientWidth; canvas.height = wrapper.clientHeight; particleCanvas.width = wrapper.clientWidth; particleCanvas.height = wrapper.clientHeight; if (tempCanvas.width) ctx.drawImage(tempCanvas, 0, 0); } window.addEventListener('resize', resizeCanvas); resizeCanvas(); const eraserSVG = `data:image/svg+xml;utf8,<svg width="80" height="50" xmlns="http://www.w3.org/2000/svg"><rect width="80" height="25" rx="4" fill="%23a6704b"/><rect y="25" width="80" height="20" fill="%23444"/><rect y="45" width="80" height="5" fill="%23ccc"/></svg>`; const fingerSvgStr = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path d="M14.5,10.5 L14.5,4 C14.5,2.5 13,2 12,2 C11,2 9.5,2.5 9.5,4 L9.5,11 L7,11 C5.5,11 4,12.5 5,14 L8,21 C8.5,22 10,22.5 11.5,22.5 L15,22.5 C16.5,22.5 18,21.5 18.5,20 L20.5,14 C21,12.5 19.5,11.5 18,11.5 L14.5,11.5 Z" fill="#ffccaa" stroke="#333" stroke-width="1.5"/> </svg>`; const encodedFingerSvg = encodeURIComponent(fingerSvgStr).replace(/'/g, "%27").replace(/"/g, "%22"); function updateCursor() { if (currentMode === 'eraser') { canvas.style.cursor = `url('${eraserSVG}') 40 25, auto`; } else if (currentMode === 'finger') { canvas.style.cursor = `url("data:image/svg+xml;utf8,${encodedFingerSvg}") 12 2, auto`; } else { const chalkSvgStr = `<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"> <path d="M4,4 L14,2 L40,28 L30,40 Z" fill="rgb(${currentColor})" stroke="rgba(0,0,0,0.4)" stroke-width="1"/> <path d="M4,4 L10,18 L36,40 L30,40 Z" fill="rgba(0,0,0,0.15)"/> </svg>`; const encodedSvg = encodeURIComponent(chalkSvgStr).replace(/'/g, "%27").replace(/"/g, "%22"); canvas.style.cursor = `url("data:image/svg+xml;utf8,${encodedSvg}") 4 4, crosshair`; } } updateCursor(); // --- 画像をチョークアート風に変換して描画する処理(バグ修正版) --- document.getElementById('image-upload').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(event) { const img = new Image(); img.onload = function() { const scale = Math.min((canvas.width * 0.8) / img.width, (canvas.height * 0.8) / img.height); // ★ 修正:サイズや座標を必ず整数(Math.floor)にする const w = Math.floor(img.width * scale); const h = Math.floor(img.height * scale); const x = Math.floor((canvas.width - w) / 2); const y = Math.floor((canvas.height - h) / 2); const tempC = document.createElement('canvas'); tempC.width = w; tempC.height = h; const tCtx = tempC.getContext('2d'); tCtx.drawImage(img, 0, 0, w, h); const imgData = tCtx.getImageData(0, 0, w, h); const data = imgData.data; const dataWidth = imgData.width; // 確実なデータ幅を取得 createParticles(canvas.width / 2, canvas.height / 2, 100); const step = 2; for (let py = 0; py < h; py += step) { for (let px = 0; px < w; px += step) { // ★ 修正:dataWidthを使って正確にインデックスを計算 const i = (py * dataWidth + px) * 4; let r = data[i]; let g = data[i+1]; let b = data[i+2]; const a = data[i+3]; if (a < 128) continue; const brightness = (r * 0.299 + g * 0.587 + b * 0.114); if (brightness > 15) { r = Math.min(255, r + 40); g = Math.min(255, g + 40); b = Math.min(255, b + 40); const dustCount = Math.floor(brightness / 20) + 2; for(let d = 0; d < dustCount; d++){ const offsetX = px + x + (Math.random() * step * 2 - step); const offsetY = py + y + (Math.random() * step * 2 - step); const alpha = (brightness / 255) * 0.7 + 0.2; ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; const size = Math.random() > 0.8 ? 2 : 1.5; ctx.fillRect(offsetX, offsetY, size, size); } } } } } img.src = event.target.result; } reader.readAsDataURL(file); e.target.value = ''; }); canvas.addEventListener('mousedown', startDrawing); canvas.addEventListener('mousemove', draw); canvas.addEventListener('mouseup', stopDrawing); canvas.addEventListener('mouseout', stopDrawing); canvas.addEventListener('touchstart', (e) => { e.preventDefault(); startDrawing(e.touches[0]); }, {passive: false}); canvas.addEventListener('touchmove', (e) => { e.preventDefault(); draw(e.touches[0]); }, {passive: false}); canvas.addEventListener('touchend', stopDrawing); function startDrawing(e) { isDrawing = true; const pos = getPos(e); lastX = pos.x; lastY = pos.y; draw(e); } function stopDrawing() { isDrawing = false; } function getPos(e) { const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } function draw(e) { if (!isDrawing) return; const pos = getPos(e); const dist = Math.hypot(pos.x - lastX, pos.y - lastY); const angle = Math.atan2(pos.y - lastY, pos.x - lastX); const steps = Math.max(dist, 1); let radius, density; if (currentMode === 'super-corner') { radius = 1.5; density = 15; } else if (currentMode === 'corner') { radius = 3; density = 25; } else if (currentMode === 'medium-flat') { radius = 9; density = 40; } else if (currentMode === 'flat') { radius = 18; density = 60; } else if (currentMode === 'eraser') { radius = 40; density = 200; } else if (currentMode === 'finger') { radius = 6; density = 50; } for (let i = 0; i <= steps; i++) { const cx = lastX + Math.cos(angle) * i; const cy = lastY + Math.sin(angle) * i; for (let p = 0; p < density; p++) { const r = Math.random() * radius; const theta = Math.random() * 2 * Math.PI; const px = cx + r * Math.cos(theta); const py = cy + r * Math.sin(theta); if (currentMode === 'eraser' || currentMode === 'finger') { let alpha = (currentMode === 'finger') ? Math.random() * 0.4 + 0.15 : Math.random() * 0.3 + 0.1; ctx.fillStyle = `rgba(26, 60, 35, ${alpha})`; ctx.fillRect(px, py, 3, 3); } else { let alpha; if (currentMode === 'super-corner') alpha = Math.random() * 0.6 + 0.4; else if (currentMode === 'corner') alpha = Math.random() * 0.5 + 0.2; else if (currentMode === 'medium-flat') alpha = Math.random() * 0.4 + 0.1; else alpha = Math.random() * 0.3 + 0.05; ctx.fillStyle = `rgba(${currentColor}, ${alpha})`; ctx.fillRect(px, py, 1.5, 1.5); } } if (currentMode === 'eraser' && Math.random() < 0.4) { createParticles(cx, cy, 3); } else if (currentMode === 'finger' && Math.random() < 0.15) { createParticles(cx, cy, 1); } } lastX = pos.x; lastY = pos.y; } function createParticles(x, y, count) { for (let i = 0; i < count; i++) { particles.push({ x: x + (Math.random() - 0.5) * 50, y: y + (Math.random() - 0.5) * 50, vx: (Math.random() - 0.5) * 1.0, vy: (Math.random() - 0.5) * 2 - 1.2, life: 1.0, decay: Math.random() * 0.02 + 0.01, size: Math.random() * 1.5 + 0.5 }); } } function animateParticles() { pCtx.clearRect(0, 0, particleCanvas.width, particleCanvas.height); for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.x += p.vx; p.y += p.vy; p.vy += 0.06; p.life -= p.decay; if (p.life <= 0) { particles.splice(i, 1); } else { pCtx.fillStyle = `rgba(255, 255, 255, ${p.life * 0.4})`; pCtx.beginPath(); pCtx.arc(p.x, p.y, p.size, 0, Math.PI * 2); pCtx.fill(); } } requestAnimationFrame(animateParticles); } animateParticles(); function setColor(btn) { currentColor = btn.getAttribute('data-color'); if (currentMode === 'eraser' || currentMode === 'finger') setMode('corner'); updateCursor(); document.querySelectorAll('.color-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } function setMode(mode) { currentMode = mode; document.getElementById('btn-super-corner').classList.remove('active'); document.getElementById('btn-corner').classList.remove('active'); document.getElementById('btn-medium-flat').classList.remove('active'); document.getElementById('btn-flat').classList.remove('active'); document.getElementById('btn-eraser').classList.remove('active'); document.getElementById('btn-finger').classList.remove('active'); document.getElementById(`btn-${mode}`).classList.add('active'); updateCursor(); } function clearBoard() { createParticles(canvas.width / 2, canvas.height / 2, 300); ctx.clearRect(0, 0, canvas.width, canvas.height); } </script></body></html>
■ 今日の学び
いつものお約束のフレーズですが、
「私は絵を描くのは好きですが上手ではありません。」
せっかくの黒板アートゲームなので、
そんな自分でも「なんだかいい雰囲気」に仕上げられる機能を
どうしても入れたいと考えました。
そこで思い出したのが、未公開記事ゲームの一つ
「画像からイラストロジックを作るゲーム」です。
あの実験では、手持ちの写真や画像データを解析して
ゲームに反映させる処理を作ったことがありました。
「その時の学びを応用すれば、手持ちの画像を
黒板アート風の質感に変換して貼り付けられるのでは…?」
そう考えて試しに機能を追加してみたところ、
これが予想以上に素敵な仕上がりになりました!ദ്ദി ( Ꙭ )✧
読み込んだ写真が、チョークの粉のザラつきや、
擦れて黒板の地が見える質感まで伴って浮かび上がる様子は、
作っている自分でも感動するレベルです。
過去の実験で得た知識が、新しいゲームの表現として
ピタッとハマる瞬間はたまりませんね。
結果的に、いつもと同じくプログラムの調整そっちのけで、
ひたすら画像を読み込ませてはチョークで加筆する
「絵を描く方」に夢中になってしまったのは……
ここだけの秘密です。(^b^*)
■ 次回は、暗闇の宝探しゲームです。
舞台は真っ暗闇。
頼りになるのは、マウスの先から放たれる「ブラックライト」の怪しい光だけ。
画面を照らしながら探索し、
闇に紛れたお宝(顔文字)たちを見つけ出す、
新感覚の宝探しゲームです。
ライトを当て続けるほど、ネオングリーンに じわっと蓄光 していき、
見つけた瞬間には光の粒が弾ける気持ちいい演出つき!
全発見までの最速を目指す「タイムアタック」に挑むもよし、
どうしても見つからない時は
「ギブアップ機能」で一斉に発光させて答え合わせすることもできます。
ちょっと不思議でクセになる光の体験。
次回もぜひ楽しみにしていてくださいね。(*^v^)ノシ
▶ 次の実験はこちら
じわっと光る、顔文字
「ブラックライト宝探し」
✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)





🌈はじめての方へ
🌳 実験一覧
🌱 このブログについて
🍃 プライバシーポリシー
🐣未記事ゲーム展示室