Show HN: 我在 tmux 配置文件中实现了贪吃蛇游戏
Snakes in a Pane: 完全使用 tmux 配置文件构建贪吃蛇游戏
March 20, 2025 • projects tmux 说实话,如果可以我早就停下来了,但我总是乐在其中。在为 tmux 创建了一个编译器,然后用 tmux 解决数独问题,接着用 tmux 播放视频之后,我并没有打算再做一个游戏。这些事情就是会发生。好吧,也许不是对你,但它们发生在我身上。
和视频播放器不同,这不仅仅是在 tmux 里面渲染贪吃蛇。整个游戏——输入、游戏逻辑和渲染——都是用 tmux 配置文件完成的。你只需要用这个配置加载 tmux,就可以玩贪吃蛇了。查看代码或者看看我在视频中的演示:
显示方式与我的视频播放器相同。它使用许多嵌套的会话来创建一个状态行堆栈,每个状态行都有足够的窗口来跨越屏幕的宽度。“显示”是通过设置窗口的样式以对应于窗口名称,然后将名称更改为相应的颜色来更新的。 在这种情况下,我只使用了两种颜色,而在视频中我使用了完整的 ANSI 颜色范围。
我在初始化屏幕的方式上有一个很大的不同。使用视频播放器时,我使用递归脚本来启动所有嵌套的 tmux 会话,并且由于我预先知道宽度(它必须是静态的,因为视频需要缩放),所以我只是生成了正确数量的 new-window 调用。 由于我希望这是 完全 tmux,我找到了一种无需 shell 脚本即可执行此操作的方法。
我没有递归调用 shell 脚本来填充高度,而是将 default-command(在创建新窗口时运行)设置为:
TMUX= tmux if-shell -F "#{e|>:#{window_height},1}" new-session
每次创建新会话时,如果该会话中的窗口高度超过一行,我们将创建一个新会话。 一旦我们填满了高度,该命令将退出而不会创建另一个会话。
为了用窗口填充每个会话,我为 session-created 添加了一个钩子:
set-hook -g session-created {
run -C "set -g @width '#{e|/:#{window_width},2}'"
run -d 1 -bC 'source-file create_windows.conf'
}
短暂延迟后,这将加载 create_windows.conf:
if -F '#{e|<:#{session_windows},#{@width}}' {
new-window -b 'exit'
select-window -t '{last}'
source-file create_windows.conf
} {
if -F '#{e|==:#{window_height},1}' {
source-file -t '$0' init.conf
}
}
此脚本检查是否有足够的空间容纳另一个窗口,如果有,则创建一个窗口并再次加载自身。 一旦我们填满了宽度,我就会检查这是否是要创建的最后一个窗口,如果是,则将主要游戏逻辑加载到 init.conf 中。
我可以用递归的按键绑定来代替递归地调用 source-file,但最终结果大致相同。 使用按键绑定可能会更快,但是您必须担心按键是否已发送到正确的会话,而这是我在此处不必执行的操作。
与显示视频不同,我每次更新只需要更改 1-2 个像素,而不是整个帧。 唯一移动的是蛇的头部和尾部以及苹果的位置。 对于游戏逻辑而言,跟踪这一点更具挑战性,但对于显示而言,这仅意味着几个 rename-window -t Y:=X 命令。
这里添加的一个功能是能够给蛇加上眼睛,这既是因为它很可爱,又可以区分头部和尾部:
是不是很可爱?
可以通过仅更改头部所在窗口的 window-status-format 来完成此操作,但是我想以更 tmux-y 的声明式方式来完成此操作。 我最终使用了“标记窗格”功能来做到这一点。 当蛇移动时,我会选择包含头部的窗口作为标记窗格,并更新每个窗口的格式,以便仅在它们是标记窗口时才显示眼睛:
set -g window-status-format '#[fg=colour0,bg=colour#{window_name}]#{?#{window_marked_flag},#{@eyes}, }'
在实施此操作之前,我以为我需要一个复杂的条件来检查方向并在不同的眼睛之间切换,但是我意识到,只有在用户给出输入时眼睛才会改变,因此我只需要在用户按下更改方向的键时设置 @eyes。
读取用户输入是我知道很容易的事情,但即使这样,我也把它弄得过于复杂了。 我使用 bind-key -n 添加了不需要前缀的绑定,并为 Up、Down、Left 和 Right 设置了这些绑定。 最初,我让这些绑定设置一个变量来表示我们需要面对的方向,然后我会在更新期间读取该变量并更改位置。 这将需要针对每个方向使用条件语句,这很麻烦。 幸运的是,我意识到了一件容易得多的事情:箭头键根据方向将 @x_change 和 @y_change 设置为 1、0 或 -1。 然后,每次更新时,我只需将更改添加到位置即可。
这也使得验证输入变得更加容易——您不希望在没有先向上或向下移动的情况下直接从左向右更改。 这就像确保在设置 @x_change 或 @y_change 之前,其中一个为零一样简单:
bind -n Left {
if -F '#{@x_change}' { } {
set -g @new_eyes ' :'
set -g @x_change -1
set -g @y_change 0
}
}
最后一部分是实现游戏逻辑。 为了非常清楚:游戏逻辑也只是更多的 tmux 配置。 没有小程序计算蛇应该去哪里,所有这些都由 tmux 本身完成。
我使用了与数独求解器相同的方法:运行 send-keys 以触发 tmux 本身中的按键绑定。 最后,我只需要一个按键绑定,它将游戏向前推进一次迭代,并使用 run -d 安排下一帧:
bind G {
# game logic goes here!
run -C "run -d '#{@speed}' -bC 'send-keys -t $0 C-b G'"
}
通过使用变量设置 run 的延迟,我可以轻松地随着吃掉的苹果数量的增加而提高游戏的速度。 从理论上讲,任何 tmux 会话都可以处理按键绑定——它们都在同一服务器上——但我决定为了安全起见,始终以最外层的会话为目标。
一旦我们有了一个将在每次更新时调用的函数,我们需要做的就是将蛇头向正确的方向移动,移动蛇尾,并检查是否吃了一个苹果。
set -Fg @head_x '#{e|%:#{e|+:#{@head_x},#{@x_change}},#{@width}}'
set -Fg @head_y '#{e|%:#{e|+:#{@head_y},#{@y_change}},#{@height}}'
if -F '#{e|<:#{@head_x},0}' {
set -Fg @head_x "#{e|+:#{@head_x},#{@width}}"
}
if -F '#{e|<:#{@head_y},0}' {
set -Fg @head_y "#{e|+:#{@head_y},#{@height}}"
}
第一部分移动头部,头部作为一个单独的变量存储在身体的其余部分,这样更容易跟踪和处理冲突。 正如我之前提到的,按键输入只是设置了 @x_change 和 @y_change,因此我所要做的就是将它们添加到头部位置。 为了允许环绕屏幕,我对它们进行取模,这需要第二步,因为取模运算符会留下负数。
为了支持碰撞(蛇吃到自己),我需要跟踪身体位置。 很难获取特定窗口的名称,因此我将此与实际显示分开跟踪。
我真正需要的是一个数组,但是 tmux 没有数组。 相反,每个段都存储为具有已知分隔符的固定长度字符串,因此 .12 :=5 . 将对应于第 12 行和第 5 列。
set -F @len "#{e|*:#{@length},10}"
set -Fg @body '#{E:##{=#{@len}:@body#}}'
# later we prepend the head position onto the body
set -Fg @body '.#{p3:@head_y}:=#{p3:@head_x}.#{@body}'
要删除最后一段,我使用字符串长度限制运算符并对其进行双重扩展,以允许使用变量作为长度。 我将段的数量存储在 @length 中,并且由于每个段的字符串都是固定长度的,因此我只需要将其乘以 10 即可。
在两侧添加分隔符是为了更容易地进行子字符串匹配,而不会遇到误报。 我用 @head_x 和 @head_y 构建一个字符串,如果该字符串在 @body 中找到,则表示蛇已经吃掉了自己,游戏结束。
if -F '#{m:*.#{p3:@head_y}:=#{p3:@head_x}.*,#{@body}}' {
display-menu -x C -y C -c /dev/pts/0 \
-T ' Game over! score: #{e|-:#{@length},3} ' \
'quit' q {
kill-server
}
}
@body 对于碰撞很方便,但不适用于移动蛇的尾巴。 为此,我(非常浪费地)设置了一个新变量,该变量告诉我哪个窗口需要在哪个步骤恢复为默认颜色。 通过跟踪蛇的长度以及已经进行了多少次迭代,我只需要查找 N 步之前的位置是什么,然后将该正方形换回来即可。
set -Fg @step "#{e|+:#{@step},1}"
run -C "set -g '@body_#{@step}' '#{@head_y}:=#{@head_x}'"
这些变量的格式设置为窗口选择器(中间带有 :=),因此可以通过双重扩展将其传递给 rename-window 以进行间接寻址:
run -C 'set @var "@body_#{e|-:#{@step},#{@length}}"'
run -C 'rename-window -t "#{E:##{#{@var}#}}" ""'
在更新期间,我们需要切换头部的颜色。 这只需要完成一次,因为它的颜色将保持不变,直到我们将其切换回来。 为了使眼睛显示在头上,我将同一窗口设置为标记窗格。 一次只能标记一个窗格,因此我不必取消设置此窗格。
run -C "rename-window -t #{@head_y}:=#{@head_x} 2"
run -C "select-pane -t #{@head_y}:=#{@head_x} -m"
这是重要的部分:检查是否吃了一个苹果。 x/y 坐标上的简单字符串匹配就足够了。 然后提高速度和长度。
我无法在 tmux 中想到一个合适的随机数生成器,但是幸运的是,FORMATS 部分中有很多变量可以为我们提供一些足够随机的数字,尤其是当我们将它们与当前步数结合使用时。 我最终使用了 client_written,我假设随着转义序列等写入终端,它会定期增加。 从我的游戏测试来看,这已经足够好了。
if -F '#{&&:#{==:#{@apple_y},#{@head_y}},#{==:#{@apple_x},#{@head_x}}}' {
set -Fg @speed "#{e|*|f|2:#{@speed},0.8}"
set -Fg @length "#{e|+:#{@length},1}"
set -F @seed "#{e|+:#{client_written},#{@step}}"
set -F @var "#{e|%:#{@seed},#{@width}}"
set -Fg @apple_x '#{@var}'
set -F @var "#{e|%:#{@seed},#{@height}}"
set -Fg @apple_y '#{@var}'
}
更新函数的最后一项工作是安排下一次更新(如果我们还没有结束游戏),然后一切再次发生。 与您希望每秒尽可能多地更新的视频播放不同,tmux 能够相当好地跟上这一点。
信不信由你,整个实现是手工编写的,并且比我实际的真实 tmux 配置行数更少——140 行对 192 行。 玩它你只需要 tmux,版本大约是 3.4 左右。 从这里获取代码并尝试一下!
通过 Mastodon 或电子邮件发送反馈。 通过 RSS 或 JSON Feed 订阅。 ← 构建每日新西兰地形图 用 5,170 个 tmux 窗口播放视频 → 归档 / 项目 / tmux © Will Richardson 2014–2025 此处表达的所有观点和意见仅代表我自己的观点。 RSS / JSON Feed