“เกมระบายสีภาพการ์ตูนลายเส้น” แบบไฟล์เดียว (HTML + JS + CSS)


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

    
แสดงความคิดเห็น (0)
ใหม่กว่า เก่ากว่า