“เกมระบายสีภาพการ์ตูนลายเส้น” แบบไฟล์เดียว (HTML + JS + CSS) ไว้ในแคนวาสด้านขวาให้เรียบร้อย
ฟีเจอร์หลัก:
- โหมดถังสีระบายส่วนต่างๆ ของรูป (SVG) ด้วยคลิก
- โหมดแปรงวาด/ยางลบบนเลเยอร์แคนวาส
- เลือกสีจากพรีเซ็ตหรือกำหนดเอง, ปรับขนาดแปรง
- Undo, ล้างทั้งหมด, ซูมเข้า/ออก, จัดกึ่งกลาง
- บันทึกเป็น PNG ได้
- ลาก/อัปโหลดรูปการ์ตูนลายเส้น ด้วยปุ่ม “เลือกไฟล์”
HTML + JS + CSS
<!DOCTYPE html> <html lang="th"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>เกมระบายสีภาพการ์ตูนลายเส้น (จากรูป JPG/PNG)</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <style> :root { --panel-w: 340px; } body { background:#f7f8fb; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Kanit', sans-serif; min-height:100vh; display:grid; grid-template-rows:auto 1fr auto; gap:1rem; } header{ background:white; box-shadow:0 6px 20px -12px rgba(0,0,0,.2); position:sticky; top:0; z-index:5; } .app{ display:grid; gap:1rem; grid-template-columns:1fr; } @media(min-width:992px){ .app{ grid-template-columns:minmax(0,1fr) var(--panel-w);} } .board-wrapper{ background:white; border-radius:20px; box-shadow:0 10px 30px -12px rgba(0,0,0,.2); overflow:hidden; position:relative; } .stage{ position:relative; width:100%; aspect-ratio:1/1; background:#fff; display:grid; place-items:center; } canvas{ position:absolute; inset:0; width:100%; height:100%; image-rendering:auto; } #lineCanvas{ pointer-events:none; } /* แสดงเฉย ๆ ห้ามวาดทับ */ #paintCanvas{ pointer-events:auto; } .panel{ background:white; border-radius:20px; box-shadow:0 10px 30px -12px rgba(0,0,0,.2); padding:1rem; position:sticky; top:84px; align-self:start; } .color-swatch{ width:34px; height:34px; border-radius:50%; border:2px solid rgba(0,0,0,.1); cursor:pointer; } .color-swatch.active{ outline:3px solid #0003; } .floating-controls{ position:absolute; right:12px; top:12px; display:flex; gap:8px; z-index:3; } .floating-controls .btn{ border-radius:999px; } .footerbar{ color:#6b7280; } </style> </head> <body> <header class="py-3 px-3 px-lg-4 d-flex flex-wrap gap-2 justify-content-between align-items-center"> <div class="d-flex align-items-center gap-2"> <span class="badge text-bg-primary rounded-pill">New</span> <h1 class="h5 m-0">เกมระบายสีภาพการ์ตูนลายเส้น (อิงจากภาพ JPG/PNG)</h1> </div> <div class="d-flex gap-2 align-items-center"> <span>อัปโหลดภาพ</span> <input type="file" id="fileInput" accept="image/*" class="form-control form-control-sm" style="max-width:240px" /> <button id="btnSave" class="btn btn-success">บันทึกเป็นรูปภาพ</button> <button id="btnReset" class="btn btn-outline-secondary">ล้างสีทั้งหมด</button> </div> </header> <main class="container app"> <section class="board-wrapper"> <div class="floating-controls"> <button id="btnUndo" class="btn btn-warning btn-sm" title="ย้อนกลับ">↶ ย้อนกลับ</button> <button id="btnModeFill" class="btn btn-primary btn-sm" title="โหมดถังสี">ถังสี</button> <button id="btnModeBrush" class="btn btn-outline-primary btn-sm" title="โหมดแปรง">แปรง</button> <button id="btnModeEraser" class="btn btn-outline-secondary btn-sm" title="ยางลบ">ยางลบ</button> </div> <div class="stage" id="stage"> <canvas id="lineCanvas"></canvas> <!-- แสดงภาพลายเส้น --> <canvas id="paintCanvas"></canvas> <!-- ระบายสี/วาด --> </div> </section> <aside class="panel"> <h2 class="h6">เครื่องมือ</h2> <div class="mb-3"> <label class="form-label">เลือกสี</label> <div class="d-flex flex-wrap gap-2 align-items-center"> <button class="color-swatch" data-color="#000000" style="background:#000000"></button> <button class="color-swatch" data-color="#ffffff" style="background:#ffffff"></button> <button class="color-swatch" data-color="#ef4444" style="background:#ef4444"></button> <button class="color-swatch" data-color="#f59e0b" style="background:#f59e0b"></button> <button class="color-swatch" data-color="#10b981" style="background:#10b981"></button> <button class="color-swatch" data-color="#3b82f6" style="background:#3b82f6"></button> <button class="color-swatch" data-color="#8b5cf6" style="background:#8b5cf6"></button> <button class="color-swatch" data-color="#f472b6" style="background:#f472b6"></button> <input id="colorPicker" type="color" value="#ff6b6b" class="form-control form-control-color ms-1" title="กำหนดสีเอง" /> </div> </div> <div class="mb-3"> <label class="form-label">ขนาดแปรง: <span id="brushSizeLabel">14</span> px</label> <input id="brushSize" type="range" min="2" max="64" value="14" class="form-range" /> </div> <div class="mb-3"> <label class="form-label">ความไวถังสี (Tolerance): <span id="tolLabel">18</span></label> <input id="tol" type="range" min="0" max="80" value="18" class="form-range" /> <div class="form-text">ค่ายิ่งสูงยิ่งยอมให้แตกต่างของสีพื้นหลังได้มาก (ช่วยกรณีเส้นไม่สนิท)</div> </div> <div class="d-flex gap-2"> <button id="btnZoomIn" class="btn btn-outline-dark">ซูม +</button> <button id="btnZoomOut" class="btn btn-outline-dark">ซูม -</button> <button id="btnCenter" class="btn btn-outline-dark">กึ่งกลาง</button> </div> <hr/> <p class="small text-muted m-0">อัปโหลดรูปเส้นขาว-ดำ แล้วใช้ “ถังสี” คลิกในพื้นที่ขาวเพื่อระบายสี หรือใช้แปรง/ยางลบวาดเพิ่มเติม</p> </aside> </main> <footer class="container py-4 footerbar small text-center"> © 2025 GuruChian · ตัวอย่าง HTML + JS (ไฟล์เดียว) · รองรับมือถือ/เดสก์ท็อป </footer> <script> // ====== State ====== const state = { mode:'fill', color:'#ff6b6b', brushSize:14, zoom:1, tol:18 }; const stage = document.getElementById('stage'); const lineCanvas = document.getElementById('lineCanvas'); const paintCanvas = document.getElementById('paintCanvas'); const lctx = lineCanvas.getContext('2d'); const pctx = paintCanvas.getContext('2d'); // Undo stack: เก็บเฉพาะชั้นสี (ImageData) const undoStack = []; const MAX_UNDO = 30; function pushUndo(){ try{ const img = pctx.getImageData(0,0,paintCanvas.width, paintCanvas.height); undoStack.push(img); if(undoStack.length>MAX_UNDO) undoStack.shift(); }catch(e){} } function undo(){ if(!undoStack.length) return; const img=undoStack.pop(); pctx.putImageData(img,0,0); } // Resize canvases to stage size function resizeCanvases(){ const r = stage.getBoundingClientRect(); [lineCanvas, paintCanvas].forEach(cv=>{ cv.width = r.width; cv.height = r.height; cv.style.width='100%'; cv.style.height='100%'; }); redrawLine(); // re-draw line image to fit new size // restore paint layer (no stored content after resize) if(savedPaint){ pctx.drawImage(savedPaint,0,0,paintCanvas.width, paintCanvas.height); savedPaint=null; } } let sourceImg = new Image(); sourceImg.crossOrigin='anonymous'; let savedPaint = null; function loadImageFromFile(file){ const url = URL.createObjectURL(file); sourceImg = new Image(); sourceImg.crossOrigin='anonymous'; sourceImg.onload = ()=>{ redrawLine(true); URL.revokeObjectURL(url); }; sourceImg.src = url; } function redrawLine(clearPaint=false){ const w = lineCanvas.width, h = lineCanvas.height; lctx.clearRect(0,0,w,h); if(sourceImg && sourceImg.width){ // Fit contain const ir = sourceImg.width/sourceImg.height; const cr = w/h; let dw=w, dh=h, dx=0, dy=0; if(ir>cr){ dh = w/ir; dy = (h-dh)/2; } else { dw = h*ir; dx = (w-dw)/2; } lctx.fillStyle = '#fff'; lctx.fillRect(0,0,w,h); lctx.drawImage(sourceImg, dx, dy, dw, dh); // เพิ่มขยายเส้นเล็กน้อยให้เป็นกำแพงชัดขึ้น (blur/dilate แบบง่าย) // วาดซ้ำทับโหมด multiply ลดรั่วไหลเวลาถังสี lctx.globalCompositeOperation = 'multiply'; lctx.drawImage(sourceImg, dx, dy, dw, dh); lctx.globalCompositeOperation = 'source-over'; } else { // ไม่มีภาพ: พื้นขาวคำแนะนำ lctx.fillStyle = '#fff'; lctx.fillRect(0,0,w,h); lctx.fillStyle = '#9ca3af'; lctx.textAlign='center'; lctx.font='16px system-ui'; lctx.fillText('อัปโหลดภาพลายเส้น (JPG/PNG) ด้วยปุ่มด้านบน', w/2, h/2); } if(clearPaint){ pctx.clearRect(0,0,w,h); undoStack.length=0; } } new ResizeObserver(()=>{ // ก่อนปรับขนาด เก็บ paint ไว้เป็นบัฟเฟอร์ชั่วคราวเพื่อไม่ให้หาย if(paintCanvas.width){ const tmp = document.createElement('canvas'); tmp.width=paintCanvas.width; tmp.height=paintCanvas.height; tmp.getContext('2d').drawImage(paintCanvas,0,0); savedPaint = tmp; } resizeCanvases(); }).observe(stage); // ====== Tool switching ====== const btnModeFill = document.getElementById('btnModeFill'); const btnModeBrush = document.getElementById('btnModeBrush'); const btnModeEraser = document.getElementById('btnModeEraser'); function setMode(m){ state.mode=m; btnModeFill.classList.toggle('btn-primary',m==='fill'); btnModeBrush.classList.toggle('btn-primary',m==='brush'); btnModeBrush.classList.toggle('btn-outline-primary',m!=='brush'); btnModeEraser.classList.toggle('btn-secondary',m==='eraser'); btnModeEraser.classList.toggle('btn-outline-secondary',m!=='eraser'); paintCanvas.style.cursor = (m==='brush'?'crosshair': m==='eraser'?'not-allowed':'pointer'); } btnModeFill.onclick=()=>setMode('fill'); btnModeBrush.onclick=()=>setMode('brush'); btnModeEraser.onclick=()=>setMode('eraser'); // ====== Color & size ====== const swatches=[...document.querySelectorAll('.color-swatch')]; const colorPicker=document.getElementById('colorPicker'); function setColor(c){ state.color=c; colorPicker.value=c; swatches.forEach(s=>s.classList.toggle('active', s.dataset.color===c)); } swatches.forEach(b=> b.onclick=()=> setColor(b.dataset.color)); colorPicker.oninput=e=> setColor(e.target.value); setColor(colorPicker.value); const brushSize=document.getElementById('brushSize'); const brushSizeLabel=document.getElementById('brushSizeLabel'); brushSize.oninput=e=>{ state.brushSize=+e.target.value; brushSizeLabel.textContent=state.brushSize; } const tol=document.getElementById('tol'); const tolLabel=document.getElementById('tolLabel'); tol.oninput=e=>{ state.tol=+e.target.value; tolLabel.textContent=state.tol; } // ====== Zoom ====== const btnZoomIn=document.getElementById('btnZoomIn'); const btnZoomOut=document.getElementById('btnZoomOut'); const btnCenter=document.getElementById('btnCenter'); function applyZoom(){ stage.style.transform=`scale(${state.zoom})`; stage.style.transformOrigin='center center'; } btnZoomIn.onclick=()=>{ state.zoom=Math.min(2.5, +(state.zoom+0.1).toFixed(2)); applyZoom(); }; btnZoomOut.onclick=()=>{ state.zoom=Math.max(0.6, +(state.zoom-0.1).toFixed(2)); applyZoom(); }; btnCenter.onclick=()=>{ state.zoom=1; applyZoom(); }; // ====== Paint bucket (flood fill) ====== function colorMatch(a, b, tol){ // a,b = [r,g,b,a] return Math.abs(a[0]-b[0])<=tol && Math.abs(a[1]-b[1])<=tol && Math.abs(a[2]-b[2])<=tol && Math.abs(a[3]-b[3])<=tol; } function getPixel(data, x, y, w){ const i=(y*w+x)*4; return [data[i], data[i+1], data[i+2], data[i+3]]; } function setPixel(data, x, y, w, col){ const i=(y*w+x)*4; data[i]=col[0]; data[i+1]=col[1]; data[i+2]=col[2]; data[i+3]=col[3]; } function hexToRgba(hex){ const v = hex.replace('#',''); const bigint = parseInt(v.length===3? v.split('').map(c=>c+c).join(''):v, 16); const r=(bigint>>16)&255, g=(bigint>>8)&255, b=bigint&255; return [r,g,b,255]; } function isBarrier(linePx){ // เส้นสีเข้มเป็นกำแพง const [r,g,b,a]=linePx; if(a===0) return false; const lum = 0.2126*r+0.7152*g+0.0722*b; return lum<128; // มืดถือว่าเป็นกำแพง } function floodFill(x, y){ const w=paintCanvas.width, h=paintCanvas.height; const paint = pctx.getImageData(0,0,w,h); const base = lctx.getImageData(0,0,w,h); // เอาเส้นไว้กันไหล const target = getPixel(paint.data, x, y, w); // สีเริ่มต้นในชั้นสี const fillCol = hexToRgba(state.color); if(colorMatch(target, fillCol, 0)) return; // สีเดิมเท่ากับสีใหม่ ไม่ต้องทำ const stack=[[x,y]]; const tol = state.tol; const visited = new Uint8Array(w*h); while(stack.length){ const [cx, cy] = stack.pop(); if(cx<0||cy<0||cx>=w||cy>=h) continue; const idx=cy*w+cx; if(visited[idx]) continue; visited[idx]=1; const lp = getPixel(base.data, cx, cy, w); if(isBarrier(lp)) continue; // หยุดที่เส้น const pp = getPixel(paint.data, cx, cy, w); if(!colorMatch(pp, target, tol)) continue; // ต้องอยู่ในพื้นที่สีเดียวกัน/โปร่งเดียวกัน setPixel(paint.data, cx, cy, w, fillCol); stack.push([cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]); } pctx.putImageData(paint,0,0); } // ====== Brush/Eraser ====== let drawing=false, last=null; function startDraw(e){ if(state.mode==='brush' || state.mode==='eraser'){ pushUndo(); drawing=true; last=getPos(e); drawPoint(last,true); e.preventDefault(); } } function moveDraw(e){ if(!drawing) return; const pos=getPos(e); drawLine(last,pos); last=pos; e.preventDefault(); } function endDraw(){ drawing=false; last=null; } function getPos(evt){ const r=paintCanvas.getBoundingClientRect(); const x=(evt.touches?evt.touches[0].clientX:evt.clientX)-r.left; const y=(evt.touches?evt.touches[0].clientY:evt.clientY)-r.top; return {x:Math.floor(x), y:Math.floor(y)}; } function drawPoint(p, begin){ pctx.save(); pctx.lineJoin='round'; pctx.lineCap='round'; pctx.lineWidth=state.brushSize; pctx.globalCompositeOperation = (state.mode==='eraser')? 'destination-out':'source-over'; pctx.strokeStyle=state.color; pctx.beginPath(); pctx.moveTo(p.x, p.y); pctx.lineTo(p.x+0.01, p.y+0.01); pctx.stroke(); pctx.restore(); } function drawLine(a,b){ pctx.save(); pctx.lineJoin='round'; pctx.lineCap='round'; pctx.lineWidth=state.brushSize; pctx.globalCompositeOperation = (state.mode==='eraser')? 'destination-out':'source-over'; pctx.strokeStyle=state.color; pctx.beginPath(); pctx.moveTo(a.x,a.y); pctx.lineTo(b.x,b.y); pctx.stroke(); pctx.restore(); } paintCanvas.addEventListener('pointerdown', (e)=>{ if(state.mode==='fill'){ pushUndo(); const p=getPos(e); floodFill(p.x, p.y); } else startDraw(e); }); paintCanvas.addEventListener('pointermove', moveDraw); window.addEventListener('pointerup', endDraw); // Touch fallback paintCanvas.addEventListener('touchstart', (e)=>{ if(state.mode==='fill'){ pushUndo(); const p=getPos(e); floodFill(p.x, p.y); } else startDraw(e); }, {passive:false}); paintCanvas.addEventListener('touchmove', moveDraw, {passive:false}); window.addEventListener('touchend', endDraw); // ====== Buttons ====== document.getElementById('btnUndo').onclick=()=>undo(); document.getElementById('btnReset').onclick=()=>{ pushUndo(); pctx.clearRect(0,0,paintCanvas.width, paintCanvas.height); }; document.getElementById('btnSave').onclick=()=>{ const out=document.createElement('canvas'); out.width=paintCanvas.width; out.height=paintCanvas.height; const o=out.getContext('2d'); o.drawImage(lineCanvas,0,0); o.drawImage(paintCanvas,0,0); out.toBlob((blob)=>{ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='coloring.png'; a.click(); }, 'image/png'); }; document.getElementById('fileInput').addEventListener('change', (e)=>{ const f=e.target.files?.[0]; if(!f) return; redrawLine(true); loadImageFromFile(f); }); // ====== Init ====== setMode('fill'); resizeCanvases(); // เคล็ดลับ: ถ้าต้องการตั้งภาพเริ่มต้นแบบ URL ให้ทำ: sourceImg.src='URL_รูปของคุณ'; แล้วเรียก redrawLine(true) หลังโหลด </script> </body> </html>