最近、Hello, World. Goodbye. で特に感情表現が豊かな機能として実装しているのが、弾幕コメントシステムです。これはニコニコ動画の弾幕コメントへのオマージュであり、プレイヤーが静かに叫んだり、他の人の声を聞いたりできる仕組みです。

🎯 目標

モジュール化され、滑らかで高性能な弾幕(弾幕コメント)システムを設計します。要件は以下の通りです:

  • メインゲームカメラと独立して動作(専用 Viewport を使用)
  • 複数のコンテンツタイプ(テキスト、画像)に対応
  • 弾幕をトラックごとに均等または集中して分配
  • 間隔を動的に計算し、重なりを回避

🎬 実装ステップ

ステップ 1: シンプルな実装から開始

まずは RichTextLabel ベースの弾幕ノードを作成し、右から左へ移動させます。

Damaku Scene

C# コード例:

public override void _Process(double delta) {
  Position -= new Vector2(Speed * (float)delta, 0);
  if (Position.X + GetWidth() < 0) QueueFree();
}

この実装では、弾幕が意図しない位置から出現し、早すぎるタイミングで消えてしまう問題が発生しました。

First Attempt Animation

原因はプレイヤーにカメラがアタッチされており、画面上の座標と実際の位置が一致しないためです。

Player Camera Tree

ステップ 2: カメラからの分離

この問題を解決するため、弾幕システムをメインカメラの影響を受けない専用の Viewport(例:640x360)に移動しました。これで、ワールドの動きに関係なく、常に画面右端から弾幕が流れます。

Danmaku Viewport

Q: 弾幕はどのように画面上に分配される?

Bilibili のコンサート動画例を見ると、複数のトラックに弾幕が流れているのが分かります。

Example Miku Concert Danmaku - Bilibili

弾幕は画面上の見えないトラックに分配されているイメージです。

Danmaku Tracks

ステップ 3: トラックマネージャの導入

DanmakuTrackManager を導入し、各トラック(行)ごとに弾幕が重ならないよう管理します。

public class DanmakuTrackManager
{
  private readonly List<List<DanmakuTrackEntry>> _tracks;
  private readonly Node _danmakuLayer;
  private readonly int _trackCount;
  private readonly float _trackHeight;
  private readonly float _screenWidth;
  private readonly float _spacing;

  public bool IsTrackAvailable(int trackIndex)
  {
    var track = _tracks[trackIndex];
    if (track.Count == 0) return true;

    var last = track[^1];
    float lastRight = last.Entry.Position.X + last.Entry.Call("GetWidth").AsSingle();
    return lastRight + 20f < _screenWidth;
  }
}

トラック数や間隔は、サンプル弾幕(例:RichTextDanmakuLabel)のサイズから自動計算します。

ステップ 4: 配分ロジックの追加

IDanmakuTrackDistributor を実装し、弾幕のトラック割り当て方法を制御します。

均等分配(ラウンドロビン)と集中分配(少数トラックに集中)を実装:

public class EvenDistributor : IDanmakuTrackDistributor
{
  public int GetTrackIndex(int danmakuCount, int trackCount)
  {
    return danmakuCount % trackCount;
  }
}

実際には、トラックが空いているかどうかをトラックマネージャで確認する必要があります。

ステップ 5: キューシステムの導入

大量の弾幕を一度に追加する場合、キューに溜めてから順次分配します。

public partial class DanmakuSystemController : Node, ISystemModule
{
  private Queue<DanmakuQueueEntry> _danmakuQueue;
  private DanmakuTrackManager _trackManager;
  private double _lastSpawnTime = 0;

  public override void _Ready()
  {
    _danmakuQueue = new Queue<DanmakuQueueEntry>();
    _trackManager = new DanmakuTrackManager();
  }

  public void AddDanmaku(DanmakuQueueEntry entry)
  {
    _danmakuQueue.Enqueue(entry);
    ProcessQueue();
  }

  public override void _Process(double delta)
  {
    if (_danmakuQueue.Count == 0) return;
    // Peek queue and if time passed, spawn a danmaku
    if (Time.GetTicksMsec() > _lastSpawnTime + SpawnInterval)
    {
      // If the next track is available on the track manager
      if (_trackManager.HasTrackSpace())
      {
        var danmakuData = _danmakuQueue.Dequeue();
        _trackManager.AddDanmakuToTrack(danmakuData.Data);
        _lastSpawnTime = Time.GetTicksMsec();
      }
    }
  }
}

このコードで、弾幕をキューに溜め、トラックに空きがあれば順次追加します。

ステップ 6: オーバーラン問題の修正

速い弾幕が遅い弾幕を追い越して重なるバグが発生しました。これを防ぐため、「スピードクリップ」を導入し、前の弾幕が安全領域を抜けるまで次の弾幕を追加しません。

// Set initial speed, cap the speed to prevent overrun
danmakuData.Speed = Math.Max(GetMaxSpeedInTrack(trackIndex), danmakuData.Speed);

GetMaxSpeedInTrack メソッド例:

private float GetMaxSpeedInTrack(int trackIndex)
{
  var track = _tracks[trackIndex];
  if (track.Count == 0) return 0;

  // Loop through track to get max speed
  return track.Max(entry => entry.Entry.Speed);
}

ステップ 7: 全体の統合

DanmakuTrackManagerIDanmakuTrackDistributorDanmakuSystemControllerRichTextDanmakuLabel を組み合わせて、メインシーンに統合します。

Danmaku System Diagram

最終結果

Final Danmaku Result

まとめ

この弾幕システムは、モジュールごとに役割を分けて設計しているため、機能追加や調整がしやすくなっています。テキストや画像など複数のコンテンツタイプに対応し、弾幕の分配や重なり回避も柔軟に制御できます。Godot 4 と C# でこうした仕組みを作る際の参考になればうれしいです。ご質問やフィードバックがあればお気軽にどうぞ。