<?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>HelloWorldGoodbye on 絣工坊博客</title>
    <link>https://kasuri.works/zh/tags/helloworldgoodbye/</link>
    <description>Recent content in HelloWorldGoodbye on 絣工坊博客</description>
    <generator>Hugo -- 0.147.6</generator>
    <language>zh</language>
    <lastBuildDate>Fri, 30 May 2025 14:32:08 -0700</lastBuildDate>
    <atom:link href="https://kasuri.works/zh/tags/helloworldgoodbye/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>在 Godot 4 中构建视频弹幕系统</title>
      <link>https://kasuri.works/zh/posts/building-danmaku-system/</link>
      <pubDate>Fri, 30 May 2025 14:32:08 -0700</pubDate>
      <guid>https://kasuri.works/zh/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>
    <item>
      <title>HelloWorld.GoodBye()</title>
      <link>https://kasuri.works/zh/posts/hello-world-goodbye/</link>
      <pubDate>Thu, 29 May 2025 14:00:11 +0000</pubDate>
      <guid>https://kasuri.works/zh/posts/hello-world-goodbye/</guid>
      <description>来自一个程序员的独立开发游戏，用像素和愤怒写下一句系统无法理解的告别。也许你也曾有过这样的时刻。</description>
      <content:encoded><![CDATA[<p>HelloWorld.GoodBye()</p>
<blockquote>
<p>“Hello World.” —— 是我最初写下的第一行代码。<br>
“GoodBye()” —— 是我在系统尽头，留给世界的函数调用。</p></blockquote>
<hr>
<h2 id="-我是谁">👋 我是谁</h2>
<p>我是一名程序员。也是一名独立游戏开发者。<br>
目前，这款游戏是我一个人做的。策划、美术、程序、写作，全靠夜里那一点点不甘心和心里那些说不出口的事。</p>
<p>从前我真的很喜欢写代码。直到有一天，我发现自己已经不再为自己写东西了。<br>
我写给 Jira，写给老板，写给面试官，写给 KPI，写给那个“足够理性、足够稳定”的职业形象。</p>
<p>后来我意识到，我不是不热爱写代码了。<br>
我只是，太久没有为自己写下什么了。</p>
<hr>
<h2 id="-为什么做这款游戏">💬 为什么做这款游戏？</h2>
<p>因为有太多话，我在现实中说不出口。</p>
<p>我想说我很努力，但还是很累。<br>
我想说我不想再背面试题了。<br>
我想说，我写的每一行代码不是为了积分，不是为了 review 评论里的“well done”，<br>
而是因为我曾经真的相信，代码是创造的语言。</p>
<p>这不是一个励志故事。<br>
这是一场“从 Hello 到 GoodBye()”的过程。<br>
不是失败，而是一次自我恢复。</p>
<hr>
<h2 id="-这是什么游戏">🎮 这是什么游戏？</h2>
<p>它是一个平台动作游戏。像素风，带叙事。<br>
它的主角是一名程序员。你可以理解为我，也可以理解为你。</p>
<p>这款游戏没有宏大叙事，没有救世主，没有打败邪恶的结局。<br>
它只是记录了一个人，在职场系统中逐渐失控，然后重新找回自我的过程。</p>
<p>或者更准确地说：</p>
<blockquote>
<p>它是我为自己写下的，一行还没 return 的函数。</p></blockquote>
<hr>
<h2 id="-当前状态">🛠 当前状态</h2>
<p>游戏仍在开发中。Demo 制作进行中，没有发布日期，没有宣传片。<br>
但我会持续更新这个博客，记录制作过程和一路上的小情绪。</p>
<p>如果你也曾有过一瞬间，<br>
觉得自己好像不是“人”了，只是一个“进程”，<br>
那欢迎你来看看这款游戏。</p>
<hr>
<p><strong>HelloWorld.GoodBye()</strong><br>
不是一个项目，是一句话。<br>
写给系统，也写给你自己。</p>
<blockquote>
<p>“我懂。”<br>
“你没有问题。”<br>
“你被看见了。”</p></blockquote>
]]></content:encoded>
    </item>
  </channel>
</rss>
