<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Danmaku on 絣工坊部落格</title>
    <link>https://kasuri.works/zh-tw/tags/danmaku/</link>
    <description>Recent content in Danmaku on 絣工坊部落格</description>
    <generator>Hugo -- 0.147.6</generator>
    <language>zh-TW</language>
    <lastBuildDate>Fri, 30 May 2025 14:32:08 -0700</lastBuildDate>
    <atom:link href="https://kasuri.works/zh-tw/tags/danmaku/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>在 Godot 4 中構建彈幕系統</title>
      <link>https://kasuri.works/zh-tw/posts/building-danmaku-system/</link>
      <pubDate>Fri, 30 May 2025 14:32:08 -0700</pubDate>
      <guid>https://kasuri.works/zh-tw/posts/building-danmaku-system/</guid>
      <description>使用 Godot 4 和 C# 從零構建一個高效能、可擴展的影片彈幕系統，支援佇列排程、軌道分配與速度修正機制，靈感來自 NicoNico 與 Bilibili 彈幕。</description>
      <content:encoded><![CDATA[<p>最近我在《Hello, World. Goodbye.》中構建了一個最能表達情緒的功能之一 —— 影片彈幕系統。這是向 NicoNico 式的彈幕留言致敬，也是一種讓玩家在沉默中向虛空吶喊，或是傾聽他人曾經吶喊過的話語的方式。</p>
<h2 id="-目標">🎯 目標</h2>
<p>設計一個模組化、流暢、高效能的彈幕系統，具有以下特點：</p>
<ul>
<li>獨立於主遊戲相機運作（透過專用 Viewport 實現）</li>
<li>支援多種內容類型（文字、圖片等）</li>
<li>彈幕可均勻分布於各軌道，或採用聚焦式分布風格</li>
<li>動態計算彈幕間距，智慧避免重疊</li>
</ul>
<h2 id="-step-by-step-construction">🎬 Step-by-Step Construction</h2>
<h3 id="第一步從簡單開始">第一步：從簡單開始</h3>
<p>我們從一個基於 <code>RichTextLabel</code> 的彈幕節點開始：</p>
<p><img alt="彈幕節點場景結構" loading="lazy" src="/posts/building-danmaku-system/danmaku-scene.en.png#center"></p>
<p>上圖展示了一個最基礎的彈幕節點場景結構。每條彈幕都是一個 <code>RichTextLabel</code> 節點，掛載在彈幕專用的父節點下，便於統一管理和批次操作。</p>
<p>附加的 C# 腳本如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Process(<span style="color:#66d9ef">double</span> delta) {
</span></span><span style="display:flex;"><span>    Position -= <span style="color:#66d9ef">new</span> Vector2(Speed * (<span style="color:#66d9ef">float</span>)delta, <span style="color:#ae81ff">0</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (Position.X + GetWidth() &lt; <span style="color:#ae81ff">0</span>) QueueFree();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>這個方案一開始運作良好，直到我們發現彈幕出現了奇怪的偏移——即使彈幕移動到 (width, 0)，它似乎還是從不對的位置出現。</p>
<p><img alt="首次嘗試動畫" loading="lazy" src="/posts/building-danmaku-system/first-attempt.en.gif#center"></p>
<p>彈幕不僅沒有從正確位置出現，還過早地消失了。為什麼會這樣？原來玩家有一個相機，導致螢幕顯示的位置與實際位置並不一致。這就是問題所在。</p>
<p><img alt="玩家相機樹" loading="lazy" src="/posts/building-danmaku-system/player-camera.en.png#center"></p>
<h3 id="第二步與相機解耦">第二步：與相機解耦</h3>
<p>為了解決這個問題，我們將彈幕系統移動到一個獨立的 Viewport 層（640x360），不再受主相機影響。這樣彈幕無論世界如何移動，都會始終從螢幕右側出現。</p>
<p><img alt="彈幕 Viewport" loading="lazy" src="/posts/building-danmaku-system/danmaku-viewport.en.png#center"></p>
<blockquote>
<p>問題：彈幕是如何分布在螢幕上的？</p></blockquote>
<p>以 B 站演唱會彈幕為例，可以看到螢幕上有多條軌道，彈幕從右向左捲動。</p>
<p><img alt="B站初音演唱會彈幕示例" loading="lazy" src="/posts/building-danmaku-system/danmaku-example.en.png#center"></p>
<p>實際上，所有彈幕都分布在螢幕上的「隱形軌道」中，如下圖所示：</p>
<p><img alt="彈幕軌道示意" loading="lazy" src="/posts/building-danmaku-system/danmaku-example-track.en.png#center"></p>
<h3 id="第三步新增軌道管理器">第三步：新增軌道管理器</h3>
<p>接下來，我們引入了 <code>DanmakuTrackManager</code>，用於管理彈幕軌道，確保彈幕不會重疊。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">DanmakuTrackManager</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> List&lt;List&lt;DanmakuTrackEntry&gt;&gt; _tracks;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> Node _danmakuLayer;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">int</span> _trackCount;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _trackHeight;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _screenWidth;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">float</span> _spacing;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">bool</span> IsTrackAvailable(<span style="color:#66d9ef">int</span> trackIndex)
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> track = _tracks[trackIndex];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (track.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> last = track[^<span style="color:#ae81ff">1</span>];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">float</span> lastRight = last.Entry.Position.X + last.Entry.Call(<span style="color:#e6db74">&#34;GetWidth&#34;</span>).AsSingle();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> lastRight + <span style="color:#ae81ff">20f</span> &lt; _screenWidth;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>系統會根據彈幕節點（如 <code>RichTextDanmakuLabel</code>）的尺寸自動計算軌道數量和間距。</p>
<h3 id="第四步新增分配器">第四步：新增分配器</h3>
<p>為了靈活地管理彈幕分配，我們需要新增一個分配器。因此我建立了一個 <code>IDanmakuTrackDistributor</code>，用於控制彈幕分配到哪個軌道。支援兩種分配模式：</p>
<ul>
<li>均勻分配：輪流填充各軌道</li>
<li>聚焦分配：集中堆疊到少數軌道，營造視覺衝擊</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">EvenDistributor</span> : IDanmakuTrackDistributor
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> GetTrackIndex(<span style="color:#66d9ef">int</span> danmakuCount, <span style="color:#66d9ef">int</span> trackCount)
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> danmakuCount % trackCount;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>這是最簡單的輪詢分配方式。實際應用中，還需結合軌道管理器判斷軌道是否可用。</p>
<h3 id="第五步新增佇列系統">第五步：新增佇列系統</h3>
<p>通常我們希望彈幕批次出現但不是一次性全都畫在螢幕上，因此需要一個佇列系統，統一分發彈幕。例如遊戲觸發某事件時，批次新增彈幕。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">partial</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">DanmakuSystemController</span> : Node, ISystemModule
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> Queue&lt;DanmakuQueueEntry&gt; _danmakuQueue;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> DanmakuTrackManager _trackManager;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">double</span> _lastSpawnTime = <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Ready()
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    _danmakuQueue = <span style="color:#66d9ef">new</span> Queue&lt;DanmakuQueueEntry&gt;();
</span></span><span style="display:flex;"><span>    _trackManager = <span style="color:#66d9ef">new</span> DanmakuTrackManager();
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> AddDanmaku(DanmakuQueueEntry entry)
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    _danmakuQueue.Enqueue(entry);
</span></span><span style="display:flex;"><span>    ProcessQueue();
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> _Process(<span style="color:#66d9ef">double</span> delta)
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (_danmakuQueue.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Peek queue and if time passed, spawn a danmaku</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (Time.GetTicksMsec() &gt; _lastSpawnTime + SpawnInterval)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// If the next track is available on the track manager</span>
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (_trackManager.HasTrackSpace())
</span></span><span style="display:flex;"><span>      {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> danmakuData = _danmakuQueue.Dequeue();
</span></span><span style="display:flex;"><span>        _trackManager.AddDanmakuToTrack(danmakuData.Data);
</span></span><span style="display:flex;"><span>        _lastSpawnTime = Time.GetTicksMsec();
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>這樣可以在 <code>_Process</code> 方法中逐幀檢查佇列和軌道空間，軌道可用時再生成彈幕，避免重疊。</p>
<h3 id="第六步修復超車問題">第六步：修復超車問題</h3>
<p>出現了一個 bug：同一軌道上速度快的彈幕會「超車」慢彈幕，導致重疊。B 站等平台也有類似現象。解決方法是引入「速度修正」機制，確保在軌道新增的彈幕速度不會超過軌道裡已有彈幕的最大速度。</p>
<p>可以在 <code>DanmakuTrackManager</code> 中新增如下程式碼：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#75715e">// 設置初始速度，限制最大速度防止超車</span>
</span></span><span style="display:flex;"><span>danmakuData.Speed = Math.Max(GetMaxSpeedInTrack(trackIndex), danmakuData.Speed);
</span></span></code></pre></div><p>然後實現 <code>GetMaxSpeedInTrack</code> 方法：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c#" data-lang="c#"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">float</span> GetMaxSpeedInTrack(<span style="color:#66d9ef">int</span> trackIndex)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">var</span> track = _tracks[trackIndex];
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (track.Count == <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// 遍歷軌道，獲取最大速度</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> track.Max(entry =&gt; entry.Entry.Speed);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="第七步系統整合">第七步：系統整合</h3>
<p>現在我們已經實現了 <code>DanmakuTrackManager</code>、<code>IDanmakuTrackDistributor</code>、<code>DanmakuSystemController</code> 和 <code>RichTextDanmakuLabel</code>，可以在主場景中將它們整合起來。</p>
<p><img alt="彈幕系統結構圖" loading="lazy" src="/posts/building-danmaku-system/system-diag.en.png#center"></p>
<h3 id="最終效果">最終效果</h3>
<p><img alt="最終彈幕效果" loading="lazy" src="/posts/building-danmaku-system/final-result.en.gif#center"></p>
<h3 id="總結">總結</h3>
<p>現在的彈幕系統已經模組化、高效能且易於擴展，支援文字、圖片等多種內容類型，彈幕分布可以均勻或聚焦，動態計算間距有效避免重疊。</p>
<p>這也是我用 Godot 4 和 C# 實現複雜功能、同時保持架構清晰的一個實踐。如果你也想在 Godot 4 裡做彈幕系統，希望這篇文章能幫到你！有問題或想法歡迎留言交流。Happy coding!</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
