跳转到内容

Smalltalk 的趣味学习 / 数字猜谜游戏

来自 Wikibooks,开放世界中的开放书籍

在本教程中,我们将创建一个“数字猜谜游戏”。

游戏规则如下:某人心中想一个随机数,你需要尽可能少地尝试次数猜出这个数字。

每次你猜测时,你会得到以下三种答案之一:

  1. “我的数字更大”
  2. “我的数字更小”
  3. “你猜对了!”

当你最终猜出数字时,游戏结束。

我们将从一个没有 UI 的版本开始,然后使它更具交互性。

第一个实现

[编辑 | 编辑源代码]

在我们的例子中,是计算机“思考”了这个随机数。但是,我们如何在 Smalltalk 中开始生成随机数呢?

通过阅读 Random 类的帮助文档,我们发现了这两种方便的用法

(6 to: 12) atRandom.
10 atRandom.

你可以在工作区中试一下。

让我们从创建一个 NumberGuessingGame 类开始。它应该有一个实例变量 number,用来保存玩家将尝试猜的随机数。

以下是类的模板

Object subclass: #NumberGuessingGame
  instanceVariableNames: 'number'
  classVariableNames: ''
  poolDictionaries: ''
  category: 'Smalltalk-Fun'

我们将如下实现初始化方法

initialize
  number := (1 to: 100) atRandom.

现在,我们将直接通过工作区与游戏进行交互。转到工作区,创建一个游戏实例

game := NumberGuessingGame new.

但是我们还不能玩!我们想进行猜测并接收答案。它应该像这样工作

game responseForGuess: 34.  "My number is bigger"
game responseForGuess: 70.  "My number is smaller"

该方法的一个简单的实现可能如下所示

responseForGuess: guess
  (number > guess) ifTrue: [^ 'My number is bigger'].
  (number < guess) ifTrue: [^ 'My number is smaller'].
  ^ 'You guessed right!'

现在游戏可以开始了。

在工作区中试一下,通过输入猜测并“打印”查看答案。

更好的交互

[编辑 | 编辑源代码]

虽然可以通过在工作区中打印消息发送来玩游戏,但这并不像它可能的那样吸引人。让我们让它变得更“交互式”。

对于基本的交互使用,例如显示警报或请求信息,可以使用 UIManager 类。系统中已经存在一个“默认”(单例)实例,可以随时使用。

首先,我们将让游戏向我们询问一个数字。请求输入的方法是使用 request: 方法。它将显示一个对话框,用户可以在其中输入一些文本。调用将返回该文本作为字符串。在工作区中尝试一下,并确保你“打印”了结果值

UIManager default request: 'What is your name?'.   "James Bond"

对话框如下所示

请求名称的对话框

其次,我们将让游戏在对话框中告诉我们响应是什么。这次方法是 inform:。扩展上面的例子,选择并评估这些语句

| name |
name := UIManager default request: 'What is your name?'.
UIManager default inform: 'Hello, ', name.

它应该像这样

显示消息的对话框

有了这些知识,我们可以实现我们相应的代码。一个用于请求数字猜测,另一个用于向玩家显示响应

askForGuess
  ^ (UIManager default request: 'What is your guess?') asNumber.

showResponseToGuess: guess
  | response |
  response := self responseForGuess: guess.
  UIManager default inform: response.

还记得 request: 的返回值是 String 类型吗?这就是为什么我们使用 asNumber 将其转换为数字。

看来我们可以一起使用这两个方法了。让我们在新的方法中这样做

play
  | guess |
  guess := self askForGuess.
  self showResponseToGuess: guess.

继续在工作区中试一下

game play.

以下是猜测输入的外观

请求猜测

以下是响应的外观

显示响应

游戏循环

[编辑 | 编辑源代码]

你可能已经注意到,我们的游戏很短暂。它只询问和响应一次。然后它停止了。

我们需要反复执行此操作,直到正确猜测到数字。在 Smalltalk 中重复执行语句块的方法恰恰是使用 Block 类。查看一下这个类,尤其是在“控制”类别中。

我们将使用 doWhileFalse: 方法来不断询问,直到猜测等于随机数。我们使用 doWhileFalse: 而不是 whileFalse:,因为我们希望我们的块至少执行一次,这样“猜测”就至少有一个初始值。

play 的新版本如下所示

play
  | guess |
  [ guess := self askForGuess.
  self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].

试一下

game play.

现在游戏循环已经就位。唯一的问题是游戏会一直“记住”初始的随机数。即使在你获胜之后。

当然,一个解决方案是在工作区中“重新初始化”游戏,如下所示

game initialize.

如果这作为我们游戏循环的一部分会更好... 但是究竟在哪里呢?让我们看看我们的选择

  • 在游戏开始之前吗?看起来不太对,因为我们在游戏初始化时已经选择了随机数。
  • 在游戏结束后吗?看起来也不太对,因为你可能想在获胜后询问随机数。

这似乎是一个两难选择。如果我们只是可以知道游戏是否获胜... 那么我们可以在开始时重新初始化游戏,只有在之前获胜的情况下。

我们只需要将这些信息添加到我们的游戏中。

添加游戏状态

[编辑 | 编辑源代码]

为了存储游戏状态,我们将引入一个新的实例变量。修改类使其看起来像这样

Object subclass: #NumberGuessingGame
  instanceVariableNames: 'number finished'
  classVariableNames: ''
  poolDictionaries: ''
  category: 'Smalltalk-Fun'

变量 finished 最初将为 false,当游戏获胜时将变为 true

相应地,将初始化方法更改为以下内容

initialize
  number := (1 to: 100) atRandom.
  finished := false.

现在我们已准备好对“游戏循环”进行必要的更改。

我们的第一个改变将是

play
  | guess |
  finished ifTrue: [self initialize].
  [ guess := self askForGuess.
  self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].

这将确保如果我们重新开始游戏,它将在必要时重新初始化自身。请记住,在初始化中,我们将 finished 设置回 false

但是,缺少一些东西:我们在游戏获胜后从未将“finished” 设置为 true!让我们这样做

play
  | guess |
  finished ifTrue: [self initialize].
  [ guess := self askForGuess.
  self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].
  finished := true.

重构游戏循环

[编辑 | 编辑源代码]

“play” 方法现在可以满足我们的要求。但是,意图在过多的低级细节中有所丢失。这与 Smalltalk 的做事方式背道而驰,可以使用一些重构。

概念分离

[编辑 | 编辑源代码]

我们将尝试将概念分离到不同的、意图清晰的方法中。但在我们这样做之前,请允许我修改 finished 的赋值,使其局限于重复的块中。你将在稍后看到原因

play
  | guess |
  finished ifTrue: [self initialize].
  [ guess := self askForGuess.
  self showResponseToGuess: guess.
  finished := (guess = number) ] doWhileFalse: [ finished ]

此更改的第一个结果是,我们可以切换到更直观的 whileFalse:(至少对于那些熟悉其他编程语言的人来说是这样)。由于我们不再在条件中使用“guess”,我们可以像这样重新组织我们的循环

play
  | guess |
  finished ifTrue: [self initialize].
  [ finished ] whileFalse: [ 
    guess := self askForGuess.
    self showResponseToGuess: guess.
    finished := (guess = number) ]

现在,让我们尝试找出我们的方法在做什么。我们可以得出以下结论

  1. 它确保游戏在必要时正确地重新初始化
  2. 它执行游戏交互的步骤
  3. 它在循环中重复这些步骤,直到游戏获胜

我们将对重新初始化和游戏交互进行重构,将其分成这些方法

reinitializeIfNeeded
  finished ifTrue: [ self initialize ]

doOneIteration
  | guess |
  guess := self askForGuess.
  self showResponseToGuess: guess.
  finished := (guess = number).

重构后的 play 版本如下所示

play
  self reinitializeIfNeeded.
  [ finished ] whileFalse: [ self doOneIteration ]

不同的抽象级别

[编辑 | 编辑源代码]

finished 同时出现在 doOneIterationplay 中感觉不太对劲。问题是我们混合了抽象级别:我们为用户交互、游戏迭代和重新初始化创建了很好的意图揭示方法,但我们仍然直接处理 finished 变量。这并不优雅,我们也可以改进它。

为此,我们创建以下方法

isFinished
  ^ finished 

beFinished
  finished := true.

并相应地修改调用位置

doOneIteration
  | guess |
  guess := self askForGuess.
  self showResponseToGuess: guess.
  (guess = number) ifTrue: [self beFinished ]

play
  self reinitializeIfNeeded.
  [ self isFinished ] whileFalse: [ self doOneIteration  ]

在你继续进行并尝试游戏之前,确保你最后一次在工作区重新初始化游戏实例

game initialize.

否则,当你尝试“玩”时会得到一个错误。这是因为我们引入了一个新的变量 (finished),它在我们的现有实例中保持未初始化 (值为 nil)。或者,你可以创建一个全新的实例

game := NumberGuessingGame new.

现在我们已经准备好再次玩游戏,我们可以玩任意多次。

尝试次数

[编辑 | 编辑源代码]

这个游戏的目标不仅是猜出一个数字,而且是“尽可能少地尝试”来猜出数字。

为了使游戏更具挑战性,我们可以在最后告诉用户尝试了多少次。让我们为此引入一个新的实例变量

Object subclass: #NumberGuessingGame
  instanceVariableNames: 'number finished tries'
  classVariableNames: ''
  poolDictionaries: ''
  category: 'Smalltalk-Fun'

不要忘记初始化它

initialize
  number := (1 to: 100) atRandom.
  finished := false.
  tries := 0.

每次玩家猜测时,这个计数器应该增加一。我们坚持我们不希望在没有揭示意图的情况下操作实例变量,所以我们创建了这个方法

increaseTries
  tries := tries + 1

并调用它

doOneIteration
  | guess |
  guess := self askForGuess.
  self showResponseToGuess: guess.
  self increaseTries.
  (guess = number) ifTrue: [self beFinished ]

最后的消息也需要改变。它应该提到尝试次数。

一种方法是将两个字符串连接起来

'Number of tries: ', tries asString.

另一种方法是使用 Stringformat: 方法

'Number of tries: {1}' format: {tries}.

再一种方法是使用流

'' writeStream
  nextPutAll: 'Number of tries: ';
  nextPutAll: tries;
  contents.

为了更好地说明,我们将最后两种方法结合起来。它看起来像这样

resposeForGuess: numberGuess
  number > numberGuess
    ifTrue: [ ^ 'My number is bigger' ].
  number < numberGuess
    ifTrue: [ ^ 'My number is smaller' ].
  ^ '' writeStream
      nextPutAll: 'You guessed right!';
      cr;
      nextPutAll: ('Number of tries: {1}' format: {tries});
      contents

它非常正确并且面向对象,但对于仅仅连接两个字符串和一个数字来说,它看起来相当复杂。

当然,那一部分也可以这样写

  ^ 'You guessed right!
Number of tries: {1}' format: {tries}.

这取决于你。

但是,如果你选择这个变体,你必须注意“尝试次数...”这句话确实在下一行的开头。这是因为在字符串文字中,Smalltalk 会遵守所有换行符、制表符等。在我们的例子中,我们有一个显式的换行符。

现在再次玩,看看你平均需要多少次尝试才能击败电脑。

现在显示尝试次数

玩得开心!

华夏公益教科书