4コマ漫画

4コマ漫画 | ひまあそび-ミニゲーム hi0a.com

view source

JavaScript

document.title = '4コマ漫画';

let { canvas, ctx, cx, cy, w, h, min, demo } = readyCanvas();

var style = document.createElement('style');

// mm → px 変換(仮に1mm ≒ 2.83px として96dpi想定)
let dpi = 96; // 初期DPI
let layout = '2col';
let paperSize = 'A5';
let freeLines = [];
let clickPoints = [];
let lineObjects = [];

// サイズ定義(mm)
const PAPER_SIZES_MM = {
  A5: { w: 148, h: 210 },
  B5: { w: 182, h: 257 }
};

function mmToPx(mm) {
  return Math.round((mm / 25.4) * dpi);
}

function updateCanvasSize() {
  const sizeMM = PAPER_SIZES_MM[paperSize];
  canvas.width = mmToPx(sizeMM.w);
  canvas.height = mmToPx(sizeMM.h);
}


function resetLines() {
  lineObjects = [];
}

function createButtons() {
  const buttons = [
    { label: 'A5', onClick: () => { paperSize = 'A5'; updateCanvasSize(); drawPanels(); } },
    { label: 'B5', onClick: () => { paperSize = 'B5'; updateCanvasSize(); drawPanels(); } },
    { label: '自由', onClick: () => { layout = 'free'; resetLines(); drawPanels(); } },
    { label: '1列', onClick: () => { layout = '1col'; drawPanels(); } },
    { label: '2列', onClick: () => { layout = '2col'; drawPanels(); } },
    { label: '96 DPI', onClick: () => { dpi = 96; updateCanvasSize(); drawPanels(); } },
    { label: '300 DPI', onClick: () => { dpi = 300; updateCanvasSize(); drawPanels(); } },
    { label: '600 DPI', onClick: () => { dpi = 600; updateCanvasSize(); drawPanels(); } }
  ];

  const btns = document.createElement('div');
  btns.style.display = 'flex';
  btns.style.position = 'fixed';
  btns.style.bottom = 0;
  btns.style.right = 0;
  const controls = document.getElementById('demo');
  controls.appendChild(btns);
  controls.style.backgroundColor = '#eee';
  buttons.forEach(btn => {
    const b = document.createElement('button');
    b.style.padding = '12px';
    b.textContent = btn.label;
    b.onclick = btn.onClick;
    btns.appendChild(b);
  });

}

function drawPanels() {
  const w = canvas.width;
  const h = canvas.height;
  const margin = mmToPx(10);            // 約10mm
  const spacing = mmToPx(3);            // 約3mm
  const spacingW = mmToPx(6);            // 約3mm
  const titleHeight = mmToPx(12);       // 題名枠高さ ≈ 12mm
  const bottomMargin = mmToPx(10);      // 下余白 ≈ 10mm

  // 背景白塗り
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, w, h);

  ctx.strokeStyle = 'black';
  ctx.lineWidth = Math.max(1, dpi / 50);  // 線の太さ DPIに応じて調整

  if (layout === 'free') {
    const box = {
      x: margin,
      y: margin,
      w: w - margin * 2,
      h: h - margin * 2
    };
    ctx.strokeStyle = 'black';
    ctx.lineWidth = Math.max(1, dpi / 50);
    ctx.strokeRect(box.x, box.y, box.w, box.h);
    ctx.clearRect(box.x, box.y, box.w, box.h);

    // 白潰し → 黒線 の順で描画
    const spacing = mmToPx(3);
    // 境界線(黒線で2辺)
    lineObjects.forEach(line => {
      line.drawBlack(ctx, spacing, Math.max(1, dpi / 150));
    });

    // 白塗り(背景を消す)
    lineObjects.forEach(line => {
      line.calculateTrimmed(lineObjects);  // ← 新規追加
      line.drawWhite(ctx, spacing);
    });

    return;
  }


  // 題名枠
  const titleX = margin;
  const titleY = margin;
  const titleW = w - margin * 2;

  if (layout === '2col') {
    // 2列用に題名を左右に分割
    const titleColW = (titleW - spacingW) / 2;
    for (let col = 0; col < 2; col++) {
      const x = titleX + col * (titleColW + spacingW);
      ctx.strokeRect(x, titleY, titleColW, titleHeight);
      ctx.clearRect(x + 1, titleY + 1, titleColW - 2, titleHeight - 2);
    }
  } else {
    // 通常の1列題名枠
    ctx.strokeRect(titleX, titleY, titleW, titleHeight);
    ctx.clearRect(titleX + 1, titleY + 1, titleW - 2, titleHeight - 2);
  }

  // 4コマ枠
  if (layout === '1col') {
    const usableHeight = h - margin * 2 - spacing * 3 - titleHeight - bottomMargin;
    const panelH = usableHeight / 4;
    const panelW = w - margin * 2;

    for (let i = 0; i < 4; i++) {
      const x = margin;
      const y = margin + titleHeight + spacing + i * (panelH + spacing);
      ctx.strokeRect(x, y, panelW, panelH);
      ctx.clearRect(x + 1, y + 1, panelW - 2, panelH - 2);
    }
  } else if (layout === '2col') {
    const rows = 4, cols = 2;
    const usableHeight = h - margin * 2 - spacing - titleHeight - bottomMargin;
    const panelH = usableHeight / rows;
    const panelW = (w - margin * 2 - spacingW) / cols;

    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const x = margin + col * (panelW + spacingW);
        const y = margin + titleHeight + spacing + row * (panelH + spacing);
        ctx.strokeRect(x, y, panelW, panelH);
        ctx.clearRect(x + 1, y + 1, panelW - 2, panelH - 2);
      }
    }
  }
}

class Line {
  constructor(p1, p2) {
    this.p1 = p1;
    this.p2 = p2;
    this.width = mmToPx(2);
    this.extended = null;
    this.trimmed = null;

    const margin = mmToPx(10);
    const box = {
      x1: margin,
      y1: margin,
      x2: canvas.width - margin,
      y2: canvas.height - margin
    };

    // まず外枠で制限された線(他線との交点未考慮)
    this.extended = clipLineToBox(p1, p2, box);
  }

  calculateTrimmed(lines) {
    if (!this.extended) return;

    const margin = mmToPx(10);
    const box = {
      x1: margin,
      y1: margin,
      x2: canvas.width - margin,
      y2: canvas.height - margin
    };

    // extended を使って他線と交差処理 → 最後に再び box で切る
    const trimmed = getTrimmedLine(
      { x: this.extended.x1, y: this.extended.y1 },
      { x: this.extended.x2, y: this.extended.y2 },
      lines.filter(line => line !== this),
      box
    );

    this.trimmed = trimmed;
  }




  drawWhite(ctx, spacing) {
    if (!this.extended) return; // ← この行を追加!
    const { x1, y1, x2, y2 } = this.extended;
    const angle = Math.atan2(y2 - y1, x2 - x1);
    const w = this.width;

    ctx.save();
    ctx.translate(x1, y1);
    ctx.rotate(angle);

    const length = Math.hypot(x2 - x1, y2 - y1);
    ctx.fillStyle = 'white';
    ctx.fillRect(-2, -w / 2, length+4, w);
    ctx.restore();
  }

  drawBlack(ctx, spacing, lineWidth) {
    const lineData = this.trimmed || this.extended;
    if (!lineData) return;
    const { x1, y1, x2, y2 } = lineData;
    const angle = Math.atan2(y2 - y1, x2 - x1);
    const w = this.width;

    ctx.save();
    ctx.translate(x1, y1);
    ctx.rotate(angle);

    const length = Math.hypot(x2 - x1, y2 - y1);
    ctx.lineWidth = lineWidth;
    ctx.strokeStyle = 'black';

    ctx.beginPath();
    ctx.moveTo(0, -w / 2);
    ctx.lineTo(length, -w / 2);
    ctx.moveTo(0, w / 2);
    ctx.lineTo(length, w / 2);
    ctx.stroke();
    ctx.restore();
  }
}

//線分の交差で切る処理
function trimLineByIntersections(p1, p2, otherLines) {
  let closestStart = p1;
  let closestEnd = p2;
  let minStartDist = Infinity;
  let minEndDist = Infinity;

  for (const other of otherLines) {
    if (!other.extended) continue;

    const ipt = getInfiniteLineIntersection(p1, p2,
                                            { x: other.extended.x1, y: other.extended.y1 },
                                            { x: other.extended.x2, y: other.extended.y2 });

    if (!ipt) continue;

    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    const dot = (ipt.x - p1.x) * dx + (ipt.y - p1.y) * dy;

    if (dot < 0) {
      const dist = Math.hypot(ipt.x - p1.x, ipt.y - p1.y);
      if (dist < minStartDist) {
        minStartDist = dist;
        closestStart = ipt;
      }
    } else {
      const dist = Math.hypot(ipt.x - p2.x, ipt.y - p2.y);
      if (dist < minEndDist) {
        minEndDist = dist;
        closestEnd = ipt;
      }
    }
  }

  return { x1: closestStart.x, y1: closestStart.y, x2: closestEnd.x, y2: closestEnd.y };
}

function getTrimmedLine(p1, p2, otherLines, box) {
  // まずboxで切る
  const extended = clipLineToBox(p1, p2, box);
  if (!extended) return null;

  // 線分として使いやすく
  const start = { x: extended.x1, y: extended.y1 };
  const end = { x: extended.x2, y: extended.y2 };

  // 他の線との交点でさらに切る
  const trimmed = trimLineByIntersections(start, end, otherLines);

  // 最後に再度boxでクリップ(交差点がbox外の可能性もある)
  return clipLineToBox({ x: trimmed.x1, y: trimmed.y1 }, { x: trimmed.x2, y: trimmed.y2 }, box);
}


function getInfiniteLineIntersection(p1, p2, q1, q2) {
  const A1 = p2.y - p1.y;
  const B1 = p1.x - p2.x;
  const C1 = A1 * p1.x + B1 * p1.y;

  const A2 = q2.y - q1.y;
  const B2 = q1.x - q2.x;
  const C2 = A2 * q1.x + B2 * q1.y;

  const det = A1 * B2 - A2 * B1;
  if (Math.abs(det) < 0.00001) return null; // 平行 or 同一直線

  const x = (B2 * C1 - B1 * C2) / det;
  const y = (A1 * C2 - A2 * C1) / det;
  return { x, y };
}



function clipLineToBox(p1, p2, box) {
  const INSIDE = 0; // 0000
  const LEFT = 1;   // 0001
  const RIGHT = 2;  // 0010
  const BOTTOM = 4; // 0100
  const TOP = 8;    // 1000

  function computeOutCode(x, y) {
    let code = INSIDE;
    if (x < box.x1) code |= LEFT;
    else if (x > box.x2) code |= RIGHT;
    if (y < box.y1) code |= TOP;
    else if (y > box.y2) code |= BOTTOM;
    return code;
  }

  let x0 = p1.x, y0 = p1.y;
  let x1 = p2.x, y1 = p2.y;

  let outcode0 = computeOutCode(x0, y0);
  let outcode1 = computeOutCode(x1, y1);
  let accept = false;

  while (true) {
    if (!(outcode0 | outcode1)) {
      // 完全に内部
      accept = true;
      break;
    } else if (outcode0 & outcode1) {
      // 完全に外部
      break;
    } else {
      // 少なくとも1点が矩形外にある
      let outcodeOut = outcode0 ? outcode0 : outcode1;
      let x, y;

      if (outcodeOut & TOP) {
        x = x0 + (x1 - x0) * (box.y1 - y0) / (y1 - y0);
        y = box.y1;
      } else if (outcodeOut & BOTTOM) {
        x = x0 + (x1 - x0) * (box.y2 - y0) / (y1 - y0);
        y = box.y2;
      } else if (outcodeOut & RIGHT) {
        y = y0 + (y1 - y0) * (box.x2 - x0) / (x1 - x0);
        x = box.x2;
      } else if (outcodeOut & LEFT) {
        y = y0 + (y1 - y0) * (box.x1 - x0) / (x1 - x0);
        x = box.x1;
      }

      if (outcodeOut === outcode0) {
        x0 = x;
        y0 = y;
        outcode0 = computeOutCode(x0, y0);
      } else {
        x1 = x;
        y1 = y;
        outcode1 = computeOutCode(x1, y1);
      }
    }
  }

  if (accept) {
    return { x1: x0, y1: y0, x2: x1, y2: y1 };
  } else {
    return null; // 可視部分なし
  }
}


function getLineIntersection(p1, p2, q1, q2) {
  const det = (p2.x - p1.x) * (q2.y - q1.y) - (p2.y - p1.y) * (q2.x - q1.x);
  if (det === 0) return null; // 平行

  const t = ((q1.x - p1.x) * (q2.y - q1.y) - (q1.y - p1.y) * (q2.x - q1.x)) / det;
  const u = ((q1.x - p1.x) * (p2.y - p1.y) - (q1.y - p1.y) * (p2.x - p1.x)) / det;

  if (t < 0 || t > 1 || u < 0 || u > 1) return null; // 線分内ではない

  return {
    x: p1.x + t * (p2.x - p1.x),
    y: p1.y + t * (p2.y - p1.y)
  };
}




function snapToEdge(x, y) {
  const SNAP_DIST = 200; // px以内なら吸着
  const margin = mmToPx(10);
  const box = {
    x1: margin,
    y1: margin,
    x2: canvas.width - margin,
    y2: canvas.height - margin
  };

  let snapX = x;
  let snapY = y;
  let minDist = SNAP_DIST;

  // 外枠4辺に吸着
  [
    { axis: 'y', value: box.y1 }, // 上
    { axis: 'y', value: box.y2 }, // 下
    { axis: 'x', value: box.x1 }, // 左
    { axis: 'x', value: box.x2 }  // 右
  ].forEach(edge => {
    const dist = Math.abs(edge.axis === 'x' ? x - edge.value : y - edge.value);
    if (dist < minDist) {
      if (edge.axis === 'x') {
        snapX = edge.value;
        minDist = dist;
      } else {
        snapY = edge.value;
        minDist = dist;
      }
    }
  });

  // 既存の線(lineObjects)への吸着
  for (const line of lineObjects) {
    if (!line.extended) continue;

    const p1 = { x: line.extended.x1, y: line.extended.y1 };
    const p2 = { x: line.extended.x2, y: line.extended.y2 };
    const pt = closestPointOnSegment({ x, y }, p1, p2);
    const dist = Math.hypot(pt.x - x, pt.y - y);

    if (dist < minDist) {
      snapX = pt.x;
      snapY = pt.y;
      minDist = dist;
    }
  }

  return { x: snapX, y: snapY };
}
function closestPointOnSegment(p, a, b) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const l2 = dx * dx + dy * dy;

  if (l2 === 0) return a;

  const t = Math.max(0, Math.min(1,
    ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2
  ));

  return {
    x: a.x + t * dx,
    y: a.y + t * dy
  };
}



















canvas.addEventListener('click', e => {
  if (layout !== 'free') return;

  const rect = canvas.getBoundingClientRect();
  let x = e.clientX - rect.left;
  let y = e.clientY - rect.top;

  const snapped = snapToEdge(x, y);
  clickPoints.push(snapped);
  //console.log(clickPoints);
  if (clickPoints.length === 2) {
    let p1 = clickPoints[0];
    let p2 = clickPoints[1];

    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    const angle = Math.atan2(dy, dx);
    const angleDeg = Math.abs(angle * 180 / Math.PI);

    let width = mmToPx(2); // デフォルトの線幅

    // 水平に近い(±15度以内)
    if (angleDeg < 9 || angleDeg > 171) {
      p2.y = p1.y;              // 完全に水平に補正
      width = mmToPx(4);        // 幅を広げる(例:4mm)

    // 垂直に近い(75〜105度)
    } else if (angleDeg > 81 && angleDeg < 99) {
      p2.x = p1.x;              // 完全に垂直に補正
      width = mmToPx(2);        // 幅を広げる(例:4mm)
    }

    const newLine = new Line(p1, p2);
    newLine.width = width; // 補正した幅を反映

    // 外枠で切り取り
    const margin = mmToPx(10);
    const box = {
      x1: margin,
      y1: margin,
      x2: canvas.width - margin,
      y2: canvas.height - margin
    };
    newLine.extended = clipLineToBox(p1, p2, box);
    lineObjects.push(newLine);
    lineObjects.forEach(line => line.calculateTrimmed(lineObjects));
    clickPoints = [];
    drawPanels();
    //detectClearRegions();
  }

});







// 初期化
createButtons();
updateCanvasSize();
drawPanels();

CSS

HTML

ページのソースを表示 : Ctrl+U , DevTools : F12

view-source:https://hi0a.com/game/canvas-4koma-manga/

ABOUT

hi0a.com 「ひまあそび」は無料で遊べるミニゲームや便利ツールを公開しています。

プログラミング言語の動作デモやWEBデザイン、ソースコード、フロントエンド等の開発者のための技術を公開しています。

必要な機能の関数をコピペ利用したり勉強に活用できます。

プログラムの動作サンプル結果は画面上部に出力表示されています。

環境:最新のブラウザ GoogleChrome / Windows / Android / iPhone 等の端末で動作確認しています。

画像素材や音素材は半分自作でフリー素材配布サイトも利用しています。LINK参照。

仕様変更、API廃止等、実験途中で動かないページもあります。