【クソアプリ】文字が跳ねまくって収集がつかないエディタを作ってみた 🦘


こんにちは、とまだです。

[クソアプリ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kuso-app)、22 日目の記事をお届けします！

突然ですが、みなさんこんな経験ありませんか？

- 「この文章イマイチだな...」と思いながら書いている
- 「どうせボツになるし...」と諦めながら入力している
- 「もう全部消しちゃおうかな...」と考えながらタイピングしている

そう、文字を入力することへの**後悔**です。

そこで考えました。

**「最初から文字が散らばるエディターを作れば、後悔する必要がないのでは？」**

むしろ、文字が跳ね回って読めなくなることを**仕様**にしてしまえば、内容を気にする必要すらない！

そんな**クソみたいな発想**から、このエディターは生まれました。

## 💡 できたもの

上の入力欄に文字を入力すると、文字が物理演算で跳ね回ります。

日本語入力にも対応しており、Enter を押すと文字が飛び散ります。

↓ デモ

https://bouncing-editor.vercel.app/

![画面収録-2024-11-06-20.17.51.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/ba1e152b-00b1-e49f-4f14-31c3b93217ff.gif)

## 🔧 実装のポイント

せっかくなので、実装の中で特に注意した点を共有します。

### 1. IME 制御の罠との戦い

日本語入力周りは、想像以上に泥沼でした。

半角入力に対応するだけならシンプルなのですが、日本語入力は「**変換中**」「**確定**」「**未確定**」などの状態があり、それぞれの状態で文字列が異なります。

```typescript
const handleKeyDown = (e: React.KeyboardEvent) => {
  // IMEの入力中は処理しない
  if (composing) return

  // Enter キーの処理
  if (e.key === 'Enter') {
    e.preventDefault()
    if (inputValue && !composing) {
      addChars(inputValue)
      setInputValue('')
    }
    return
  }

  // ...
```

それぞれを考慮した上で、入力された文字列を適切に処理する必要があります。

実務でもハマったことがあるポイントではありますが、今回は特に難しかったです。

### 2. 物理演算の実装

物理演算は、以下のような要素を実装しています。

- 重力加速度
- 空気抵抗
- 床との衝突判定
- 速度の減衰

流石に不慣れだったので、今回は ChatGPT 先生に大いに助けられました。

最終的に、物理演算の核となる部分は以下のようになっています。

こういうときにも `useEffect` が使えると知れたのは良い学びです。

```typescript
// 物理演算の核となる部分
useEffect(() => {
  // 静止状態の場合は計算をスキップ
  if (isStatic) return;

  // 物理パラメータの設定
  const gravity = params.gravity; // 重力加速度
  const bounce = -0.7; // 反発係数（衝突時のエネルギー損失）
  const friction = 0.99; // 摩擦係数（地面との接触時の減速）

  // 約60FPS (1000ms/60 ≈ 16ms) でフレーム更新
  const interval = setInterval(() => {
    setPosition((pos) => {
      // 速度に基づいて新しい位置を計算
      const newX = pos.x + velocity.x;
      const newY = pos.y + velocity.y;

      // 左右の壁との衝突判定（x座標が±100を超えた場合）
      if (newX < -100 || newX > 100) {
        setVelocity((v) => ({ ...v, x: v.x * bounce }));
      }

      // 地面との衝突判定（y座標が20を超えた場合）
      if (newY > 20) {
        // 地面との衝突時の処理
        setVelocity((v) => ({
          x: v.x * friction, // 水平方向に摩擦を適用
          y: v.y * bounce, // 垂直方向に反発を適用
        }));

        // 垂直方向の速度が十分小さくなった場合、静止状態とみなす
        if (Math.abs(velocity.y) < 0.5) {
          setIsStatic(true);
        }

        // 地面の位置（y=20）で固定
        return { x: newX, y: 20 };
      }

      // 通常時は重力を適用
      setVelocity((v) => ({ x: v.x, y: v.y + gravity }));

      // 新しい位置を返す
      return { x: newX, y: newY };
    });
  }, 16);

  // コンポーネントのクリーンアップ時にインターバルを解除
  return () => clearInterval(interval);
}, [velocity, isStatic, params.gravity]);
```

### 3. パフォーマンスとの戦い

当初は**すべての文字**に対して物理演算を行っていましたが、文字数が増えるとブラウザが悲鳴を上げ始めました。

そこで以下のような対策を行いました。

- 文字の数に制限を設ける
- 速度が小さくなった文字は静止状態にする
- requestAnimationFrame の使用
- useCallback と useMemo の活用

```typescript
// パフォーマンス改善前
const animate = () => {
  chars.forEach((char) => {
    updatePosition(char.position, char.velocity);
  });
};

// パフォーマンス改善後
const animate = () => {
  chars.forEach((char) => {
    if (!char.isStatic) {
      // 動いている文字だけ更新
      updatePosition(char.position, char.velocity);

      // 速度が小さければ静止状態に
      if (isAlmostStopped(char.velocity)) {
        char.isStatic = true;
      }
    }
  });
};
```

## 🎮 ソースコード

実際のコードは GitHub で公開しています。
スターを付けていただいても、文字は跳ね返ります。

[リポジトリへのリンク]

## 🐛 既知の問題

- 大量の文字を入力するとブラウザが固まる
- たまに文字が宇宙まで飛んでいく（仕様です）
- コピー＆ペーストすると死ぬ
- 文字が画面外に消えることがある（探さないでください）

## 📝 まとめ

クソアプリを作って気づいたこと。

- クソアプリこそ真面目に作るべき
- IME 周りは想像以上に複雑
- 物理演算は楽しい
- パフォーマンス改善は大事
- 無駄な機能を真面目に作ると学びが多い

最後に、ここまで読んでいただいた方、ありがとうございました！
この記事を読んで、「自分も何か作ってみよう」と思っていただけたら嬉しいです。

## 他にもアドベントカレンダー記事を書いています！

他にも、2024 年のアドベントカレンダーに参加しています。

以下の記事でまとめているので、よければ他の記事も読んでいただけると嬉しいです！

https://qiita.com/tomada/items/025b3745df8c484f5856