ANSI escape code 及 Lua 封装

云风 2021-05-20 17:05

这两天想给一个想法做个简单的原型,因为涉及人机交互,需要在屏幕上绘制一些简单的交互元素。当然,现在有很多工具可供利用。过去遇到这种事情,我会尝试用已有的各种开源游戏引擎(我尤其推荐PICO-8),或是直接在浏览器中用 css/javascript 写等等。

最近几年我玩了大量 RogueLike ,想尝试一下在 console 下用 ascii 字符来拼凑画面。这很有趣,能让我回忆起小时候 Apple ][ 上花掉的大把时光。同时我想用 Lua 来做开发,却不想引入ncurses这样的第三方库。最好能用几分钟就可以从零搭建起开发环境。

最好的选择当然是用ANSI escape code通过标准输出在 console 界面上作画了。

需要用到的是 CSI (Control Sequence Introducer) sequences ,也就是向标准输出写入一个 ESC(\x1b)[ 再跟上参数加可以命令字母。它可以用来控制光标的位置,这样就不限于在终端下一行行顺序输出文字。

最常用的是 CSI n ; m H 移动光标到决定位置;CSI n J 清屏;CSI n (A/B/C/D) 移动光标;CSI s 保存当前位置;CSI u 恢复保存的位置。

直接输出控制字符用起来会非常繁琐。当然是最个简单的封装了。我花了十几分钟做了个基本可用的东西:

-- cursor.lua
local C = {}

local write = io.write

function C.clear()
    write "\x1b[2J"
end

local function drawline(line)
    write("\x1b[s", line, "\x1b[u\x1b[B")
    return drawline
end

local function move_cursor(match)
    return "\x1b[" .. #match .. "C"
end

local function trans_draw(cache, key)
    if key == "%" then
        key = "%%"
    end
    local pat = "[" .. key .. "+]"
    local function trans_drawline(line)
        line = line:gsub(pat, move_cursor)
        drawline(line)
        return trans_drawline
    end
    cache[key] = trans_drawline
    return trans_drawline
end

local drawline = setmetatable( { [true] = drawline }, { __index = trans_draw, __mode = "v" } )

function C.draw(y,x,trans)
    write("\x1b[",y,";",x,"H")
    return drawline[trans or true]
end

return C

这里就提供了两个 api ,一个是 cursor.clear() 清屏;另一个是 cursor.draw(行,列,可选的透明字符) 用来绘制一组 ascii 字符。

核心在于这个 draw 函数,它可以把光标移动到指定位置后,绘制多行文字。这里支持透明字符(通常是空格),用户可以指定透明字符,在绘制过程中跳过那些位置,避免覆盖背景。

例如,下面的代码可以在一个方框内绘制一张笑脸。注意,这里的方框是在最后绘制的,但不会遮挡前面已经绘制好的笑脸图案。

local c = require "cursor"

c.clear()

c.draw(3,4)
    " XXXXXXX"
    "X       X"
    "X       X"
    "X       X"
    "X       X"
    " XXXXXXX"

c.draw(5,6)
    "^   ^"
    "  

接下来的问题是,怎样让画面动起来?我用 coroutine 实现了一个非常简单的框架。


local c = require "cursor"

local function run(main)
    local term = {}
    local co = coroutine.create(function()
        main()
        return term
    end)
    while true do
        c.clear()
        local ok, err = coroutine.resume(co)
        if not ok then
            error(err)
        end
        if err == term then
            break
        end
        os.execute "sleep 0.1"
    end
end


这样,用 run 执行一个函数,这个函数中每画一帧就调用 coroutine.yield() 即可。

怎样和键盘交互?

标准的做法是读 stdin 。在 Lua 里可以用 io.read(1) 。但是标准输入有两个问题:

即使我们用 io.stdin:setvbuf "none" 把标准输入的缓冲关掉,在终端下依然需要按回车才能读到输入。这是因为终端默认是经典模式 canonical mode ,这个模式下,终端只有在用户按下回车后,才会把整行的输入发给应用程序(这样,终端才能正确处理诸如退格,方向键等输入)。
stdin 没有标准 API 设置为非阻塞模式,阻塞模式下,如果没有输入,程序就会挂起等待输入。
我想了一下,通过写一个简单的 C 程序把这两个问题一并解决。

这个程序会先将终端设置为 None canonical mode ,这样每次按键都会直接进入 stdin 。然后它把获得的输入写到 stdout (同时把回车转换为 0xff );另外,它创建一个线程,每隔 0.1 秒把 \n 写入标准输出。

接下来,我们可以通过管道把这个程序的输出重定向给 Lua 脚本。这样,Lua 中就能每隔 0.1 秒拿到一行字符串,这个字符串就是过去 0.1 秒的键盘输入(其中回车是 0xff)。

由于 Windows 不是 posix 系统,所以设置 None canonical mode  和开线程都需要为 Windows 单独实现。

最后,前面那个 run 函数就可以把 sleep 改成  io.read "l" 。


local function run(main)
    local term = {}
    local co = coroutine.create(function()
        main()
        return term
    end)
    while true do
        local keys = io.read "l"
        c.clear()
        local ok, err = coroutine.resume(co, key)
        if not ok then
            error(err)
        end
        if err == term then
            break
        end
    end
end


有了这么一段小程序,就可以用 Lua 愉快的开发跨平台的 Rogue Like 游戏了 :D

[返回] [原文链接]