“เกมระบายสีภาพการ์ตูนลายเส้น” แบบไฟล์เดียว (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>