【Unity】MiniGame编辑器小游戏(一)俄罗斯方块【Tetris】

更新日期:2025年6月14日。

项目源码:后续章节发布

索引

系列简介

本系列博客准备整点不一样的活,有没有想过开发在Unity编辑器中运行的游戏(打开一个EditorWindow直接玩)?

当然,这样的游戏体量会有亿点点小,毕竟EditorWindow的资源有限,所以主要为休闲、逻辑、解密类游戏,但好处是打开一个窗口即玩,一键即可关闭窗口,秉承了Unity开箱即用的原则。

且每个小游戏的体量足够小,仅仅为一个脚本,不含其他任何资源。

只不过,这需要对Unity编辑器开发具备基础的了解,然后,我们就可以正式开始了。

俄罗斯方块【Tetris】

本篇的目标是开发一个俄罗斯方块【Tetris】小游戏。

一、游戏最终效果

Unity编辑器小游戏:俄罗斯方块

二、玩法简介

俄罗斯方块是一款经典的益智游戏,其玩法简单却富有挑战性。

玩家需要通过旋转和移动从屏幕顶部不断落下的四连方块,将它们放置在游戏板的底部。目标是填满一行或多行,当一行被完全填满时,该行会消除,玩家获得分数。游戏会随着时间推移逐渐加快难度,玩家需要尽可能多地消除行数,避免方块堆积到游戏板顶部,否则游戏结束。

三、正式开始

1.定义游戏窗口类

首先,定义俄罗斯方块的游戏窗口类MiniGame_Tetris,其继承至MiniGameWindow【小游戏窗口基类】

csharp 复制代码
    /// <summary>
    /// 俄罗斯方块
    /// </summary>
    public class MiniGame_Tetris : MiniGameWindow
    {
    
    }

注意:MiniGameWindow包含在小游戏基础模块中,其在EditorWindow中模拟实现了一套小游戏的基础开发组件,譬如:

1.游戏视口渲染(Viewport);

2.游戏逻辑更新(Update);

3.MiniGameObject游戏对象:类似于运行时的GameObject

4.动画组件(Animation):用于在EditorWindow中播放动画;

5.简易物理系统:重力、碰撞检测、射线检测等。
后续放出源码后将会深入讲解此基础模块。

2.规划游戏窗口、视口区域

通过覆写虚属性实现规划游戏视口区域大小:

csharp 复制代码
        /// <summary>
        /// 游戏名称
        /// </summary>
        public override string Name => "俄罗斯方块 [Tetris]";
        /// <summary>
        /// 游戏窗体大小
        /// </summary>
        public override Vector2 WindowSize => new Vector2(400, 430);
        /// <summary>
        /// 游戏视口区域
        /// </summary>
        public override Rect ViewportRect => new Rect(5, 25, 200, 400);

注意:游戏窗体大小必须 > 游戏视口区域。

然后通过代码打开此游戏窗口:

csharp 复制代码
        [MenuItem("MiniGame/俄罗斯方块 [Tetris]", priority = 1)]
        private static void Open_MiniGame_Tetris()
        {
            MiniGameWindow.OpenWindow<MiniGame_Tetris>();
        }

便可以看到游戏的窗口、视口区域如下(左侧深色凹陷区域为视口区域):

3.绘制方块背景板

俄罗斯方块游戏实现起来还是比较简单的,因为他的整个游戏背景仅是一系列方块组成,所以我们先来绘制如下这样的方块背景板:

定义背景宽度(横向方块数量)背景高度(纵向方块数量)方块大小

csharp 复制代码
        private const int WIDTH = 10;
        private const int HEIGHT = 20;
        private const int BLOCKSIZE = 20;

用一个bool型二维数组存储所有背景方块:

csharp 复制代码
        //所有背景方块
        private bool[,] _panel = new bool[WIDTH, HEIGHT];

注意:为什么是bool型?

因为bool型正好可以表示一个方块的状态:true为该位置存在方块,false为该位置不存在方块。

然后在OnGameViewportGUI方法中绘制背景板:

csharp 复制代码
        private Rect _blockRect = new Rect();
        private GUIStyle _blockGS;

        protected override void OnGameViewportGUI()
        {
            base.OnGameViewportGUI();

            DrawPanel();
        }
        /// <summary>
        /// 绘制画布
        /// </summary>
        private void DrawPanel()
        {
            for (int h = 0; h < HEIGHT; h++)
            {
                for (int w = 0; w < WIDTH; w++)
                {
                	//存在方块显示青色,不存在显示灰色
                    GUI.color = _panel[w, h] ? Color.cyan : Color.gray;
                    DrawBlock(w, h);
                    GUI.color = Color.white;
                }
            }
        }
        /// <summary>
        /// 绘制方块
        /// </summary>
        private void DrawBlock(int x, int y)
        {
            _blockRect.Set(x * BLOCKSIZE, y * BLOCKSIZE, BLOCKSIZE, BLOCKSIZE);
            GUI.Box(_blockRect, "", _blockGS);
        }

注意:OnGameViewportGUI即为游戏视口区域的GUI绘制方法,其中左上角坐标为(0, 0),右下角坐标为(ViewportRect.width, ViewportRect.height)

4.四连方块

①.四连方块简介

在俄罗斯方块中,我们所控制的从顶部下落的方块叫做四连方块,四连方块共有7种类型:

他们的字母命名如下:

方块 中文别称 英文别称
I 长条、棍子 long bar, stick, straight
T
O 方形、田 square, block, sun
J gamma, left gun, inverse L, reverse
L right gun
S inverse skew, right snake
Z skew, left snake, reverse S
②.定义四连方块类型

使用一个枚举TetrominoType代表四连方块类型:

csharp 复制代码
        /// <summary>
        /// 四连方块类型
        /// </summary>
        public enum TetrominoType
        {
            /// <summary>
            /// 口口口口
            /// </summary>
            I,
            /// <summary>
            /// 口
            /// 口口口
            /// </summary>
            J,
            /// <summary>
            /// ㅤㅤ    口
            /// 口口口
            /// </summary>
            L,
            /// <summary>
            /// 口口
            /// 口口
            /// </summary>
            O,
            /// <summary>
            ///   ㅤ口口
            /// 口口
            /// </summary>
            S,
            /// <summary>
            /// 口口
            /// ㅤ  口口
            /// </summary>
            Z,
            /// <summary>
            /// ㅤ  口
            /// 口口口
            /// </summary>
            T
        }
③.定义四连方块

定义一个类Tetromino代表四连方块:

csharp 复制代码
        /// <summary>
        /// 四连方块
        /// </summary>
        public class Tetromino
        {
            /// <summary>
            /// 四连方块的位置
            /// </summary>
            public Vector2Int Position;
            /// <summary>
            /// 四连方块的旋转
            /// </summary>
            public int Rotation;
            /// <summary>
            /// 四连方块的类型
            /// </summary>
            public TetrominoType Type;
            /// <summary>
            /// 方块1的位置偏移
            /// </summary>
            public Vector2Int Block1Offset;
            /// <summary>
            /// 方块2的位置偏移
            /// </summary>
            public Vector2Int Block2Offset;
            /// <summary>
            /// 方块3的位置偏移
            /// </summary>
            public Vector2Int Block3Offset;
            /// <summary>
            /// 方块4的位置偏移
            /// </summary>
            public Vector2Int Block4Offset;
        }

这里的Position代表了四连方块处于方块背景板中的具体位置,而Block1OffsetBlock4Offset这四个变量,分别代表了四连方块中的四个小方块基于Position的位置偏移值。

通过Position + 偏移值(Offset)即可算出小方块真实的位置,然后将该位置标记为true,即表明了该位置存在方块,从而该位置便会渲染为青色。

④.生成四连方块

游戏一开始,便会生成一个四连方块:

csharp 复制代码
		//定义一个缓存对象,避免每次重复新建对象
        private Tetromino _tetrominoCache = new Tetromino();

        /// <summary>
        /// 当前的四连方块
        /// </summary>
        public Tetromino CurrentTetromino { get; private set; }

        protected override void OnInit()
        {
            base.OnInit();

            CurrentTetromino = GenerateTetromino();
        }

        /// <summary>
        /// 生成四连方块
        /// </summary>
        private Tetromino GenerateTetromino()
        {
        	//四连方块坐标重置到(4, -1),y = -1使得他超出到屏幕上方不可见
            _tetrominoCache.Position = new Vector2Int(4, -1);
            //四连方块旋转归零
            _tetrominoCache.Rotation = 0;
            //随机一个方块类型
            _tetrominoCache.Type = (TetrominoType)Random.Range(0, 7);
            //更新一下四个小方块的位置
            _tetrominoCache.UpdateBlocks();
            return _tetrominoCache;
        }

注意:OnInit即为游戏窗口打开后的初始化方法,在其中完成游戏的初始化操作。

⑤.更新四连方块

每次重新生成旋转等,都需要更新四连方块(也即是更新其中四个小方块的位置偏移):

csharp 复制代码
            /// <summary>
            /// 更新方块
            /// </summary>
            public void UpdateBlocks()
            {
                switch (Type)
                {
                    case TetrominoType.I:
                        UpdateI();
                        break;
                    case TetrominoType.J:
                        UpdateJ();
                        break;
                    case TetrominoType.L:
                        UpdateL();
                        break;
                    case TetrominoType.O:
                        UpdateO();
                        break;
                    case TetrominoType.S:
                        UpdateS();
                        break;
                    case TetrominoType.Z:
                        UpdateZ();
                        break;
                    case TetrominoType.T:
                        UpdateT();
                        break;
                }
            }

根据不同的四连方块类型,需要重新计算四个小方块的位置偏移,比如I类型:

csharp 复制代码
            private void UpdateI()
            {
            	//未旋转(角度0)、旋转180度时,显示为横着的(------),所以四个小方块横向排列(y坐标偏移为0)
                if (Rotation == 0 || Rotation == 180)
                {
                    Block1Offset = new Vector2Int(-1, 0);
                    Block2Offset = new Vector2Int(0, 0);
                    Block3Offset = new Vector2Int(1, 0);
                    Block4Offset = new Vector2Int(2, 0);
                }
                //旋转90度时、旋转180度时,显示为竖着的(|),所以四个小方块竖向排列(x坐标偏移为0)
                else if (Rotation == 90 || Rotation == 270)
                {
                    Block1Offset = new Vector2Int(0, -1);
                    Block2Offset = new Vector2Int(0, 0);
                    Block3Offset = new Vector2Int(0, 1);
                    Block4Offset = new Vector2Int(0, 2);
                }
            }

其他类型也同理,这里就不赘述了。

⑥.绘制四连方块

接下来便是绘制四连方块:

csharp 复制代码
        protected override void OnGameViewportGUI()
        {
            base.OnGameViewportGUI();

			//绘制方块背景板
            DrawPanel();
            //绘制四连方块(绘制顺序靠后,会在层级上挡住前面的背景板)
            DrawTetromino();
        }
        /// <summary>
        /// 绘制四连方块
        /// </summary>
        private void DrawTetromino()
        {
            if (CurrentTetromino != null)
            {
            	//四连方块绘制为黄色
                GUI.color = Color.yellow;
                //分别计算四个小方块的偏移量,然后绘制该小方块
                DrawBlock(CurrentTetromino.Position.x + CurrentTetromino.Block1Offset.x, CurrentTetromino.Position.y + CurrentTetromino.Block1Offset.y);
                DrawBlock(CurrentTetromino.Position.x + CurrentTetromino.Block2Offset.x, CurrentTetromino.Position.y + CurrentTetromino.Block2Offset.y);
                DrawBlock(CurrentTetromino.Position.x + CurrentTetromino.Block3Offset.x, CurrentTetromino.Position.y + CurrentTetromino.Block3Offset.y);
                DrawBlock(CurrentTetromino.Position.x + CurrentTetromino.Block4Offset.x, CurrentTetromino.Position.y + CurrentTetromino.Block4Offset.y);
                GUI.color = Color.white;
            }
        }

现在运行程序大概就是这样的:

5.控制四连方块(移动、旋转)

我们可以控制四连方块左右移动、旋转、加速下落等。

OnGamePlayingEvent方法中编写控制逻辑:

csharp 复制代码
        protected override void OnGamePlayingEvent(Event e, Vector2 mousePosition)
        {
            base.OnGamePlayingEvent(e, mousePosition);

            if (CurrentTetromino != null)
            {
                if (e.type == EventType.KeyDown)
                {
                    switch (e.keyCode)
                    {
                        case KeyCode.A:
                        	//A键左移
                            CurrentTetromino.Position.x -= 1;
                            //检测是否超出边界或碰壁
                            if (CheckTetrominoMove(CurrentTetromino))
                            {
                            	//如果是,则撤销移动
                                CurrentTetromino.Position.x += 1;
                            }
                            Repaint();
                            break;
                        case KeyCode.D:
                        	//D键右移
                            CurrentTetromino.Position.x += 1;
                            if (CheckTetrominoMove(CurrentTetromino))
                            {
                                CurrentTetromino.Position.x -= 1;
                            }
                            Repaint();
                            break;
                        case KeyCode.W:
                        	//W键旋转
                            int last = CurrentTetromino.Rotation;
                            //顺时针旋转90度
                            CurrentTetromino.Rotation += 90;
                            if (CurrentTetromino.Rotation >= 360) CurrentTetromino.Rotation = 0;
                            CurrentTetromino.UpdateBlocks();
                            //同样的,如果超出边界或碰壁,撤销旋转
                            if (CheckTetrominoMove(CurrentTetromino))
                            {
                                CurrentTetromino.Rotation = last;
                                CurrentTetromino.UpdateBlocks();
                            }
                            Repaint();
                            break;
                        case KeyCode.S:
                        	//S键加速下落,直接增加下落速度即可
                            _downSpeed = 0.1f;
                            break;
                    }
                }
            }
        }

注意:OnGamePlayingEvent即为游戏输入事件检测方法,在其中编写与输入事件相关的逻辑。

6.四连方块碰壁检测

四连方块尝试左右移动、旋转时,都需要检测是否超出边界或碰壁:

csharp 复制代码
        /// <summary>
        /// 四连方块尝试左右移动、旋转时,检测是否超出边界或碰壁
        /// </summary>
        private bool CheckTetrominoMove(Tetromino tetromino)
        {
            int x1 = tetromino.Position.x + tetromino.Block1Offset.x;
            int y1 = tetromino.Position.y + tetromino.Block1Offset.y;
            if (x1 < 0 || x1 >= WIDTH || y1 < 0 || y1 >= HEIGHT || _panel[x1, y1])
            {
                return true;
            }

            int x2 = tetromino.Position.x + tetromino.Block2Offset.x;
            int y2 = tetromino.Position.y + tetromino.Block2Offset.y;
            if (x2 < 0 || x2 >= WIDTH || y2 < 0 || y2 >= HEIGHT || _panel[x2, y2])
            {
                return true;
            }

            int x3 = tetromino.Position.x + tetromino.Block3Offset.x;
            int y3 = tetromino.Position.y + tetromino.Block3Offset.y;
            if (x3 < 0 || x3>= WIDTH || y3 < 0 || y3 >= HEIGHT || _panel[x3, y3])
            {
                return true;
            }

            int x4 = tetromino.Position.x + tetromino.Block4Offset.x;
            int y4 = tetromino.Position.y + tetromino.Block4Offset.y;
            if (x4 < 0 || x4 >= WIDTH || y4 < 0 || y4 >= HEIGHT || _panel[x4, y4])
            {
                return true;
            }

            return false;
        }

7.堆叠方块

四连方块落到底部后,或碰到其他已落底的方块,将堆叠到底部。

OnGamePlayingUpdate方法中编写四连方块的下落逻辑:

csharp 复制代码
        private float _downSpeed = 0.005f;
        private float _timer = 0;
        
        protected override void OnGamePlayingUpdate()
        {
            base.OnGamePlayingUpdate();

            if (CurrentTetromino != null)
            {
                _timer += _downSpeed;
                if (_timer >= 1)
                {
                    _timer -= 1;
                    //四连方块下落一格
                    CurrentTetromino.Position.y += 1;
                    //检测是否落地
                    CheckTetrominoDown(CurrentTetromino);
                    //重绘窗口(游戏视口内容产生变化时,都需要调一下)
                    Repaint();
                }
            }
        }

注意:OnGamePlayingUpdate即为游戏逻辑更新方法,在其中编写游戏逻辑更新相关的代码。

接下来是四连方块堆叠的逻辑:

csharp 复制代码
		/// <summary>
		/// 四连方块尝试下落时,检测是否落地,如果落地,则存储到背景板,并检测是否可消除
		/// </summary>
		private void CheckTetrominoDown(Tetromino tetromino)
		{
			//检测是否落地
		    if (TetrominoIsDown(tetromino))
		    {
		    	//如果已落底,则倒回一格(我们是先下落一格,再判断的是否落底,所以要倒回去)
		        tetromino.Position.y -= 1;
		        //存储到背景板(堆叠到底部)
		        SetTetrominoToPanel(tetromino);
		        //检测是否可消除
		        EliminatePanel();
		
				//重新生成四连方块
		        CurrentTetromino = GenerateTetromino();
		        _downSpeed = 0.005f;
		    }
		}
        /// <summary>
        /// 检测四连方块是否落地
        /// </summary>
        private bool TetrominoIsDown(Tetromino tetromino)
        {
        	//分别检测四个小方块是否抵达边界,或碰到已落底的方块
        	//任意小方块满足条件,则证明整个四连方块已落底
            int x1 = tetromino.Position.x + tetromino.Block1Offset.x;
            int y1 = tetromino.Position.y + tetromino.Block1Offset.y;
            if (x1 >= 0 && x1 < WIDTH && y1 >= 0 && y1 < HEIGHT && _panel[x1, y1])
            {
                return true;
            }

            int x2 = tetromino.Position.x + tetromino.Block2Offset.x;
            int y2 = tetromino.Position.y + tetromino.Block2Offset.y;
            if (x2 >= 0 && x2 < WIDTH && y2 >= 0 && y2 < HEIGHT && _panel[x2, y2])
            {
                return true;
            }

            int x3 = tetromino.Position.x + tetromino.Block3Offset.x;
            int y3 = tetromino.Position.y + tetromino.Block3Offset.y;
            if (x3 >= 0 && x3 < WIDTH && y3 >= 0 && y3 < HEIGHT && _panel[x3, y3])
            {
                return true;
            }

            int x4 = tetromino.Position.x + tetromino.Block4Offset.x;
            int y4 = tetromino.Position.y + tetromino.Block4Offset.y;
            if (x4 >= 0 && x4 < WIDTH && y4 >= 0 && y4 < HEIGHT && _panel[x4, y4])
            {
                return true;
            }

            if (y1 >= HEIGHT || y2 >= HEIGHT || y3 >= HEIGHT || y4 >= HEIGHT)
            {
                return true;
            }

            return false;
        }
        /// <summary>
        /// 设置四连方块到画板
        /// </summary>
        private void SetTetrominoToPanel(Tetromino tetromino)
        {
        	//在方块背景板中,分别将四个小方块所在的位置设置为true,也即是该位置存在方块
            int x1 = tetromino.Position.x + tetromino.Block1Offset.x;
            int y1 = tetromino.Position.y + tetromino.Block1Offset.y;
            if (x1 >= 0 && x1 < WIDTH && y1 >= 0 && y1 < HEIGHT)
            {
                _panel[x1, y1] = true;
            }

            int x2 = tetromino.Position.x + tetromino.Block2Offset.x;
            int y2 = tetromino.Position.y + tetromino.Block2Offset.y;
            if (x2 >= 0 && x2 < WIDTH && y2 >= 0 && y2 < HEIGHT)
            {
                _panel[x2, y2] = true;
            }

            int x3 = tetromino.Position.x + tetromino.Block3Offset.x;
            int y3 = tetromino.Position.y + tetromino.Block3Offset.y;
            if (x3 >= 0 && x3 < WIDTH && y3 >= 0 && y3 < HEIGHT)
            {
                _panel[x3, y3] = true;
            }

            int x4 = tetromino.Position.x + tetromino.Block4Offset.x;
            int y4 = tetromino.Position.y + tetromino.Block4Offset.y;
            if (x4 >= 0 && x4 < WIDTH && y4 >= 0 && y4 < HEIGHT)
            {
                _panel[x4, y4] = true;
            }
        }

8.消除方块

在四连方块堆叠到底部的同时,就需要检测一次是否可消除方块,我们只需要一行一行的检测即可,因为消除方块的前提就是堆满一行

csharp 复制代码
        /// <summary>
        /// 检测消除
        /// </summary>
        private void EliminatePanel()
        {
        	//单次总消除行数,一次消除行数越多,得分越高
            int lines = 0;
            //从最底部向上检测
            for (int h = HEIGHT - 1; h >= 0; h--)
            {
                bool isEliminate = true;
                for (int w = 0; w < WIDTH; w++)
                {
                	//只要一行中发现一个空方格,就不可消除
                    if (!_panel[w, h])
                    {
                        isEliminate = false;
                        break;
                    }
                }

                if (isEliminate)
                {
                	//消除此行
                    EliminateLine(h);
                    lines += 1;
                    h++;
                }
            }
            //得分
            _score += lines * lines;
        }
        /// <summary>
        /// 消除一行
        /// </summary>
        private void EliminateLine(int line)
        {
        	//消除一行的逻辑也即是其上方的所有行向下顺移一格
            for (int h = line; h >= 1; h--)
            {
                for (int w = 0; w < WIDTH; w++)
                {
                    _panel[w, h] = _panel[w, h - 1];
                }
            }
        }

9.绘制游戏操作说明

游戏的得分,操作说明等其他UI统一绘制在OnOtherGUI方法中:

csharp 复制代码
        protected override void OnOtherGUI()
        {
            base.OnOtherGUI();

            Rect rect = new Rect(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + 5, 50, 20);
            GUI.Label(rect, "Score:");
            rect.x += 50;
            rect.width = 100;
            EditorGUI.IntField(rect, _score);

            rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 100, 25, 20);
            GUI.Button(rect, "W");
            rect.x += 30;
            rect.width = 100;
            GUI.Label(rect, "Rotate");

            rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 75, 25, 20);
            GUI.Button(rect, "S");
            rect.x += 30;
            rect.width = 100;
            GUI.Label(rect, "Fast down");

            rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 50, 25, 20);
            GUI.Button(rect, "A");
            rect.x += 30;
            rect.width = 100;
            GUI.Label(rect, "Move left");

            rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 25, 25, 20);
            GUI.Button(rect, "D");
            rect.x += 30;
            rect.width = 100;
            GUI.Label(rect, "Move right");
        }

注意:OnOtherGUI为绘制游戏视口区域之外的其他UI的方法,但不做强制限制。

这里绘制出来的效果如下:

至此,一个简单的俄罗斯方块小游戏就完成了,试玩效果如下:俄罗斯方块【Tetris】

10.暂停游戏

游戏窗口的左上角有一个三角形播放按钮,默认情况下,打开窗口后该按钮处于按下状态(游戏播放中),点击该按钮可暂停游戏:

11.退出游戏

默认退出游戏按键为Esc键,也可覆写该虚属性重定义退出键:

csharp 复制代码
        /// <summary>
        /// 退出键
        /// </summary>
        public virtual KeyCode QuitKey { get; } = KeyCode.Escape;