1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Matter.jsで「時間切れのSNSに💩を降らせる」Chrome拡張を作った話

1
Posted at

どうも!
keniです。

指定したサイトを見続けると、画面に💩が降ってきて休憩に入るChrome拡張機能を作りました。

名前は Poop Break です。

Adobe Express - 画面収録 2026-08-11 17.02.30.gif

サイトブロッカーとしては、やっていることは普通です。対象サイトと利用時間を決め、時間になったら休憩を促す。

ただ、休憩画面を素直にモーダルにすると、あまりにも真面目でした。

そこでMatter.jsを使い、時間切れになると💩を降らしながら画面を埋めるようにしました。見た目はくだらないですが、実装してみると「ページ上で物理演算するUI」を成立させるための学びがいくつかありました。

この記事では自身の復習も兼ねて、Chrome拡張の基本的な作り方ではなく、実装中に特に考えた部分をまとめます。

↓Producthuntに挑戦予定(2026年8月27日)です。よければ応援していただけますと励みになります🙏
Poop Break - Scroll too long, and poop falls. | Product Hunt

作ったもの

Poop Breakは、XやYouTubeなど、指定したサイトの連続閲覧時間を計測します。

制限時間になると休憩画面が出て、カウントダウンの間、💩が上から降ってきます。休憩時間、対象サイト、ON / OFFはpopupから設定できます。完全無料です。

技術スタックは以下です。

Manifest V3 / Vanilla JavaScript / Matter.js 0.20.0 / Canvas API / Shadow DOM

最初に決めたのは、「何を見すぎたことにするか」だった

「利用時間を計測する」と言っても、仕様は意外と曖昧です。

  • 別タブを見ている間も数えるのか
  • 他のアプリに切り替えた時間はどうするのか
  • 同じサイトを開き直したら続きから数えるのか
  • 日次の合計時間なのか、連続閲覧時間なのか

Poop Breakでは、対象タブが表示され、かつChromeウィンドウにフォーカスがある時間だけを数えています。タブを離れた時点で連続閲覧時間はリセットします。

const tracker = setInterval(() => {
  if (document.hidden || !document.hasFocus()) return;

  seconds += 1;

  if (seconds >= currentSettings.usageLimit * 60) {
    clearInterval(tracker);
    showPoopBreak(currentSettings.breakTime, () => startTracking());
  }
}, 1000);

document.addEventListener('visibilitychange', () => {
  if (document.hidden) resetSeconds({ clearStoredUsage: true });
});

これは「正しい計測」ではなく、プロダクトの目的に合わせた定義です。

日次の利用制限を作るなら、タブやページをまたいでService Worker側で合計時間を持つ方が自然です。一方で今回は、惰性でスクロールしている一続きの時間に区切りを入れたかったので、タブを離れたらリセットする方がしっくりきました。

タイマーを書く前に「何分数えるか」を決めないと、実装はできても体験がぶれます。

ページのCSSに💩を食べられないよう、Shadow DOMに閉じ込めた

Content ScriptでUIを出すと、サイト側のCSSとぶつかります。

ニュースサイト、動画サイト、SNS。どのページに入るか分からないので、グローバルなbuttoncanvasの指定だけでも見た目が壊れる可能性があります。

そこで、休憩画面はShadow DOMの中に置きました。

function getShadowRoot() {
  let host = document.getElementById('poop-break-host');

  if (!host) {
    host = document.createElement('div');
    host.id = 'poop-break-host';
    host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none';
    document.documentElement.appendChild(host);
  }

  if (!host.shadowRoot) host.attachShadow({ mode: 'open' });
  return host.shadowRoot;
}

休憩中だけpointer-eventsを有効にし、スクロール操作と対象キーを止めます。動画が再生されていれば一時停止します。

重要だったのは表示処理より後始末です。休憩が終わったらCanvas、イベントリスナー、スクロール制御、host要素をすべて元に戻します。

ページの上に演出を載せる実装は、出すときより消すときの方が気を遣います。残った💩は、ユーザーにもDOMにも嫌われます。

💩を無限に落とすと、先にCPUが休憩に入る

最初は、Matter.jsのBodyを作って落とし続ければよいと思っていました。

しかし画面を埋めるほど落とすと、衝突判定の対象が増えていきます。演出が派手になるほど、CPUが苦しくなる。これはさすがに本末転倒でした。

そこで、💩を次の3段階で扱っています。

  1. 落下中だけ物理Bodyとして扱う
  2. 十分に遅くなったら静的Bodyにして、積み上がった高さだけ記録する
  3. 画面が埋まったら見た目の位置と角度を保存し、Matter.jsのWorldから完全に外す
function finalizePile(now) {
  if (finalization.finalized || !isScreenFilled()) return;

  if (!finalization.startedAt) finalization.startedAt = now;
  const elapsed = now - finalization.startedAt;

  if (!finalization.locked && elapsed >= 420) {
    // 一度静止させてから、見た目だけを保存する
    for (const poop of poops) {
      Body.setVelocity(poop.body, { x: 0, y: 0 });
      Body.setAngularVelocity(poop.body, 0);
      Body.setStatic(poop.body, true);
    }
    finalization.locked = true;
  }

  if (!finalization.locked || elapsed < 1120) return;

  bakedPoops = poops.map((poop) => ({
    x: poop.body.position.x,
    y: poop.body.position.y,
    angle: poop.body.angle,
    size: poop.size,
    color: poop.color,
  }));

  for (const poop of poops) Composite.remove(engine.world, poop.body);
  poops = [];
  finalization.finalized = true;
}

ここでのポイントは、画面に残す必要があるのは「物理Body」ではなく「積み上がったように見える絵」だと割り切ることです。

最終状態ではCanvasに描くだけなので、衝突判定はゼロです。物理演算は見せ場の最初だけに使い、静止画にできる状態になったら卒業してもらいます。

物理演算は固定タイムステップ、描画はrequestAnimationFrame

requestAnimationFrameの間隔は端末やタブの状態で変わります。その差分をそのままMatter.jsに渡すと、重いときほど💩が不自然にワープしやすくなります。

そこで描画はブラウザに合わせつつ、物理演算だけ約60Hzの固定タイムステップで進めています。

function tick(now) {
  const fixedDelta = 1000 / 60;
  const elapsed = Math.min(100, Math.max(0, now - previousTime));
  previousTime = now;

  physicsAccumulator = Math.min(fixedDelta * 5, physicsAccumulator + elapsed);

  while (physicsAccumulator >= fixedDelta) {
    Engine.update(engine, fixedDelta);
    physicsAccumulator -= fixedDelta;
  }

  draw(now);
  animationFrame = requestAnimationFrame(tick);
}

elapsedとaccumulatorに上限を置いているのは、バックグラウンドから復帰した直後に、止まっていた数秒分を一気にシミュレーションしないためです。

物理演算が正確でも、復帰時にブラウザが固まったら意味がありません。厳密さより、演出として破綻しないことを優先しました。

見た目は複雑、当たり判定は円でよい

💩の形に合わせた複雑なPolygonを作ることもできますが、今回の当たり判定は円です。

const body = Bodies.circle(x, -size * 1.3, colliderRadius, {
  restitution: 0.035,
  friction: 0.94,
  frictionAir: 0.012,
});

描画はCanvasで独自に行い、色ごとのスプライトはキャッシュしています。

function getSprite(color, face) {
  const key = `${color}:${face ? 'face' : 'back'}`;
  const cached = spriteCache.get(key);
  if (cached) return cached;

  const sprite = document.createElement('canvas');
  // ここで💩を一度だけ描画する
  spriteCache.set(key, sprite);
  return sprite;
}

正確な当たり判定より、「落ちて、跳ねて、積もっているように見える」ことの方が大事でした。

この割り切りで、実装もパフォーマンスもかなり扱いやすくなりました。UIの演出なら、物理的な正しさより視覚的な納得感を優先してよい場面は多いと思います。

まとめ

Poop Breakを作って改めて感じたのは、変なUIほど仕様と終了処理を真面目に考える必要がある、ということです。

  • 時間計測は、コードを書く前に「何を利用中とするか」を決める
  • 任意ページへ出すUIは、Shadow DOMでサイト側のCSSから隔離する
  • 物理演算は、動いている間だけ使う
  • 画面に残すべきものをBodyではなく描画データとして持つと軽くできる
  • 見た目の正確さより、体験としての納得感を優先する

題材は💩ですが、「物理演算を使った一回きりのWeb UI」を作るときには、わりと真面目に応用できる話だと思います。
気になった方は、ぜひ使ってみてください。(もちろん完全無料です!!!)

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?