前言

啊,我真的懒得写了。明明应该好好学 Python 的,怎么学起 Lua 了……

其实,我是看到别人用 LuaLÖVE 框架做了俄罗斯方块,自己也想做一个,才研究了一下 Lua (明明 Python 也可以做啊)。

配置

Lua

Lua官网上下载即可,然后配置环境变量,将包含 Lua 的文件夹路径加入环境变量中。之后就可以在命令行执行:

lua54

显示相关信息,则说明 Lua 配置成功。要注意的是,下载的版本不同,执行的命令可能也不同,我的版本是 lua-5.4.2 。这一问题在后面配置 VSCode 时还会遇到。

LÖVE

LÖVE这里下载,配置环境变量。将包含 LÖVE 的文件夹路径加入环境变量中。之后在命令行执行:

love

会启动默认的 LÖVE 程序。

VSCode

没错,我还是想用 VSCode ,所以就需要配置一下了。

VSCode 需要使用相应的拓展。我使用的是 Code RunnerLove2D Support ,在配置拓展时,需要对 Code Runner 的默认配置进行修改,并设置 love.exe 的路径(我也不知道为什么还要再设一次路径,明明有环境变量了)。

"code-runner.executorMap": {
"lua": "lua54",
},
"pixelbyte.love2d.path": "xxx\\love-11.4-win64\\love.exe",

Code Runner 是用来跑 Lua 的, Love2D Support 是跑 LÖVE 的。在 Love2D Support 拓展的设置中,可以设置开启 LÖVE 的控制台,这样就能看见 print()的输出了()。

测试

测试 Lua 的情况。

print("hello world!")
print("你好,世界!")

如果中文输出出现乱码,很有可能因为使用的终端是 cmd ,有两种方法解决该问题。第一种方法是将使用的终端改为 powershell;第二种方法则是将 cmd 的编码改为 UTF-8

"terminal.integrated.defaultProfile.windows": "PowerShell",
"terminal.integrated.shellArgs.windows": ["/K chcp 65001 >nul"],

测试 LÖVE 的情况。

-- 初始化矩形的一些默认值
function love.load()
x, y, w, h = 20, 20, 60, 20
end

-- 每一帧增加矩形的尺寸
function love.update(dt)
w = w + 1
h = h + 1
end

-- 绘制有颜色的矩形
function love.draw()
love.graphics.setColor(0, 100, 100)
love.graphics.rectangle("fill", x, y, w, h)
end

按住 Alt + L,应当会有 LÖVE 程序启动,绘制一个不断变大的青色矩形。当然,拓展的这个快捷键可以在设置中进行修改。

编写游戏

基础

配置文件

在游戏目录中的 conf.lua 文件夹,会在 LÖVE 模块加载前运行,可以使用该文件重写 love.conf 函数。

love.conf 函数有一个参数,该参数是一个包含所有默认值的 table 类型参数。通过 love.conf 函数,可以修改默认值,关闭不需要的模块:

function love.conf(t)
-- 修改默认值
t.window.width = 1024
t.window.height = 768

-- 关闭不需要的模块
t.modules.joystick = false
t.modules.physics = false
end

以下是 11.311.4 的全部默认值:

function love.conf(t)
t.identity = nil -- The name of the save directory (string)
t.appendidentity = false -- Search files in source directory before save directory (boolean)
t.version = "11.3" -- The LÖVE version this game was made for (string)
t.console = false -- Attach a console (boolean, Windows only)
t.accelerometerjoystick = true -- Enable the accelerometer on iOS and Android by exposing it as a Joystick (boolean)
t.externalstorage = false -- True to save files (and read from the save directory) in external storage on Android (boolean)
t.gammacorrect = false -- Enable gamma-correct rendering, when supported by the system (boolean)

t.audio.mic = false -- Request and use microphone capabilities in Android (boolean)
t.audio.mixwithsystem = true -- Keep background music playing when opening LOVE (boolean, iOS and Android only)

t.window.title = "Untitled" -- The window title (string)
t.window.icon = nil -- Filepath to an image to use as the window's icon (string)
t.window.width = 800 -- The window width (number)
t.window.height = 600 -- The window height (number)
t.window.borderless = false -- Remove all border visuals from the window (boolean)
t.window.resizable = false -- Let the window be user-resizable (boolean)
t.window.minwidth = 1 -- Minimum window width if the window is resizable (number)
t.window.minheight = 1 -- Minimum window height if the window is resizable (number)
t.window.fullscreen = false -- Enable fullscreen (boolean)
t.window.fullscreentype = "desktop" -- Choose between "desktop" fullscreen or "exclusive" fullscreen mode (string)
t.window.vsync = 1 -- Vertical sync mode (number)
t.window.msaa = 0 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.depth = nil -- The number of bits per sample in the depth buffer
t.window.stencil = nil -- The number of bits per sample in the stencil buffer
t.window.display = 1 -- Index of the monitor to show the window in (number)
t.window.highdpi = false -- Enable high-dpi mode for the window on a Retina display (boolean)
t.window.usedpiscale = true -- Enable automatic DPI scaling when highdpi is set to true as well (boolean)
t.window.x = nil -- The x-coordinate of the window's position in the specified display (number)
t.window.y = nil -- The y-coordinate of the window's position in the specified display (number)

t.modules.audio = true -- Enable the audio module (boolean)
t.modules.data = true -- Enable the data module (boolean)
t.modules.event = true -- Enable the event module (boolean)
t.modules.font = true -- Enable the font module (boolean)
t.modules.graphics = true -- Enable the graphics module (boolean)
t.modules.image = true -- Enable the image module (boolean)
t.modules.joystick = true -- Enable the joystick module (boolean)
t.modules.keyboard = true -- Enable the keyboard module (boolean)
t.modules.math = true -- Enable the math module (boolean)
t.modules.mouse = true -- Enable the mouse module (boolean)
t.modules.physics = true -- Enable the physics module (boolean)
t.modules.sound = true -- Enable the sound module (boolean)
t.modules.system = true -- Enable the system module (boolean)
t.modules.thread = true -- Enable the thread module (boolean)
t.modules.timer = true -- Enable the timer module (boolean), Disabling it will result 0 delta time in love.update
t.modules.touch = true -- Enable the touch module (boolean)
t.modules.video = true -- Enable the video module (boolean)
t.modules.window = true -- Enable the window module (boolean)
end

各配置详细的作用请参考:Config Files - LOVE (love2d.org)

运行

love.run()是运行的主函数,它包含了运行时的主循环,不同版本的默认值不同。

-- The default function for 11.0, used if you don't supply your own.
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end

-- We don't want the first frame's dt to include time taken by love.load.
if love.timer then love.timer.step() end

local dt = 0

-- Main loop time.
return function()
-- Process events.
if love.event then
love.event.pump()
for name, a,b,c,d,e,f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a,b,c,d,e,f)
end
end

-- Update dt, as we'll be passing it to update
if love.timer then dt = love.timer.step() end

-- Call update and draw
if love.update then love.update(dt) end -- will pass 0 if love.timer is disabled

if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())

if love.draw then love.draw() end

love.graphics.present()
end

if love.timer then love.timer.sleep(0.001) end
end
end

可以参见love.run - LOVE (love2d.org)

任务目标

做一款现代俄罗斯方块,具有以下特点:

  • 配色:每个方块的颜色有要求
  • 延迟锁定:方块接触到地面后,不会立刻锁定
  • 旋转系统:根据踢墙表,存在踢墙情况
  • 出块系统:使用 7-Bag 而不是纯随机的出块方式

可以参见俄罗斯方块心得记录。在写代码时,学习参考了 MrZ 大佬的代码:

实现

以下是我的代码,还没写完(小声)

场地参考

场地的话,计划使用 10×40 的场地,其中正常游戏只使用 10×20,剩下 10×20 用于存储顶出显示场地的方块。通过建立一个以场地为参考的坐标描述,我们可以描述方块的位置信息,并将游戏逻辑与绘图的逻辑分开。

实现起来,主要是通过一个二维数组记录 10*40 场地的情况。其中,每格不同的数值代表不同的方块。目前,0 代表无方块,1~7 代表七种方块。这样,以后既可以拓展格子的类型,又可以绘制彩色的场地。

从数据结构的角度,先消除在线性表靠后数据,再消除在线性表靠前的数据,操作要更方便一些。因此,消行的顺序就很关键。

方块表达

方块,以及方块与其他要素的交互是最麻烦的一部分。

方块表达时,采用以下的方式。

各方块的边界框

方块的边界框是包含四个方向形状的外接矩形。除了 IO 的边界框是 4×4 和 2×2 以外,其他方块的边界框是 3×3 ,若要确定每个 mino 的位置,需要知道此刻边界框的位置和方块的朝向。在我们这次的编写中,我们通过边界框左上角的场地坐标,确定方块的位置。此外,在方块新生成的时候, IO 是居中的,而其他方块是偏左的,它们的边界框左上角坐标也不相同。这些生成时的初始坐标,记录在一个表中。

-- 方块活动框左上角坐标 (x,y)
local initPos={
{4,22}, -- I
{4,22}, -- J
{4,22}, -- L
{5,22}, -- O
{4,22}, -- S
{4,22}, -- Z
{4,22} -- T
}

为了表示各个方向下的方块形状,也采用了表结构存储,在已知方块类型和方块朝向后,就能立刻获取其形状。

-- 记录的各方向的方块形状(从下往上):1-原位(0) 2-顺时针位(R) 3-180度位(2) 4-逆时针位(L)
local blocks={
-- I
{
{{0,0,0,0},{0,0,0,0},{1,1,1,1},{0,0,0,0}}, --I1
{{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}}, --I2
{{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, --I3
{{0,1,0,0},{0,1,0,0},{0,1,0,0},{0,1,0,0}} --I4
},
-- J
{
{{0,0,0},{1,1,1},{1,0,0}}, --J1
{{0,1,0},{0,1,0},{0,1,1}}, --J2
{{0,0,1},{1,1,1},{0,0,0}}, --J3
{{1,1,0},{0,1,0},{0,1,0}} --J4
},
-- L
{
{{0,0,0},{1,1,1},{0,0,1}}, --L1
{{0,1,1},{0,1,0},{0,1,0}}, --L2
{{1,0,0},{1,1,1},{0,0,0}}, --L3
{{0,1,0},{0,1,0},{1,1,0}} --L4
},
-- O
{
{{1,1},{1,1}}, --O1
{{1,1},{1,1}}, --O2
{{1,1},{1,1}}, --O3
{{1,1},{1,1}} --O4
},
-- S
{
{{0,0,0},{1,1,0},{0,1,1}}, --S1
{{0,0,1},{0,1,1},{0,1,0}}, --S2
{{1,1,0},{0,1,1},{0,0,0}}, --S3
{{0,1,0},{1,1,0},{1,0,0}} --S4
},
-- Z
{
{{0,0,0},{0,1,1},{1,1,0}}, --Z1
{{0,1,0},{0,1,1},{0,0,1}}, --Z2
{{0,1,1},{1,1,0},{0,0,0}}, --Z3
{{1,0,0},{1,1,0},{0,1,0}} --Z4
},
-- T
{
{{0,0,0},{1,1,1},{0,1,0}}, --T1
{{0,1,0},{0,1,1},{0,1,0}}, --T2
{{0,1,0},{1,1,1},{0,0,0}}, --T3
{{0,1,0},{1,1,0},{0,1,0}} --T4
}
}

在后面的实现中,还使用了不少的表,比如行状态表、列状态表、颜色表、踢墙表等等。

方块碰撞

这点是最复杂的地方,而且牵扯到踢墙的概念。

由于每个方块的边界框并不与某一方向时方块的最小外接矩形重合,方块与场地边界的碰撞检测就稍显麻烦。不对边界框中完全没有 mino 的行或列进行特殊处理的话,就可能造成(场地下边界或场地左右边界)数组越界或是方块悬空的状况。因此,根据方块的形状表,我计算了对应的行状态与列状态表(遇事不决就打表)。在此基础上,可以实现一个判断方块是否超出场地,与场地内方块重叠的函数。通过该函数,我们可以做到判断方块是否落地(落地了就要计时,准备延迟锁定了)。通过该函数,我们可以判断踢墙的结果是否可行。

踢墙的概念这里不再多说。有踢墙表之后,只需要按顺序进行平移的尝试,成功则采用,不成功则方块不能旋转。

延迟锁定

目前我只做了延迟锁定,其表现为:

  • 非落地状态下,不进行延迟锁定计时。
  • 落地状态下,移动或旋转后变为非落地状态后,不进行延迟锁定计时。
  • 落地状态下不会立即锁定,而是保留一段继续操作的时间。
  • 不进行移动或旋转,在设定的时间(锁定延迟)过后,方块锁定。
  • 落地状态下无法移动(左右卡住),或无法旋转(踢墙表检测全部失败)时,视作未进行操作。注意 O 的旋转虽然看起来没有变化,但它是确确实实成功旋转了的,所以会刷新锁定延迟。

同时,需要注意,为了避免无限刷新锁定延迟的情况,应该增加一个操作次数限制:

  • 非落地状态下,不计入操作次数限制。
  • 落地状态下,一次移动或旋转后,计一次操作次数。
  • 落地状态下无法移动(左右卡住),或无法旋转(踢墙表检测全部失败)时,视作未进行操作,不计入操作次数限制。注意 O 的旋转虽然看起来没有变化,但它是确确实实成功旋转了的,所以会计一次操作次数。
  • 当操作次数用尽后,移动和旋转将不再刷新锁定延迟。

今天(2022.8.23)我实现操作次数的时候,发现还有一个问题:

  • 如果操作次数用尽后,踢墙产生的偏移不能向上。

    这个规定的意义在于,避免玩家通过踢墙将方块“抬高”,然后方块下落,刷新延迟锁定,导致无限操作而不锁定的问题。

出块序列

实际上就是一个队列,当队列短于设定的数量时。按选定的随机生成器,生成一段新序列加到队尾。

如果是 bag 出块,则保证要添加的新序列中每种方块各出现一次

暂存

暂存的实现思路很简单,就是记录下暂存的方块 id 和暂存的操作次数限制而已。但是在这个过程中,我意识到关于暂存计数、锁延计数的重置时机问题。

根据游戏逻辑,在暂存之后,更换的方块会重新从场地顶部开始下落,并且方块的锁延计数会重置。暂存计数就是为了避免玩家通过无限的暂存,重置锁延计数的情况。所以暂存之后,锁延计数会重置,暂存计数 -1。而暂存计数重置的时机,应当是一个方块成功锁定后,这是毫无疑问的。

可是,锁延计数是什么时候重置呢?在没有暂存功能的情况下,锁延计数在方块成功锁定后,或者新生成方块时重置都是没有问题的。但是引入了暂存功能后,暂存方块会导致方块的锁延计数重置。因此,如果将锁延计数在方块成功锁定后重置,会导致暂存后新方块的锁延计数未重置。所以锁延计数应当在生成方块时更新。

换个角度想想,暂存后的方块,本质就是新生成了一个方块,这和方块锁定后新生成方块的原理应当一致。而锁延计数会在方块锁定后和暂存后重置,因此在生成方块时重置锁延计数能很好地解决这两种情况。

此外,在暂存中无方块和暂存中有方块时,对序列的处理有一定的区别。前者是将当前块暂存,从序列中新取一块作为当前块;后者是将当前块与暂存块交换。

发布

创建 .love 文件

将游戏文件变为 .exe 文件的步骤比较简单:

  • 将游戏文件加入 .zip 压缩文件中
  • 将后缀更改为.love

此文件就可以通过以下方式运行:

love xxx.love

针对 windows 平台构建

  • love.exe.zip 文件合并为一个 .exe 文件
  • .dll 文件、许可 license.txt 和 合并得到的 .exe 文件放在同一个目录下,即可运行游戏

网页发布

推荐使用 Davidobot/love.js,该作者目前还保持着更新,适用于 11.4 版本

通过以下方式安装:

npm i love.js

npm -g i love.js

然后构建兼容版本:

love.js game.love game -c

如果命令无法正常执行(比如说,打开了 love.js 文件),则可以使用以下命令:

love.js.cmd game.love game -c

即可完成构建。该命令会在当前目录生成一个文件夹:

Tetris
│ game.data
│ game.js
│ index.html
│ love.js
│ love.wasm

└─theme
bg.png
love.css

其中,game.datagame.js 是游戏的生成文件, index.html 就是生成的网页,而 theme 目录下的文件是页面的美化相关的文件。love.jslove.wasmlove 框架对应的文件。

游戏基本上完成了,剩下的东西主要是美化辅助和提升操作手感的方面(阴影和 DAS ),大概率不会更新了……

戳这里体验一下

各种发布方式(包括网页发布)具体可以请参考:Game Distribution - LOVE (love2d.org)