找回密码
 立即注册
查看: 458|回复: 1

用Lua做个俄罗斯方块游戏

[复制链接]
发表于 2021-12-3 09:06 | 显示全部楼层 |阅读模式
1. 俄罗斯方块

       俄罗斯方块是当前全世界下载做多,移植平台最多的经典游戏,而整个游戏过程又非常简单,玩家通过调整正在屏幕上方下落的方块的位置和方向,让它们在屏幕上拼成一块块完整的整体,只要拼成一行即可消除得到奖励。具体的介绍就不细说了,相信大部分同学都是玩着这个游戏长大的。其核心规则也非常简单:
       1. 随机产生方块;
       2. 方块下落;
       3. 玩家调整方向和左右平移;
       4. 方块落地;
       5. 满行消除,满屏幕游戏结束。
       随机产生方块,大家应该非常好理解,就是在一定的时候,系统在每个方块开始下落的时候,产生一个随机形状的方块(确切的说是从下面方块的7种形状中随机一种出来),然后方块逐渐开始下落,在下落过程中玩家可以调整方块的方向,以及左右移动方块的位置让它可以在落地的时候,刚好放到某个位置,落地后如果一行都填满了方块,则消除该行然后得到相应的奖励。然后重复执行随机下一个方块及以后的逻辑直至屏幕方块在屏幕顶上不能落下为止。
方块
       既然要随机产生方块,那就应该知道俄罗斯方块一共有多少形状,如下图所示,应该能容易的发现,俄罗斯方块经典的7种形状,都是由4个小方块组合构成。那么首先可以构建一个小方块,然后使用4个这样的小方块拼出形状。



俄罗斯方块形状图

1.1 创建小方块预制体

       整个游戏使用UGUI搭建完成,先做一个小方块的Prefab,如下图,这个小方块就是一张图片,这样在屏幕上显示的每个方块都是一样的Image。(如果以后要加入皮肤系统,也可以直接更改方块的精灵图来实现这个功能。)
       小方块的属性为:宽度(Width)是50像素,高度(Height)也是50像素,锚点(Anchors)是在左上角,轴心点(Pivot)也被调整到左上角了。为什么要这样调整,是因为在后面章节中搭建主游戏区域的时候,以及方块在屏幕上显示的时候调整相关坐标都会以这些属性为基准。



小方块预制体

2. ToLua#

       使用Unity复刻俄罗斯方块,相信大家已经司空见惯。这次就使用ToLua的框架来做一个俄罗斯方块游戏。
       ToLua的使用网上也有非常多教程,大家在github上把ToLua克隆下来。新建Unity3D工程,把刚才ToLua路径下的内容放到新的工程目录(注意是工程目录,不是Assets目录)下,然后就可以使用了。



ToLua目录

3. 搭建场景

       开始搭建游戏的场景,上面说到游戏区域是根据小方块预制体的属性为基准。一个经典俄罗斯方块棋盘是10格*20格的大小,使用小方块预制体的宽度和高度乘以棋盘大小就是搭建实际游戏主显示区域的大小,具体到屏幕上就是500像素*1000像素。当然也可以搭建其他型号如12格*24格,15格*20格大小的屏幕,根据具体需求和小方块预制体的基准搭建即可。
       屏幕右上角是下一个俄罗斯方块显示区域,因为俄罗斯方块形状最大是4格,所以这个区域的大小设定为200像素*200像素。



游戏场景

4. 方块形状

       在前面已经了解到经典俄罗斯有7种不同的形状,现在就对各种俄罗斯方块形状详细的分析一下。首先定义一个4*4的二维数组,红色部分代表具体的每一种形状,无色部分就是没有数据的部分,见下图所示。 把红色部分用1来表示有方块,无色部分用0表示没有方块。那么就可以轻易的定义出每个基本形状的数据结构。



俄罗斯方块形状

4.1 形状数据


  • 横条
    {0, 0, 0, 0},
    {1, 1, 1, 1},
    {0, 0, 0, 0},
    {0, 0, 0, 0}

  • T型
    {0, 0, 0, 0},
    {0, 0, 0, 0},
    {1, 1, 1, 0},
    {0, 1, 0, 0}

  • L型
    {0, 0, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 1, 0}

  • 反L型
    {0, 0, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 0, 0},
    {1, 1, 0, 0}

  • Z型
    {0, 0, 0, 0},
    {0, 0, 0, 0},
    {1, 1, 0, 0},
    {0, 1, 1, 0}

  • 反Z型
    {0, 0, 0, 0},
    {0, 0, 0, 0},
    {0, 1, 1, 0},
    {1, 1, 0, 0}

  • 方块型
    {0, 0, 0, 0},
    {0, 1, 1, 0},
    {0, 1, 1, 0},
    {0, 0, 0, 0}
4.2 旋转变形数据

       定义好了每个基本形状的数据结构,但是到现在为止还没有定义每个方块基本形状旋转过后的数据。
旋转一般有两种方案:
       一种是Super Rotation System,其思路如下图是根据基本形状的数据结构,实时计算每次旋转后的值。方法很简单,在方块坐标系里面找到中心点,然后交换方块坐标系中的X,Y值。如果是顺时针旋转就X*-1,逆时针则Y*-1。但是由于其中心点有可能不是整数,旋转的时候可以能会出现一些BUG,处理起来很复杂。因为这次不是使用的这个旋转系统,所以有兴趣的同学可以去官方WiKi详细了解一下。



Tetris SRS

       另一种就是Arika Rotation System,也就是这次游戏需要使用的系统,如下图所示,其需要把方块形状的所有顺序都先用数据定义出来,然后用数组配置好。而不是用程序实时计算旋转后的数据。



Tetris ARS

4.3 随机生成方块

       每个基本形状的方块都有顺时针旋转和逆时针旋转的变形数据,为了方便暂时只考虑其多种变形都是由顺时针旋转变形而来。比如L形状的方块有4种变形数据,把这几种变形数据都用上面的数据结构定义出来放进一个形状列表里面,然后把装有这几种变形数据结构的形状列表放进所有的方块列表里面。

  • L型方块所有数据结构
-- L型
Block_L_0 = {
    {0, 0, 0, 0},
    {0, 0, 0, 0},
    {1, 1, 1, 0},
    {1, 0, 0, 0}}
Block_L_1 = {
    {0, 0, 0, 0},
    {1, 1, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 0, 0}}
Block_L_2 = {
    {0, 0, 0, 0},
    {0, 0, 0, 0},
    {0, 0, 1, 0},
    {1, 1, 1, 0}}
Block_L_3 = {
    {0, 0, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 1, 0}}
   
BlockList = {
    [3] = {Block_L_0, Block_L_1, Block_L_2, Block_L_3},
}       把形状的数据都定义好了后,生成方块就可以先在方块列表BlockList里面随机一个形状出来,然后在这个形状列表里面随机一个具体的变形方块数据出来就可以了。

5. 操作

       俄罗斯方块的游戏规则非常简单,简单到只有几个操作,下落、左右平移还有旋转变形。在实现这几个操作之前,需要先定义好主屏幕棋盘和当前方块的数据结构。

  • 主屏幕棋盘:
-- 主面板宽度
BoardWidth = 10
-- 主面板高度
BoardHeight = 20
-- 主面板数据
board = {}

--[[
    初始化主面板
]]
function this.InitBoard()
    for i=1,BoardHeight do
        board = {}
        for j=1,BoardWidth do
            board[j] = 0
        end
    end
end

  • 当前方块:
-- 当前俄罗斯方块数据
currentBlock = {
    ['mainIndex'] = 1,      -- 在方块表中的形状位置
    ['subIndex'] = 1,       -- 在形状表中的位置
    ['curRow'] = 1,         -- 在主面板所在的当前行
    ['curColumn'] = 1,      -- 在主面板所在的当前列
    ['block']={}            -- 具体俄罗斯方块
}   5.1 下落

下面三个字段是来控制方块下落的决策。因为方块是一格一格下落的,也就是说不可能使用每帧都下落一格来计算,当duration的间隔大于maxDuration这个最大间隔时间的时候,就把上面当前俄罗斯方块的curRow字段加上fallSpeed就行。当按下向下加速的按键时,duration这个条件就不在起作用,可以设定一个非常小的间隔来调用下落的逻辑。

  • 下落逻辑相关字段
local fallSpeed = 1                 -- 下落速度
local duration = 0.0                -- 自动下落时间间隔
local maxDuration = 0.4             -- 自动下落最大时间间隔

  • 方块下落
--[[
    方块下落
]]
function this.Fall()
    local row = currentBlock['curRow']  + fallSpeed

    -- 已经落底 检查是否可以消除
    if this.CheckGround(row, currentBlock['curColumn']) then        
         -- 当前方块插入到主面板
        -- 渲染主面板
        -- 更新当前和下一个俄罗斯方块
    else -- 不落底,当前俄罗斯方块的所在行数增加
        currentBlock['curRow'] = row
    end
end       方块下落的时候需要在每次下落之前检查是否落在地面或者方块下方是否已经有方块了,如果是检查到下方有方块或者到底了就不能再继续下落了。

  • 检查是否触及地面
--[[
    检查触及地面   
    @param: row 即将下落的行
    @param: column 当前方块所在列
    return: true 下面有障碍; false 下面无障碍
]]
function this.CheckGround(row, column)
    local block = currentBlock['block']

    -- 遍历查询方块数据的下面是否有障碍或者到底了
    for i=#block, 1, -1 do
        for j=1,#block do
            if block[j] == 1 then
                -- 触底
                if row + i-1 >= BoardHeight then  
                    return true
                end

                -- 下方有方块
                if row +i >= 1 then
                    if board[row + i][column + j] == 1 then
                        return true
                    end
                end

            end
        end
    end

    return false
end5.2 左右平移

       左右平移相对来说比下落简单一些,当按下左右移动的按键的时候,只需要检查左右两边的边界障碍就行了。

  • 左右平移逻辑
--[[
    左右平移
    @param: left--true 向左平移; false 向右平移
]]
function this.HorizontalMove( left )
    -- 检查左右是否有障碍或者墙壁
    if this.CheckWall(left) then
        return
    end

    if left==true then
        currentBlock['curColumn'] = currentBlock['curColumn'] -1
    else
        currentBlock['curColumn'] = currentBlock['curColumn'] +1
    end
end5.3 旋转

       因为使用的是ARS旋转系统,把所有的变形数据都放进了方块列表集合中。那么在旋转变形的时候只需要找到当前方块在形状表中的位置,然后把位置增1就能得到顺时针旋转后的数据。有一点需要注意的是旋转后的方块形状是否会遇到障碍,或者是否超出边界,遇到这些情况是不能旋转的。

  • 旋转方块逻辑
--[[
    旋转方块
]]
function this.RotateBlock(  )
    local idx = currentBlock['mainIndex']
    local idx1 = currentBlock['subIndex']
    local len = #BlockList[idx]

    if len == 1 then
        return
    end

    if idx1 == len then
        idx1 = 1
    else
        idx1 = idx1 + 1
    end

    if this.CanRotateBlock(currentBlock['curRow'], currentBlock['curColumn'], idx, idx1) == true then
        currentBlock['subIndex'] = idx1
        currentBlock['block'] = {}
        Util.Clone(BlockList[idx][idx1], currentBlock['block'])
    end
   
end

  • 判定是否能旋转方块
--[[
    是否能旋转方块
    旋转后超出边界,这个时候不能旋转
   
    @param: row 当前方块所在行
    @param: column 当前方块所在列
    @param: mainIndex 旋转后方块在方块表中的形状位置
    @param: subIndex 旋转后方块在形状表中的位置
    return: true 可以旋转; false 不能旋转
]]
function this.CanRotateBlock(row, column, mainIndex, subIndex)
    local block = BlockList[mainIndex][subIndex]

    -- 遍历上下左右是否出边界或者有障碍
    for i=1,#block do
        for j=1,#block do
            if block[j] == 1 then
                local newRow = row + i
                local newCol = column + j
                if newCol < 1 or newCol > BoardWidth  then
                    return false
                elseif newRow < 1 or newRow > BoardHeight then
                    return false
                elseif  board[newRow][newCol] == 1 then
                    return false
                end
            end
        end
    end

    return true
end5.4 消除

       到现在为止,已经实现了玩家操作的相关逻辑,但还没有实现方块落地后消除判定的逻辑。要实现消除判定逻辑,需要再捋一捋棋盘的数据逻辑。游戏最开始生成了一个10*20的棋盘,每个格子上面的数据都是0。当方块落地后,就需要把当前方块的相关数据插入到对应的棋盘中。

  • 方块数据插入进主面板
--[[
    把下落方块数据插入进主面板
]]
function this.InsetBoard()
    local row = currentBlock['curRow']
    local column = currentBlock['curColumn']
    local block = currentBlock['block']

    for i=1,#block do
        for j=1,#block do
            if block[j] == 1 then
                board[row + i][column + j] = 1
            end
        end
    end

    -- 清空当前俄罗斯方块数据
    currentBlock = {['mainIndex'] = 1, ['subIndex'] = 1, ['curRow'] = 1, ['curColumn'] = 1, ['block']={}}
end       把数据插入到棋盘中后,对应形状的位置就会把数据置成1。每次落地插入数据后要做的操作就是计算当前行是否被填满,这时倒序逐行遍历棋盘,只要每行的数累加等于BoardWidth,就可以判定当行已经被填满,是需要删除的。如果小于BoardWidth,表示当前行还有空隙,不能被消除。
       消除这一行就是把当前这一行上面的所有行往下移一行,然后把第一行的数据全部再填为0。那么多行被消除的情况就需要考虑当前这一行被消除了,上面的数据被下移了,又要从当前这一行继续往前倒序遍历。

  • 消除逻辑
--[[
    有方块插入,刷新主面板操作   
    return:消除的行数
]]
function this.RefreshBoard()
    local eliminate = {}
    for i=BoardHeight,1, -1 do
        sum = 0
        for j= 1, BoardWidth do
            sum = sum +board[j]
        end

        -- 如果和跟宽度一样,说明此行每列都填满了方块
        if sum == BoardWidth then
            table.insert( eliminate, i )
        end
    end

    -- 清除已经满了的行
    local len = #eliminate

    -- PS:这儿是从最底下开始清除,这样第二次清除的时候,第二次清除的那一行又变成了第一行
    if len > 0 then
        for i=1, len do
            for j= eliminate + (i-1), 2, -1 do
                Util.Clone(board[j-1], board[j])
            end
            for j = 1, #board[1] do
                board[1][j] = 0
            end
        end
    end

    return len
end
6. 渲染

       到现在为止还不能看到这个游戏的面貌,是因为把所有的数据和操作都完成了,也准备好了基本的方块预制体和UI场景,但是没有把方块渲染到面板上。那么接下来的任务就是渲染。
6.1 渲染游戏主区域

       渲染主显示面板有两种方法:一种是生成10*20的方块,然后把每个坐标都显示在棋盘相关格子上,每次根据board里面的数据调用SetActive开关方块显示就可以。还有一种就是生成10*20的方块,但是不先预制到棋盘面板上,而是放到屏幕外,根据board里面的数据计算所在的格子,然后把方块移动到相关格子。由于前者频繁的开关SetActive会加大CPU的开销,所以选择后者这种方法。
还有一点需要注意的是因为小方块预制体的锚点和轴心点都调整为左上角,那么在计算坐标的时候需要使用的是RectTransform里面的anchoredPosition3D来调整相对位置,而不是使用localPosition。

  • 渲染主面板
--[[
    渲染主面板
]]
function this.RendererBoard()
    local count = 1
    for i=1,BoardHeight do
        for j=1,BoardWidth do
            if board[j] == 1 then
                boardObjs[count]:GetComponent('RectTransform').anchoredPosition3D = Vector3((j- 1) * 50, (i -1) * -50, 0)
                count  = count +1
            end
        end
    end

    if count < lastCount then
        for i=count, lastCount do
            boardObjs:GetComponent('RectTransform').anchoredPosition3D = Vector3(-10000, -10000, 0)
        end
    end

    lastCount = count
end6.2 渲染当前俄罗斯方块

       渲染当前俄罗斯方块的方法也有两种,一种取用棋盘主面板生成的方块对象池来使用,因为这两者是互斥的;还有一种就是这次使用的,生成4个小方块预制体实例,然后根据当前方块所在的行与列来渲染到主面板上面。

  • 渲染当前方块
--[[
    渲染当前俄罗斯方块
]]
function this.RendererCurrentBlock()
    local row = currentBlock['curRow']
    local column = currentBlock['curColumn']
    local block = currentBlock['block']

    local count = 1

    for i=1,#block do
        for j=1,#block do
            if row + i >= 1 then
                if block[j] == 1 then
                    currentObjs[count]:GetComponent('RectTransform').anchoredPosition3D = Vector3((column + j- 1) * 50, (row + i -1) * -50, 0)
                    count  = count +1
                end
            end
        end
    end

end
6.3 渲染下一个俄罗斯方块

       由于下一个俄罗斯方块是单独的一块4*4区域,所以直接把下一个方块的4个有方块的数据直接填充到这个单独区域就可以了。

  • 渲染下一个方块
--[[
    渲染下一个俄罗斯方块
]]
function this.RendererNextBlock()
    local block = BlockList[nextBlock['mainIndex']][nextBlock['subIndex']]

    local count = 1

    for i=1,#block do
        for j=1,#block do
            if block[j] == 1 then
                nextObjs[count]:GetComponent('RectTransform').anchoredPosition3D = Vector3((j- 1) * 50, (i -1) * -50, 0)
                count  = count +1
            end
        end
    end

end
7. 完结
       需要注意的就是因为Lua中的表赋值操作是引用,没有深拷贝,如果想要做一个克隆操作而不改变原来的数据,就需要自己实现一个深拷贝函数。

  • 深拷贝
--[[
    因为Lua表中赋值操作是引用,没有深拷贝,需要自己实现一个深拷贝
    @param: src 拷贝源表
    @param: dest 拷贝目标表
]]
function this.Clone( src, dest )
    for k,v in pairs(src) do
        if type(v) == 'table' then
            dest[k] = {}
            this.Clone(v, dest[k])
        else
            dest[k] = v
        end
    end
end
       至此,俄罗斯方块的基础功能已经都用Lua实现完成。有兴趣的同学可以根据这个思路去实现很多其他的俄罗斯方块的玩法。

工程地址

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2021-12-3 09:13 | 显示全部楼层
多谢刚好在学lua有了个例子
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-25 18:23 , Processed in 0.138620 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表