ChuanXin 发表于 2023-3-11 13:30

游戏开发From2ToHero 用Unity制作你的第一款方块拼图游戏 ...

笔者很喜欢玩一款EasyBrain出品的休闲方块拼图游戏Blockudoku,不过有时会因为选择的底部的方块不能旋转而错失良机。
为了体验一下可以旋转的作弊的快乐,今天笔者带大家从零开始制作这款游戏的升级Plus版。



Easybrain出品的Blockudoku

1. 玩法介绍

这是一款俄罗斯方块+拼图+消除游戏。



玩法介绍

(1)每回合游戏底部的三个选择区域会生成三个随机的俄罗斯方块。
(2)玩家选择对应的方块,即可将其放置到中间9*9的消除面板上。
(3)当消除面板上有一整行、一整列或者3*3的大格子被填满,就会消除该区域,并为玩家计分。当一次消除时匹配的模式越多,玩家的奖励分就会越多。
(4)当选择区域的三个方块都使用完。系统自动生成新的三个方块。
(5)当消除面板放不下所有选择区域的方块时,游戏结束。
(6)额外的,当玩家选中一个选择按钮后,再次点击即可控制其上的方块顺时针旋转90度。
2. 基本概念

游戏中的俄罗斯方块叫做BlockItem,玩家可以选中它,旋转它,并将其放置到消除面板上。
每一个BlockItem由1到多个格子(我们称之为CellItem)组成。
在游戏的底部是一个名为SelectBoard的选择面板,每回合它会为其上的三个选择按钮SelectItem随机分配3个BlockItem。
玩家点击选择按钮即可选中其上的俄罗斯方块。将鼠标移动到消除面板BattleBoard上时,如果消除面板上显示了半透明状的选中方块(FollowBlock),玩家点击消除面板即可将其放置在点击处。



基本概念

当消除面板上的格子填满了整行、整列或者9个3x3大格子中的某一个,即可消除该区域内的所有格子。
3. 准备工作

3.1 下载项目

笔者最初准备手把手带大家做一遍,不过鉴于这个玩法的实现复杂度不高,加上大部分的核心内容都在下文【核心功能说明】中详细讲解,以及可以为大家提供一些基础的UI素材(其实就是为了偷懒)。建议大家先从Github上下载下来这个项目的成品,然后一起食用:
不过,即便不下载这个项目,只要你有一点Unity的编程基础,跟着后续的讲解动手,也可以轻松实现全部的功能。
3.2 打开项目

用Unity2020.3.39f1及以上的版本打开项目(原则上Unity2020.3+都可以)。如下是整个项目的结构:



项目结构


[*]Arts下是游戏的所有美术资源
[*]Resources下目前只有方块BlockItem的Prefab
[*]Scenes下是所有的Demo场景
[*]Scripts的内容详见下文【脚本一览】
我们打开Assets/Scenes目录,该目录下的四个场景分别代表的是实现这款游戏的制作的每一步。

[*]Demo1_CreateAllBlocks演示了如何生成所有的俄罗斯方块BlockItem;
[*]Demo2_DragBlockToBoard演示了如何将BlockItem添加到消除面板;
[*]Demo3_BlockClear演示了如何消除;
[*]Demo4_Blockudoku是添加了选择面板后相对完整的示例。
我们可以先打开Demo1_CreateAllBlocks场景,如果能正常运行游戏,那我们就已经成功地迈出了第一步。
3.3 脚本一览

如下是项目中的相关脚本,名称和前文中的【基本概念】是一致的。



游戏相关脚本

4. 核心功能说明

4.1 生成不同Block块

关联文件:

[*]场景:

[*]Demo1_CreateAllBlocks.unity

[*]脚本:

[*]CellData.cs
[*]BlockData.cs
[*]BlockDataFactory.cs
[*]CellItem.cs
[*]BlockItem.cs
[*]BlockItemCreator.cs
[*]Demo1_CreateAllBlocks.cs

[*]资源:

[*]BlockItem.prefab

<hr/>我们先来看下游戏中会用到的俄罗斯方块Block,如下图:



所有方块类型

每个Block具备了不同数量的格子(Cell),有不同的形状(Shape),可以做四种以90度单位的旋转(Rotate),具备初始的颜色(Color)。
所以我们先创建一个方块的数据类,名为BlockData.cs(下载了项目的,可以打开该脚本)。
先在脚本中添加形状类型BlockShapeType、旋转类型BlockRotateType和颜色类型BlockColorType三个枚举:
    public enum BlockShapeType
    {
      Cell1,
      
      Cell2,
      
      Cell3Line,
      Cell3L,
      
      Cell4Line,
      Cell4Square,
      Cell4L,
      Cell4L2,
      Cell4Z,
      Cell4Z2,
      Cell4T,

      Cell5X,
      Cell5L,
      Cell5U,
      
      Length,
    }

    public enum BlockRotateType
    {
      Default,
      Degree90,
      Degree180,
      Degree270,
      
      Length,
    }

    public enum BlockColorType
    {
      Red,
      Green,
      Blue,
      Yellow,
      Purple,
      
      Length,
    }
生成一个Block,需要提供它的形状、旋转、颜色以及格子信息,格子的数量和最小包围盒可以通过这些信息推算得到。
由此我们定义BlockData的相关数据字段:
   
    public class BlockData
    {
      public BlockShapeType ShapeType;
      public BlockRotateType RotateType;
      public BlockColorType ColorType;
      
      public Vector2Int RectSize;
      public int CellCount;
      
      public List<CellData> BlockCells;
      
      //...
    }对于组成Block的每一个格子Cell,最基本地需要具备颜色、在Block中的相对位置、在BlockData的BlockCells列表中的索引。为了方便以后的扩展,比如添加技能格子等,可以额外添加格子种类和格子数值字段。
我们新建一个格子的数据类CellData.cs,添加上述的字段:
   
    public class CellData
    {
      public BlockColorType ColorType;
      
      //Block info the cell belongs to
      public Vector2Int BlockRect;
      public Vector2Int Position;
      public int Index;

      //Reserve for future use
      public CellType Type;
      public int Value;
      
      //...
    }接下来我们定义Cell在Block中的Position的含义:我们以Block的最小包围矩形的左下角为原点,右向为x轴正方向,上方为y轴正方向。
比如下图是一个BlockShapeType为Cell4Z的Block,它的四个格子的坐标位置分别为:(0,1),(1,1),(1,0),(2,0)。



Cell在Block中的坐标示意

Block的最小包围矩形我们称之为RectSize。特别的,当最小包围矩形的宽度或高度的尺寸为偶数时,我们取中心点最近的左下角的格子为中心格子即可。
<hr/>那么,生成俄罗斯方块最重要的是——配置每一个Block的格子的坐标。我们新建一个名为BlockDataFactory.cs的脚本,在这个脚本里手写每一个Block的Cell的位置信息。(在这个示例中我们为了方便将其写在了代码里,但是更好的方式将其通过配置或者工具进行管理)
字典BlockShapeDictionary的Key为Block的ShapeType和RotateType,通过这两者可以确定Block的Cells的唯一的位置信息,即字典的Value。这个字典比较大,大约有400行左右,此处略过了除Z字形之外的。
public static class BlockDataFactory
{
    private static readonly Dictionary<Vector2Int, List<Vector2Int>> BlockShapeDictionary =
      new Dictionary<Vector2Int, List<Vector2Int>>()
      {
            #region Cell1...

            #region Cell2...

            #region Cell3...

            #region Cell4
            ...
            
            /*
                * x x
                *   x x
                */
            {
                new Vector2Int((int)BlockShapeType.Cell4Z, (int)BlockRotateType.Default),
                new List<Vector2Int>()
                  { new Vector2Int(0, 1), new Vector2Int(1, 1), new Vector2Int(1, 0), new Vector2Int(2, 0) }
            },

            /*
                *   x
                * x x
                * x
                */
            {
                new Vector2Int((int)BlockShapeType.Cell4Z, (int)BlockRotateType.Degree90),
                new List<Vector2Int>()
                  { new Vector2Int(1, 2), new Vector2Int(1, 1), new Vector2Int(0, 1), new Vector2Int(0, 0) }
            },
            
            /*
                * x x
                *   x x
                */
            {
                new Vector2Int((int)BlockShapeType.Cell4Z, (int)BlockRotateType.Degree180),
                new List<Vector2Int>()
                  { new Vector2Int(2, 0), new Vector2Int(1, 0), new Vector2Int(1, 1), new Vector2Int(0, 1) }
            },
            
            /*
                *   x
                * x x
                * x
                */
            {
                new Vector2Int((int)BlockShapeType.Cell4Z, (int)BlockRotateType.Degree270),
                new List<Vector2Int>()
                  { new Vector2Int(0, 0), new Vector2Int(0, 1), new Vector2Int(1, 1), new Vector2Int(1, 2) }
            },
            
            ...
            #endregion

            #region Cell5...
      };

    public static BlockData CreateBlockData(BlockShapeType shapeType, BlockRotateType rotateType,
      BlockColorType colorType)
    {
      if (!BlockShapeDictionary.TryGetValue(new Vector2Int((int)shapeType, (int)rotateType), out var shapeCells))
      {
            Debug.LogError($"BlockDataFactory == CreateBlock: does not contain key {shapeType.ToString()}, {rotateType.ToString()}");
            return null;
      }
      
      var blockData = new BlockData(shapeType, rotateType, colorType, shapeCells);
      return blockData;
    }

    public static BlockData RotateBlockTo(this BlockData data, BlockRotateType rotateType)
    {
      if (data == null)
      {
            return null;
      }
      
      if (!BlockShapeDictionary.TryGetValue(new Vector2Int((int)data.ShapeType, (int)rotateType), out var shapeCells))
      {
            Debug.LogError($"BlockDataFactory == RotateBlockTo: does not contain key {data.ShapeType.ToString()}, {rotateType.ToString()}");
            return null;
      }
      
      data.SetRotateType(rotateType, shapeCells);
      return data;
    }
}特别注意,为了以后的扩展,我们要保证每个Block下的Cell的索引是一致的。只有这样才能在旋转后,虽然每个格子的位置发生了变化,但是相对位置是不变的。



经过旋转,格子的相对位置不变

现在,在Block和Cell的数据定义上,我们已经基本完成,再细化下两者的方法即可:
为BlockData添加旋转的方法,本质上旋转Block只是改变它的所有Cells的位置。

public class BlockData
{
   
    ... Attributes

    public BlockData(){}
   
    public BlockData(BlockShapeType shapeType, BlockRotateType rotateType, BlockColorType colorType, List<Vector2Int> cellPositions)
    {
      Assert.IsTrue(cellPositions != null && cellPositions.Count > 0);

      ShapeType = shapeType;
      RotateType = rotateType;
      ColorType = colorType;
      BlockCells = new List<CellData>(cellPositions.Count);
      RectSize = GetBlockRectSize(cellPositions);
      for (int i = 0; i < cellPositions.Count; i++)
      {
            var cellPos = cellPositions;
            BlockCells.Add(new CellData(colorType, cellPos, RectSize, i));
      }

      CellCount = BlockCells.Count;
    }

    public void AutoRotate(bool ifReverse = false)
    {
      this.RotateBlockTo(ifReverse ? GetPrevRotateType(RotateType) : GetNextRotateType(RotateType));
    }

    public void SetRotateType(BlockRotateType rotateType, List<Vector2Int> cellPositions, bool ifForceChange = false)
    {
      if(ifForceChange == false && rotateType == RotateType)
            return;
      
      RotateType = rotateType;

      if (BlockCells.Count != cellPositions.Count)
      {
            Debug.LogError($"BlockData == SetVariant: BlockCells.Count != cellPositions.Count");
            return;
      }
      
      RectSize = GetBlockRectSize(cellPositions);
      
      for (int i = 0; i < BlockCells.Count; i++)
      {
            var blockCell = BlockCells;
            blockCell.UpdateRectAndPos(RectSize, cellPositions);
      }
    }

    private Vector2Int GetBlockRectSize(List<Vector2Int> cells)
    {
      var rectSize = Vector2Int.zero;

      if (cells == null || cells.Count == 0)
      {
            return rectSize;
      }
      
      foreach (var cell in cells)
      {
            rectSize.x = Mathf.Max(rectSize.x, cell.x);
            rectSize.y = Mathf.Max(rectSize.y, cell.y);
      }

      return rectSize;
    }
   
    private BlockRotateType GetNextRotateType(BlockRotateType rotateType)
    {
      return (BlockRotateType)(((int)rotateType + 1) % (int)BlockRotateType.Length);
    }
   
    private BlockRotateType GetPrevRotateType(BlockRotateType rotateType)
    {
      return (BlockRotateType)(((int)rotateType - 1 + (int)BlockRotateType.Length) % (int)BlockRotateType.Length);
    }

    public BlockData Clone()
    {
      var cloneData = new BlockData()
      {
            ShapeType = ShapeType, RotateType = RotateType, ColorType = ColorType, RectSize = RectSize,
            CellCount = CellCount
      };
      cloneData.BlockCells = new List<CellData>(BlockCells.Count);
      foreach (var cellData in BlockCells)
      {
            cloneData.BlockCells.Add(cellData.Clone());
      }

      return cloneData;
    }
}我们也为CellData添加几个位置相关的计算方法,以备后续使用:

public class CellData
{
    ... Attributes
   
    public CellData(){}
   
    public CellData(BlockColorType colorType, Vector2Int position, Vector2Int blockRect, int index = 0, CellType type = CellType.Normal, int value = 0)
    {
      ColorType = colorType;
      Position = position;
      BlockRect = blockRect;
      Type = type;
      Index = index;
      Value = value;
    }
   
    public void UpdateRectAndPos(Vector2Int blockRect, Vector2Int position)
    {
      BlockRect = blockRect;
      Position = position;
    }

    public Vector2Int GetPosToBlockCenter()
    {
      var blockRect = BlockRect;
      var cellPos = Position;
      var centerPos = new Vector2Int(blockRect.x / 2, blockRect.y / 2);
      var posToCenter = cellPos - centerPos;
      return posToCenter;
    }

    public CellData Clone()
    {
      var cellData = new CellData(ColorType, Position, BlockRect, Index, Type, Value);
      return cellData;
    }
}<hr/>定义完成了Block和Cell的Data数据类后,我们开始制作两者的实体管理类,我们分别新建两个继承MonoBehaviour的脚本:CellItem.cs和BlockItem.cs
CellItem有两个主要功能,一个是控制显示的颜色(我们用切换Sprite来实现),一个是更新在Block实体内的相对位置。
    public class CellItem : MonoBehaviour
    {
      public Sprite[] FullSprites;
      private CellData _data;
      public Image BgImage;
      public Vector2 CellSize = new Vector2(100f, 100f);
      public RectTransform RectTrans => transform as RectTransform;
      
      public void SetData(CellData data)
      {
            _data = data;
            UpdateCell();
      }

      public void SetCellSize(Vector2 cellSize)
      {
            CellSize = cellSize;
            UpdateCell();
      }
      
      public void UpdateCell()
      {
            UpdateColor();
            UpdateLocalPosition();
      }

      private void UpdateColor()
      {
            if (_data != null)
            {
                BgImage.sprite = FullSprites[(int)_data.ColorType];      
            }
      }

      private void UpdateLocalPosition()
      {
            RectTrans.anchoredPosition = GetLocalPositionOnBlock();
      }
      
      public Vector3 GetLocalPositionOnBlock()
      {
            var blockRect = _data.BlockRect;
            var cellPos = _data.Position;
            var cellSize = CellSize;
            var centerPos = new Vector2(blockRect.x * cellSize.x * 0.5f, blockRect.y * cellSize.y * 0.5f);
            var localPos = new Vector2(cellPos.x * cellSize.x, cellPos.y * cellSize.y) - centerPos;
            return localPos;
      }

      public CellData GetCellData(bool ifClone = false)
      {
            return ifClone ? _data.Clone() : _data;
      }
    }BlockItem的主要功能有三点:根据BlockData更新表现、旋转、以及创建克隆块或者克隆格子。克隆块用来在消除面板上显示提示,克隆格子用来添加到消除面板上。
    public class BlockItem : MonoBehaviour
    {
      public CellItem CopyCellOne;
      public CanvasGroup CanvasAlpha;
      public RectTransform RectTrans => transform as RectTransform;
      public Vector2Int CellSize = new Vector2Int(100, 100);
      private List<CellItem> _cells = new List<CellItem>();
      private BlockData _data;

      public void SetData(BlockData data)
      {
            if (data == null)
            {
                Debug.LogError("BlockItem == SetData data is null");
                return;
            }

            //TODO use a pool
            foreach (var oldCell in _cells)
            {
                Destroy(oldCell.gameObject);   
            }
            
            _cells.Clear();
            
            _data = data;

            foreach (var cellData in data.BlockCells)
            {
                var newCell = CreateCell(RectTrans, cellData);
                _cells.Add(newCell);
            }
      }

      public BlockData GetData(bool ifClone = false)
      {
            return ifClone ? _data.Clone() : _data;
      }

      public void SetAlpha(float alpha)
      {
            CanvasAlpha.alpha = alpha;
      }

      public void AutoRotate(bool ifReverse = false)
      {
            _data.AutoRotate(ifReverse);
            UpdateCell();
      }

      private void UpdateCell()
      {
            foreach (var cell in _cells)
            {
                cell.UpdateCell();
            }
      }
      
      public void SetLocalPosByCenter(Vector3 centerPos)
      {
            RectTrans.anchoredPosition = GetBlockLocalPosByCenter(centerPos);
      }

      private Vector3 GetBlockLocalPosByCenter(Vector3 centerPos)
      {
            var xOffset = _data.RectSize.x % 2 == 0 ? 0 : CellSize.x / 2;
            var yOffset = _data.RectSize.y % 2 == 0 ? 0 : CellSize.y / 2;
            return centerPos + new Vector3(xOffset, yOffset);
      }

      private CellItem CreateCell(RectTransform root, CellData cellData)
      {
            var cellItem = Instantiate(CopyCellOne, root);
            cellItem.gameObject.SetActive(true);
#if UNITY_EDITOR
            cellItem.name = $"cell_{cellData.Index}:[{cellData.Position.x},{cellData.Position.y}]";
#endif
            cellItem.SetData(cellData);
            cellItem.SetCellSize(CellSize);
            return cellItem;
      }

      public BlockItem Clone(RectTransform root)
      {
            var newBlock = Instantiate(this, root);
            newBlock.gameObject.SetActive(true);
            newBlock.SetData(_data.Clone());
            return newBlock;
      }

      public List<CellItem> CloneCells(RectTransform root, Vector3 centerPos)
      {
            var clonedCells = new List<CellItem>(_cells.Count);

            foreach (var cell in _cells)
            {
                var clonedCell =CreateCell(root, cell.GetCellData(true));
                var blockLocalPos = GetBlockLocalPosByCenter(centerPos);
                var cellLocalPos = cell.GetLocalPositionOnBlock();
                clonedCell.RectTrans.anchoredPosition = blockLocalPos + cellLocalPos;
                clonedCells.Add(clonedCell);
            }

            return clonedCells;
      }
    }通过组合BlockItem和CellItem,我们制作一个Block的Prefab,命名为BlockItem.prefab,将其置于Assets/Resources/Game/Prefabs/Blocks/目录下:



BlockItem.prefab的绑定

BlockItem引用了位于其下的CopyRoot下的CellItem实体,将CellItem作为模板来生成它的格子实体。
CellItem的Full Sprites引用了所有的颜色Sprite,每个Sprite的顺序,对应了BlockColorType的int值。
我们在前文中已经实现了通过BlockDataFactory来创建Block数据,现在我们再创建一个根据BlockData来生成BlockItem实体的工具脚本,命名为BlockItemCreator.cs。
    public static class BlockItemCreator
    {
      public static readonly string DefaultBlockItemPath = "Game/Prefabs/Blocks/BlockItem";
      
      public static BlockItem CreateBlockItem(BlockData blockData)
      {
            var blockItem = Object.Instantiate(Resources.Load<BlockItem>(DefaultBlockItemPath));
#if UNITY_EDITOR
            blockItem.name = $"Block{blockData.ShapeType.ToString()}-{blockData.RotateType.ToString()}";
#endif
            blockItem.SetData(blockData);
            return blockItem;
      }
    }<hr/>至此,我们可以开始做第一个——展示所有的Block块,通过点击按钮控制它们顺时针或逆时针旋转——的Demo1了。
新建一个场景命名为Demo1_CreateAllBlocks.unity,并新建一个继承MonoBehaviour的名为Demo1_CreateAllBlocks.cs的脚本,挂载到在场景中新建的名为“=====Demo1_Manager=====”的空GameObject上。
Demo1管理器脚本的功能为,在游戏运行时,生成所有在BlockDataFactory中配置的Block;当玩家按左右两边的按钮,所有Block自动旋转。
public class Demo1_CreateAllBlocks : MonoBehaviour
{
    public RectTransform BlockRoot;
    public Button PreRotateButton;
    public Button NextRotateButton;
   
    private List<BlockItem> _blocks = new List<BlockItem>();

    private void Start()
    {
      for (int i = 0; i < (int)BlockShapeType.Length; i++)
      {
            for (int j = 0; j < (int)BlockRotateType.Length; j++)
            {
                var block = CreateNewBlock(i, j, BlockRoot);
                ((RectTransform)block.transform).anchoredPosition = new Vector2(j * 400, -i * 400);
                _blocks.Add(block);
            }
      }
      
      PreRotateButton.onClick.AddListener(() =>
      {
            foreach (var b in _blocks)
            {
                b.AutoRotate(true);
            }
      });
      
      NextRotateButton.onClick.AddListener(() =>
      {
            foreach (var b in _blocks)
            {
                b.AutoRotate();
            }
      });
    }

    private BlockItem CreateNewBlock(int blockShapeType, int blockVariation, Transform blockParent)
    {
      var blockData = BlockDataFactory.CreateBlockData((BlockShapeType)blockShapeType, (BlockRotateType)blockVariation, BlockColorType.Red);
      
      //change block cell's color
      for (int i = 0; i < blockData.CellCount; i++)
      {
            blockData.BlockCells.ColorType = (BlockColorType)i;
      }
      
      var block = BlockItemCreator.CreateBlockItem(blockData);
      block.transform.SetParent(blockParent, false);
      block.transform.localPosition = Vector3.zero;
      return block;
    }
}我们在场景中进行对应的绑定:



Demo1管理器在场景中的绑定

运行游戏,当当当当~所有的俄罗斯方块便生成出来了。



Demo1运行效果

4.2 放置Block块到Board面板上

关联文件:

[*]场景:

[*]Demo2_DragBlockToBoard.unity

[*]脚本:

[*]BoardData.cs
[*]BattleBoard.cs
[*]Demo2_DragBlockToBoard.cs

<hr/>要将Block放置到消除面板上,我们就先定义一下消除面板的数据结构,新建一个名为BoardData.cs的脚本。
它的主要功能是,组织管理向它的Cells列表中添加和移除CellData信息。注意,BoardData只关心CellData,不关心BlockData。因为Block添加到消除面板后,它原有的Block数据就没有用处了,我们在Board上只处理Block上的Cells就足够了。

public class BoardData
{
    public int Width;
    public int Height;
    public CellData[,] Cells;
   
    public BoardData(){}
   
    public BoardData(int width, int height)
    {
      Width = width;
      Height = height;
      Cells = new CellData;
    }

    #region Add block to board
    public bool CanAddBlock(Vector2Int centerIndex,BlockData blockData)
    {
      var cells = blockData.BlockCells;
      foreach (var cell in cells)
      {
            var posToCenter = cell.GetPosToBlockCenter();
            var targetPos = posToCenter + centerIndex;
            if (targetPos.x < 0 || targetPos.x >= Width || targetPos.y < 0 || targetPos.y >= Height)
            {
                return false;
            }

            if (Cells != null)
            {
                return false;
            }
      }

      return true;
    }

    public void AddBlock(Vector2Int centerIndex, BlockData blockData)
    {
      foreach (var cell in blockData.BlockCells)
      {
            var newCell = cell.Clone();
            var targetIndex = cell.GetPosToBlockCenter() + centerIndex;
            Cells = newCell;
      }
    }

    public CellData RemoveCell(Vector2Int position)
    {
      var cell = Cells;
      if (cell != null)
      {
            Cells = null;
      }

      return cell;
    }
    #endregion

    #region Check cell's matchment and clear

    public bool TryRemoveCell(Vector2Int position, out CellData cellData)
    {
      cellData = Cells;
      if (cellData != null)
      {
            Cells = null;
            return true;
      }

      return false;
    }

    #endregion
   
    #region Other Interfaces
    public void SetCellData(CellData cellData)
    {
      var pos = cellData.Position;
      Cells = cellData;
    }
   
    public CellData GetCellData(Vector2Int pos)
    {
      return Cells;
    }

    public bool TryGetCellData(int x, int y, out CellData cellData)
    {
      cellData = Cells;
      return cellData != null;
    }
   
    public void Clear()
    {
      for (int y = 0; y < Height; y++)
      {
            for (int x = 0; x < Width; x++)
            {
                Cells = null;
            }
      }
    }
    #endregion
}<hr/>定义好了BoardData后,我们新建一个继承MonoBehaviour的Board管理类,名为BattleBoard.cs。
BattleBoard主要负责将玩家选中的方块,绘制到他鼠标指向的位置。当玩家松手时,将Block生成到消除面板上。(BattleBoard的消除功能会在下一部分介绍,可以将TryMatchAndClearBoard方法先注释掉)
public class BattleBoard : MonoBehaviour
{
    public int BoardWidth = 9;
    public int BoardHeight = 9;
    public float CellSize = 100f;
    public RectTransform BoardRoot;
   
    private Dictionary<Vector2Int, CellItem> _cellItems = new Dictionary<Vector2Int, CellItem>();
    private RectTransform _rectTransform => transform as RectTransform;
   
    private BoardData _boardData;
   
    private BlockItem _followBlock;
    private Vector2Int? _followBlockIndex;

    #region Initialize
    private void Awake()
    {
      _boardData = new BoardData(BoardWidth, BoardHeight);
    }
    #endregion

    #region Check input and make a following block
   
    /// <summary>
    /// Transfer the ui position to board index
    /// </summary>
    /// <param name="uiPos">The local position of the board</param>
    /// <param name="boardIndex">It starts from (0, 0) at the left bottom</param>
    public bool TryGetBoardIndexByUIPos(Vector3 uiPos, out Vector2Int boardIndex)
    {
      var boardSize = new Vector2(BoardWidth * CellSize, BoardHeight * CellSize);
      var transferPos = new Vector2(uiPos.x + boardSize.x * 0.5f, uiPos.y + boardSize.y * 0.5f);
      boardIndex = new Vector2Int(Mathf.FloorToInt(transferPos.x / CellSize), Mathf.FloorToInt(transferPos.y / CellSize));
      return boardIndex.x >= 0 && boardIndex.x < BoardWidth && boardIndex.y >= 0 && boardIndex.y < BoardHeight;
    }

    /// <summary>
    /// Check if the following block can be created
    /// </summary>
    /// <param name="targetIndex">Index on board</param>
    /// <param name="block">It should be create with it's center at the targetIndex</param>
    private bool CanCreateFollowingBlock(Vector2Int targetIndex, BlockItem block)
    {
      return _boardData.CanAddBlock(targetIndex, block.GetData());
    }

    /// <summary>
    /// Try to create a following block or make the following block move by the ui position
    /// </summary>
    /// <param name="uiPos">Local position of the board</param>
    /// <param name="block">The origin block to be created with</param>
    public bool TryFollowBlock(Vector3 uiPos, BlockItem block)
    {
      if(TryGetBoardIndexByUIPos(uiPos, out var boardIndex))
      {
            if (CanCreateFollowingBlock(boardIndex, block))
            {
                if(_followBlock == null)
                {
                  CreateFollowBlock(boardIndex, block);
                }
                _followBlock.SetLocalPosByCenter(GetLocalPosByIndex(boardIndex));
                _followBlockIndex = boardIndex;
                return true;
            }
      }

      if (_followBlock)
      {
            TryRemoveFollowBlock();
      }
      return false;
    }
   
    /// <summary>
    /// Create a following block
    /// </summary>
    /// <param name="boardIndex">The board index and the center of the block to be put on</param>
    /// <param name="block">The origin block to be created with</param>
    private void CreateFollowBlock(Vector2Int boardIndex, BlockItem block)
    {
      if (_followBlock != null)
      {
            Debug.LogError("BattleBoard == CreateFollowingBlock FollowingBlock is not null");
            Destroy(_followBlock.gameObject);
      }

      _followBlock = block.Clone(_rectTransform);
      _followBlock.SetLocalPosByCenter(GetLocalPosByIndex(boardIndex));
      _followBlock.SetAlpha(0.35f);
      _followBlockIndex = boardIndex;
    }
   
    /// <summary>
    /// Remove the following block
    /// </summary>
    public bool TryRemoveFollowBlock()
    {
      if (_followBlock != null)
      {
            Destroy(_followBlock.gameObject);
            _followBlock = null;
            return true;
      }

      _followBlockIndex = null;
      return false;
    }

    /// <summary>
    /// Get the local position by index
    /// </summary>
    /// <param name="boardIndex">The board index</param>
    private Vector3 GetLocalPosByIndex(Vector2Int boardIndex)
    {
      var boardSize = new Vector2(BoardWidth * CellSize, BoardHeight * CellSize);
      var localPos = new Vector3(boardIndex.x * CellSize - boardSize.x * 0.5f, boardIndex.y * CellSize - boardSize.y * 0.5f);
      return localPos + new Vector3(CellSize * 0.5f, CellSize * 0.5f);
    }

    #endregion

    #region Add block to board

    public bool TryAddBlock()
    {
      if (_followBlock == null || !_followBlockIndex.HasValue)
      {
            return false;
      }
      
      return TryAddBlockInternal(_followBlockIndex.Value, _followBlock);
    }

    private bool TryAddBlockInternal(Vector2Int boardIndex, BlockItem block)
    {
      if (_boardData.CanAddBlock(boardIndex, block.GetData()))
      {
            var addedCells = block.CloneCells(BoardRoot, GetLocalPosByIndex(boardIndex));
            foreach (var cell in addedCells)
            {
                _cellItems.Add(cell.GetCellData().GetPosToBlockCenter() + boardIndex, cell);
                _boardData.AddBlock(boardIndex, block.GetData());
            }               
            return true;
      }

      return false;
    }

    #endregion

    #region Match and clear Board
   
    public bool TryMatchAndClearBoard(out List<MatchType> matchTypes, out List<CellData> matchedCells)
    {
      matchedCells = null;
      
      if (!_boardData.TryGetAllMatches(out matchTypes, out var matchedIndexs))
      {
            return false;
      }

      matchedCells = new List<CellData>();

      foreach (var index in matchedIndexs)
      {
            if (!_cellItems.TryGetValue(index, out var cellItem))
            {
                Debug.LogError("BattleBoard == TryMatchAndClearBoard Cell is not exist!");
                continue;
            }
            matchedCells.Add(cellItem.GetCellData(true));

            TryRemoveCell(index);
      }

      return true;
    }

    private bool TryRemoveCell(Vector2Int boardIndex)
    {
      if (_cellItems.TryGetValue(boardIndex, out var cell))
      {
            _boardData.TryRemoveCell(boardIndex, out var _);
            Destroy(cell.gameObject);
            _cellItems.Remove(boardIndex);
            return true;
      }

      return false;
    }
   
    public void ClearBoard()
    {
      TryRemoveFollowBlock();
      
      foreach (var cell in _cellItems.Values)
      {
            Destroy(cell.gameObject);
      }
      _cellItems.Clear();
      _boardData.Clear();
    }
    #endregion
}该脚本通过public bool TryFollowBlock(Vector3 uiPos, BlockItem block)方法,将玩家选中的Block块,绘制到鼠标指定的格子里。此处的uiPos是经过转换后的,鼠标相对于消除面板中心的坐标位置。
我们通过如下的计算,将鼠标相对于面板中心的坐标位置,换算成消除面板上的索引值(这个索引坐标系的原点是左下角,右边是x轴正方向,上方是y轴正方向)
public bool TryGetBoardIndexByUIPos(Vector3 uiPos, out Vector2Int boardIndex)
{
    var boardSize = new Vector2(BoardWidth * CellSize, BoardHeight * CellSize);
    var transferPos = new Vector2(uiPos.x + boardSize.x * 0.5f, uiPos.y + boardSize.y * 0.5f);
    boardIndex = new Vector2Int(Mathf.FloorToInt(transferPos.x / CellSize), Mathf.FloorToInt(transferPos.y / CellSize));
    return boardIndex.x >= 0 && boardIndex.x < BoardWidth && boardIndex.y >= 0 && boardIndex.y < BoardHeight;
}<hr/>至此,我们可以开始做第二个——将底部随机生成的俄罗斯方块添加到消除面板上,通过左右按钮可以获得不同形状和旋转角度的块——的Demo2了。
同样是新建一个名为Demo2_DragBlockToBoard.unity的场景,在场景中新建一个空GameObject名为“=====Demo2_Manager=====”,在该GameObject上附上新建的继承MonoBehaviour的Demo2管理器脚本Demo2_DragBlockToBoard.cs。
public class Demo2_DragBlockToBoard : MonoBehaviour
{
    public RectTransform DisplayRoot;
    public BattleBoard Board;
    public Image BgImage;
   
    public Button PreVariantBtn;
    public Button NextVariantBtn;
    public Button ReplayBtn;
   
    private BlockItem _randomBlock;
   
    private void Start()
    {
      CreateSelectBlock();
      PreVariantBtn.onClick.AddListener(() =>
      {
            CreateSelectBlock();
      });
      NextVariantBtn.onClick.AddListener(() =>
      {
            _randomBlock.AutoRotate();
      });
      ReplayBtn.onClick.AddListener(() =>
      {
            Board.ClearBoard();
            CreateSelectBlock();
      });
    }

    private void CreateSelectBlock()
    {
      if (_randomBlock)
      {
            Destroy(_randomBlock.gameObject);
            _randomBlock = null;
      }
      
      var blockData = BlockDataFactory.CreateBlockData(
            (BlockShapeType)Random.Range(0, (int)BlockShapeType.Length),
            (BlockRotateType)Random.Range(0, (int)BlockRotateType.Length),
            (BlockColorType)Random.Range(0, (int)BlockColorType.Length));

      _randomBlock = BlockItemCreator.CreateBlockItem(blockData);
      _randomBlock.transform.SetParent(DisplayRoot, false);
    }

    void Update()
    {
      DealWithInputClick();
    }

    private void DealWithInputClick()
    {
      bool ifBlockValid = false;
      
      var mousePos = Input.mousePosition;
      if (RectTransformUtility.RectangleContainsScreenPoint(BgImage.rectTransform, mousePos))
      {
            if(RectTransformUtility.ScreenPointToLocalPointInRectangle(BgImage.rectTransform, mousePos, null, out var localPos))
            {
                ifBlockValid = Board.TryFollowBlock(localPos, _randomBlock);
               
                if (ifBlockValid && Input.GetMouseButtonDown(0))
                {
                  Board.TryAddBlock();
                }
            }
      }

      if (!ifBlockValid)
      {
            Board.TryRemoveFollowBlock();
      }
    }
}Demo2管理器的实现有一个重点,就是如何将玩家的鼠标的坐标位置转换成在消除面板上的以消除面板中心为原点的相对坐标。
Unity提供了一个很便捷的工具方法:RectTransformUtility.ScreenPointToLocalPointInRectangle,我们通过这个方法将获得的localPos传入给了BoardItem。
if(RectTransformUtility.ScreenPointToLocalPointInRectangle(BgImage.rectTransform, mousePos, null, out var localPos))
{
    ifBlockValid = Board.TryFollowBlock(localPos, _randomBlock);
   
    if (ifBlockValid && Input.GetMouseButtonUp(0))
    {
      Board.TryAddBlock();
    }
}最后,我们在场景中进行相应的绑定。



Demo2管理器在场景内绑定

3,2,1——运行游戏,我们便成功地将随机生成的方块添加到了消除面板上。



Demo2运行效果

4.3 匹配消除面板上的格子并消除

关联文件:

[*]场景:

[*]Demo3_BlockClear.unity

[*]脚本:

[*]BoardMatchUtil.cs
[*]Demo3_BlockClear.cs

<hr/>已经快要接近终点了,这一节我们来轻松地实现一下消除的模式匹配。
我们的游戏有三种消除方式——当一整行、一整列或者一个3*3的大格子(整个面板上有9个这样的大格子)满了,即可消除该部分的所有格子。
这里的难点是,玩家虽然放下了一个方块,但可能会同时触发多个模式的消除,而我们的程序应该能够准确识别出他同时触发的所有模式,并且给予玩家更高的奖励分数。
新建一个处理匹配的工具类,命名为BoardMatchUtil.cs。我们先在其中手动添加所有的匹配模式的枚举。(虽然有更好的方式可以跳过这个枚举来实现,但是这里为了简单清晰,依然采用了枚举的形式)
    public enum MatchType
    {
      Horizen0,
      Horizen1,
      Horizen2,
      Horizen3,
      Horizen4,
      Horizen5,
      Horizen6,
      Horizen7,
      Horizen8,

      Vertical0,
      Vertical1,
      Vertical2,
      Vertical3,
      Vertical4,
      Vertical5,
      Vertical6,
      Vertical7,
      Vertical8,

      NineCell0,
      NineCell1,
      NineCell2,
      NineCell3,
      NineCell4,
      NineCell5,
      NineCell6,
      NineCell7,
      NineCell8,
      
      Length,
    }我们新建一个BoardMatchUtil工具类,在静态初始化的时候,用for循环去生成了单独的9+9+9=27种匹配模式。
所谓的匹配,本质上就是查询匹配列表的每一项,当所有条件都满足,既满足了该项匹配模式。
public static class BoardMatchUtil
{
    //TODO set by configuration
    private static readonly int DefaultBoardWidth = 9;
    private static readonly int DefaultBoardHeight = 9;
   
    private static Dictionary<int, List<Vector2Int>> _matchDic = new Dictionary<int, List<Vector2Int>>();

    static BoardMatchUtil()
    {
      //All Horizontal Match
      for (int i = (int)MatchType.Horizen0; i <= (int)MatchType.Horizen8; i++)
      {
            var index = i - (int)MatchType.Horizen0;

            var horizonMatchList = new List<Vector2Int>();
            for (int j = 0; j < DefaultBoardWidth; j++)
            {
                horizonMatchList.Add(new Vector2Int(j, index));
            }
            _matchDic = horizonMatchList;
      }
      
      //All Vertical Match
      for (int i = (int)MatchType.Vertical0; i <= (int)MatchType.Vertical8; i++)
      {
            var index = i - (int)MatchType.Vertical0;
            
            var verticalMatchList = new List<Vector2Int>();
            for (int j = 0; j < DefaultBoardHeight; j++)
            {
                verticalMatchList.Add(new Vector2Int(index, j));
            }
            _matchDic = verticalMatchList;
      }
      
      //All Nine Cell Match
      for (int i = (int)MatchType.NineCell0; i <= (int)MatchType.NineCell8; i++)
      {
            var index = i - (int)MatchType.NineCell0;
            var nineCellMatchList = new List<Vector2Int>();
            
            for (int j = 0; j < 3; j++)
            {
                for (int k = 0; k < 3; k++)
                {
                  nineCellMatchList.Add(new Vector2Int(index % 3 * 3 + j, index / 3 * 3 + k));
                }
            }
            _matchDic = nineCellMatchList;
      }
    }

    public static bool TryGetAllMatches(this BoardData boardData, out List<MatchType> matchTypes, out HashSet<Vector2Int> matchedIndexs)
    {
      //TODO use a cache to avoid new
      matchTypes = new List<MatchType>();
      matchedIndexs = new HashSet<Vector2Int>();
      
      for (int i = 0; i < (int)MatchType.Length; i++)
      {
            var matchType = (MatchType)i;
            if (boardData.IfMatch(matchType, out var matchList))
            {
                matchTypes.Add(matchType);
                foreach (var pos in matchList)
                {
                  matchedIndexs.Add(pos);
                }
            }
      }

      return matchTypes.Count > 0;      
    }
   
    private static bool IfMatch(this BoardData boardData, MatchType matchType, out List<Vector2Int> matchList)
    {
      if (!_matchDic.TryGetValue((int)matchType, out matchList))
      {
            Debug.LogError($"BoardMatchUtil == MatchType not found:{matchType.ToString()}");
            return false;
      }

      foreach (var pos in matchList)
      {
            if (boardData.GetCellData(pos) == null)
            {
                return false;
            }
      }
      return true;
    }
}在实现你自己的TryGetAllMatches方法的时候,你需要注意一件事——不同的匹配模式下可能会有重复匹配到的格子,在返回所有匹配格子时,需要排除重复格子。笔者在这里直接使用了HashSet集合来处理。
如果你在前文中注释掉了BattleBoard.cs中的TryMatchAndClearBoard方法,记得打开注释。
<hr/>现在,让我们选中之前制作的Demo2_DragBlockToBoard.unity场景,Ctrl+D将其复制一份新命名为Demo3_BlockClear.unity。
将其中的“=====Demo2_Manager=====”重命名为“=====Demo3_Manager=====”,移除上面的Demo2_DragBlockToBoard的Component,新添加上名为Demo3_BlockClear.cs的脚本。
Demo3的管理器脚本和Demo2的基本一致,只是在DealWithInputClick()方法中新加了一个Board.TryMatchAndClearBoard的判断。
public class Demo3_BlockClear : MonoBehaviour
{
    //... same with Demo2_DragBlockToBoard

    private void DealWithInputClick()
    {
      bool ifBlockValid = false;
      
      var mousePos = Input.mousePosition;
      if (RectTransformUtility.RectangleContainsScreenPoint(BgImage.rectTransform, mousePos))
      {
            if(RectTransformUtility.ScreenPointToLocalPointInRectangle(BgImage.rectTransform, mousePos, null, out var localPos))
            {
                ifBlockValid = Board.TryFollowBlock(localPos, _randomBlock);
               
                if (ifBlockValid && Input.GetMouseButtonUp(0))
                {
                  Board.TryAddBlock();
                  
                  CreateSelectBlock();
                  
                  if (Board.TryMatchAndClearBoard(out var matchTypes ,out var matchedCells))
                  {
                        //TODO Deal with score
                        Board.TryRemoveFollowBlock();
                  }
                }
            }
      }

      if (!ifBlockValid)
      {
            Board.TryRemoveFollowBlock();
      }
    }
}Demo3的管理器在场景中的绑定和Demo2完全一致。



Demo3管理器绑定

运行游戏,就可以愉快地消除了。

http://pic3.zhimg.com/v2-c8a7ad8385f6f645e9db4ad93227c296_b.gif

Demo3运行效果

4.4 制作Blockudoku Plus

关联文件:

[*]场景:

[*]Demo4_Blockudoku.unity

[*]脚本:

[*]SelectItem.cs
[*]SelectBoard.cs
[*]SelectBlockInfo.cs

<hr/>经过上面的3个步骤,我们距离Blockudoku Plus已经只有一步之遥了。确切的说是一步半之遥。
我们先制作游戏的选择面板和选择按钮。新建两个继承MonoBehaviour的脚本,分别命名为SelectItem.cs和SelectBoard.cs。
SelectItem是选择面板上的选择按钮,主要的功能是展示绑定的方块,以及处理方块的选择和旋转。
未选中的SelectItem被点击则变为选中状态,选中状态再被点击,即可旋转其上的方块。
public class SelectItem : MonoBehaviour
{
    public Button BgButton;
    public Image BgImage;
    public Color UnSelectColor;
    public Color SelectColor;
   
    public bool IfSelect
    {
      get => _ifSelect;
      set
      {
            _ifSelect = value;
            OnSelect(_ifSelect);
      }
    }

    public bool IfBind => _blockItem != null;

    private int _index;
   
    private bool _ifSelect = false;

    private BlockItem _blockItem;

    private Action<SelectBlockInfo> _onSelectBlock;

    private void Start()
    {
      BgButton.onClick.AddListener(OnButtonClick);
      IfSelect = false;
    }

    public void BindBlock(int index, BlockItem blockItem, Action<SelectBlockInfo> onSelectBlock)
    {
      Assert.IsNotNull(blockItem);
      _index = index;
      if (_blockItem)
      {
            Destroy(_blockItem.gameObject);
      }
      
      _blockItem = blockItem;
      _onSelectBlock = onSelectBlock;
      _blockItem.RectTrans.SetParent(transform, false);
      _blockItem.RectTrans.anchoredPosition = Vector2.zero;
      IfSelect = false;
    }

    public SelectBlockInfo UnBindBlock()
    {
      var blockItem = _blockItem;
      _blockItem = null;
      return new SelectBlockInfo() { SelectIndex = _index, SelectBlock = blockItem };
    }

    public void ReBindBlock(BlockItem blockItem)
    {
      _blockItem = blockItem;
      _blockItem.RectTrans.SetParent(transform, false);
      _blockItem.RectTrans.anchoredPosition = Vector2.zero;
      IfSelect = true;
    }

    public int RemoveBlock()
    {
      if (_blockItem)
      {
            Destroy(_blockItem.gameObject);
      }
      _blockItem = null;
      IfSelect = false;
      return _index;
    }

    private void OnButtonClick()
    {
      if (!IfSelect)
      {
            IfSelect = true;
      }
      else
      {
            if (_blockItem)
            {
                _blockItem.AutoRotate();
            }   
      }
      
      _onSelectBlock?.Invoke(new SelectBlockInfo() { SelectIndex = _index, SelectBlock = _blockItem });
    }

    private void OnSelect(bool ifSelect)
    {
      BgImage.color = ifSelect ? SelectColor : UnSelectColor;
    }
}SelectBoard作为选择面板的管理类,需要管理其上的所有选择按钮、维护玩家选择块的状态,以及随机生成多个选择按钮上的内容。
public class SelectBoard : MonoBehaviour
{
    public RectTransform DisplayRoot;
    public SelectItem CopyItem;

    private List<SelectItem> _selectItems;
    private Func<List<BlockData>> _getSelectBlocks;

    private int _selectCount = 0;

    public SelectBlockInfo SelectedBlock { get; private set; } = null;

    public void InitSelectBoard(int selectCount, Func<List<BlockData>> getSelectBlocks)
    {
      Assert.IsNotNull(getSelectBlocks);
      Assert.IsTrue(selectCount > 0);
      
      _selectCount = selectCount;
      _getSelectBlocks = getSelectBlocks;
      _selectItems = new List<SelectItem>(_selectCount);
      
      for (int i = 0; i < _selectCount; i++)
      {
            var newItem = Instantiate(CopyItem, DisplayRoot, false);
            _selectItems.Add(newItem);
      }
    }

    public void CreateSelectBlocks()
    {
      CreateSelectBlocks(_getSelectBlocks.Invoke());
      OnSelectedChanged(null);
    }

    private void CreateSelectBlocks(List<BlockData> blockDataList)
    {
      Assert.IsTrue(blockDataList.Count == _selectCount);
      
      for (int i = 0; i < _selectCount; i++)
      {
            var blockData = blockDataList;
            var selectItem = _selectItems;
            var blockItem = BlockItemCreator.CreateBlockItem(blockData);
            selectItem.BindBlock(i, blockItem, item =>
            {
                OnSelectedChanged(item);
            });
      }
    }

    public void RemoveSelectBlock()
    {
      if(SelectedBlock == null)
      {
            return;
      }
      
      var selectItem = _selectItems;
      selectItem.RemoveBlock();
      SelectedBlock = null;
    }
   
    public bool IfEmpty()
    {
      foreach (var selectItem in _selectItems)
      {
            if (selectItem.IfBind)
            {
                return false;
            }
      }

      return true;
    }

    private void OnSelectedChanged(SelectBlockInfo selectBlockInfo)
    {
      SelectedBlock = selectBlockInfo;

      var selectedIndex = SelectedBlock?.SelectIndex ?? -1;
      
      for (int i = 0; i < _selectItems.Count; i++)
      {
            if (i == selectedIndex)
            {
                continue;
            }
            
            _selectItems.IfSelect = false;
      }
    }
}其他模块可能访问选择面板的相关选择信息,我们新建一个SelectBlockInfo.cs类来处理相应的信息传递。
    public class SelectBlockInfo
    {
      public int SelectIndex;
      public BlockItem SelectBlock;
    }<hr/>完成了选择面板的功能后,我们要开始做最后一个——具备完整Blockudoku功能,并能旋转方块——的Demo4了!
新建一个场景命名为Demo4_Blockudoku.unity,并新建一个继承MonoBehaviour的名为Demo4_Blockudoku.cs的脚本,挂载到在场景中新建的名为“=====Demo4_Manager=====”的空GameObject上。
Demo4管理器脚本的功能为,每一轮为选择面板生成3个方块,玩家可以选择或者旋转方块,将方块放置于消除面板上,满足匹配条件的块即可消除得分。当选择面板上全空时,重新生成新的3个方块。
public class Demo4_Blockudoku : MonoBehaviour
{
    public BattleBoard Board;
    public Image BgImage;
    public Text ScoreText;
    public SelectBoard SelectBoard;
    public int SelectCount = 3;
   
    public Button ReplayBtn;

    public int Score
    {
      get=> _score;
      
      set
      {
            _score = value;
            ScoreText.text = $"Score: {_score}";
      }
    }
    private int _score = 0;
   
    public BlockItem SelectBlock => SelectBoard != null ? SelectBoard.SelectedBlock?.SelectBlock : null;
   
    private void Start()
    {
      ReplayBtn.onClick.AddListener(() =>
      {
            ReplayGame();
      });
      
      SelectBoard.InitSelectBoard(SelectCount, () =>
      {
            var blockDataList = new List<BlockData>();
            for (int i = 0; i < SelectCount; i++)
            {
                var blockData = BlockDataFactory.CreateBlockData(
                  (BlockShapeType)Random.Range(0, (int)BlockShapeType.Length),
                  (BlockRotateType)Random.Range(0, (int)BlockRotateType.Length),
                  (BlockColorType)Random.Range(0, (int)BlockColorType.Length));
                blockDataList.Add(blockData);
            }
            return blockDataList;
      });
      
      SelectBoard.CreateSelectBlocks();
      
      Score = 0;
    }

    void Update()
    {
      CheckInputClick();
    }

    private void CheckInputClick()
    {
      if (SelectBlock == null)
      {
            return;
      }

      bool ifBlockValid = false;
      
      var mousePos = Input.mousePosition;
      if (RectTransformUtility.RectangleContainsScreenPoint(BgImage.rectTransform, mousePos))
      {
            if(RectTransformUtility.ScreenPointToLocalPointInRectangle(BgImage.rectTransform, mousePos, null, out var localPos))
            {
                ifBlockValid = Board.TryFollowBlock(localPos, SelectBlock);
               
                if (ifBlockValid && Input.GetMouseButtonUp(0))
                {
                  Board.TryAddBlock();

                  SelectBoard.RemoveSelectBlock();
                  
                  if (SelectBoard.IfEmpty())
                  {
                        SelectBoard.CreateSelectBlocks();
                  }

                  if (Board.TryMatchAndClearBoard(out var matchTypes ,out var matchedCells))
                  {
                        Score += matchTypes.Count * matchedCells.Count;
                  }
                  
                  Board.TryRemoveFollowBlock();
                }
            }
      }

      if (!ifBlockValid)
      {
            Board.TryRemoveFollowBlock();
      }
    }

    private void ReplayGame()
    {
      Board.ClearBoard();
      SelectBoard.CreateSelectBlocks();
      Score = 0;
    }
}Demo4的管理器和Demo3的功能差不多,只是增加了一点对SelectBoard的管理和简单的积分计算。
让我们再按下图进行Demo4管理器在场景中的绑定。



Demo4管理器在场景内绑定

屏住呼吸,点击游戏运行——当当当当,我们的第一款俄罗斯方块拼图消除游戏诞生啦~



Demo4运行效果

虽然此时此刻,它还有点幼稚,有些生硬。但它的的确确在此刻破壳而出了~
5. 小结

我们经过了以上生成不同方块、将方块添加到消除面板上、添加匹配消除规则以及制作选择面板和积分显示的4个步骤,完成了一款略微粗糙但却挺有趣味的俄罗斯方块拼图消除游戏Blockudoku Plus。
6. 扩展

这个教程里的不少代码都是为了方便大家学习而写得简答粗暴,并不高效;最终的操作和表现也相当生硬。
如果知友感兴趣的话,后续还有几个可以把这个游戏完善的方向:
(1)可以尝试为游戏内的BlockData、CellData、BlockItem以及CellItem添加池管理器以避免多次的小内存创建。参考使用下面这款AssetStore中的免费池管理器插件能让你事半功倍:
(2)可以优化游戏的操作,添加拖拽的表现;添加更多的游戏反馈:
如何为游戏添加游戏感可以参考笔者过往的文章:
另外,真正在游戏内不同时机添加不同表现效果时,大概率会需要合理地处理异步的命令执行,同样推荐一款可以帮你提升效率的插件:
(3)在笔者制作这款Demo时,尝试使用了Midjourney生成游戏的参考图,并最终采用了和它类似的配色。


在编写代码的过程中,使用了GitHub的AI编程工具Copilot,它的代码辅助生成着实惊艳,大部分BlockDataFactory中的注释和代码都是它自动生成的。唯一不足是它生成的位置信息没有和索引保持一致。


不得不感慨未来已来,拥抱AI。
以上。

XGundam05 发表于 2023-3-11 13:38

我最近在开发眼动交互,我看到这个我在想,能不能用眼动-头动或者眼动-微手势交互去放方块,应该更快吧…只是有点中二…

stonstad 发表于 2023-3-11 13:41

眼动啊~虽然有些费眼,但如果能用来玩rollingsky这样的游戏,就完美了[调皮]

Zephus 发表于 2023-3-11 13:46

[调皮]懂了,这就去做个rollingsky眼动操作demo
页: [1]
查看完整版本: 游戏开发From2ToHero 用Unity制作你的第一款方块拼图游戏 ...