« April 2021 | Main | June 2021 »

May 20, 2021

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

May 08, 2021

构建工具从 Make 到 Ninja

最近,我们把自研游戏引擎的构建工具从 GNU Make 迁移到了 Ninja

迁移动机是这样的:

我为引擎编写了最初的 Makefile ,它可以很好的工作在 MinGW / MacOSX / iOS 平台。把基本框架搭好以后,用起来也比较方便。但是,参与开发的同事一直有用 MSVC 开发的需求,而我们迟迟没有在 Makefile 的框架里增加 MSVC 的支持。用 MSVC 的同事一直在手工维护一个 MSVC 的项目。

渐渐的,同时维护 Makefile 和 MSVC 的工程成了一个负担。

实际上,现在惯用的方法都是用一个高阶的语言去描述项目构建流程,再翻译成不同平台下的构建脚本。即使用 GNU Make ,通常我们也是先用 Make 本身设计一个框架,在这个框架下去描述构建脚本,再让 Make 在不同平台下生成不同的流程。

如果不介意引入新的工具,那么 Autoconf ,CMake ,Premake 都可以解决这个问题。

我们的项目其实混用了不少构建工具。每个第三方库都直接使用它自己的构建流程。使用最多的是 CMake ,其次是 PreMake/GENie 。在这几年的使用过程中,我意识到想维护好 CMake 构建脚本是一件很困难的事。或者对使用的人来说,可以享受一键编译的方便,但编写和调试都是非常麻烦的。

我们的项目需求比较单一,在很长时间直接用 Make 维护两个平台的编译环境并不麻烦,开发成本远小于维护 CMake 脚本。但最近,想引入第三个开发环境(MSVC),有一些新的开发工作,所以就重新考虑这个问题了。

如果考虑把描述构建流程和实际的构建脚本分离,就没有必要坚持使用单个工具(GNU Make )。我更倾向于使用 Lua 来描述,毕竟我们项目的主体开发语言就是 Lua 。我们所有的开发人员都有丰富的 Lua 开发经验。Lua 比 Make 要容易用的多。

一旦不使用 Make 中那些用来生成构建流程的特性,Ninja 这种功能更少,解决问题更明确的工具就更合适。

Ninjia 的设计原则就是构建脚本易于人阅读(方便调试),但不易于人直接书写(方便机器解析)。同时,构建流程可以获得更高的效率。减少构建时间能直接提高开发效率。

我们使用了自己开发的 luamake 来生成 Ninja 脚本。相比 CMake 来说,Lua 对我们更熟悉,调试更方便;相比 PreMake ,我们自己维护的工具目的更纯粹,更聚焦于项目的需求。