その他

CSSやJSで作る、掴んで崩せる楽しいタスクタワー

投稿日2026/09/28

更新日2026/9/22

タスクリストに物理演算を持ち込んだら、こうなりました。

カードが重力に従って積み上がり、ドラッグすれば他のカードを押しのけて崩れ、チェックを入れると横にスライドして消え、残ったカードがガタガタと落下して再び安定する――そんなタスクタワーを、外部ライブラリなしのバニラJavaScriptだけで実装したデモです。

内部には衝突検出(SAT)、衝突応答(インパルスベース)、摩擦・反発・慣性を備えた本格的な2D剛体物理エンジンが組み込まれており、タスク管理という実用的なUIにゲームエンジンさながらの物理挙動を組み合わせた実験的な作品です。

Code コード

<main class="kumonosu-stage" data-stroke="on" aria-label="CSSやJSで作る、掴んで崩せる楽しいタスクタワー">
	<div class="kumonosu-twr" style="width:300px;height:320px">
		<ul class="kumonosu-twr-pile" aria-label="やることリスト">
		</ul>
	</div>
</main>
* {
	box-sizing: border-box;
}
body {
	margin: 0;
	background: #101217;
	font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.kumonosu-stage {
	position: relative;
	width: 100%;
	height: 100vh;
	height: 100dvh;
	overflow: hidden;
	touch-action: none;
	--card: #30343c;
	--fill-on: #a9c4ff;
	--fill-slab: #30343c;
	--focus: #8eb4ff;
	--ink: #f1f3f7;
	--ink-2: #e3e7ef;
	--ink-4: #a0a9b9;
	--ink-rgb: 241, 243, 247;
	--on-ink: #172033;
	--pane-edge: #444a55;
}
.kumonosu-stage>.kumonosu-twr {
	position: absolute;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%) scale(var(--kumonosu-scale, 1));
	transform-origin: center;
}
[data-stroke="on"] .kumonosu-twr-card {
	outline: 1px solid var(--pane-edge);
	outline-offset: -1px;
}
.kumonosu-twr {
	position: relative;
	-webkit-user-select: none;
	user-select: none;
}
.kumonosu-twr-pile {
	position: absolute;
	inset: 0;
	list-style: none;
	margin: 0;
	padding: 0;
}
.kumonosu-twr-row {
	position: absolute;
	left: 50%;
	bottom: 0;
	touch-action: none;
}
.kumonosu-twr-card {
	display: flex;
	align-items: center;
	gap: 10px;
	height: 100%;
	padding: 0 14px;
	border-radius: 11px;
	background: var(--fill-slab, var(--card));
	transform-origin: 50% 50%;
	cursor: grab;
	transition: translate 260ms cubic-bezier(0.36, 0, 0.7, 0.2), rotate 260ms cubic-bezier(0.36, 0, 0.7, 0.2), opacity 200ms ease 60ms;
}
.kumonosu-twr-row[data-out] .kumonosu-twr-card {
	translate: 118% 0;
	rotate: 5deg;
	opacity: 0;
	transition-delay: 90ms, 90ms, 150ms;
}
.kumonosu-twr-row[data-hand] {
	z-index: 3;
}
.kumonosu-twr-row[data-hand] .kumonosu-twr-card {
	cursor: grabbing;
	translate: 0 -2px;
}
.kumonosu-twr-check {
	flex: none;
	width: 19px;
	height: 19px;
	display: grid;
	place-items: center;
	padding: 0;
	border: 0;
	border-radius: 6px;
	background: transparent;
	box-shadow: inset 0 0 0 1.5px rgba(var(--ink-rgb), 0.22);
	color: var(--on-ink);
	cursor: pointer;
	transition: background-color 140ms ease, box-shadow 140ms ease;
}
.kumonosu-twr-check svg {
	opacity: 0;
	transition: opacity 110ms ease;
}
.kumonosu-twr-check:hover {
	box-shadow: inset 0 0 0 1.25px rgba(var(--ink-rgb), 0.38);
}
.kumonosu-twr-row[data-out] .kumonosu-twr-check {
	background: var(--fill-on, var(--ink));
	box-shadow: inset 0 0 0 1.25px var(--fill-on, var(--ink));
}
.kumonosu-twr-row[data-out] .kumonosu-twr-check svg {
	opacity: 1;
}
.kumonosu-twr-check:focus-visible {
	outline: 2px solid var(--focus, #4c8dff);
	outline-offset: 2px;
}
.kumonosu-twr-text {
	font-size: 15px;
	line-height: 1;
	letter-spacing: -0.005em;
	color: var(--ink-2);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.kumonosu-twr-row[data-out] .kumonosu-twr-text {
	text-decoration: line-through;
	color: var(--ink-4);
}
@media (prefers-reduced-motion: reduce) {
	.kumonosu-twr-card {
		transition-duration: 1ms, 1ms, 1ms;
	}
}
@media (prefers-reduced-motion: reduce) {
	.kumonosu-twr-check, .kumonosu-twr-check svg {
		transition-duration: 1ms;
	}
}
(() => {
	const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
	const CW = 206;
	const CH = 46;
	const HW = CW / 2;
	const HH = CH / 2;
	const MAX = 8;
	const W = 300;
	const BASE_H = 320;
	const boxH = (n) => Math.max(BASE_H, n * CH + 40);
	const INSET = 10;
	const grav = (v) => 900 + (clamp(v, 0, 100) / 100) * 3300;
	const RESTITUTION = 0;
	const frictionOf = (sl) => 0.85 - sl * 0.65;
	const DT = 1 / 240;
	const MAXSTEPS = 16;
	const REST_V = 6;
	const REST_W = 0.04;
	const REST_N = 60;
	const GRAB = 4;
	const VMAX = 1800;
	const SCRAPE = 150;
	const LEAN = [-0.42, 0.68, -0.15, 0.51, -0.86, 0.24, 0.73, -0.57];
	const TODOS = ["ナディアに返信する", "ドメインを更新する", "航空券を予約する", "アイコンを書き出す", "請求書の支払いをする", "大家さんに電話する", "バックアップを取る", "植物に水をやる", ];
	const FLOOR = 100,
		LEFT = 101,
		RIGHT = 102,
		ROOF = 103;
	const tower = (n, sl, fr, lift = 0) => Array.from({
		length: n
	}, (_, i) => box(i, LEAN[i % LEAN.length] * sl * 16, HH + (n - 1 - i) * CH + lift, HW, HH, fr, ), );

	function box(id, x, y, hw, hh, friction, mass = 1) {
		const still = mass === 0;
		return {
			id,
			x,
			y,
			a: 0,
			vx: 0,
			vy: 0,
			w: 0,
			hw,
			hh,
			friction,
			invM: still ? 0 : 1 / mass,
			invI: still ? 0 : 12 / (mass * (4 * hw * hw + 4 * hh * hh)),
		};
	}
	const massOf = (hw, hh) => ({
		invM: 1,
		invI: 12 / (4 * hw * hw + 4 * hh * hh)
	});
	const E1 = 1,
		E2 = 2,
		E3 = 3,
		E4 = 4;
	const keyOf = (k) => k[0] | (k[1] << 4) | (k[2] << 8) | (k[3] << 12);

	function incident(hx, hy, px, py, c, s, nx0, ny0) {
		const nx = -(c * nx0 + s * ny0);
		const ny = -(-s * nx0 + c * ny0);
		let a, b;
		if (Math.abs(nx) > Math.abs(ny)) {
			if (nx > 0) {
				a = {
					x: hx,
					y: -hy,
					k: [0, 0, E3, E4]
				};
				b = {
					x: hx,
					y: hy,
					k: [0, 0, E4, E1]
				};
			} else {
				a = {
					x: -hx,
					y: hy,
					k: [0, 0, E1, E2]
				};
				b = {
					x: -hx,
					y: -hy,
					k: [0, 0, E2, E3]
				};
			}
		} else if (ny > 0) {
			a = {
				x: hx,
				y: hy,
				k: [0, 0, E4, E1]
			};
			b = {
				x: -hx,
				y: hy,
				k: [0, 0, E1, E2]
			};
		} else {
			a = {
				x: -hx,
				y: -hy,
				k: [0, 0, E2, E3]
			};
			b = {
				x: hx,
				y: -hy,
				k: [0, 0, E3, E4]
			};
		}
		for (const v of [a, b]) {
			const x = v.x,
				y = v.y;
			v.x = px + c * x - s * y;
			v.y = py + s * x + c * y;
		}
		return [a, b];
	}

	function clip(vin, nx, ny, offset, edge) {
		const out = [];
		const d0 = nx * vin[0].x + ny * vin[0].y - offset;
		const d1 = nx * vin[1].x + ny * vin[1].y - offset;
		if (d0 <= 0) out.push(vin[0]);
		if (d1 <= 0) out.push(vin[1]);
		if (d0 * d1 < 0) {
			const t = d0 / (d0 - d1);
			const v = {
				x: vin[0].x + t * (vin[1].x - vin[0].x),
				y: vin[0].y + t * (vin[1].y - vin[0].y),
				k: d0 > 0 ? [...vin[0].k] : [...vin[1].k],
			};
			if (d0 > 0) {
				v.k[0] = edge;
				v.k[2] = 0;
			} else {
				v.k[1] = edge;
				v.k[3] = 0;
			}
			out.push(v);
		}
		return out;
	}

	function collide(A, B) {
		const cA = Math.cos(A.a),
			sA = Math.sin(A.a);
		const cB = Math.cos(B.a),
			sB = Math.sin(B.a);
		const dpx = B.x - A.x,
			dpy = B.y - A.y;
		const dAx = cA * dpx + sA * dpy,
			dAy = -sA * dpx + cA * dpy;
		const dBx = cB * dpx + sB * dpy,
			dBy = -sB * dpx + cB * dpy;
		const a11 = Math.abs(cA * cB + sA * sB),
			a12 = Math.abs(-cA * sB + sA * cB);
		const a21 = Math.abs(-sA * cB + cA * sB),
			a22 = Math.abs(sA * sB + cA * cB);
		const fAx = Math.abs(dAx) - A.hw - (a11 * B.hw + a12 * B.hh);
		const fAy = Math.abs(dAy) - A.hh - (a21 * B.hw + a22 * B.hh);
		if (fAx > 0 || fAy > 0) return [];
		const fBx = Math.abs(dBx) - (a11 * A.hw + a21 * A.hh) - B.hw;
		const fBy = Math.abs(dBy) - (a12 * A.hw + a22 * A.hh) - B.hh;
		if (fBx > 0 || fBy > 0) return [];
		const REL = 0.95,
			ABS = 0.01;
		let axis = 0;
		let sep = fAx;
		let nx = dAx > 0 ? cA : -cA,
			ny = dAx > 0 ? sA : -sA;
		if (fAy > REL * sep + ABS * A.hh) {
			axis = 1;
			sep = fAy;
			nx = dAy > 0 ? -sA : sA;
			ny = dAy > 0 ? cA : -cA;
		}
		if (fBx > REL * sep + ABS * B.hw) {
			axis = 2;
			sep = fBx;
			nx = dBx > 0 ? cB : -cB;
			ny = dBx > 0 ? sB : -sB;
		}
		if (fBy > REL * sep + ABS * B.hh) {
			axis = 3;
			sep = fBy;
			nx = dBy > 0 ? -sB : sB;
			ny = dBy > 0 ? cB : -cB;
		}
		let fnx, fny, front, snx, sny, neg, pos;
		let negE, posE, inc;
		if (axis === 0 || axis === 1) {
			fnx = nx;
			fny = ny;
			const hmain = axis === 0 ? A.hw : A.hh;
			const hside = axis === 0 ? A.hh : A.hw;
			front = A.x * fnx + A.y * fny + hmain;
			if (axis === 0) {
				snx = -sA;
				sny = cA;
				negE = E3;
				posE = E1;
			} else {
				snx = cA;
				sny = sA;
				negE = E2;
				posE = E4;
			}
			const side = A.x * snx + A.y * sny;
			neg = -side + hside;
			pos = side + hside;
			inc = incident(B.hw, B.hh, B.x, B.y, cB, sB, fnx, fny);
		} else {
			fnx = -nx;
			fny = -ny;
			const hmain = axis === 2 ? B.hw : B.hh;
			const hside = axis === 2 ? B.hh : B.hw;
			front = B.x * fnx + B.y * fny + hmain;
			if (axis === 2) {
				snx = -sB;
				sny = cB;
				negE = E3;
				posE = E1;
			} else {
				snx = cB;
				sny = sB;
				negE = E2;
				posE = E4;
			}
			const side = B.x * snx + B.y * sny;
			neg = -side + hside;
			pos = side + hside;
			inc = incident(A.hw, A.hh, A.x, A.y, cA, sA, fnx, fny);
		}
		const c1 = clip(inc, -snx, -sny, neg, negE);
		if (c1.length < 2) return [];
		const c2 = clip(c1, snx, sny, pos, posE);
		if (c2.length < 2) return [];
		const out = [];
		for (const v of c2) {
			const s = fnx * v.x + fny * v.y - front;
			if (s > 0) continue;
			const k = v.k;
			const key = axis >= 2 ? keyOf([k[2], k[3], k[0], k[1]]) : keyOf(k);
			out.push({
				x: v.x - s * fnx,
				y: v.y - s * fny,
				nx,
				ny,
				sep: s,
				key,
				pn: 0,
				pt: 0,
				mn: 0,
				mt: 0,
				bias: 0,
				fresh: true,
			});
		}
		return out;
	}
	const world = (gravity, restitution) => ({
		bodies: [],
		gravity,
		restitution,
		arbiters: new Map(),
		held: null,
	});
	const GRIP = 8;
	const CARRY = 1.6;
	const isStatic = (b) => b.invM === 0 && b.invI === 0;

	function remove(W, id) {
		W.bodies = W.bodies.filter((b) => b.id !== id);
		for (const [k, arb] of W.arbiters)
			if (arb.a.id === id || arb.b.id === id) W.arbiters.delete(k);
	}
	const SLOP = 0.5;
	const BIAS = 0.2;
	const PUSH = 90;
	const RVEL = 90;
	const ITER = 12;

	function step(W, dt, hit) {
		const inv = 1 / dt;
		const B = W.bodies;
		let hardest = 0;
		for (let i = 0; i < B.length; i++) {
			for (let j = i + 1; j < B.length; j++) {
				const a = B[i],
					b = B[j];
				const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
				if (a.invM === 0 && b.invM === 0 && a.invI === 0 && b.invI === 0) {
					W.arbiters.delete(key);
					continue;
				}
				const r = Math.hypot(a.hw, a.hh) + Math.hypot(b.hw, b.hh);
				const cs = (b.x - a.x) ** 2 + (b.y - a.y) ** 2 > r * r ? [] : collide(a, b);
				if (!cs.length) {
					W.arbiters.delete(key);
					continue;
				}
				const old = W.arbiters.get(key);
				if (old && old.a === a) {
					for (const c of cs) {
						const o = old.cs.find((p) => p.key === c.key);
						if (o) {
							c.pn = o.pn;
							c.pt = o.pt;
							c.fresh = false;
						}
					}
				}
				W.arbiters.set(key, {
					a,
					b,
					cs,
					friction: Math.sqrt(a.friction * b.friction)
				});
			}
		}
		const held = W.held === null ? null : B.find((x) => x.id === W.held) ?? null;
		if (held) {
			const carried = new Set([held, ...riders(W, held)]);
			for (const arb of W.arbiters.values()) {
				if (carried.has(arb.a) && carried.has(arb.b)) arb.friction = CARRY * Math.sqrt(arb.a.friction * arb.b.friction);
				else if (arb.a === held || arb.b === held) arb.friction = 0;
			}
		}
		for (const b of B) {
			if (b.invM === 0) continue;
			b.vy -= W.gravity * dt;
			b.w *= 0.995;
		}
		for (const {
				a,
				b,
				cs
			}
			of W.arbiters.values()) {
			for (const c of cs) {
				const r1x = c.x - a.x,
					r1y = c.y - a.y;
				const r2x = c.x - b.x,
					r2y = c.y - b.y;
				const rn1 = r1x * c.nx + r1y * c.ny;
				const rn2 = r2x * c.nx + r2y * c.ny;
				const kn = a.invM + b.invM + a.invI * (r1x * r1x + r1y * r1y - rn1 * rn1) + b.invI * (r2x * r2x + r2y * r2y - rn2 * rn2);
				c.mn = kn > 1e-12 ? 1 / kn : 0;
				const tx = c.ny,
					ty = -c.nx;
				const rt1 = r1x * tx + r1y * ty;
				const rt2 = r2x * tx + r2y * ty;
				const kt = a.invM + b.invM + a.invI * (r1x * r1x + r1y * r1y - rt1 * rt1) + b.invI * (r2x * r2x + r2y * r2y - rt2 * rt2);
				c.mt = kt > 1e-12 ? 1 / kt : 0;
				c.bias = Math.min(PUSH, -BIAS * inv * Math.min(0, c.sep + SLOP));
				const dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
				const dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
				const vn = dvx * c.nx + dvy * c.ny;
				if (c.fresh && vn < -RVEL) {
					c.bias = Math.max(c.bias, -W.restitution * vn);
					hardest = Math.max(hardest, -vn);
				}
				const px = c.pn * c.nx + c.pt * tx;
				const py = c.pn * c.ny + c.pt * ty;
				a.vx -= a.invM * px;
				a.vy -= a.invM * py;
				a.w -= a.invI * (r1x * py - r1y * px);
				b.vx += b.invM * px;
				b.vy += b.invM * py;
				b.w += b.invI * (r2x * py - r2y * px);
			}
		}
		for (let it = 0; it < ITER; it++) {
			for (const {
					a,
					b,
					cs,
					friction
				}
				of W.arbiters.values()) {
				for (const c of cs) {
					const r1x = c.x - a.x,
						r1y = c.y - a.y;
					const r2x = c.x - b.x,
						r2y = c.y - b.y;
					let dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
					let dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
					const vn = dvx * c.nx + dvy * c.ny;
					let dpn = c.mn * (-vn + c.bias);
					const pn0 = c.pn;
					c.pn = Math.max(pn0 + dpn, 0);
					dpn = c.pn - pn0;
					let px = dpn * c.nx,
						py = dpn * c.ny;
					a.vx -= a.invM * px;
					a.vy -= a.invM * py;
					a.w -= a.invI * (r1x * py - r1y * px);
					b.vx += b.invM * px;
					b.vy += b.invM * py;
					b.w += b.invI * (r2x * py - r2y * px);
					dvx = b.vx - b.w * r2y - a.vx + a.w * r1y;
					dvy = b.vy + b.w * r2x - a.vy - a.w * r1x;
					const tx = c.ny,
						ty = -c.nx;
					const vt = dvx * tx + dvy * ty;
					let dpt = c.mt * -vt;
					const max = friction * c.pn;
					const pt0 = c.pt;
					c.pt = Math.min(max, Math.max(-max, pt0 + dpt));
					dpt = c.pt - pt0;
					px = dpt * tx;
					py = dpt * ty;
					a.vx -= a.invM * px;
					a.vy -= a.invM * py;
					a.w -= a.invI * (r1x * py - r1y * px);
					b.vx += b.invM * px;
					b.vy += b.invM * py;
					b.w += b.invI * (r2x * py - r2y * px);
				}
			}
		}
		for (const b of B) {
			b.x += b.vx * dt;
			b.y += b.vy * dt;
			b.a += b.w * dt;
		}
		if (hit && hardest > 0) hit(hardest);
	}
	const release = (_b) => {};

	function steer(W, b, tx, ty, dt, {
		speed = 2400,
		accel = 40000,
		turn = 14,
		tvx = 0,
		tvy = 0,
	} = {}, ) {
		const ex = tx - b.x,
			ey = ty - b.y;
		const dist = Math.hypot(ex, ey);
		const close = dist > 0 ? Math.min(speed, dist / (dt * 8), Math.sqrt(1.2 * accel * dist)) / dist : 0;
		let wantX = tvx + ex * close;
		let wantY = tvy + ey * close;
		const len = Math.hypot(wantX, wantY);
		if (len > speed) {
			wantX *= speed / len;
			wantY *= speed / len;
		}
		const dv = accel * dt;
		let vx = b.vx + Math.max(-dv, Math.min(dv, wantX - b.vx));
		let vy = b.vy + Math.max(-dv, Math.min(dv, wantY - b.vy)) + W.gravity * dt;
		const carried = riders(W, b);
		const aboard = new Set(carried);
		for (const arb of W.arbiters.values()) {
			if (arb.a !== b && arb.b !== b) continue;
			if (aboard.has(arb.a === b ? arb.b : arb.a)) continue;
			const depth = isStatic(arb.a === b ? arb.b : arb.a) ? -1 : -2.5;
			for (const c of arb.cs) {
				if (c.sep > depth) continue;
				const nx = arb.a === b ? c.nx : -c.nx;
				const ny = arb.a === b ? c.ny : -c.ny;
				const into = vx * nx + vy * ny;
				if (into > 0) {
					vx -= into * nx;
					vy -= into * ny;
				}
			}
		}
		for (const r of carried) {
			const side = r.friction * GRIP * W.gravity * dt;
			r.vx += Math.max(-side, Math.min(side, vx - r.vx));
			r.vy += Math.max(0, vy - r.vy);
		}
		b.vx = vx;
		b.vy = vy;
		b.w = Math.max(-turn, Math.min(turn, -b.a * turn));
	}

	function riders(W, base) {
		const out = [];
		const seen = new Set([base]);
		const queue = [base];
		while (queue.length) {
			const low = queue.shift();
			for (const arb of W.arbiters.values()) {
				if (arb.a !== low && arb.b !== low) continue;
				const other = arb.a === low ? arb.b : arb.a;
				if (seen.has(other) || isStatic(other) || !arb.cs.length) continue;
				const up = arb.a === low ? arb.cs[0].ny : -arb.cs[0].ny;
				if (up < 0.5) continue;
				seen.add(other);
				out.push(other);
				queue.push(other);
			}
		}
		return out;
	}
	const host = document.querySelector('.kumonosu-stage');
	const wrap = host.querySelector('.kumonosu-twr');
	const pile = host.querySelector('.kumonosu-twr-pile');
	const n = 6,
		sl = 0.45,
		fr = frictionOf(sl),
		H = boxH(n);
	const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
	const T = world(grav(50), RESTITUTION);
	let room, grip = null,
		going = null,
		raf = 0,
		refill = 0;
	const rows = new Map();

	function row(b) {
		const li = document.createElement('li');
		li.className = 'kumonosu-twr-row';
		Object.assign(li.style, {
			width: `${CW}px`,
			height: `${CH}px`,
			marginLeft: `${-HW}px`
		});
		const card = document.createElement('div');
		card.className = 'kumonosu-twr-card';
		const button = document.createElement('button');
		button.type = 'button';
		button.className = 'kumonosu-twr-check';
		button.setAttribute('aria-label', `完了にする:${TODOS[b.id]}`);
		button.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m20 6-11 11-5-5"/></svg>';
		button.addEventListener('click', () => strike(b.id));
		const text = document.createElement('span');
		text.className = 'kumonosu-twr-text';
		text.textContent = TODOS[b.id];
		card.append(button, text);
		li.append(card);
		pile.append(li);
		rows.set(b.id, li);
		return li;
	}

	function draw() {
		const bodies = T.bodies.filter(b => b.id < FLOOR);
		const alive = new Set(bodies.map(b => b.id));
		for (const [id, el] of rows)
			if (!alive.has(id) && id !== going) {
				el.remove();
				rows.delete(id);
			}
		for (const b of bodies) {
			const el = rows.get(b.id) || row(b);
			el.style.transform = `translate(${b.x.toFixed(2)}px, ${(-(b.y-HH)).toFixed(2)}px) rotate(${-b.a}rad)`;
			el.toggleAttribute('data-hand', !!grip?.live && grip.id === b.id);
		}
		if (!bodies.length && going === null && !refill) {
			refill = setTimeout(() => {
				refill = 0;
				T.bodies.push(...tower(n, sl, fr, room.floor + Math.min(100, room.top - room.floor - n * CH)));
				draw();
				go();
			}, 700);
		}
	}

	function measure() {
		const scale = Math.min(1, host.clientWidth / W, host.clientHeight / H);
		wrap.style.setProperty('--kumonosu-scale', scale);
		const height = host.clientHeight / scale,
			width = host.clientWidth / scale;
		room = {
			l: -width / 2 + INSET,
			r: width / 2 - INSET,
			top: (H + height) / 2 - INSET,
			floor: (H - height) / 2 + INSET
		};
		const {
			l,
			r,
			top,
			floor
		} = room, wide = r - l + 400, tall = top - floor + 400, mid = (l + r) / 2;
		for (const [id, x, y, hw, hh] of [
				[FLOOR, mid, floor - 100, wide / 2, 100],
				[LEFT, l - 100, floor + tall / 2 - 200, 100, tall / 2],
				[RIGHT, r + 100, floor + tall / 2 - 200, 100, tall / 2],
				[ROOF, mid, top + 100, wide / 2, 100]
			]) {
			const b = T.bodies.find(b => b.id === id);
			if (b) Object.assign(b, {
				x,
				y,
				hw,
				hh
			});
			else T.bodies.push(box(id, x, y, hw, hh, fr, 0));
		}
		for (const b of T.bodies)
			if (b.id < FLOOR) {
				b.x = clamp(b.x, room.l + HW, room.r - HW);
				b.y = clamp(b.y, room.floor + HH, room.top - HH);
			}
		T.arbiters.clear();
	}

	function run() {
		if (raf) return;
		let prev = 0,
			acc = 0,
			calm = 0;

		function tick(t) {
			acc += prev ? Math.min((t - prev) / 1000, MAXSTEPS * DT) : DT;
			prev = t;
			if (grip && t - grip.t > 34) {
				grip.vx *= 0.4;
				grip.vy *= 0.4;
			}
			let steps = 0;
			while (acc >= DT && steps < MAXSTEPS) {
				if (grip?.live) {
					const b = T.bodies.find(b => b.id === grip.id);
					if (b) {
						const c = Math.cos(b.a),
							s = Math.sin(b.a);
						steer(T, b, grip.tx - (c * grip.lx - s * grip.ly), grip.ty - (s * grip.lx + c * grip.ly), DT, {
							tvx: grip.vx,
							tvy: grip.vy
						});
					}
				}
				step(T, DT);
				acc -= DT;
				steps++;
			}
			const moving = grip?.live || T.bodies.some(b => b.id < FLOOR && (Math.hypot(b.vx, b.vy) > REST_V || Math.abs(b.w) > REST_W));
			calm = moving ? 0 : calm + steps;
			draw();
			if (calm < REST_N) raf = requestAnimationFrame(tick);
			else {
				raf = 0;
				for (const b of T.bodies) {
					b.vx = 0;
					b.vy = 0;
					b.w = 0;
				}
			}
		}
		raf = requestAnimationFrame(tick);
	}

	function go() {
		if (!reduced) {
			run();
			return;
		}
		cancelAnimationFrame(raf);
		raf = 0;
		for (let i = 0; i < 2400; i++) step(T, DT);
		for (const b of T.bodies) {
			b.vx = 0;
			b.vy = 0;
			b.w = 0;
		}
		draw();
	}

	function scrape(gone) {
		for (const b of T.bodies)
			if (b.id < FLOOR && b.y > gone.y) b.vx += (Math.random() * 2 - 1) * (0.3 + sl) * SCRAPE / Math.max(1, (b.y - gone.y) / CH);
	}

	function strike(id) {
		if (going !== null || grip) return;
		const b = T.bodies.find(b => b.id === id);
		if (!b) return;
		going = id;
		const el = rows.get(id);
		el.setAttribute('data-out', '');
		el.querySelector('button').disabled = true;
		const next = [...rows].find(([key]) => key !== id);
		if (document.activeElement === el.querySelector('button')) next?.[1].querySelector('button').focus({
			preventScroll: true
		});
		scrape(b);
		remove(T, id);
		draw();
		go();
		setTimeout(() => {
			el.remove();
			rows.delete(id);
			going = null;
			draw();
		}, reduced ? 0 : 400);
	}

	function local(e) {
		const r = wrap.getBoundingClientRect(),
			k = r.width / W;
		return {
			x: (e.clientX - r.left) / k - W / 2,
			y: (r.bottom - e.clientY) / k,
			k
		};
	}
	host.addEventListener('pointerdown', e => {
		if (e.button !== 0 || grip || going !== null || e.target.closest('button')) return;
		const p = local(e);
		let chosen = null,
			near = 12 / p.k;
		for (const b of T.bodies) {
			if (b.id >= FLOOR) continue;
			const dx = p.x - b.x,
				dy = p.y - b.y,
				c = Math.cos(-b.a),
				s = Math.sin(-b.a);
			const dist = Math.hypot(Math.max(0, Math.abs(c * dx - s * dy) - HW), Math.max(0, Math.abs(s * dx + c * dy) - HH));
			if (dist <= near) {
				near = dist;
				chosen = b;
			}
		}
		if (!chosen) return;
		const b = chosen,
			c = Math.cos(-b.a),
			s = Math.sin(-b.a),
			dx = p.x - b.x,
			dy = p.y - b.y;
		grip = {
			id: b.id,
			pointer: e.pointerId,
			lx: c * dx - s * dy,
			ly: s * dx + c * dy,
			ox: e.clientX,
			oy: e.clientY,
			tx: p.x,
			ty: p.y,
			vx: 0,
			vy: 0,
			t: e.timeStamp,
			live: false
		};
		host.setPointerCapture(e.pointerId);
	});
	host.addEventListener('pointermove', e => {
		if (!grip || grip.pointer !== e.pointerId) return;
		const g = grip,
			p = local(e),
			nx = clamp(p.x, room.l, room.r),
			ny = clamp(p.y, room.floor, room.top),
			dt = Math.max(4, e.timeStamp - g.t) / 1000;
		g.vx = g.vx * 0.5 + (nx - g.tx) / dt * 0.5;
		g.vy = g.vy * 0.5 + (ny - g.ty) / dt * 0.5;
		g.t = e.timeStamp;
		g.tx = nx;
		g.ty = ny;
		if (!g.live) {
			if (Math.hypot(e.clientX - g.ox, e.clientY - g.oy) < GRAB) return;
			g.live = true;
			const b = T.bodies.find(b => b.id === g.id),
				m = massOf(HW, HH);
			b.invM = m.invM * 0.25;
			b.invI = m.invI * 0.25;
			b.a -= Math.round(b.a / (2 * Math.PI)) * 2 * Math.PI;
			T.held = b.id;
			scrape(b);
			run();
		}
	});

	function drop(e) {
		if (!grip || grip.pointer !== e.pointerId) return;
		const g = grip;
		grip = null;
		T.held = null;
		if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
		if (g.live) {
			const b = T.bodies.find(b => b.id === g.id);
			Object.assign(b, massOf(HW, HH));
			release(b);
			b.vx = clamp(b.vx, -VMAX, VMAX);
			b.vy = clamp(b.vy, -VMAX, VMAX);
			go();
		}
		draw();
	}
	for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) host.addEventListener(name, drop);
	measure();
	T.bodies.push(...tower(n, sl, fr, room.floor));
	draw();
	new ResizeObserver(() => {
		measure();
		go();
	}).observe(host);
})();

Explanation 詳しい説明

仕様

このデモは、外部ライブラリを一切使わず、バニラJavaScriptで2D剛体物理シミュレーションを実装し、タスクリストのカードを物理的に積み上げるUIです。

主な機能は以下のとおりです。

  • 2D剛体物理エンジン: SAT(分離軸定理)ベースの衝突検出、インパルスベースの衝突応答、摩擦・反発係数、質量と慣性モーメントの計算を備えた本格的な物理エンジンが組み込まれています。固定タイムステップ(1/240秒)で安定したシミュレーションを行います。
  • タワー積み上げ: 6枚のタスクカードがフィボナッチ螺旋的なわずかなずれ(LEAN値)を持って積み上げられ、重力で自然に安定します。
  • ドラッグ操作: ポインターイベントでカードを掴み、自由に動かせます。掴んだカードの上に乗っているカードは一緒に運ばれ(riders検出)、他のカードとぶつかると物理的に押しのけます。
  • チェックで消去: チェックボタンを押すとカードが右にスライドして消え、その上にあったカードが落下して新しい平衡状態に落ち着きます。
  • 自動再補充: 全カードが消えると、約0.7秒後に新しいタワーが自動で再生成されます。
  • 壁・床・天井: 画面端に不可視の静的ボディ(床・左右の壁・天井)を配置し、カードが画面外に飛び出さないようにしています。リサイズ時に再計算されます。
  • 休止判定: 全カードの速度と角速度が閾値以下の状態が一定フレーム続くと、シミュレーションを停止してCPU負荷を下げます。操作があれば再開します。
  • prefers-reduced-motion対応: モーション軽減設定が有効な場合、アニメーションを即座に最終状態まで進め、トランジション時間も1msに短縮します。
  • アクセシビリティ: 各チェックボタンにaria-labelが設定されており、ステージ全体にもaria-labelが付与されています。フォーカスの移動処理も行っています。

カスタマイズ

  • タスクの変更: TODOS配列の文字列を書き換えるだけで、表示されるタスク名を変更できます。
  • カード枚数: n変数(デフォルト6)とTODOS配列の要素数を合わせて変更すると、カードの枚数を変えられます。最大8枚(MAX)まで対応しています。
  • 重力の強さ: grav関数の値やworldに渡す重力値を変更すると、カードの落下速度や安定性が変わります。
  • 摩擦: sl(スリッパリー値、デフォルト0.45)を変更すると、カード同士の滑りやすさが変わります。大きくすると滑りやすく、小さくすると安定します。
  • カードのサイズ: CW(幅206px)とCH(高さ46px)を変更すると、カードの大きさが変わります。
  • 積み方のずれ: LEAN配列の値を変更すると、初期積み上げ時のカードの横ずれ量が変わります。0にすると綺麗に積み上がります。
  • 配色: CSSの--card、--fill-on、--inkなどのカスタムプロパティを変更すると、全体の配色を変えられます。
  • カードの角丸: .kumonosu-twr-cardのborder-radius(デフォルト11px)を変更するとカードの形状が変わります。

注意点

  • 外部ライブラリ不使用: すべてバニラJavaScriptで実装されています。物理エンジン部分だけで相当なコード量があるため、カスタマイズには物理シミュレーションの知識が必要です。
  • 固定タイムステップ: シミュレーションは1/240秒の固定ステップで動作し、1フレームあたり最大16ステップまで実行します。極端にフレームレートが低い環境では物理の精度が下がる場合があります。
  • パフォーマンス: カード枚数が少ないため通常は軽量ですが、物理計算は毎ステップすべてのボディペア間で衝突判定を行うため、枚数を大幅に増やすとCPU負荷が上がります。
  • ポインターキャプチャ: setPointerCaptureを使用しているため、ドラッグ中にポインターがステージ外に出ても操作が継続します。pointercancelとlostpointercaptureにも対応しています。
  • レスポンシブ: ステージのスケールはMath.min(1, clientWidth/W, clientHeight/H)で計算され、CSSのtransform: scale()で適用されます。物理空間のサイズは固定で、表示のみ縮小されます。
  • 消去アニメーションとの競合: カードの消去中(going !== null)は他のチェック操作やドラッグが無効化されます。連打すると操作を受け付けないタイミングがあります。
  • 型注釈のコメント: コード内に空の型注釈コメントが残っています。TypeScriptから変換されたコードであることを示しており、動作には影響しません。