BYTEPATH #14 - Console
Introduction
In this article we'll go over the Console room. The Console is considerably easier to implement than everything else we've been doing so far because in the end it boils down to printing some text on the screen.
这一篇我们来做 Console 房间。和前面那些系统比起来,Console 实现起来要简单得多,因为说到底,它主要就是把一些文字打印到屏幕上。
The Console room will be composed of 3 different types of objects: lines, input lines and modules. Lines are just normal colored text lines that appear on the screen. In the example above, for instance, ":: running BYTEPATH..." would be a line. As a data structure this will just be a table holding the position of the line as well as its text and colors.
Console 房间会由三类对象组成:普通文本行、输入行,以及模块。普通行就是屏幕上那些正常显示出来的彩色文字。比如上面的例子里,:: running BYTEPATH... 就是一行。作为数据结构,它其实就是一张表,里面存这行文字的位置、内容和颜色。
Input lines are lines where the player can type things into. In the example above they are the ones that have "arch" in them. Typing certain commands in an input line will trigger those commands and usually they will either create more lines and modules. As a data structure this will be just like a line, except there's some additional logic needed to read input whenever the last line added to the room was an input one.
输入行则是玩家可以往里输入内容的那种行。上面那个例子里,带着 "arch" 的就是输入行。玩家在输入行里敲某些命令之后,就会触发相应行为,通常会再生成新的文本行或者模块。它的数据结构和普通行差不多,只不过需要额外加一层逻辑:当房间里最后新增的是输入行时,就开始读取玩家输入。
Finally, a module is a special object allows the user to do things that are a bit more complex than just typing commands. The whole set of things that appear when the player has to pick a ship, for instance, is one of those modules. A lot of commands will spawn these objects, so, for instance, if the player wants to change the volume of the game he will type "volume" and then the Volume module will appear and will let the player choose whatever level of sound he wants. These modules will all be objects of their own and the Console room will handle creating and deleting them when appropriate.
最后,模块是一种更特殊的对象,用来处理那些光靠打一串命令还不够方便的事情。比如让玩家选择飞船时整块出现的界面,就属于模块。很多命令最终都会生成这样的对象。举个例子,如果玩家想调节音量,就输入 "volume",然后弹出一个 Volume 模块,让他在那里选具体的音量级别。这些模块都会各自作为独立对象存在,而 Console 房间负责在合适的时候创建和删除它们。
Lines
So let's start with lines. The basic way in which we can define a line is like this:
那我们先从最简单的普通行开始。它最基础的定义方式可以像这样:
{
x = x, y = y,
text = love.graphics.newText(font, {boost_color, 'blue text', default_color, 'white text'}
}So it has a x, y position as well as a text attribute. This text attribute is a Text object. We'll use LÖVE's Text objects because they let us define colored text easily. But before we can add lines to our Console room we have to create it, so go ahead and do that. The basics of it should be the same as the SkillTree one.
也就是说,一行最少要有一个 x, y 位置,以及一个 text 属性。这个 text 属性其实是一个 Text 对象。这里我们会用 LÖVE 自带的 Text,因为它让彩色文字处理起来很方便。不过在把文本行加进 Console 之前,你得先把 Console 房间本身建出来,这部分和 SkillTree 房间的基础结构应该是差不多的,我就默认你能自己写出来了。
We'll add a lines table to hold all the text lines, and then in the draw function we'll go over this table and draw each line. We'll also add a function named addLine which will add a new text line to the lines table:
接下来我们加一个 lines 表,用来保存所有文本行;然后在 draw 函数里遍历这张表,把每一行都画出来。同时,再写一个 addLine 函数,专门往 lines 里塞新行:
function Console:new()
...
self.lines = {}
self.line_y = 8
camera:lookAt(gw/2, gh/2)
self:addLine(1, {'test', boost_color, ' test'})
end
function Console:draw()
...
for _, line in ipairs(self.lines) do love.graphics.draw(line.text, line.x, line.y) end
...
end
function Console:addLine(delay, text)
self.timer:after(delay, function()
table.insert(self.lines, {x = 8, y = self.line_y,
text = love.graphics.newText(self.font, text)})
self.line_y = self.line_y + 12
end)
endThere are a few additional things happening here. First there's the line_y attribute which will keep track of the y position where we should add a new line next. This is incremented by 12 every time we call addLine, since we want new lines to be added below the previous one, like it happens in a normal terminal.
这里面还有几个小细节。第一个是 line_y,它用来记录下一行文字应该画在什么 y 坐标上。每次调用 addLine,它都会加 12,因为我们希望新行总是出现在上一行的下面,就像普通终端那样一行行往下堆。
Additionally the addLine function has a delay. This delay is useful because whenever we're adding multiple lines to the console, we don't want them to be added all the same time. We want a small delay between each addition because it makes everything feel better. One extra thing we could do here is make it so that on top of each line being added with a delay, its added character by character. So that instead of the whole line going in at once, each character is added with a small delay, which would give it an even nicer effect. I'm not doing this for the sake of time but it's a nice challenge (and we already have part of the logic for this in the InfoText object).
另外,addLine 还带了一个延迟参数。这个延迟很有用,因为当我们一次往控制台里加很多行时,不想让它们在同一帧里全部蹦出来。每一行之间稍微错开一点点时间,整体观感会舒服很多。你甚至还能继续往前走一步:不仅让每一行延迟出现,还让它逐字符慢慢打出来。也就是说,不是整行一下子出现,而是字符一个一个往外蹦,那样会更像真的终端。我这里为了节省篇幅就不展开做了,不过这其实是个很不错的练习,而且我们在 InfoText 里已经做过一部分类似逻辑。
All that should look like this:
照这样做出来,大概会像这样:

And if we add multiple lines it also looks like expected:
如果一次多加几行,效果也会符合预期:

Input Lines
Input lines are a bit more complicated but not by much. The first thing we wanna do is add an addInputLine function, which will act just like the addLine function, except it will add the default input line text and enable text input from the player. The default input line text we'll use is [root]arch~ , which is just some flavor text to be placed before our input, like in a normal terminal.
输入行会稍微复杂一点,但也没有复杂到哪里去。第一步是写一个 addInputLine 函数。它和 addLine 很像,只不过它会先放上一段默认输入前缀,然后开启玩家的文字输入。这里我们默认用的前缀是 [root]arch~ ,本质上就是模仿终端提示符,在玩家真正输入内容之前先摆一段壳子。
function Console:addInputLine(delay)
self.timer:after(delay, function()
table.insert(self.lines, {x = 8, y = self.line_y,
text = love.graphics.newText(self.font, self.base_input_text)})
self.line_y = self.line_y + 12
self.inputting = true
end)
endAnd base_input_text looks like this:
而 base_input_text 可以像这样定义:
function Console:new()
...
self.base_input_text = {'[', skill_point_color, 'root', default_color, ']arch~ '}
...
endWe also set inputting to true whenever we add a new input line. This boolean will be used to tell us when we should be picking up input from the keyboard or not. If we are, then we'll simply add all characters that the player types to a list, put this list together as a string, and then add that string to our Text object. This looks like this:
每次新增输入行时,我们还会把 inputting 设成 true。这个布尔值用来告诉系统:现在是不是应该开始接收键盘输入。如果是,那我们就把玩家敲出来的字符逐个塞进一个列表里,再把这个列表拼成字符串,更新到 Text 对象里。代码如下:
function Console:textinput(t)
if self.inputting then
table.insert(self.input_text, t)
self:updateText()
end
end
function Console:updateText()
local base_input_text = table.copy(self.base_input_text)
local input_text = ''
for _, character in ipairs(self.input_text) do input_text = input_text .. character end
table.insert(base_input_text, input_text)
self.lines[#self.lines].text:set(base_input_text)
endAnd Console:textinput will get called whenever love.textinput gets called, which happens whenever the player presses a key:
Console:textinput 需要在 love.textinput 被调用时一起接上,而 love.textinput 会在玩家输入字符时触发:
-- in main.lua
function love.textinput(t)
if current_room.textinput then current_room:textinput(t) end
endOne last thing we should do is making sure that the enter and backspace keys work. The enter key will turn inputting to false and also take the contents of the input_text table and do something with them. So if the player typed "help" and then pressed enter, we'll run the help command. And the backspace key should just remove the last element of the input_text table:
最后,还有两个键得单独处理好:回车和退格。回车的作用是把 inputting 设成 false,并拿 input_text 里的内容去做点实际操作。比如玩家输入 "help" 再按下回车,那我们就去执行 help 命令。退格则很简单,就是把 input_text 里的最后一个字符删掉:
function Console:update(dt)
...
if self.inputting then
if input:pressed('return') then
self.inputting = false
-- Run command based on the contents of input_text here
self.input_text = {}
end
if input:pressRepeat('backspace', 0.02, 0.2) then
table.remove(self.input_text, #self.input_text)
self:updateText()
end
end
endFinally, we can also simulate a blinking cursor for some extra points. The basic way to do this is to just draw a blinking rectangle at the position after the width of base_input_text concatenated with the contents of input_text.
如果你还想再加一点味道,可以顺手模拟一个闪烁光标。最简单的办法,就是在 base_input_text 和当前输入内容拼起来之后的末尾,画一个会闪烁的小矩形。
function Console:new()
...
self.cursor_visible = true
self.timer:every('cursor', 0.5, function()
self.cursor_visible = not self.cursor_visible
end)
endIn this way we get the blinking working, so we'll only draw the rectangle whenever cursor_visible is true. Next for the drawing the rectangle:
这样我们就得到一个会周期性开关的光标显示状态。接下来只要在 cursor_visible 为真时把光标画出来就行:
function Console:draw()
...
if self.inputting and self.cursor_visible then
local r, g, b = unpack(default_color)
love.graphics.setColor(r, g, b, 96)
local input_text = ''
for _, character in ipairs(self.input_text) do input_text = input_text .. character end
local x = 8 + self.font:getWidth('[root]arch~ ' .. input_text)
love.graphics.rectangle('fill', x, self.lines[#self.lines].y,
self.font:getWidth('w'), self.font:getHeight())
love.graphics.setColor(r, g, b, 255)
end
...
endIn here the variable x will hold the position of our cursor. We add 8 to it because every line is being drawn by default starting at position 8, so if we don't take this into account the cursor's position will be wrong. We also consider that the cursor rectangle's width is the width of the 'w' letter with the current font. Generally w is the widest letter to use so we'll go with that. But this could also be any other fixed number like 10 or 8 or whatever else.
这里的 x 就是光标应该出现的位置。之所以额外加上 8,是因为所有行默认都是从 x=8 开始画的,不把这个偏移算进去,光标就会对不齐。至于光标矩形的宽度,这里直接用了当前字体下字母 'w' 的宽度。一般来说,w 算是比较宽的字符,所以拿它来估一个光标宽度还挺顺手。当然,你也完全可以直接写死成 8、10 之类的固定数值。
And all that should look like this:
最终效果应该像这样:

Modules
Modules are objects that contain certain logic to let the player do something in the console. For instance, the ResolutionModule that we'll implement will let the player change the resolution of the game. We'll separate modules from the rest of the Console room code because they can get a bit too involved with their logic, so having them as separate objects is a good idea. We'll implement a module that looks like this:
模块就是那些带着特定逻辑、让玩家在控制台里完成某件事情的对象。比如我们接下来要做的 ResolutionModule,就是专门让玩家修改游戏分辨率的。之所以把模块单独拆出去,而不是全都揉在 Console 房间里,是因为它们一旦逻辑复杂起来,很快就会变得很臃肿。让它们作为独立对象存在,会更清晰一些。我们先来实现这样一个模块:

This module in particular gets created and added whenever the player has pressed enter after typing "resolution" on an input line. Once it's activated it takes control away from the console and adds a few lines with Console:addLine to it. It then also has some selection logic on top of those added lines so we can pick our target resolution. Once the resolution is picked and the player presses enter, the window is changed to reflect that new resolution, we add a new input line with Console:addInputLine and disable selection on this ResolutionModule object, giving control back to the console.
这个模块会在玩家在输入行里键入 "resolution" 并按下回车之后被创建出来。激活之后,它会暂时接管输入控制权,先通过 Console:addLine 往控制台里补几行可选项,然后自己负责处理选中逻辑,让玩家能在这些分辨率之间上下切换。等玩家确认选择并按下回车之后,窗口分辨率就会更新,接着我们再调用 Console:addInputLine 增加新的输入行,同时关闭这个 ResolutionModule 的选择逻辑,把控制权还给 Console。
All modules will work somewhat similarly to this. They get created/added, they do what they're supposed to do by taking control away from the Console room, and then when their behavior is done they give it control back. We can implement the basics of this on the Console object like this:
其他模块大体也都会按这个模式运作:先被创建出来,短暂接管 Console 的控制权,把自己的事情做完,然后再把控制权还回去。Console 这边的基础接法可以像这样:
function Console:new()
...
self.modules = {}
...
end
function Console:update(dt)
self.timer:update(dt)
for _, module in ipairs(self.modules) do module:update(dt) end
if self.inputting then
...
end
function Console:draw()
...
for _, module in ipairs(self.modules) do module:draw() end
camera:detach()
...
endBecause we're mostly coding this by ourselves we can skip some formalities here. Even though I just said we'll have this sort of rule/interface between Console object and Module objects where they exchange control of the player's input with each other, in reality all we have to do is simply add modules to the self.modules table, update and draw them. Each module will take care of activating/deactivating itself whenever appropriate, which means that on the Console side of things we don't really have to do much.
因为这里基本上还是一个人写,所以很多形式上的东西其实可以省掉。虽然我刚才说了 Console 和 Module 之间有一种“交换控制权”的关系,但实际代码里,我们需要做的也不过就是把模块加进 self.modules 表,然后在主循环里更新和绘制它们。至于什么时候激活、什么时候停用,各个模块自己处理就好了。所以站在 Console 这边看,事情其实没那么复杂。
Now for the creation of the ResolutionModule:
接着来看 ResolutionModule 是怎么生成出来的:
function Console:update(dt)
...
if self.inputting then
if input:pressed('return') then
self.line_y = self.line_y + 12
local input_text = ''
for _, character in ipairs(self.input_text) do
input_text = input_text .. character
end
self.input_text = {}
if input_text == 'resolution' then
table.insert(self.modules, ResolutionModule(self, self.line_y))
end
end
...
end
endIn here we make it so that the input_text variable will hold what the player typed into the input line, and then if this text is equal to "resolution" we create a new ResolutionModule object and add it to the modules list. Most modules will need a reference to the console as well as the current y position where lines are added to, since the module will be placed below the lines that exist currently in the console. So to achieve that we pass both self and self.line_y when we create a new module object.
这里的逻辑很直接:先把玩家在输入行里打出来的内容拼成一个 input_text 字符串;如果这个字符串正好等于 "resolution",那就创建一个新的 ResolutionModule,并把它塞进 modules 列表里。大多数模块都需要拿到 Console 本身的引用,以及当前文本行已经推进到的 y 坐标。因为模块通常要出现在已有文字下面,所以在创建模块时,我们把 self 和 self.line_y 一并传进去。
The ResolutionModule itself is rather straightforward. For this one in particular all we'll have to do is add a bunch of lines as well as some small amount of logic to select between each line. To add the lines we can simply do this:
ResolutionModule 自己的逻辑其实也不复杂。对它来说,主要就是先加几行分辨率选项,再补上一点简单的选择逻辑。要把这些选项打印出来,可以直接这么写:
function ResolutionModule:new(console, y)
self.console = console
self.y = y
self.console:addLine(0.02, 'Available resolutions: ')
self.console:addLine(0.04, ' 480x270')
self.console:addLine(0.06, ' 960x540')
self.console:addLine(0.08, ' 1440x810')
self.console:addLine(0.10, ' 1920x1080')
endTo make things easy for now all the resolutions we'll concern ourselves with are the ones that are multiples of the base resolution, so all we have to do is add those 4 lines.
为了先把事情做简单一点,我们这里只考虑那些是基础分辨率整数倍的选项,所以目前只需要列出这四种就够了。
After this is done all we have to do is add the selection logic. The selection logic feels like a hack but it works well: we'll just place a rectangle on top of the current selection and move this rectangle around as the player presses up or down. We'll need a variable to keep track of which number we're in now (1 through 4), and then we'll draw this rectangle at the appropriate y position based on this variable. All this looks like this:
接下来要补的就是选择逻辑。这个实现看起来有点土办法,但很好用:我们直接在当前选中的那一行外面套一个矩形高亮,玩家按上下键时,就让这个矩形跟着移动。为此,我们需要一个变量记录当前选中了第几个选项,也就是 1 到 4 之间的某个数字,再根据这个数字把矩形画到对应位置。代码如下:
function ResolutionModule:new(console, y)
...
self.selection_index = sx
self.selection_widths = {
self.console.font:getWidth('480x270'), self.console.font:getWidth('960x540'),
self.console.font:getWidth('1440x810'), self.console.font:getWidth('1920x1080')
}
endThe selection_index variable will keep track of our current selection and we start it at sx. sx is either 1, 2, 3 or 4 based on the size we chose in main.lua when we called the resize function. selection_widths holds the widths for the rectangle on each selection. Since the rectangle will end up covering each resolution, we need to figure out its size based on the size of the characters that make up the string for that resolution.
selection_index 用来记录当前选中项,我们把它初始化成 sx。sx 本身就代表你在 main.lua 里通过 resize 选中的缩放倍数,所以它天然会是 1、2、3、4 中的一个。至于 selection_widths,则是每个选项对应的高亮矩形宽度。因为矩形要刚好盖住分辨率字符串,所以我们得先把每一项文本在当前字体下的宽度算出来。
function ResolutionModule:update(dt)
...
if input:pressed('up') then
self.selection_index = self.selection_index - 1
if self.selection_index < 1 then self.selection_index = #self.selection_widths end
end
if input:pressed('down') then
self.selection_index = self.selection_index + 1
if self.selection_index > #self.selection_widths then self.selection_index = 1 end
end
...
endIn the update function we'll handle the logic for when the player presses up or down. We just need to increase or decrease selection_index and take care to not go below 1 or above 4.
在 update 里,我们处理上下按键的逻辑:按上就让 selection_index 减一,按下就加一,同时保证它别掉到 1 以下,也别超过 4。
function ResolutionModule:draw()
...
local width = self.selection_widths[self.selection_index]
local r, g, b = unpack(default_color)
love.graphics.setColor(r, g, b, 96)
local x_offset = self.console.font:getWidth(' ')
love.graphics.rectangle('fill', 8 + x_offset - 2, self.y + self.selection_index*12,
width + 4, self.console.font:getHeight())
love.graphics.setColor(r, g, b, 255)
endAnd in the draw function we just draw the rectangle at the appropriate position. Again, this looks terrible and full of weird numbers all over but we need to place the rectangle in the appropriate location, and there's no "clean" way of doing it.
在 draw 里要做的,就是把这个高亮矩形画到对应位置上。是的,这段代码看起来依旧充满了各种奇怪的小数字,观感也不算优雅,但你就是得把矩形挪到正确的位置上,而这种场景通常没有什么特别“干净”的写法。
The only thing left to do now is to make sure that this object is only reading input whenever it's active, and that it's active only right after it has been created and before the player has pressed enter to select a resolution. After the player presses enter it should be inactive and not reading input anymore. A simple way to do this is like this:
现在还差最后一件事:确保这个对象只有在激活状态下才会读取输入,而且它只会在刚创建出来、到玩家按下回车确认之前这段时间里处于激活状态。玩家一旦确认选择,它就应该立即失活,不再继续接收输入。一个很直接的做法如下:
function ResolutionModule:new(console, y)
...
self.console.timer:after(0.02 + self.selection_index*0.02, function()
self.active = true
end)
end
function ResolutionModule:update(dt)
if not self.active then return end
...
if input:pressed('return') then
self.active = false
resize(self.selection_index)
self.console:addLine(0.02, '')
self.console:addInputLine(0.04)
end
end
function ResolutionModule:draw()
if not self.active then return end
...
endThe active variable will be set to true a few frames after the module is created. This is to avoid having the rectangle drawn before the lines are added, since the lines are added with a small delay between each other. If this active variable is not active then the update nor the draw function won't run, which means we won't be reading input for this object nor drawing the selection rectangle. Additionally, whenever we press enter we set active to false, call the resize function and then give control back to the Console by adding a new input line. All this gives us the appropriate behavior and everything should work as expected now.
这里的 active 会在模块创建后的几帧之后才被设成 true。这样做是为了避免高亮矩形比文本本身更早出现,因为这些文本行本来就是带着一点延迟一行一行加进去的。如果 active 不是真,那么这个模块的 update 和 draw 都不会执行,于是它既不会读取输入,也不会画出选择框。等玩家按下回车时,我们再把 active 改回 false,调用 resize 更新分辨率,然后通过新增一个输入行把控制权交回给 Console。整套下来,行为就会如我们所愿。
Exercises
227. (CONTENT) Make it so that whenever there are more lines than the screen can cover in the Console room, the camera scrolls down as lines and modules are added.
227. (CONTENT) 让 Console 房间在文本行数量超出屏幕可见范围时,随着新行和模块不断加入,自动让相机向下滚动。
228. (CONTENT) Implement the AchievementsModule module. This displays all achievements and what's needed to unlock them. Achievements will be covered in the next article, so you can come back to this exercise later!
228. (CONTENT) 实现 AchievementsModule。这个模块会显示所有成就,以及每个成就的解锁条件。成就会在下一篇里讲,所以你也可以稍后再回来做这题。
229. (CONTENT) Implement the ClearModule module. This module allows for clearing of all saved data or the clearing of the skill tree. Saving/loading data will be covered in the next article as well, so you can come back to this exercise later too.
229. (CONTENT) 实现 ClearModule。这个模块允许玩家清除所有存档数据,或者只清空技能树。存档与读档同样会在下一篇讲,所以这题也可以晚一点再做。
230. (CONTENT) Implement the ChooseShipModule module. This modules allows the player to choose and unlocks ships with which to play the game. This is what it looks like:
230. (CONTENT) 实现 ChooseShipModule。这个模块允许玩家选择并解锁要使用的飞船。效果大概像这样:
231. (CONTENT) Implement the HelpModule module. This displays all available commands and lets the player choose a command without having to type anything. The game also has to support gamepad only players so forcing the player to type things is not good.
231. (CONTENT) 实现 HelpModule。它会列出所有可用命令,并允许玩家直接选择命令,而不是必须亲手输入。毕竟游戏还得照顾只用手柄的玩家,强迫他们打字并不是个好主意。
232. (CONTENT) Implement the VolumeModule module. This lets the player change the volume of sound effects and music.
232. (CONTENT) 实现 VolumeModule。它用于调整音效和音乐的音量。
233. (CONTENT) Implement the mute, skills, start ,exit and device commands. mute mutes all sound. skills changes to the SkillTree room. start spawns a ChooseShipModule and then starts the game after the player chooses a ship. exit exits the game.
233. (CONTENT) 实现 mute、skills、start、exit 和 device 这几个命令。mute 用来静音所有声音;skills 会切换到 SkillTree 房间;start 会生成一个 ChooseShipModule,并在玩家选好飞船后开始游戏;exit 用来退出游戏。
END
And this is it for the console. With only these three ideas (lines, input lines and modules) we can do a lot and use this to add a lot of flavor to the game. The next article is the last one and in it we'll cover a bunch of random things that didn't fit in any other articles before.
Console 这一块讲到这里就差不多了。光靠这三个概念,也就是普通行、输入行和模块,其实就已经能做出很多东西,也足够给游戏加上不少独特的味道。下一篇就是最后一篇了,里面会补上一些前面各篇都不太好安放的零散内容。