注册/登录

在iOS平台上开发猜数游戏

移动开发 iOS 移动应用
本文将把《猜数游戏》作为例子,为大家讲述在iOS平台上开发猜数游戏。裁判从1到100以内随机选择一个整数,然后让玩家猜测选择的是什么数。每次猜测后,如果猜错了,裁判会告诉玩家是猜大了还是猜小了,直到玩家猜出来。大家可以看看作者代码如何。

这些天终于戒掉了星际争霸2,开始学习iOS开发了。虽然还只是一知半解,但学了几天后,觉得单视图的iOS应用开发起来太轻松了,就忍不住想自己动手做点小玩意。

我也没有什么好的创意,只是偶然看到猜数的游戏,觉得用选取器这个控件很适合,就决定做了。

虽然这个游戏大多数人都玩过,不过我还是介绍下规则吧:裁判从1到100以内随机选择一个整数,然后让玩家猜测选择的是什么数。每次猜测后,如果猜错了,裁判会告诉玩家是猜大了还是猜小了,直到玩家猜出来。当然,用的次数越少就越好。如果用二分法的话,7次以内肯定能猜出来的。

而我要做的这个游戏中,裁判将由应用本身来担当。玩家只要在选取器里选定一个数,然后点击选择按钮,就会得知猜测的情况;同时选取器也自动更新,删除不符合的数据,避免玩家选择错误的数据。

接下来考虑界面。

它需要一个选取器来选数,需要一个选择按钮来确定所选的数,还需要一个重玩按钮来重置游戏。而在通知方面,我觉得猜错时可以直接用标签来告知玩家,而在猜中时则弹出一个确认对话框比较好。

于是就开工了,运行Xcode,创建一个GuessNumber项目,在GuessNumberViewController.h里声明控件变量。

  1. @interface GuessNumberViewController : UIViewController { 
  2.     UILabel *label; 
  3.     UIPickerView *picker; 
  4.  
  5. @property (nonatomic, retain) IBOutlet UILabel *label; 
  6. @property (nonatomic, retain) IBOutlet UIPickerView *picker; 
  7.  
  8. - (IBAction)chooseButtonPressed; 
  9. - (IBAction)resetButtonPressed; 
  10.  
  11. @end 

然后用Interface Builder画出这样一个界面出来,并与控件变量和行为连接起来:

再打开GuessNumberViewController.m,加上如下代码,准备工作就做完了:

  1. @implementation GuessNumberViewController 
  2.  
  3. @synthesize label; 
  4. @synthesize picker; 
  5.  
  6. - (void)viewDidUnload { 
  7.     self.label = nil; 
  8.     self.picker = nil; 
  9.  
  10. - (void)dealloc { 
  11.     [label release]; 
  12.     [picker release]; 
  13.     [super dealloc]; 
  14.  
  15. @end 

不过这个程序还只是个空壳,还得为它写实现逻辑。先看UIPickerView。翻看SDK文档,发现它并不能直接设置和显示数据,而是用UIPickerViewDelegate和UIPickerViewDataSource这2个协议来完成的。简化起见,我就没创建一个模型类了,而是直接让GuessNumberViewController来实现了:

  1. @interface GuessNumberViewController : UIViewController 
  2. <UIPickerViewDelegate, UIPickerViewDataSource> 

UIPickerViewDelegate主要需要实现这2个方法中的一个:

◆pickerView:titleForRow:forComponent:

◆pickerView:viewForRow:forComponent:reusingView:

前者是直接让每行显示一个字符串,而后者是每行显示一个视图,那自然是前者更方便了。

可是每行究竟要显示什么数据呢?看上去可以用一个数组来保存现有的数,然后直接将行号作为数组的索引来获取即可。

可让我诧异的是创建数组时,居然没有Python里range这样方便的函数,于是得自己写个方法来实现了:

  1. + (NSMutableArray *)makeArrayFrom:(NSInteger)begin to:(NSInteger)end { 
  2.     NSMutableArray *array = [[[NSMutableArray alloc]initWithCapacity:end - begin + 1]autorelease]; 
  3.     for (NSInteger i = begin; i <= end; ++i) { 
  4.         [array addObject:[NSString stringWithFormat:@"%d", i]]; 
  5.     } 
  6.     return array; 

这样的实现让我很担心性能,觉得还不如C的数组好用。而且NSMutableArray为什么是NSArray的子类啊,有可能接收到一个NSArray对象,以为它是不变的,结果使用时却莫名其妙地变了啊!有木有!难道接收到后每次都copy一份不影响性能吗?可这货毕竟是标准库里的,你得逼自己接受它…(好戏还在后头)

考虑到这样创建很成问题,于是决定事先创建好一个完整的数组,然后每次要用时就copy一份。便给GuessNumberViewController加上2个私有变量NSMutableArray *pickerData和NSMutableArray *totalData,并在viewDidLoad方法中初始化它们。

  1. #define MAX_NUMBER 100 
  2.  
  3. - (void)viewDidLoad { 
  4.     self.totalData = [GuessNumberViewController makeArrayFrom:1 to:MAX_NUMBER]; 
  5.     [self resetGame]; 
  6.     [super viewDidLoad]; 
  7.  
  8. - (void)resetGame { 
  9.     NSMutableArray *totalDataCopy = [totalData mutableCopy]; 
  10.     self.pickerData = totalDataCopy; 
  11.     [totalDataCopy release]; 
  12.  
  13. - (void)viewDidUnload { 
  14.     self.label = nil; 
  15.     self.picker = nil; 
  16.     self.pickerData = nil; 
  17.     self.totalData = nil; 
  18.  
  19. - (void)dealloc { 
  20.     [label release]; 
  21.     [picker release]; 
  22.     [pickerData release]; 
  23.     [totalData release]; 
  24.     [super dealloc]; 

数据准备好后,就可以实现– pickerView:titleForRow:forComponent:了:

  1. - (NSString *)pickerView:(UIPickerView *)pickerView 
  2.            titleForRow:(NSInteger)row 
  3.           forComponent:(NSInteger)component { 
  4.     return [pickerData objectAtIndex:row]; 

再来看看UIPickerViewDataSource,这个协议也有2个方法:

◆numberOfComponentsInPickerView:

◆pickerView:numberOfRowsInComponent:

前者返回这个选择器由几个部分组成,这里我只需要一组即可;后者返回每个部分有多少行,其实也就是pickerData的长度而已:

  1. - (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView { 
  2.     return 1; 
  3.  
  4. - (NSInteger)pickerView:(UIPickerView *)pickerView 
  5. numberOfRowsInComponent:(NSInteger)component { 
  6.     return [pickerData count]; 

这时候NSArray又囧到我了。那个count方法说明了count不是个属性,因此数组的长度并没有用变量来保存。事后我也查了下,发现是以nil来判断数组结尾的,这效率对长数组来说绝对是个灾难。

现在选择器的逻辑已经实现了,此时测试应该可以看到一个包含1~100的选择器了,不过游戏的逻辑还并没实现。

于是再给GuessNumberViewController加上NSInteger guessedTimes和NSInteger secretNumber,分别用于保存玩家猜的数和随机数。

接着修改resetGame的逻辑来初始化它们:

  1. - (void)resetGame { 
  2.     guessedTimes = 0; 
  3.     secretNumber = arc4random() % MAX_NUMBER + 1; 
  4.     NSMutableArray *totalDataCopy = [totalData mutableCopy]; 
  5.     self.pickerData = totalDataCopy; 
  6.     [totalDataCopy release]; 
  7.     [picker reloadComponent:0]; 

然后就是重点的chooseButtonPressed方法了,这个方法中需要删除NSMutableArray的一部分,而且肯定是在头或尾部删除。查了下SDK文档,适合这种批量删除的方法有:

◆removeObjectsAtIndexes:

◆removeObjectsInArray:

◆removeObjectsInRange:

◆removeObjectsFromIndices:numIndices:

看上去很多是吧,别急,一个一个来看。

◆ removeObjectsAtIndexes:这个方法接收一个NSIndexSet参数。而NSIndexSet的创建和NSMutableArray差不多,也就是得循环生成,放弃。

◆removeObjectsInArray:就更直接了,直接接收一个NSArray参数。为了删除一个数组的一部分而去创建另一个数组,有病啊?放弃。

◆removeObjectsInRange:接收一个NSRange参数。NSRange是一个结构,包括location和length这2个字段,看上去这就是我想要的。它实际上是用removeObjectAtIndex:来删除对象的,所以自己写循环来删除会更快。

◆removeObjectsFromIndices:numIndices:已经被deprecated了,放弃。考虑到自己写循环太麻烦,所以还是将就着使用– removeObjectsInRange:了,实现的算法我就不解释了:

  1. - (void)alertWithMessage:(NSString *)message { 
  2.     UIAlertView *alert = [[UIAlertView alloc] 
  3.                           initWithTitle:nil 
  4.                           message:message 
  5.                           delegate:nil 
  6.                           cancelButtonTitle:@"确定" 
  7.                           otherButtonTitles:nil]; 
  8.     [alert show]; 
  9.     [alert release]; 
  10.  
  11. - (IBAction)chooseButtonPressed { 
  12.     ++guessedTimes; 
  13.     NSInteger row = [picker selectedRowInComponent:0]; 
  14.     NSString *selected = [pickerData objectAtIndex:row]; 
  15.     NSInteger selectedNumber = [selected integerValue]; 
  16.     NSInteger cutIndex; 
  17.      
  18.     if (selectedNumber == secretNumber) { 
  19.         [self alertWithMessage:[NSString stringWithFormat:@"你猜中了!"]]; 
  20.         [self resetGame]; 
  21.     } else { 
  22.         cutIndex = [pickerData indexOfObject:[NSString stringWithFormat:@"%d", selectedNumber]]; 
  23.         if (selectedNumber > secretNumber) { 
  24.             label.text = [NSString stringWithFormat:@"第%d次猜数,你猜得太大了。", guessedTimes]; 
  25.             [pickerData removeObjectsInRange:NSMakeRange(cutIndex, [pickerData count] - cutIndex)]; 
  26.         } else { 
  27.             label.text = [NSString stringWithFormat:@"第%d次猜数,你猜得太小了。", guessedTimes]; 
  28.             [pickerData removeObjectsInRange:NSMakeRange(0, cutIndex + 1)]; 
  29.         } 
  30.     } 
  31.     [picker reloadComponent:0]; 

***别忘了resetButtonPressed,它只是调用resetGame方法而已:

  1. - (IBAction)resetButtonPressed { 
  2.     [self resetGame]; 

现在就可以开玩了,效果如下:

看上去iOS开发的确很简单,只是Objective-C恶心了一点而已。

不过别高兴得太早,这篇文章还没完成一半呢。玩了一会后我就立刻感到不爽了:从100个数里找到想要选择的数太难了。

如果把选取器拆成2个部分,分别选择十位和个位就会方便多了。不过这样一来就不能猜1~100了,而应该猜0~99;MAX_NUMBER这个名字也不再合适,应该改成TOTAL_NUMBERS。

除此之外,如果继续用数组实现的话,我得保存1个十位的数组和10个个位的数组,这样维护起来太头疼了。好在数据和UIPickerView并没有绑定起来,可以自己实现取数逻辑,所以干脆保存当前最小和***的数,然后计算出十位和个位得了。

于是再给GuessNumberViewController加上NSInteger beginNumber和NSInteger endNumber这2个私有变量,然后开始修改取数逻辑:

  1. - (NSString *)pickerView:(UIPickerView *)pickerView 
  2.              titleForRow:(NSInteger)row 
  3.             forComponent:(NSInteger)component { 
  4.     if (component == 0) { 
  5.         return [NSString stringWithFormat:@"%d", beginNumber / 10 + row]; 
  6.     } 
  7.      
  8.     if ([picker selectedRowInComponent:0] == 0) { 
  9.         return [NSString stringWithFormat:@"%d", beginNumber % 10 + row]; 
  10.     } 
  11.      
  12.     return [NSString stringWithFormat:@"%d", row]; 
  13.  } 
  14.  
  15. - (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView { 
  16.     return 2; 
  17.  
  18. - (NSInteger)pickerView:(UIPickerView *)pickerView 
  19. numberOfRowsInComponent:(NSInteger)component { 
  20.     NSInteger unitsDigitOfBeginNumber = beginNumber % 10; 
  21.     NSInteger unitsDigitOfEndNumber = endNumber % 10; 
  22.     NSInteger tenthsDigitOfBeginNumber = beginNumber / 10; 
  23.     NSInteger tenthsDigitOfEndNumber = endNumber / 10; 
  24.     NSInteger differenceBetweenTenthsDigits = tenthsDigitOfEndNumber - tenthsDigitOfBeginNumber; 
  25.     NSInteger rowOfTenthsPlace; 
  26.      
  27.     if (component == 0) { 
  28.         return differenceBetweenTenthsDigits + 1; 
  29.     } 
  30.      
  31.     rowOfTenthsPlace = [picker selectedRowInComponent:0]; 
  32.     if (rowOfTenthsPlace == 0) { 
  33.         if (differenceBetweenTenthsDigits == 0) { 
  34.             return unitsDigitOfEndNumber - unitsDigitOfBeginNumber + 1; 
  35.         } 
  36.         return 10 - unitsDigitOfBeginNumber; 
  37.     } 
  38.      
  39.     if (rowOfTenthsPlace == differenceBetweenTenthsDigits) { 
  40.         return unitsDigitOfEndNumber + 1; 
  41.     } 
  42.      
  43.     return 10; 

这里的逻辑比刚才复杂多了,不过慢慢看应该能看懂的,我也就不解释了。接下来就是chooseButtonPressed的逻辑了,这次它只要更改beginNumber和endNumber,不需要维护数组了。

  1. - (IBAction)chooseButtonPressed { 
  2.     ++guessedTimes; 
  3.      
  4.     NSInteger tenthsPlaceRow = [picker selectedRowInComponent:0]; 
  5.     NSInteger unitsPlaceRow = [picker selectedRowInComponent:1]; 
  6.      
  7.     NSString *tenthsDigitString = [self pickerView:picker titleForRow:tenthsPlaceRow forComponent:0]; 
  8.     NSString *unitsDigitString = [self pickerView:picker titleForRow:unitsPlaceRow forComponent:1]; 
  9.      
  10.     NSInteger tenthsDigit = [tenthsDigitString integerValue]; 
  11.     NSInteger unitsDigit = [unitsDigitString integerValue]; 
  12.      
  13.     NSInteger selectedNumber = tenthsDigit * 10 + unitsDigit; 
  14.      
  15.     if (selectedNumber == secretNumber) { 
  16.         [self alertWithMessage:[NSString stringWithFormat:@"你猜中了!"]]; 
  17.         [self resetGame]; 
  18.     } else if (selectedNumber > secretNumber) { 
  19.         statusLabel.text = [NSString stringWithFormat:@"第%d次猜数,你猜得太大了。", guessedTimes]; 
  20.         endNumber = selectedNumber - 1; 
  21.     } else { 
  22.         statusLabel.text = [NSString stringWithFormat:@"第%d次猜数,你猜得太小了。", guessedTimes]; 
  23.         beginNumber = selectedNumber + 1; 
  24.     } 
  25.     [picker reloadAllComponents]; 

而重置的代码也得改改:

  1. - (void)resetGame { 
  2.     guessedTimes = 0; 
  3.     secretNumber = arc4random() % TOTAL_NUMBERS; 
  4.     beginNumber = 0; 
  5.     endNumber = TOTAL_NUMBERS - 1; 
  6.     label.text = @""
  7.     [picker reloadAllComponents]; 

现在再运行一下,会发现一堆bug。最严重的一个就是选择的十位数改变时,个位不会相应地改变。好在UIPickerViewDelegate协议提供了pickerView:didSelectRow:inComponent:方法,只要在十位改变时,重新载入个位的数据即可:

  1. - (void)pickerView:(UIPickerView *)pickerView didSelectRow:(NSInteger)row inComponent:(NSInteger)component { 
  2.     if (component == 0) { 
  3.         [picker reloadComponent:1]; 
  4.     } 

此外就是按了选择按钮后,个位数有时候会超过9。调试了一番后我发现是[picker reloadAllComponents]这行代码的问题,它会先reload部件1,再reload部件0,而我的代码逻辑中,部件1的数据(个位)是依赖于部件0(十位)的,于是就出错了。解决办法很简单,依次reload即可:

  1. [picker reloadComponent:0]; 
  2. [picker reloadComponent:1]; 

现在bug是搞定了,可是有个问题不太爽:游戏结束时弹出确认对话框,我还没点确定,游戏就已经重置了,有点不符合习惯。好在UIAlertView有个delegate属性,它的– alertView:didDismissWithButtonIndex:方法就可以延缓重置时机了。于是老办法,给GuessNumberViewController实现UIAlertViewDelegate协议,然后进行如下修改:

  1. - (void)alertWithMessage:(NSString *)message { 
  2.     UIAlertView *alert = [[UIAlertView alloc] 
  3.                           initWithTitle:nil 
  4.                           message:message 
  5.                           delegate:self 
  6.                           cancelButtonTitle:@"再玩一次" 
  7.                           otherButtonTitles:nil]; 
  8.     [alert show]; 
  9.     [alert release]; 
  10.  
  11. - (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex { 
  12.     [self resetGame]; 

再把调用alertWithMessage下一行的[self resetGame]删掉即可。接下来还有什么问题呢?结束时的提示太无聊了,没动力继续玩下去,于是再改改chooseButtonPressed:

  1. if (selectedNumber == secretNumber) { 
  2.     if (guessedTimes < 4) { 
  3.         [self alertWithMessage:[NSString stringWithFormat:@"哼,人家才不告诉你%d次就猜中是很稀罕的呢!", guessedTimes]]; 
  4.     } else if (guessedTimes < 8) { 
  5.         [self alertWithMessage:[NSString stringWithFormat:@"别多想啦,%d次猜中是很正常的啦,继续加油吧!", guessedTimes]]; 
  6.     } else { 
  7.         [self alertWithMessage:[NSString stringWithFormat:@"笨蛋,%d次才猜中,你有没有用心在猜啊!", guessedTimes]]; 
  8.     } 

现在如何呢?还有个很大的问题——数字是左对齐的,应该弄成居中对齐的啊!可是翻了一遍文档,确实没找到哪里可以设置对齐属性。在网上搜了一阵,发现需要自己创建UILabel作为每行的视图,然后设置UILabel的对齐属性:

  1. - (UIView *)pickerView:(UIPickerView *)pickerView 
  2.             viewForRow:(NSInteger)row 
  3.           forComponent:(NSInteger)component 
  4.            reusingView:(UIView *)view { 
  5.     UILabel *digitLabel; 
  6.     if (view) { 
  7.         digitLabel = (PickerViewLabel *)view; 
  8.     } else { 
  9.         digitLabel = [[[UILabel alloc] initWithFrame:CGRectMake(0.0f, 0.0f, [pickerView rowSizeForComponent:component].width, [pickerView rowSizeForComponent:component].height)] autorelease]; 
  10.     } 
  11.      
  12.     NSString *title; 
  13.     if (component == 0) { 
  14.         title = [NSString stringWithFormat:@"%d", beginNumber / 10 + row]; 
  15.     } else if ([picker selectedRowInComponent:0] == 0) { 
  16.         title = [NSString stringWithFormat:@"%d", beginNumber % 10 + row]; 
  17.     } else { 
  18.         title = [NSString stringWithFormat:@"%d", row]; 
  19.     } 
  20.  
  21.     digitLabel.text = title; 
  22.     digitLabel.textAlignment = UITextAlignmentCenter; 
  23.     return digitLabel; 

这样一来就有很多label了,所以原来的label就改名为statusLabel以作区分吧。而chooseButtonPressed中也需要更改数值的获取方法

  1. NSString *tenthsDigitString = ((UILabel *)[picker viewForRow:tenthsPlaceRow forComponent:0]).text; 
  2. NSString *unitsDigitString = ((UILabel *)[picker viewForRow:unitsPlaceRow forComponent:1]).text; 

改完后立刻发现背景不对劲,变成白色的了。于是去掉digitLabel的背景色,顺便将文本设为粗体:

  1. digitLabel.backgroundColor = [UIColor clearColor]; 
  2. digitLabel.font = [UIFont boldSystemFontOfSize:24.0]; 

现在是否OK了呢?不,你会发现点击一行时,还会出现蓝色的高亮背景。这现象是怎么产生的呢?原来UIPickerView是用UITableView实现的,而UITableCell在选中时默认会高亮。

简单的解决办法就是设置digitLabel.userInteractionEnabled = YES,这样一来digitLabel就拦截了点击事件,不会传递给UITableCell了。

可是这个办法仍然有问题:默认的UIPickerView在点击一行时,会滚动定位到这行;而拦截了事件后,自动滚动的功能也就没了。

于是更好的办法就是在点击时获取这个UITableCell,将它设为不高亮。然而坑爹的是UITableCell属于私有API,SDK文档里找不到资料,调用它的方法得使用performSelector方法。

为此需要自定义一个PickerViewLabel类:

  1. @interface PickerViewLabel : UILabel { 
  2.  
  3.  
  4. @end 
  5.  
  6. @implementation PickerViewLabel 
  7.  
  8. - (void)didMoveToSuperview { 
  9.     UIView *superview = [self superview]; 
  10.     if ([superview respondsToSelector:@selector(setShowSelection:)]) { 
  11.         [superview performSelector:@selector(setShowSelection:) withObject:NO]; 
  12.     } 
  13.  
  14. @end 

这个didMoveToSuperview方法的名字很囧,它其实是在父视图改变时被调用的。

因为高亮也是改变的一种,所以它就会被调用了。拿到父视图后并不能判断它是否是UITableCell类的对象,因为我们没有UITableCell的声明头文件。于是通过respondsToSelector判断它是否能调用setShowSelection:,再通过performSelector调用这个方法。

现在总该可以了吧?不,还有个问题:重新载入UIPickerView的组件时,有时会停在莫名其妙的一行上。于是在resetGame的末尾加上:

  1. [picker selectRow:0 inComponent:0 animated:NO]; 
  2. [picker selectRow:0 inComponent:1 animated:NO]; 

同时也加在chooseButtonPressed的第三种里:

  1. // ... 
  2. else { 
  3.     statusLabel.text = [NSString stringWithFormat:@"第%d次猜数,你猜得太小了。", guessedTimes]; 
  4.     beginNumber = selectedNumber + 1; 
  5.     [picker selectRow:0 inComponent:0 animated:NO]; 
  6.     [picker selectRow:0 inComponent:1 animated:NO]; 

好了,最终效果如下:

虽然按钮的样式还能改改,不过必须用到背景图,我就懒得找图了。至于还能改进的地方,我觉得就是太不耐玩了。如果加入联机对战模式或许就大不一样了,2个玩家可以比拼谁先猜出来,就可以获得更多乐趣了。当然,这玩意犯不着搞那么复杂的东东出来,反正也就自己做着玩而已。***想说的是,iOS开发难在细节。它看上去很简单,但很多细枝末节的方面若想精益求精,就不得不耗费苦心。一是标准库并不好用,二是SDK设计得并不周到,三是你不得不用到私有API。

责任编辑:佚名 keakon的涂鸦馆
点赞
收藏