« 构建工具从 Make 到 Ninja | 返回首页 | 缓存在 Lua 中的配置表 »

ANSI escape code 及 Lua 封装

这两天想给一个想法做个简单的原型,因为涉及人机交互,需要在屏幕上绘制一些简单的交互元素。当然,现在有很多工具可供利用。过去遇到这种事情,我会尝试用已有的各种开源游戏引擎(我尤其推荐 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)
    "^   ^"
    "  <  "
    "  _  "


c.draw(1,1, " ")
    "+-------------+"
    "|             |"
    "|             |"
    "|             |"
    "|             |"
    "|             |"
    "|             |"
    "|             |"
    "|             |"
    "+-------------+"

接下来的问题是,怎样让画面动起来?我用 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) 。但是标准输入有两个问题:

  1. 即使我们用 io.stdin:setvbuf "none" 把标准输入的缓冲关掉,在终端下依然需要按回车才能读到输入。这是因为终端默认是经典模式 canonical mode ,这个模式下,终端只有在用户按下回车后,才会把整行的输入发给应用程序(这样,终端才能正确处理诸如退格,方向键等输入)。
  2. 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

Comments

js
最近广东疫情消息比较多,云风哥最近要多注意安全
十年前的云风大哥是否曾为那无处安放的迷茫思绪困扰过,活着到底为了什么...
@Anonymous #和table.getn只对数组形式的table有效
发现一个比较奇怪的情况。 #table获取的长度不是实际长度,竟然大于实际数据数量。 local lt = {} --1-153 156-158 162 999 for i = 1, 153 do lt[i] = i end --for i = 156, 158 do -- lt[i] = i --end lt[156] = 156 --lt[157] = 157 --lt[158] = 158 --lt[162] = 162 --lt[999] = 999 local function get_num(lt) local num = 0 for k, v in pairs(lt) do num = num + 1 end return num end print(#lt, get_num(lt)) 输出:156 154
整理了一个 Fantasy Console 列表 https://github.com/paladin-t/fantasy
云凤哥竟然在阿里
一直在仰望风哥的路上前行,神一样的男人
风哥能出一篇讲玩的那些rogueLike 的游戏吗? 我比较熟悉的有雨中冒险、黑帝斯和以撒的结合,其余的就不甚了解了。
第一的双下巴狂喜 :))

Post a comment

非这个主题相关的留言请到:留言本