ksg

ksg@software_engineer

Portfolio Blog

next-js2026/10/07

積読・積みプラ・積みゲーを「ガチャ」で崩すWebアプリ「TsumiBreak」を作った

積読・積みプラ・積みゲーを「ガチャ」で崩すWebアプリ「TsumiBreak」を作った

はじめに

買ったまま手を付けていない本、作っていないプラモデル、遊んでいないゲーム。いわゆる「積み」を、罪悪感なく楽しく崩すためのWebアプリ「TsumiBreak(ツミブレイク)」を作りました。

積みを「コレクション」として眺めるアプリはたくさんあります。TsumiBreakはその逆で、積みを減らす行動を後押しすることだけに絞っています。

この記事では、どんな考えでこの形にしたのかと、主な機能の作りを紹介します。

積みが崩れない理由は3つある

自分の積みを振り返ると、崩れない理由は次の3つでした。

  1. 何から手を付けるか迷う:積みが多いほど選べなくなり、結局どれにも手を付けない
  2. 始めるハードルが高い:「ちゃんと時間を取ってから」と思っているうちに日が過ぎる
  3. 崩しても達成感がない:1つ終わっても、山がまだ高いので減った気がしない

TsumiBreakでは、それぞれに1つずつ機能を当てています。

理由

機能

何から手を付けるか迷う

崩しガチャ(次に手を付けるものをランダムで決める)

始めるハードルが高い

15分タイマー(「15分だけ」と決めて始める)

崩しても達成感がない

資産額メーターとXシェア(減った金額を見える化する)

機能1:崩しガチャ

ダッシュボードのガチャを回すと、積んでいるアイテムの中から1つがランダムに選ばれます。「自分で選ばない」ことで、迷う時間をゼロにするのが狙いです。

抽選そのものは、とても単純です。

/** 均等確率で 1 件選ぶ。rand は [0, 1) を返す関数(テスト用に差し替え可能) */
export function pickRandom<T>(items: readonly T[], rand: () => number = Math.random): T | null {
  if (items.length === 0) return null
  const idx = Math.min(Math.floor(rand() * items.length), items.length - 1)
  return items[idx]
}

/** 引き直し用。候補が 2 件以上あれば直前の結果を除外して選ぶ */
export function pickNext<T extends { id: string }>(
  items: readonly T[],
  previousId: string | null,
  rand: () => number = Math.random,
): T | null {
  const pool = previousId && items.length > 1 ? items.filter((i) => i.id !== previousId) : items
  return pickRandom(pool, rand)
}

工夫したのは2点です。

  • 乱数を引数で差し替えられるようにした:rand を渡せるので、テストでは決まった値を返す関数を渡して「どれが選ばれるか」を確かめられます。
  • 引き直しで同じものが出ないようにした:純粋にランダムだと、引き直しても同じものが出ることがあります。体験としては「壊れている」と感じるので、候補が2件以上あるときは直前の結果を外しています。

抽選の候補は、カテゴリ(プラモ・本・ゲーム)や「途中のものも含めるか」で絞り込めます。

演出はSVGとCSSアニメーションだけ

ガチャの演出は、カプセルトイ風にしました。ハンドルが回り、カプセルが落ちてきて、揺れてからパカッと開きます。全体で約2.5秒です。

画像や動画は使わず、マシンもカプセルもSVGで描いて、CSSのkeyframesで動かしています。最後の光のアニメーションが終わったとき(onAnimationEnd)に結果を表示します。

結果が出たら紙吹雪(canvas-confetti)を飛ばします。紙吹雪は使うときだけ import() で読み込むので、最初のページ表示を重くしません。

void import('canvas-confetti').then(({ default: confetti }) =>
  confetti({ particleCount: 80, spread: 70, origin: { y: 0.55 }, zIndex: 1000, disableForReducedMotion: true }),
)

OSで「視差効果を減らす」を設定している人には、disableForReducedMotion で紙吹雪を出しません。ガチャの演出も飛ばして、すぐに結果を表示します。

機能2:15分タイマー

ガチャで選ばれたアイテムは、そのまま15分タイマーを始められます。「全部やる」ではなく「15分だけ」と決めると、始めるハードルがぐっと下がります。

タイマーは画面を移動しても、ブラウザを再読み込みしても続くようにしました。開始時刻と長さを localStorage に保存して、残り時間は毎回「開始時刻+長さ−今」で計算します。

export type TimerState = { itemId: string; itemTitle: string; startedAt: number; durationMs: number }

export function remainingMs(s: TimerState, now: number): number {
  return Math.max(0, s.startedAt + s.durationMs - now)
}

/** 記録用の経過分。タイマー終了後に時間が経っていても durationMs を上限にする */
export function elapsedMinutes(s: TimerState, now: number): number {
  const elapsed = Math.min(now, s.startedAt + s.durationMs) - s.startedAt
  return Math.max(0, Math.round(elapsed / 60_000))
}

「1秒ごとに残り時間を1減らす」作りにすると、スマホでタブが裏に回ったときにタイマーが止まってずれます。時刻から毎回計算すれば、裏に回っていても正しい残り時間が出ます。

localStorage から読んだ値は、型と範囲(長さは4時間まで、など)を確かめてから使います。プライベートブラウズなどで localStorage が使えない環境でも、例外で落ちずに画面上ではタイマーが動くようにしています。

機能3:資産額メーターとXシェア

アイテムには価格を登録できます。ダッシュボードでは「まだ積んでいる金額」と「今月崩した金額」を出しています。

積みの山は減った気がしにくいのですが、「今月 6,200円分を消化した」と金額で見ると、崩した実感が湧きます。

崩し完了にすると、紙吹雪と一緒に「MISSION COMPLETE!」の画面が出て、そのままXにシェアできます。シェアには、件数と金額が入った画像付きのカードが付きます。この画像はDBに保存せず、URLのパラメータから動的に作っています(作り方は別の記事にまとめます)。

使っている技術

分類

技術

フレームワーク

Next.js 16(App Router、Server Actions)

UI

Tailwind CSS v4、shadcn/ui、Lucide

認証・DB

Supabase(Auth、PostgreSQL、RLS)

バーコード読み取り

barcode-detector(ZXingのWebAssembly版)

PWA

Serwist

テスト

Vitest、Testing Library、Playwright

ホスティング

Vercel

ホーム画面に追加すればアプリのように使えるよう、PWAにしています。ストアに出すネイティブアプリにしなかったのは、URLを開けばすぐ試せることを優先したからです。

本屋や模型店で「これ、もう持ってたっけ?」を確かめられるバーコード読み取りも入れました。仕組みは次の記事で詳しく書きます。

おわりに

積みを崩すのに必要なのは、立派な管理機能より「とりあえず15分やってみる」きっかけでした。ガチャ、タイマー、金額の見える化は、どれもそのきっかけを作るための機能です。

無料で使えて、登録はメールアドレスだけです。積みに心当たりのある方は、ぜひ試してみてください。

https://tsumi-break.vercel.app

おすすめの記事