#10 ビジュアルコーディング パーティクル

インタラクティブパーティクル——ユーザーの操作に反応する

シリーズ最終回です。これまで学んだ生成・移動・透明度・物理・分裂・爆発・軌跡・煙・炎のすべてを、ユーザーのインタラクションと組み合わせます。

動かないエフェクトは「デモ」ですが、触れるエフェクトは「体験」になります。クリック・ドラッグ・ホバーに反応するパーティクルシステムを段階的に作り上げましょう。


マウスイベントの取得

Canvas でマウス操作を受け取るには3種類のイベントが基本です。

var mouse = {
  x: 0, y: 0,
  down: false,
  prevX: 0, prevY: 0
};

canvas.addEventListener('mousemove', function(e) {
  var rect = canvas.getBoundingClientRect();
  mouse.prevX = mouse.x;
  mouse.prevY = mouse.y;
  mouse.x = e.clientX - rect.left;
  mouse.y = e.clientY - rect.top;
});

canvas.addEventListener('mousedown', function(e) {
  mouse.down = true;
});

canvas.addEventListener('mouseup', function(e) {
  mouse.down = false;
});

getBoundingClientRect() でキャンバスの画面上の位置を取得し、クリック座標からオフセットを引くことでキャンバス内座標に変換します。

mouse.down フラグを mousedown / mouseup で切り替えることで、「ドラッグ中かどうか」を判定できます。


マウスドラッグで炎の軌跡を描く

mouse.downtrue の間だけパーティクルを生成します。

マウスドラッグで炎の軌跡を描く
var particles = [];
var mouse = { x: W/2, y: H/2, down: false };

(function() {
var el = document.querySelector('canvas');
if (!el) return;
el.addEventListener('mousemove', function() {
  mouse.x = mx; mouse.y = my;
  if (mouse.down) {
    for (var i = 0; i < 6; i++) {
      var angle = Math.random() * Math.PI * 2;
      var speed = Math.random() * 2 + 0.5;
      particles.push({
        x: mouse.x + (Math.random()-0.5)*10,
        y: mouse.y + (Math.random()-0.5)*10,
        vx: Math.cos(angle)*speed,
        vy: Math.sin(angle)*speed - 1,
        life: 50+Math.floor(Math.random()*30),
        maxLife: 80
      });
    }
  }
});
el.addEventListener('mousedown', function() { mouse.down = true; });
el.addEventListener('mouseup', function() { mouse.down = false; });
})();

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '13px monospace';
ctx.fillText('ドラッグして描いてください', 10, H - 12);

ctx.globalCompositeOperation = 'lighter';
particles.forEach(function(p) {
  p.vx += (Math.random()-0.5)*0.4;
  p.vy -= 0.05;
  p.x += p.vx; p.y += p.vy;
  p.life--;
  var ratio = p.life / p.maxLife;
  var hue = ratio * 55;
  ctx.globalAlpha = ratio * 0.8;
  ctx.fillStyle = 'hsl('+hue+',100%,'+(30+ratio*60)+'%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, (4+ratio*2)*ratio, 0, Math.PI*2);
  ctx.fill();
  ctx.globalAlpha = 1;
});
ctx.globalCompositeOperation = 'source-over';

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

クリックで色付き爆発

クリックした位置でパーティクルが爆発します。クリックごとに色相が変わるカラフルな爆発です。

クリックで色付き爆発パーティクル
var particles = [];
var colorIndex = 0;

function explodeAt(x, y, hue) {
var n = 50 + Math.floor(Math.random() * 30);
for (var i = 0; i < n; i++) {
  var angle = (Math.PI * 2 / n) * i + (Math.random()-0.5)*0.3;
  var speed = Math.random() * 5 + 2;
  particles.push({
    x: x, y: y,
    vx: Math.cos(angle)*speed,
    vy: Math.sin(angle)*speed,
    life: 60+Math.floor(Math.random()*40),
    maxLife: 100,
    hue: hue + (Math.random()-0.5)*40
  });
}
}

(function() {
var el = document.querySelector('canvas');
if (!el) return;
el.addEventListener('click', function() {
  var hue = (colorIndex * 137.5) % 360;
  colorIndex++;
  explodeAt(mx, my, hue);
});
})();

explodeAt(W/2, H/2, 200);

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.18)';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '13px monospace';
ctx.fillText('クリックして爆発させてください', 10, H - 12);

particles.forEach(function(p) {
  p.vy += 0.08;
  p.vx *= 0.98; p.vy *= 0.98;
  p.x += p.vx; p.y += p.vy;
  p.life--;
  var ratio = p.life / p.maxLife;
  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'hsl('+p.hue+',90%,'+(50+ratio*25)+'%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 3*ratio+0.5, 0, Math.PI*2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

マウスホバーでパーティクルが弾ける

マウスに近いパーティクルを反発させます。第4回で学んだ反発場の応用です。

ホバーでパーティクルが弾ける——反発場
var particles = [];
var mouse = { x: -999, y: -999 };

for (var i = 0; i < 250; i++) {
particles.push({
  x: Math.random()*W,
  y: Math.random()*H,
  ox: 0, oy: 0,
  vx: 0, vy: 0,
  hue: Math.random()*360
});
}

(function() {
var el = document.querySelector('canvas');
if (!el) return;
el.addEventListener('mousemove', function() {
  mouse.x = mx; mouse.y = my;
});
el.addEventListener('mouseleave', function() {
  mouse.x = -999; mouse.y = -999;
});
})();

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.3)';
ctx.fillRect(0, 0, W, H);

particles.forEach(function(p) {
  var dx = mouse.x - p.x;
  var dy = mouse.y - p.y;
  var dist = Math.max(Math.sqrt(dx*dx+dy*dy), 1);

  if (dist < 100) {
    var force = (100 - dist) / 100 * 4;
    p.vx -= (dx/dist) * force;
    p.vy -= (dy/dist) * force;
  }

  p.vx *= 0.88;
  p.vy *= 0.88;
  p.x += p.vx;
  p.y += p.vy;

  p.x = Math.max(0, Math.min(W, p.x));
  p.y = Math.max(0, Math.min(H, p.y));

  var speed = Math.sqrt(p.vx*p.vx+p.vy*p.vy);
  var brightness = 45 + speed*10;
  ctx.fillStyle = 'hsl('+p.hue+',80%,'+Math.min(brightness,80)+'%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2.5, 0, Math.PI*2);
  ctx.fill();
});

requestAnimationFrame(loop);
}
loop();

速度が速いほど明るくなるため、「弾かれた瞬間」が視覚的に分かります。


タッチ対応

スマートフォン対応のために touchstart / touchmove イベントも処理します。

function getPos(e, canvas) {
  var rect = canvas.getBoundingClientRect();
  var source = e.touches ? e.touches[0] : e;
  return {
    x: source.clientX - rect.left,
    y: source.clientY - rect.top
  };
}

canvas.addEventListener('touchstart', function(e) {
  e.preventDefault();
  var pos = getPos(e, canvas);
  mouse.x = pos.x;
  mouse.y = pos.y;
  mouse.down = true;
}, { passive: false });

canvas.addEventListener('touchmove', function(e) {
  e.preventDefault();
  var pos = getPos(e, canvas);
  mouse.x = pos.x;
  mouse.y = pos.y;
}, { passive: false });

canvas.addEventListener('touchend', function() {
  mouse.down = false;
});

e.preventDefault(){ passive: false } の組み合わせで、タッチ操作によるページスクロールを防ぎます。


パーティクルシステムクラスにまとめる

大規模なプロジェクトでは、パーティクルの管理をクラス(またはオブジェクト)に封じ込めると再利用しやすくなります。

function ParticleSystem() {
  this.particles = [];
}

ParticleSystem.prototype.emit = function(x, y, options) {
  var opt = options || {};
  var n = opt.count || 10;
  for (var i = 0; i < n; i++) {
    var angle = Math.random() * Math.PI * 2;
    var speed = (opt.speed || 1) * (Math.random() * 0.5 + 0.75);
    this.particles.push({
      x: x, y: y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: opt.life || 60,
      maxLife: opt.life || 60,
      hue: opt.hue !== undefined ? opt.hue : Math.random() * 360
    });
  }
};

ParticleSystem.prototype.update = function() {
  this.particles.forEach(function(p) {
    p.vy += 0.1;
    p.x += p.vx; p.y += p.vy;
    p.life--;
  });
  this.particles = this.particles.filter(function(p) { return p.life > 0; });
};

ParticleSystem.prototype.draw = function() {
  var self = this;
  self.particles.forEach(function(p) {
    var ratio = p.life / p.maxLife;
    ctx.globalAlpha = ratio;
    ctx.fillStyle = 'hsl(' + p.hue + ',80%,65%)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3 * ratio, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
  });
};

フルインタラクティブパーティクルキャンバス

クリック爆発とドラッグ軌跡を統合した最終デモです。

フルインタラクティブ——クリック爆発 + ドラッグ軌跡
var particles = [];
var mouse = { x: W/2, y: H/2, down: false, prevX: W/2, prevY: H/2 };
var colorIdx = 0;

function explodeAt(x, y, hue) {
var n = 45 + Math.floor(Math.random()*20);
for (var i = 0; i < n; i++) {
  var angle = (Math.PI*2/n)*i + (Math.random()-0.5)*0.4;
  var speed = Math.random()*5+2;
  particles.push({
    x:x, y:y,
    vx:Math.cos(angle)*speed,
    vy:Math.sin(angle)*speed,
    life:60+Math.floor(Math.random()*40),
    maxLife:100,
    hue:hue+(Math.random()-0.5)*30,
    type:'burst'
  });
}
}

function trailAt(x, y, hue) {
for (var i = 0; i < 4; i++) {
  var angle = Math.random()*Math.PI*2;
  var speed = Math.random()*2+0.5;
  particles.push({
    x:x+(Math.random()-0.5)*8,
    y:y+(Math.random()-0.5)*8,
    vx:Math.cos(angle)*speed,
    vy:Math.sin(angle)*speed-0.8,
    life:40+Math.floor(Math.random()*20),
    maxLife:60,
    hue:hue,
    type:'trail'
  });
}
}

(function() {
var el = document.querySelector('canvas');
if (!el) return;
el.addEventListener('mousemove', function() {
  mouse.prevX = mouse.x; mouse.prevY = mouse.y;
  mouse.x = mx; mouse.y = my;
  if (mouse.down) trailAt(mouse.x, mouse.y, (colorIdx*137.5)%360);
});
el.addEventListener('mousedown', function() {
  mouse.down = true;
  mouse.x = mx; mouse.y = my;
  var hue = (colorIdx*137.5)%360; colorIdx++;
  explodeAt(mouse.x, mouse.y, hue);
});
el.addEventListener('mouseup', function() { mouse.down = false; });
el.addEventListener('touchstart', function(e) {
  e.preventDefault();
  mouse.x = mx; mouse.y = my;
  mouse.down = true;
  var hue = (colorIdx*137.5)%360; colorIdx++;
  explodeAt(mouse.x, mouse.y, hue);
}, {passive:false});
el.addEventListener('touchmove', function(e) {
  e.preventDefault();
  mouse.x = mx; mouse.y = my;
  if (mouse.down) trailAt(mouse.x, mouse.y, (colorIdx*137.5)%360);
}, {passive:false});
el.addEventListener('touchend', function() { mouse.down = false; });
})();

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '13px monospace';
ctx.fillText('クリック:爆発 / ドラッグ:軌跡', 10, H-12);

ctx.globalCompositeOperation = 'lighter';

particles.forEach(function(p) {
  if (p.type === 'burst') {
    p.vy += 0.1;
    p.vx *= 0.97; p.vy *= 0.97;
  } else {
    p.vx += (Math.random()-0.5)*0.3;
    p.vy -= 0.04;
  }
  p.x += p.vx; p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  ctx.globalAlpha = ratio * 0.85;
  if (p.type === 'burst') {
    ctx.fillStyle = 'hsl('+p.hue+',90%,'+(50+ratio*25)+'%)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3*ratio+0.5, 0, Math.PI*2);
    ctx.fill();
  } else {
    var hue = ratio*55;
    ctx.fillStyle = 'hsl('+hue+',100%,'+(30+ratio*60)+'%)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, (4+ratio*2)*ratio, 0, Math.PI*2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
});

ctx.globalCompositeOperation = 'source-over';
particles = particles.filter(function(p) { return p.life > 0 && particles.length > 0; });
if (particles.length > 1500) particles.splice(0, particles.length - 1500);

requestAnimationFrame(loop);
}
loop();

シリーズのまとめ

10回を通してパーティクルシステムの全体像を実装しました。

学んだこと
1生成・移動・削除の基本サイクル
2透明度・サイズ変化・残像エフェクト
3重力・風力・空気抵抗・バウンス
4引力場・反発場・渦場・フローフィールド
5分裂・連鎖・花火の実装
6爆発(閃光・衝撃波・破片)
7軌跡(残像・履歴・グラデーション)
8煙(ラジアルグラデーション・合成モード)
9炎(パーティクル・ピクセルシミュレーション)
10インタラクション(クリック・ドラッグ・タッチ)

ここからできること

パーティクルの基礎を身につけたら、次のステップへ進めます。

  • ゲームエフェクト: 攻撃ヒット・魔法の詠唱・レベルアップ演出
  • UI強調: ボタンクリックのリプル・ページトランジション・ローディングアニメーション
  • データビジュアライゼーション: データの変化をパーティクルで視覚化
  • WebGL化: Three.js の Points・ShaderMaterial を使った GPU パーティクル(数十万粒のリアルタイム描画)

CanvasのパーティクルはWebGLへの入り口でもあります。このシリーズで学んだ「速度・寿命・力」という概念は、どのプラットフォームでも共通です。ぜひ自分だけのパーティクルエフェクトを作ってみてください。