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

使用 VB.NET 2013 完成 Windows 数独游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (33投票s)

2014年10月26日

GPL3

27分钟阅读

viewsIcon

67015

downloadIcon

3375

本文是一个关于如何使用 VB.NET 编写自己的数独游戏的教程。

引言

21世纪初,数独流行起来,我也开始玩数独。在玩了许多网页版、报纸版和 iOS 设备上的数独游戏后,我决定看看自己是否能编写一个。经过4天的努力,这就是成果。

这个项目演示了几个编程概念,如单例模式、共享代码、委托和事件、多线程以及 MVC 编程模式。

背景

基本的数独游戏在一个 9x9 的网格上进行,该网格又细分为 3x3 的小网格。游戏开始时,一些单元格已经填入了 1 到 9 的数字。游戏的目标是填满空白单元格,使每一行、每一列和每一个 3x3 小网格都只包含每个数字一次。这是一个示例谜题。

本文主要描述了游戏是如何组合在一起的,以及我在开发过程中做出的一些设计和代码决策的背景。

在编写游戏时,我以 MVC(即模型-视图-控制器)编程模式为指导。它用于将程序的各个部分分离成逻辑组件。模型包含数据。视图包含所有与 UI 相关的代码。控制器包含业务逻辑,并将模型和视图连接起来。视图不知道正在显示的数据的任何信息。同样,模型也不知道数据是如何被使用或显示的。通过分离代码的不同逻辑部分,更新和维护代码变得更加容易。同样,以相同的方式组织代码也使长期维护变得更容易。

下图显示了 MVC 模式的不同部分如何相互作用。

网上有几篇优秀的文章更详细地描述了这种编程模式。

Using the Code

该项目使用 VS 2013 编写。它完整且可以加载、编译和运行。

设计用户界面

我通过设计用户界面(UI,或视图)来开始这个项目。我可以使用一个 Panel 控件,然后在上面绘制 9x9 的网格和游戏。但这将需要大量的幕后代码来检查鼠标点击等等。我可以使用像其他人一样使用的 DataGridView 控件。或者创建我自己的自定义用户控件。但最终,我决定使用 TableLayoutPanel 作为基础网格。

我将 TableLayoutPanel 分成 3 行 3 列来表示外部网格。在每个单元格中,我添加了另一个 TableLayoutPanel,它又被分成 3 行 3 列来表示较小的 3x3 网格。在每个单元格中,我添加了一个 Label 控件,并将其 Dock 属性设置为 Fill。我这样做是因为 TableLayoutPanel 中的每个单元格只能接受一个控件。通过使用 TableLayoutPanel 并更改 CellBorderStyleBackground 颜色,它为棋盘提供了恰到好处的强调,以显示整个 9x9 网格以及 3x3 子网格。然后我添加了复选框、按钮等,以根据我希望游戏的外观和操作方式来完成 UI。下图是游戏 UI 在设计器视图中的样子。

在数独中,用户需要将值输入到空单元格中。有些游戏在主网格的顶部、底部或侧面使用一系列数字按钮。另一些则使用弹出窗口。我选择在我的游戏中采用后一种方法。这样,用户就不必为了输入值而将鼠标移动很远。

我向项目添加了一个新的 Form,然后添加了带有数字 19 的按钮。然后我通过设置 FormBorderStyle = none 来关闭边框。我这样做是因为当它放在主谜题上时,边框会显得笨重。因此,我在右上角添加了另一个带有“X”的按钮,以允许用户在不输入值的情况下关闭此窗口。这是它在设计器视图中的样子。

因为我想将此数字键盘放置在屏幕上,使其覆盖刚刚点击的单元格,所以将 StartPosition 属性设置为 Manual 非常重要。否则,当您第一次打开此表单时,Windows 将忽略任何定位此表单的尝试。

接下来是 UI 逻辑。基本上,每个按钮、复选框、标签等都有某种动作。在进行 UI 逻辑设计时,我在控制器中添加了存根代码和一些注释,以便以后知道该做什么。代码在开始时基本上是这样的。

在表单后面的代码中,我有这个

    Private Sub btnNew_Click(sender As Object, e As EventArgs) Handles btnNew.Click
        _clsGameController.NewButtonClicked()
    End Sub

然后在 Controller 类中,我有以下内容

    Friend Sub NewButtonClicked()
        ' Add code to process new game button command
    End Sub

为了完成视图,我在项目中又添加了两个窗体。一个是关于/帮助框,另一个在谜题完成时弹出。

一旦 UI 的基本代码编写完成,下一步就是弄清楚如何创建新的谜题。

生成新数独谜题

玩了许多不同的游戏后,我意识到一些商业数独游戏不会生成新的谜题。相反,它们加载了预先构建的谜题。一旦你玩完所有谜题,它们就会开始重复。过了一段时间,游戏就会变得索然无味。因此,为了实现这个目标,我希望这个游戏能够创建新的谜题。我很快发现,知道游戏怎么玩和实际创建一个新谜题是两回事。

第一步是弄清楚如何生成一个有效的数独谜题。事实证明,创建一个有效的数独谜题实际上非常简单。许多人编写了示例来演示如何做到这一点。在查看了不同的示例后,我将这个示例改编用于我的项目:

谜题创建的下一部分是根据游戏难度开始移除单元格。根据网上各种可用资源,这里有一个表格,对游戏难度级别进行了评级。

难度级别 给定数字数量
非常简单 50 - 60 个给定数字
简单 36 到 49 个给定数字
中等 32 到 35 个给定数字
困难 28 到 31 个给定数字
专家 22 到 27 个给定数字

有些人会争辩说,难度评级也应包括解决谜题所需的概念或技巧。并且所需技巧越多,评级越困难。因此,两个给定数字数量相同的谜题可能具有不同的评级。但这超出了本项目范围。

在较简单的级别上,我可以随意移除单元格,谜题仍然可以解决。但正如我在玩许多困难或专家级别的谜题后发现的,当移除的单元格太多时,就会出现一个点,必须开始猜测才能解决谜题。虽然有些人会争辩说这是游戏的一部分,但我认为猜测会削弱游戏的核心概念,即仅凭纯粹的逻辑来解决谜题,而猜测中没有逻辑可言。

所以,我必须在移除单元格时加入代码来解决谜题。一开始,我考虑了以下代码逻辑:

    Create puzzle
    Do
        Remove x number of cells
    Loop until puzzle is solvable

或者以流程图形式呈现

但我很快意识到,以下是移除单元格的更好方法

    Create puzzle
    Do
        Remove one cell
        Solve puzzle
    Loop while puzzle is solvable and there are more cells to remove

以及流程图形式

也就是说,当我一次移除一个单元格时,就解决这个谜题。这样,如果我刚刚移除的单元格使谜题无法解决,我就可以回溯并选择另一个单元格。

接下来是如何解决游戏。同样,有很多方法。有些人编写了暴力算法,另一些人使用约束编程,还有一些人使用高德纳的跳舞链接算法。在研究了不同的技术后,我决定实现高德纳的跳舞链接算法。网上有几篇优秀的文章,描述了该技术以及使用不同编程语言的实际实现。我将此版本整合到我的项目中:

当我在项目中打开 Option Strict 时,这段代码生成了几个警告和错误。修复它们都足够简单。

一旦我弄清楚如何创建一个新的数独谜题,下一个任务是如何管理实际的谜题生成。

谜题管理

简单级别的谜题生成时间不长。事实上,根本察觉不到。较难级别的谜题生成需要一些时间。为了获得积极的游戏体验,我不想让用户在程序生成新游戏时等待。为了解决这个问题,游戏将生成几个后台任务,为每个级别生成新游戏。每个级别将有 5 个游戏等待加载,当用户点击“新游戏”时。为了进一步改善用户体验,生成的游戏将在程序关闭时保存,以便下次用户运行程序时,预生成的游戏会加载,游戏立即可用。

毋庸置疑,当本项目首次加载时,为每个级别创建新游戏需要一些时间,因为没有预构建的游戏随项目保存。因此,任何后续加载/运行都将快得多,因为它有机会在后台构建一些谜题并保存它们。

实际上,我们并不需要为每个级别生成 5 个游戏,因为即使是专家级别,生成一个新游戏所需的时间也比用户解决它所需的时间要短。但为了以防用户在最终玩游戏之前决定多次点击“新游戏”,我们将为每个级别维护最多 5 个新游戏。

在 .NET 中启动后台线程非常容易。这是一个例子。首先我们需要将 System.Threading 命名空间添加到我们的代码中。

Imports System.Threading

实际启动线程的代码如下所示

    Friend Sub CreateNewGame()
        Dim tThread As New Thread(AddressOf GenerateNewGame)    ' Define a new thread
        tThread.IsBackground = True                             ' Set it as a background thread
        tThread.Start()                                         ' Start it
    End Sub

后台线程将执行的代码是这样的

    Private Sub GenerateNewGame()
        Dim uCells(,) As CellStateClass = GenerateNewBoard()
        Dim e As GameGeneratorEventArgs = New GameGeneratorEventArgs(uCells)
        RaiseEvent GameGeneratorEvent(Me, e)
    End Sub

此后台线程是自终止的。这意味着,一旦谜题生成并且事件引发,线程就会终止,因为没有其他事情可做。

还有其他时候,我们需要在程序打开时保持后台线程运行。在这种情况下,代码看起来会像下面这样

    Private _bStop as Boolean

    Private Sub GameMaker()
        _bStop = False
        Do
            ' Do something
        Loop Until _bStop
    End Sub

_bStop 变量允许控制器在用户关闭游戏时终止此后台任务。否则,代码将陷入无限循环。从技术上讲,我们不需要使用 _bStop 变量。当程序退出时,所有后台任务都会中止。但这并不是一个干净的退出方式,所以我们添加了 _bStop 变量。当应用程序关闭时,我们将 _bStop 变量设置为 True,以便循环可以正常退出。我们可以添加更多代码来检查后台任务是否在关闭应用程序之前正常退出,但这只是一个简单的游戏。但在更复杂的程序中,如果需要确保后台线程正常关闭,无论如何,请在关闭应用程序之前执行这些检查。一个例子是写入数据库的后台任务。当应用程序关闭时,我们希望确保所有待处理的数据库写入都在关闭连接之前完成。

使用后台线程的另一个原因是,这样在生成新谜题时,UI 不会被拖慢。

我使用这段代码来管理谜题生成任务以及管理已构建的谜题。当用户请求一个新谜题时,它会从队列中移除,并生成一个新的游戏创建线程。

我们可以让这个循环在用户玩游戏时一直运行,但是当这个线程没有什么可做的时候,为什么要消耗不必要的 CPU 周期呢?这可能会普遍降低计算机速度并影响整个游戏体验。

基本上,我们在这里想要做的是,一旦生成了所有 5 个谜题,就将循环置于某种暂停状态,直到下一次需要生成新谜题。为此,我们将使用 AutoResetEvent 类。这是类级别声明

    Private _MakeMoreGames As New AutoResetEvent(False)

AutoResetEvent 允许两个或多个线程相互发送信号。在上面的代码中,当我需要暂停后台线程时,线程会进行以下调用

    _MakeMoreGames.WaitOne()

当我需要唤醒线程时,我从另一个线程进行此调用。

    _MakeMoreGames.Set()

线程就是这样相互发送信号的。当一个谜题从谜题队列中移除时,我会在 AutoResetEvent 上调用 Set。这会向等待的后台线程发送信号,使其本质上唤醒并创建另一个谜题。

GameMaker 子例程的完整代码如下所示

    Private Sub GameMaker()
        Do
            Try
                SyncLock _objQLock
                    If _qGames Is Nothing Then
                        _qGames = New Queue(Of CellStateClass(,))
                    End If
                    If _qGames.Count < _cDepth Then
                        _clsGameGenerator.CreateNewGame()
                    End If
                End SyncLock
            Catch ex As Exception
                ' Process error
            End Try
            _MakeMoreGames.WaitOne()
        Loop Until _bStop
    End Sub

在循环开始时,代码会检查队列中是否有足够的游戏。如果没有,它会生成一个线程来创建一个新游戏。

    _clsGameGenerator.CreateNewGame()

线程一旦生成,就会进入休眠状态。

    _MakeMoreGames.WaitOne()

当新谜题创建完成后,事件代码如下所示

        Private Sub GameGeneratorEvent(sender As Object, e As GameGeneratorEventArgs) _
                    Handles _clsGameGenerator.GameGeneratorEvent
            SyncLock _objQLock
                If (_qGames Is Nothing) Then
                    _qGames = New Queue(Of CellStateClass(,))
                End If
                _qGames.Enqueue(e.Cells)
            End SyncLock
            _MakeMoreGames.Set()
        End Sub

一旦新谜题入队,它会向主循环发送信号以唤醒。

    _MakeMoreGames.Set()

然后,游戏管理线程将唤醒并检查是否需要创建更多游戏。通过使用 AutoResetEvent 类,主循环不需要一直运行。它在需要运行时才运行。

因为我正在创建一个多线程应用程序,所以在访问存储生成游戏的变量时保持线程安全非常重要。为了保持线程安全,我使用了 SyncLock 语句。简而言之,SyncLock 语句确保多个线程不会同时执行受保护的语句块。SyncLock 会阻止每个线程进入该块,直到没有其他线程正在执行它。

为了使 SyncLock 语句工作,我们需要声明一个类级别的 Object 变量。

    Private _objQLock as New Object

一旦声明了对象,我们就可以像这样使用 SyncLock 语句

    SyncLock _objQLock
        _qGames = New Queue(Of CellStateClass(,))
    End SyncLock

SyncLock .. End SyncLock 块保证无论代码如何退出该块,都将释放锁。即使在发生未处理的错误情况下也是如此。

显然,每当我们锁定 Game 队列时,都需要使用相同的 Object 变量。换句话说,对于我们需要保护的每个数据对象,它都应该有一个匹配的 Object 变量,以便与 SyncLock 语句一起使用。

另一种同步访问代码块的方法是使用 Mutex。这是使用 Mutex 的另一个示例。我们声明一个类级别的 Mutex 变量

    Private _mQueueMutex As New Mutex

使用方法如下

    _mQueueMutex.WaitOne()
    _qGames = New Queue(Of CellStateClass(,))
    _mQueueMutex.ReleaseMutex()

这种技术的问题是,如果第二行发生错误,那么第三行可能不会执行,并且 Mutex 将不会被释放。为了解决这个问题,代码应该被 Try ... Catch 语句包围。所以它看起来会像下面这样

    Try
        _mQueueMutex.WaitOne()
        _qGames = New Queue(Of CellStateClass(,))
    Catch ex As Exception
        Throw ex
    Finally
        _mQueueMutex.ReleaseMutex()
    End Try

或者,如果错误可以安全地忽略

    Try
        _mQueueMutex.WaitOne()
        _qGames = New Queue(Of CellStateClass(,))
    Finally
        _mQueueMutex.ReleaseMutex()
    End Try

ReleaseMutex 放在 Finally 块中,可以保证 Mutex 被释放。两种技术都有效,但使用 SyncLock 只有三行代码,看起来更简洁。

在编写游戏生成器的其余部分时,我遇到了另一个关于随机数生成器的问题。一般来说,随机数生成器并不是真正的随机。它看起来随机,但并非如此。它使用数学公式生成一系列数字,从统计学上讲,输出是“随机”的。在控制台项目中尝试以下代码:

    Dim rnd1 As New Random
    Console.Write("First sequence :")
    For I As Int32 = 1 To 10
        Console.Write("{0, 5}", rnd1.Next(100))
    Next
    Console.WriteLine()

    Dim rnd2 As New Random
    Console.Write("Second sequence:")
    For I As Int32 = 1 To 10
        Console.Write("{0, 5}", rnd2.Next(100))
    Next

它将输出以下内容

First sequence :   37   65   63   35   30    4   76   89   53    1
Second sequence:   37   65   63   35   30    4   76   89   53    1

如您所见,即使我们创建了两个不同的 Random 类实例,它也生成了相同的数字序列。多次运行它将生成与上面所示不同的数字序列,但两个序列仍将匹配。如果我们将此代码放入我们的游戏生成器中,它基本上会一遍又一遍地生成相同的游戏,这将非常无聊。

为了解决这个问题,Random 类允许我们用一个种子值来初始化随机数生成器。最常用的种子值是当前日期/时间或从午夜开始的秒数。为了进一步“随机化”种子值,我将 Ticks 与 10,000 进行模运算,如下所示。

    Dim tsp As New TimeSpan(DateTime.Now.Ticks)   ' Create a seed value 
                                                    ' using the current time.
   Dim iSeed As Int32 = _
        CInt((CLng(tsp.TotalMilliseconds * 10000) Mod Int32.MaxValue) Mod 10000)

这样,它将生成一个介于 0 到 9,999 之间的种子值,而不是一个只有低位数字不同的巨大数字。

下一个问题是如何实现这个随机代码,以确保在整个游戏中只使用它的一个实例。答案是使用单例模式。 单例模式是一种将类的实例化限制为单个对象的方法。有几种方法可以实现该模式,搜索网络将产生许多示例。

以下是实现单例模式的两种方法。第一种方法称为延迟实例化。之所以这样称呼,是因为该类在首次被调用执行某个操作时才被实例化。

Friend Class SingletonClass

    Private Shared _instance As SingletonClass
    Private Shared _objInstanceLock As New Object

    Private Sub New()
        ' Declared private so that no one can instantiate this class.
    End Sub

    Friend Shared Sub DoSomething()
        If _instance Is Nothing Then
            SyncLock _objInstanceLock
                If _instance Is Nothing Then
                    _instance = New SingletonClass
                    _instance.InitInstance()
                End If
            End SyncLock
        End If
        _instance.DoSomethingInstance()
    End Sub

    Private Sub DoSomethingInstance()
        ' Do some action
    End Sub

    Private Sub InitInstance()
        ' Initialize instance level variables
    End Sub

End Class

这是实现单例模式的另一种方式。这被称为懒惰实例化,因为该类在 GetInstance 函数首次被调用时实例化。在可以使用类的任何其他方法或属性之前,需要首先调用 GetInstance 属性。

Friend Class SingletonClass

    Private Shared _instance As SingletonClass
    Private Shared _objInstanceLock As New Object

    Private Sub New()
        ' Declared private so that no one can instantiate this class.
    End Sub

    Friend Shared ReadOnly Property GetInstance As SingletonClass
        Get
            If _instance Is Nothing Then
                SyncLock _objInstanceLock
                    If _instance Is Nothing Then
                        _instance = New SingletonClass
                        _instance.InitInstance()
                    End If
                End SyncLock                    
            End If
            Return _instance                
        End Get
    End Property

    Friend Sub DoSomething()
        ' Do some action in the instance
    End Function

    Private Sub InitInstance()
        ' Initialize instance level stuff.
    End Sub

End Class

在这两个例子中,New 构造函数都声明了 Private 修饰符。这有助于强制执行单例模式,因为它阻止其他人创建自己的实例。在网上搜索,可以找到关于两者区别以及何时使用它们的详细解释。

要使用第一个单例模式,我们只需这样调用它

    SingletonClass.DoSomething

要使用第二种模式,我们必须创建一个变量来保存实例。

    Dim instance as SingletonClass

然后我们需要在可以使用 Singleton 类中的函数之前获取一个实例。

    instance = SingletonClass.GetInstance
    instance.DoSomething

第一种方法看起来更简单,因为不必记住在使用之前先获取实例。但两种方法都有优缺点,网络上对此有详细讨论。

如果我们仔细查看第二个例子,有几个变量和方法带有 Shared 修饰符。 Shared 修饰符基本上表示函数或变量始终存在。如果没有该修饰符,它只会在创建实例时存在。一个 Shared 函数不能访问实例变量,但一个实例函数可以访问一个 Shared 函数或变量。

所以,在下面的代码中

    instance = SingletonClass.GetInstance
    instance.DoSomething

第一行通过调用 Shared 函数 GetInstance 加载类的实例。然后,在第二行,我们可以调用实例方法 DoSomething

有时程序中需要随处可用的代码。例如,检查索引变量以确保它在 19 之间。

    Friend Class Common

        Friend Shared Function IsValidIndex(iIndex As Int32) As Boolean
            Return ((1 <= iIndex) AndAlso (iIndex <= 9))
        End Function

        Friend Shared Function IsValidIndex(uIndex As CellIndex) As Boolean
            Return (IsValidIndex(uIndex.Col, uIndex.Row))
        End Function

        Friend Shared Function IsValidIndex(iCol As Int32, iRow As Int32) As Boolean
            Return (IsValidIndex(iCol) AndAlso IsValidIndex(iRow))
        End Function

        Friend Shared Function IsValidStateEnum(iState As Int32) As Boolean
            Return ((0 <= iState) AndAlso (iState <= 4))
        End Function

    End Class

有人会说这是过度杀戮,因为我们编写了这个游戏,所有东西都应该在 19 之间。这是真的,但如果你在一个大型项目中与一群人合作,强制执行这条规则会有帮助,以防有人没有收到关于索引限制的备忘录。然后可以扩展这个函数,如果索引超出有效范围,则抛出错误。这是一个例子

    Friend Shared Function IsValidIndex(iIndex As Int32) As Boolean
        If ((1 <= iIndex) AndAlso (iIndex <= 9)) Then
            Return True
        Else
            Throw New Exception("Index is outside of the valid range of 1 through 9.")
        End If
    End Function

如果我们看上面 Common 类,前三个函数都有相同的名称 IsValidIndex。这叫做方法重载,它是面向对象编程范式的一部分。我们使用相同的名称是因为它做相同的事情:它检查索引是否在 19(含)之间。这三个函数的区别在于参数列表。这叫做方法的签名。根据签名,编译器就知道要使用哪个函数。

最后,当一个游戏生成后,我们如何通知某人新游戏刚刚创建并将其排队到其他游戏中?有几种方法可以做到这一点。我可以编写代码,一旦游戏创建,就让同一个线程将其排队。但这有什么乐趣呢?此外,它可能会变得混乱,因为我们 then 需要将数据变量暴露给其他类并传递类实例...这肯定会是混乱的编码,并且维护起来是噩梦。

既然我正在使用后台线程来生成游戏,我认为最好使用事件和委托来完成这种通知。第一部分是声明一个委托和匹配的事件,如下所示

    Friend Delegate Sub GameGeneratorEventHandler(sender As Object, e As GameGeneratorEventArgs)
    Friend Event GameGeneratorEvent As GameGeneratorEventHandler

我也可以这样声明它

    Friend Delegate Sub GameGeneratorEventHandler(uCells(,) as CellStateClass)
    Friend Event GameGeneratorEvent As GameGeneratorEventHandler

区别在于 Delegate 声明的参数。但为了保持与 Windows Forms 控件相同的样式,我使用了第一种方法。这要求我创建自己的自定义 EventArgs 类。

    Friend Class GameGeneratorEventArgs
        Inherits EventArgs

        Private _uCells(,) As CellStateClass

        Friend ReadOnly Property Cells As CellStateClass(,)
            Get
                Return _uCells
            End Get
        End Property

        Friend Sub New(uCells As CellStateClass(,))
            _uCells = uCells
        End Sub

    End Class

注意类声明中使用了 Inherits EventArgs。因此,一旦新谜题生成,我就创建一个 EventArgs 对象并将其传递给 RaiseEvent 委托。

    Dim e As GameGeneratorEventArgs = New GameGeneratorEventArgs(uCells)
    RaiseEvent GameGeneratorEvent(Me, e)

另一方面,Game Manager 类包含以下声明和事件监听器

    Private WithEvents _clsGameGenerator As GameGenerator

    Private Sub GameGeneratorEvent(sender As Object, _
            e As GameGeneratorEventArgs) Handles _clsGameGenerator.GameGeneratorEvent
        ' Do something with the incoming event arguments.
    End Sub

后来,我在游戏中添加了一个计时器,这样用户就知道他/她解决谜题需要多长时间。计时器还使用事件和委托来告诉程序时间何时已过。

当计时器到期时,它会更新 UI 上的一个标签以指示已过去的时间。由于计时器在后台任务中运行,它不能直接更新标签。相反,它必须使用 Invoke 将调用传递到创建控件的正确线程。让我向您展示如何完成。首先,我们需要声明一个回调例程。

    Private Delegate Sub SetStatusCallback(sMsg As String)

然后,在设置 Label 文本的函数中,代码如下所示

    Private Sub SetStatusText(sMsg As String)
       If lblStatus.InvokeRequired Then
            Dim callback As New SetStatusCallback(AddressOf SetStatusText)
            Me.Invoke(callback, New Object() {sMsg})
        Else
            lblStatus.Text = sMsg
        End If
    End Sub

首先,我们通过在 Label 上调用 InvokeRequired 来检查 Label 是否需要被调用。如果为 true,则创建回调例程并 Invoke 它。否则,只需更新 Label 文本。

至此,我们已经创建了基本的 UI 以及游戏生成和管理代码。接下来是模型。

构建模型

模型基本上是存储游戏数据的地方。因此,当用户点击“新游戏”时,控制器将从游戏管理器中加载一个新游戏到模型中。控制器负责根据视图中的用户输入对模型数据进行更改。理论上,模型不应该包含任何业务逻辑或操作数据的代码。它只包含数据。然而,我做了一个小小的让步。

玩数独时,解决游戏的一个方面是在空单元格中输入笔记或铅笔标记。笔记基本上是特定单元格可能包含的数字。例如

在上面突出显示的单元格中,数字 149 是可能的答案。这是因为其他数字已经出现在此单元格所在的列或 3x3 网格中。为了帮助用户,程序将生成笔记。由于笔记是数据的一部分,我将生成笔记的代码放在 Model 类中。因此,当 Model 加载新游戏时,它首先会生成笔记。

程序在为用户生成笔记时将使用数独的基本规则。即,数字 19 在每一行、每一列或每个 3x3 网格中只能出现一次。如果一个数字出现在行、列或 3x3 网格中,它将被从笔记中消除。

控制器 (Controller)

一旦模型构建完成,最后一部分就是构建业务或游戏逻辑。所有这些都放入 Controller 类中。到目前为止,UI 所做的所有调用都只是指向 Controller 类中的存根代码。游戏逻辑实际上是项目中编写起来最简单的部分。

我只是编写了所有在我构建 UI 时生成的存根代码。挑战的部分是突出显示刚刚选择的单元格。为了直接在控件上绘制,我需要将控件的图形对象传递给 Controller。所以我不得不修改存根代码的参数。

这是在选定单元格周围绘制高亮边框的代码

    Private Sub DrawBorder(uSelectedCell As CellIndex, _
                           e As PaintEventArgs, bHighlight As Boolean)
        With _lLabels(uSelectedCell.Col, uSelectedCell.Row)
            Dim highlightPen As Pen
            If bHighlight Then
                highlightPen = New Pen(Brushes.CadetBlue, 4)
            Else
                highlightPen = New Pen(.BackColor, 4)
            End If
            e.Graphics.DrawRectangle(highlightPen, 0, 0, .Width, .Height)
            highlightPen.Dispose()
        End With
    End Sub

一旦我确定了画笔颜色,无论是高亮还是取消高亮,我都会在 Label 的边框周围画一个矩形。Pen 对象的宽度为 4 像素,因此很容易看到。

    e.Graphics.DrawRectangle(highlightPen, 0, 0, .Width, .Height)

请注意,我将 PaintEventArgs 传递给了此函数,因为 Graphics 对象就位于其中。这来自 Windows Forms 控件的 Paint 事件。

    Private Sub LabelA1_Paint(sender As Object, e As PaintEventArgs) Handles LabelA1.Paint
        _clsGameController.PaintCellEvent(1, 1, e)
    End Sub

当然,在这个函数和上面的 DrawBorder 函数之间还有其他几个函数。但重点是,我将 PaintEventArgs 传递给控制器,以防它需要直接在 Label 控件上绘制。

此外,在显示空单元格的注释时,我直接在控件上绘制它们,而不是设置 Label 控件的文本。

    Private Sub DrawNotes(iCol As Int32, iRow As Int32, e As PaintEventArgs)
        With _Model.Cell(iCol, iRow)
            If .HasNotes Then
                Dim drawFont As New Font("Arial", 8)
                For I As Int32 = 1 To 3
                    For J As Int32 = 1 To 3
                        Dim noteIndex As Int32 = J + ((I - 1) * 3)
                        If .Notes(noteIndex) Then
                            With _lLabels(iCol, iRow)
                                Dim X As Int32 = CInt((.Width / 3) * (J - 1))
                                Dim Y As Int32 = CInt((.Height / 3) * (I - 1))
                                e.Graphics.DrawString(noteIndex.ToString, _
                                       drawFont, Brushes.Black, X, Y)
                            End With
                        End If
                    Next
                Next
                drawFont.Dispose()
            End If
        End With
    End Sub

一旦我确定需要在单元格中绘制注释,我就会获取一个使用 Arial 字体、大小为 8Font 对象

    Dim drawFont As New Font("Arial", 8)

然后,我用以下方式计算出写入数字的 XY 坐标

    Dim X As Int32 = CInt((.Width / 3) * (J - 1))
    Dim Y As Int32 = CInt((.Height / 3) * (I - 1))

然后,我最终用以下这行代码绘制出数字

    e.Graphics.DrawString(noteIndex.ToString, drawFont, Brushes.Black, X, Y)

所有这些代码都存在于 Controller 类中。有人可能会争辩说,实际的绘图代码应该放在 View 中,因为那是 View 的功能。而 Controller 应该只包含游戏逻辑,因为那是 Controller 的功能。在这种情况下,Controller 的工作是进行所有计算,然后告诉 View 绘制什么以及在哪里绘制。也许在未来的版本中,我会这样做。

保存用户设置

在 VS 2013 中保存设置很容易。只需调出 Project 属性,点击左侧的设置选项卡,然后创建所有您需要的设置。

然后可以使用以下语法在代码中访问这些设置

    My.Settings.[setting name]

这是一个来自项目的示例

    cbDifficultyLevel.SelectedIndex = My.Settings.Level

在这里,我将组合框控件设置为保存的最后一个 Level 设置。

由于设置的范围设置为 User,因此设置将保存到以下位置

[user directory]\AppData\Local\SudokuPuzzle\
SudokuPuzzle.vshost.exe_Url_jnscgesaimzkgsbjhoygfwifnfxk1g51\1.0.0.0\user.config

由于 Windows 生成的随机字符串,实际目录名称在不同机器上会不同。但那是设置的通用位置。

[user directory]\AppData\Local\SudokuPuzzle\...

命名空间和总结

代码的最后一次添加是 Namespace 的使用。通过使用 Namespace,我可以将代码拆分为 MVC 模式的不同部分。因此,属于 Model 的代码将被以下内容包围

Namespace Model

    Friend Class GameModel
       ...
    End Class

End Namespace

要访问上面示例中的 GameModel 类,完全限定名为 SudokuPuzzle.Model.GameModel。使用 Namespace 有助于强制执行 MVC 编程模式。需要注意的是,Windows Forms 不喜欢被包含在 Namespace 中。 SudokuPuzzleNamespace 在应用程序属性中分配。

此外,程序的主要入口点是 Main 窗体。通常,在 MVC 模型中,Controller 是主要入口点。但对于这个项目,由于项目是作为 Windows Forms 应用程序创建的,我只是将主要入口点保留为 Main 窗体。

游戏运行时是这个样子

关注点

当我开始规划解决谜题的代码时,我以为我会以人类的方式来实现它。也就是说,通过使用几种技术来得出每个空单元格的答案。但随着我的进展,我发现那并不是解决谜题最有效的方法。所以我开始研究其他人解决这个问题的不同方法。其中一种让我印象深刻的技术是高德纳的舞蹈链算法。

我一直以为数独是在一个 9x9 的网格上玩的,这个网格又细分为 3x3 的小网格。但在我的研究中,我发现数独游戏有几种变体。也许将来,我会扩展这个游戏,加入其他一些变体,比如 4x4 的网格,甚至是 16x16 的网格。

从 .NET 开始,无论使用何种语言,数组都是基于零的。在我的代码中,我使用了 1 到 9 的索引,而不是 0 到 8,以使事情对我来说更简单。本质上,我忽略了数组中基于零的元素。

在规划网格时,我决定采用 Excel 的单元格寻址方式。也就是 [列][行]。它看起来是这样的:

        ' +--------+--------+--------+
        ' |11 21 31|41 51 61|71 81 91|
        ' |12 22 32|42 52 62|72 82 92|
        ' |13 23 33|43 53 63|73 83 93|
        ' +--------+--------+--------+
        ' |14 24 34|44 54 64|74 84 94|
        ' |15 25 35|45 55 65|75 85 95|
        ' |16 26 36|46 56 66|76 86 96|
        ' +--------+--------+--------+
        ' |17 27 37|47 57 67|77 87 97|
        ' |18 28 38|48 58 68|78 88 98|
        ' |19 29 39|49 59 69|79 89 99|
        ' +--------+--------+--------+

我是这样处理每个 3x3 网格的

        ' +--------+--------+--------+
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' |..  1 ..|..  2 ..|..  3 ..|
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' +--------+--------+--------+
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' |..  4 ..|..  5 ..|..  6 ..|
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' +--------+--------+--------+
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' |..  7 ..|..  8 ..|..  9 ..|
        ' |.. .. ..|.. .. ..|.. .. ..|
        ' +--------+--------+--------+

我曾考虑为 Model 编写一个自定义的 Collections 类。但为了保持简单,我决定不这样做。

循环遍历数组或数据列表时,很想使用 For Each。但是,有一个陷阱。元素只是原始的副本。因此,如果需要操作对象,则需要改用 For = 循环。例如:清空数组时,很想这样做:

    For Each Item As CellStateClass In _uCells
        Item = Nothing
    Next

但由于我们正在操作数组的实际元素,因此我们必须改用这种方式

    For I As Int32 = 0 To 80
        _uCells(I) = Nothing
    Next

正如单例模式和 SyncLock/Mutex 语句所演示的,编码解决方案没有对错之分。有时,这只是个人偏好的问题。这就是编程艺术发挥作用的地方。

多年前我参加的一次代码审查中,一位初级程序员写了这段代码

    Dim bFlag as Boolean

    ...
    Select Case bFlag
        Case True
            ' Do something
        Case Else
            ' Do something else
    End Select

我们花了一些时间讨论编写这种语句的优劣。不用说,这段代码通过了审查,因为从技术上讲,它确实有效。就我个人而言,我绝不会写那样的代码。对我来说,首选方法是

    If bFlag Then
        ' Do something
    Else
        ' Do something else
    End If

以下是此版本游戏中我省略的一些功能列表

  • 当“谜题完成”对话框弹出时,在背景中添加某种烟花显示。
  • 当用户在游戏中途退出时保存游戏状态,并在下次程序加载时恢复。
  • 当用户暂停游戏时,在隐藏游戏的空白面板上涂鸦一些东西。
  • 记录每个级别的前 10 个最佳时间。
  • 允许用户更改游戏的配色方案或外观。
  • 实现打印例程,以便用户可以打印出游戏网格。
  • 实现遮罩图案,使其在垂直轴上对称。
  • 使预生成的游戏数量可变。目前,它被硬编码为 5。
  • 在主屏幕右下角显示每个级别已生成的游戏数量。

历史

  • 2014-10-24:代码/文章首次发布
© . All rights reserved.