65.9K
CodeProject 正在变化。 阅读更多。
Home

Silverlight Falling Blocks

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.45/5 (20投票s)

2008年12月3日

CPOL

11分钟阅读

viewsIcon

66426

downloadIcon

1389

使用 Silverlight 2 构建一个方块掉落游戏。

引言

尽管尚未发生,但 Silverlight 将像多年前 .NET 所做的那样,极大改变开发世界。将 .NET 的强大功能与轻量级 Web 足迹相结合,将真正把您日常普通的 ASP.NET 网站提升到新的高度。更不用说对桌面软件的强大影响了。(我的意思是,经过一些调整,Silverlight 也可以作为桌面应用程序分发。)

现在,我喜欢我见过的许多花哨动画和超级转换示例。天知道我喜欢看颜色渐变,圆形生长和缩小,以及所有那些通用的交互式设计之美。但是,这并不是真正让我兴奋的技术。你可以说我疯了,但事实是我有一个标准化的、基于 .NET 的接口,用于向客户端交付应用程序。是的,Web 是标准化的,但浏览器似乎不是。是的,你可以使用 JavaScript、VBScript 或其他任何可用的东西,但我喜欢 C#,我喜欢 .NET,我喜欢我现在可以交付一个完全在该领域开发的解决方案。我们已经在数据库中使用 C# 一段时间了(甚至在 Oracle 中!),并且有一段时间,我们有那些 ActiveX 文档……哦,我的意思是 XBAP 将 WPF 的强大功能带到了 Web。然而,直到 SL2 的出现,我们才真正看到一项技术具有如此深远的应用场景,但仍然高度轻量(2MB)。几年前,我写了一个方块掉落游戏的克隆——Blox——它展示了 GDI+ 的强大功能以及它所能实现的一些功能。我认为使用 Silverlight 2 从头开始重写整个应用程序会很酷。

点击此处玩游戏.

游戏规则

对于那些在八十年代和九十年代在洞穴中度过的人来说,《俄罗斯方块》(Tetris)是一款益智视频游戏,最初由 Alexey Pajitnov 于 1985 年 6 月在莫斯科苏联科学院 Dorodnicyn 计算中心工作时设计和编程。(我建议阅读维基百科上的文章以获取更多信息,因为详细描述超出了本文的范围。)它由四个方块组成七种形状:I、L、J、S、Z、T、O。这些形状从天而降,必须以一种完全填充一行且不留空隙的方式放置。形状也可以旋转以帮助获得最佳放置。当使用各种形状的掉落方块填满一行时,该行消失并获得积分。基本上就是这样。

开发 Blox

与任何游戏一样,在实际深入研究积分甚至表单的语义之前,了解游戏的物理特性非常重要。这些是我们必须考虑的影响屏幕上元素(artifacts)的因素。它们通常不需要用户交互,但也可以需要。在我们的 Blox API 中,用于构建方块掉落游戏,有五个主要考虑因素:

  • 重力 - 一旦形状在方块掉落世界中实体化,它立即开始向游戏底部掉落。随着时间的推移,重力可以增加以使游戏更难。这意味着需要一个计时器来处理方块的下降。
  • 旋转 - 方块掉落世界中的形状可以顺时针旋转 90、180、270 和 360/0 度。旋转必须遵守所有其他规则。
  • 边界 - 形状不能穿过其他形状,也不能超出游戏的边界。
  • 对齐 - 由于形状不能相互穿过,所以形状可以堆叠在一起是合理的。事实上,当堆叠的形状过多以至于无法放置新方块时,游戏就输了。
  • 运动 - 形状可以随意向侧面(左右)移动(前提是它们的路径没有被任何其他形状或游戏边界阻挡)。

显然还有更多需要考虑的事项,但这五项足以解释基本功能。

关键组件

Blox API 的主要类是 `GameField`、`Shape`、`Block` 和 `LineManager`(如下图所示)

silverlight_tetris/primary_classes.jpg

`GameField` 是一个 Silverlight 用户控件,代表游戏的可视区域。它提供背景颜色、大小、边界和游戏速度。它还为 `Shape` 和 `LineManager` 提供了一个宿主表面。从视觉上看,`GameField` 只包含一个元素:一个网格。通用模式如下:加载时,`GameField` 用足够多的 `Block` 对象填充其内部网格,以填充整个游戏表面(由 `GameHeight` 和 `GameWidth` 确定)。`GameField` 的 `Loaded` 方法中与本次讨论相关的部分如下所示:

_blocks = new BlockCollection (this.GameWidth, this.GameHeight);

//populate width
foreach (int game_width in Enumerable.Range(0, this.GameWidth))
{
 ColumnDefinition col_def = new ColumnDefinition();
 //col_def.Width = new GridLength(25);
 LayoutRoot.ColumnDefinitions.Add(col_def);
}

//populate height
foreach (int game_height in Enumerable.Range(0, this.GameHeight))
{
   RowDefinition row_def = new RowDefinition();
   //row_def.Height = new GridLength(25);
   LayoutRoot.RowDefinitions.Add(row_def);
}

////populate controls
foreach (int game_height in Enumerable.Range(0, this.GameHeight))
{

  LineManagers.Add(new LineManager(this.GameWidth, game_height));

  foreach (int game_width in Enumerable.Range(0, this.GameWidth))
  {
      //add a block to that area
      Block block = new Block();
      block.SetValue(Grid.ColumnProperty, game_width);
      block.SetValue(Grid.RowProperty, game_height);
      LayoutRoot.Children.Add(block);
      Blocks.Add(block, game_width, game_height);
  }    
}

从示例中可以看出,`GameHeight` 和 `GameWidth` 决定了 `GameField` 内部网格的行数和列数。接下来,一个 `Block` 对象被添加到网格的每个单元格中。该方块也被添加到由 `Blocks` 属性表示的 `BlockCollection` 中。`BlockCollection` 是一个简单的类,它封装了一个 `Block` 对象的二维数组,其代码如下所示。

public class BlockCollection
{
    Block[,] _blocks;
    int _width, _height;

    public BlockCollection(int width, int height)
    {
        this._height = height;
        this._width = width;
        _blocks = new Block[width, height];
    }

    public Block this[int left, int top]
    {
        get
        {
            //dont throw error, just return 
            if(left >= _width )
                left = _width - 1;
            if(top >= _height)
                top = _height -1;
            return _blocks[left, top];
        }
    }

    internal void Add(Block block, int left, int top)
    {
        _blocks[left, top] = block;
    }
}

Blox API 不使用 WPF 动画——这似乎没有必要;相反,通过将网格中单个 `Block` 对象“打开”或“关闭”来实现运动的外观。Block 类提供了 `Occupy` 和 `Clear` 对象来完成此操作。

public void Occupy(Shape shape)
{
    LayoutRoot.Background = shape.Background;
    _isoccupied = true;
}

public void Occupy(Shape shape, Thickness borders, CornerRadius corners)
{
    LayoutRoot.Background = shape.Background;
    LayoutRoot.BorderThickness = borders;
    LayoutRoot.CornerRadius = corners;
    _isoccupied = true;
}

public void Occupy(Brush background)
{
    LayoutRoot.Background = background;
    _isoccupied = true;
}

public void Clear()
{
    LayoutRoot.Background = GameField.Singleton.FieldBackground;
    _isoccupied = false;
}

这个过程通过两种方式管理。`LineManager` 可以逐行执行此操作,将给定行的每个方块向下移动一格。`Shape` 也可以执行此操作,在这种情况下,它会协调方块的重新配置以响应重力或旋转。让我们首先看一下 `Shape`。

形状

`Shape` 是方块掉落世界中所有可能形状的抽象基类。如前所述,它可以是 I、J、L、S、Z、T 或 O。如果你愿意,可以设计一些新形状。向 `GameField` 添加新形状就像以下代码一样简单:

SquareShape square = new SquareShape();
square.Background = new SolidColorBrush(Colors.Purple);
square.Left = 10;
square.Top = 0;
control_gamefield.AddShape(square);

在 `GameField` 内部,`AddShape` 看起来是这样的:

public void AddShape(Shape shape)
{
    if (Blocks[shape.Left, shape.Top].IsOccupied)
    {
        if (GameOver != null)
            GameOver();
        }
        else
        {
            ActiveShape = shape;
            ActiveShape.Wedged += (target_shape) =>
            {
                ActiveShape = null;
                if (ShapeWedged != null)
                    ShapeWedged();
            };                
            shape.Draw(this);
            shape.Initialize(this);
        }
    }

从这个例子中,你可以看到游戏的一些关键机制。首先是游戏结束的条件;当没有空间放置新添加的形状时(通过检查形状左上角方块的 `IsOccupied` 属性来指示),游戏就结束了。请注意,这不一定在 `GameField` 的顶部。在之前的列表中,我们看到 `SquareShape` 被放置在顶部,但这并非必须如此。`IsOccupied` 暴露了 `Block` 的私有字段 `_isoccupied`。如果确实有空间,新形状被设置为 `GameField` 的 `ActiveShape`,在形状上设置了一个 `Wedged` 事件,形状实际绘制在屏幕上,最后,形状被初始化。我们将从 `Draw` 开始讨论。

绘制/清除

`Draw` 是 `Shape` 类的一个抽象方法。对于 `Square` 类,`Draw` 看起来是这样的:

public override void Draw(IShapeRenderer field)
{
    field.Blocks[Left, Top].Occupy(this);
    field.Blocks[Left + 1, Top].Occupy(this);
    field.Blocks[Left, Bottom].Occupy(this);
    field.Blocks[Left + 1, Bottom].Occupy(this);
}

如你所见,这里的核心思想是针对给定形状在适当的方块上调用 `Occupy`。基于这个发现,很容易理解为什么 `Square` 随后的 `Clear` 会定义如下:

field.Blocks[Left, Top].Clear();
field.Blocks[Left + 1, Top].Clear();
field.Blocks[Left, Bottom].Clear();
field.Blocks[Left + 1, Bottom].Clear();

考虑到某些形状在旋转时会占据不同的方块,对于除 `Square` 以外的任何形状,形状对象都有一个 `ShapeAxis` 属性(表示形状当前旋转的角度)。对于 I 形(`Line` 类),它只有两种不同的表示形式,`Draw` 方法如下所示:

switch (this.ShapeAxis)
{
    case 0: case 180:
        field.Blocks[Left , Top].Occupy(this);
        field.Blocks[Left - 1, Top].Occupy(this);
        field.Blocks[Left + 1, Top].Occupy(this);
        field.Blocks[Left + 2, Top].Occupy(this);
        break;
    case 90: case 270:
        field.Blocks[Left, Top].Occupy(this);
        field.Blocks[Left, Top + 1].Occupy(this);
        field.Blocks[Left, Top + 2].Occupy(this);
        field.Blocks[Left, Top + 3].Occupy(this);
        break;
}

对于 L 形,它有每个轴的表示,`Draw` 如下所示:

switch (this.ShapeAxis)
{
    case 0:
        field.Blocks[Left, Top].Occupy(this);
        field.Blocks[Left - 1, Top].Occupy(this);
        field.Blocks[Left + 1, Top].Occupy(this);
        field.Blocks[Left + 1, Top - 1].Occupy(this);
        break;
    case 90:
        field.Blocks[Left, Top].Occupy(this);
        field.Blocks[Left, Top - 1].Occupy(this);
        field.Blocks[Left, Top + 1].Occupy(this);
        field.Blocks[Left + 1, Top + 1].Occupy(this);
        break;
    case 180:
        field.Blocks[Left, Top].Occupy(this);
        field.Blocks[Left - 1, Top ].Occupy(this);
        field.Blocks[Left - 1, Top + 1 ].Occupy(this);
        field.Blocks[Left + 1, Top].Occupy(this);
        break;
    case 270:
        field.Blocks[Left, Top].Occupy(this);
        field.Blocks[Left , Top - 1].Occupy(this);
        field.Blocks[Left - 1, Top - 1].Occupy(this);
        field.Blocks[Left, Top + 1].Occupy(this);
        break;                
}

初始化

基抽象类上的 `Initialize` 看起来像这样

public virtual void Initialize(GameField field)
{
    _timer_descent = new DispatcherTimer();
    _timer_descent.Interval = TimeSpan.FromSeconds(field.GameSpeed);
    _timer_descent.Tick += (sender, args) =>
    {
        Decend(field);
    };
    _timer_descent.Start();
}

这意味着 `Initialize` 的主要目的是启动与每个 `Shape` 关联的计时器,该计时器允许形状下落。将其定义为 `GameField` 的一部分更有意义,因为目前每个形状都使用 `GameField` 定义的通用游戏速度;但是,我选择这样做是作为一种扩展机制,例如,如果有人打算在游戏中提供质量。毕竟,可以设计一种新形状,其下落速度比 `GameSpeed` 快(或慢)某个因子。`Descend` 是由每个 `GameSpeed` 调用的函数,它在基类 `Shape` 中定义,看起来像这样

public virtual void Descend(GameField field)
{
    if (CanDescend(field))
    {
        ClearShape(field);
        Top += 1;
        DrawShape(field);
    }
    else
    {
        //stop the decent timer for now
        _timer_descent.Stop();
        Wedge(field);

        if (Wedged != null)
            Wedged(this);
    }
}

从上面的示例中可以看出,`Wedge` 事件在这里被触发。基本上,当形状不能再向下移动时,它会进入楔入状态,从而触发 `Wedged` 事件。`Descend` 总是调用抽象方法 `CanDescend`,该方法对于每种形状类型都有独特的定义。例如,Z 形的 `CanDescend` 定义如下:

if (_timer == null)
{
    _timer = new DispatcherTimer();
    _timer.Interval = TimeSpan.FromSeconds(GameField.Singleton.GameSpeed);
    _timer.Tick += (A, B) =>
    {
        for (int line = GameHeight - 1; line >= 0; line--)
        {
            LineManager manager = LineManagers[line];
            if (manager.IsFull())
            {
                manager.ClearBlocks();
                if (Score != null)
                    Score(ScoreIncrement);
                manager.ShiftDown();
            }
        }
    };
    _timer.Start();
}

行管理器

如果您重新检查之前的 `AddShape` 列表,您会注意到一旦形状对象被楔入,它就会被设置为 `null`(通过 `ActiveShape` 属性)。就 `GameField` 而言,不再有形状对象。方块仍然存在(事实上,由于没有在形状上调用 Clear,构成形状的单个方块仍然存在,这意味着您仍然会在屏幕上看到该形状)。这种行为与方块掉落游戏一致。问题是,当底部的方块消失后,谁来管理方块的持续下降呢?答案是 `LineManager` 类。如果您还记得上面 `GameField` 的 `Loaded` 列表,`LineManager` 是为 `GameField` 内部网格中的每一行创建的。`GameField` 的 `Loaded` 方法中与本次讨论相关的部分如下所示:

if (_timer == null)
{
    _timer = new DispatcherTimer();
    _timer.Interval = TimeSpan.FromSeconds(GameField.Singleton.GameSpeed);
    _timer.Tick += (A, B) =>
    {
        for (int line = GameHeight - 1; line >= 0; line--)
        {
            LineManager manager = LineManagers[line];
            if (manager.IsFull())
            {
                manager.ClearBlocks();
                if (Score != null)
                    Score(ScoreIncrement);
                manager.ShiftDown();
            }
        }
    };

    _timer.Start();
}

除了初始化游戏区域的边界,`Loaded` 还初始化并启动一个计时器,该计时器根据 `GameSpeed` 属性循环遍历每一行,调用 `IsFull`。如果该行确实已满,该行的管理器会清除该行上的所有方块,触发 `Score` 事件,并传入 `GameField` 的 `ScoreIncrement` 属性。完成后,将调用管理器的 `ShiftDown`。`ShiftDown` 的目的是将上方 `LineManager` 中的所有方块复制到当前 `LineManager`,然后在新行上重新绘制每个方块,有效地将上方行向下移动一行。

public bool ShiftDown()
{
    if (_blocks.Count == 0)
        return false;

    //copy the line above me
    if (LineManagers[_line_number - 1]._blocks.Count > 0)
    {
        ClearBlocks(); //suspect

        _blocks = new List<BlockInfo>(LineManagers[_line_number - 1]._blocks);

        //populate this line with the item above's blocks
        foreach (BlockInfo block_info in _blocks)
        {
            GameField.Singleton.Blocks[block_info.Left, 
                      _line_number].Occupy(block_info.BlockColor);
        }
    }
    else
    {               
        //clear all blocks
        foreach (BlockInfo block_info in _blocks)
        {
            GameField.Singleton.Blocks[block_info.Left, _line_number].Clear();
        }
        _blocks = new List<BlockInfo>( LineManagers[_line_number - 1]._blocks);
    }

    return LineManagers[_line_number - 1].ShiftDown();
}

从示例中可以看出,这是递归的,从清除的行开始,向上移动。

将方块注册到行管理器

最后要注意的是 `Shape` 上的抽象方法 `Wedge`。为了使 `LineManager` 功能正常工作,它必须预先知道任何给定行上有哪些方块以及这些方块占据什么位置。由于每个形状都不同,并且在其下降过程中占据不同的方块配置,因此当它最终停止时,此功能会延迟到它。我们已经展示了这发生在 `Descend` 函数中。当没有空间绘制形状时,执行以下代码:

//stop the decent timer for now
_timer_descent.Stop();

Wedge(field);

if (Wedged != null)
    Wedged(this);

如你所见,在 `Wedged` 事件被触发之前,会在 `Shape` 上调用 `Wedge`(导致实际形状的独特楔入实现被调用)。以下是 `Wedge` 在 `Square` 类中的定义:

field.LineManagers[Top].AddBlock(new BlockInfo
{
    Left = this.Left,
    BlockColor = this.Background,
});

field.LineManagers[Top].AddBlock(new BlockInfo
{
    Left = this.Right,
    BlockColor = this.Background,
});

field.LineManagers[Bottom].AddBlock(new BlockInfo
{
    Left = this.Left,
    BlockColor = this.Background,
});

field.LineManagers[Bottom].AddBlock(new BlockInfo
{
    Left = this.Right,
    BlockColor = this.Background,
});

整合起来

要相对快速地使用此库,实际有两种方法。第一种非常简单。在 Visual Studio 中打开一个 Silverlight 应用程序项目(请参阅“参考资料”以了解如何获取 Visual Studio 和 Silverlight 工具包)。打开后,将本文中的库 *Block.Silverlight.Library* 添加到您的项目中。现在,打开 *Page.xaml*。此时,它应该看起来像这样:

<UserControl x:Class="Blox.Silverlight.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
       >
    <Grid x:Name="LayoutRoot" Background="White">
    </Grid>
</UserControl>

接下来,在 *Page.xaml* 标记中添加一个 XAML 引用

xmlns:blox="clr-namespace:Blox.Silverlight;assembly=Blox.Silverlight"

我使用 `blox` 作为命名空间,但您可以使用任何名称。

现在,将以下内容添加到 Grid 中

<blox:SimpleTetrisGame />

您的最终列表应如下所示:

<UserControl x:Class="Blox.Silverlight.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    xmlns:blox="clr-namespace:Blox.Silverlight;assembly=Blox.Silverlight.Library"         
    >
    <Grid x:Name="LayoutRoot" Background="White">
        <blox:SimpleTetrisGame />
    </Grid>
</UserControl>

完成此操作后,构建并按下 F5。您应该会看到一个如下所示的屏幕:

这是简单的方块掉落游戏的屏幕。它代表了一个基本的方块掉落实现,包含关卡(通过获得 100 的倍数实现)。当然,这取决于我个人对填满一行得分的偏好。您可能想要不同的分数,或者为连续得分、回到第一行等给予更多分数。要使其更加“活跃”,您可以直接使用 `GameField` 控件。

<Border BorderThickness="2" BorderBrush="White" 
                            Margin="10,10,10,100" Grid.Column="0" >
    <blox:GameField x:Name="control_gamefield" GameHeight="20" 
          GameWidth="20"  GameSpeed=".5" ScoreIncrement="10" >
        <blox:GameField.FieldBackground>
            <SolidColorBrush Color="Black" Opacity=".85" />
        </blox:GameField.FieldBackground>
    </blox:GameField>
</Border>

如果您选择这样做,您将需要处理适当的事件,以使基本游戏功能正常运行。这是 `SimpleTetrisControl` 构造函数中游戏字段的使用方式:

control_gamefield.ShapeWedged += () =>
{
    //control_gamefield.AddShape(control_shape_preview.NextShape);
    LoadShape();
};

control_gamefield.GameOver += () =>
{
    txt_game_over_message.Text = "Game Over!";
    txt_final_score.Text = "Final Score:" +  Score.ToString();
    border_game_over.Visibility = Visibility.Visible;
};

control_gamefield.Score += (points) =>
{
    Score += points;
    txt_score.Text = Score.ToString();

    if (Score % 100 == 0)
    {
        switch (Score){
            case 100:
                control_gamefield.FieldStopDark.Color = Colors.DarkGray;
                break;
            case 200:
                control_gamefield.FieldStopDark.Color = Colors.LightGray;
                break;
            case 300:
                control_gamefield.FieldStopDark.Color = Colors.Yellow;
                break;
        }

        control_gamefield.GameSpeed -= .1;
    }
};

请查阅随附代码以获取更多详细信息。

结论

嘿,希望您喜欢剖析代码并创建新的、令人兴奋的俄罗斯方块实现。

© . All rights reserved.