斯坦福大学IOS7开发课程3

斯坦福大学IOS7开发课程3

课程3主要内容是继续之前的纸牌游戏。上节课实现的纸牌游戏App只是实现了简单的纸牌翻转,这节课使其变得真正可玩。

主要分为三个部分:

  1. 先回顾上节课编写的App
  2. 对上节课的App改进
  3. 使游戏变得可玩

上节课实现的纸牌翻转App

  1. 将背景颜色设置为绿色

    打开Main.storyboard,选中View后,在属性栏里将Background的颜色设置为绿色。

加载失败
  1. 添加纸牌正反面的图片

    在网上获取纸牌正反面图片,比如:

加载失败 加载失败

选中Assets.xcassets,将两张图片拖至其中。

加载失败
  1. 添加按钮,并将按钮的背景图片改为纸牌正面图片(CardFront), 文字改为“♠️A”
加载失败
  1. 添加一个Label,显示纸牌翻转的次数

  2. 为按钮添加“Touch Up Inside”事件,命名为touchCardButton

  3. LabelViewController.m@interface ViewController() @end之间添加“Referencing Outlets“,命名为flipsLabel

加载失败
  1. 另外,在ViewController.m@interface ViewController() @end之间添加int属性flipCount

  2. ViewController.m中实现touchCardButton事件函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    - (IBAction)touchCardButton:(UIButton *)sender {
    if ([sender.currentTitle length]) {
    [sender setBackgroundImage:[UIImage imageNamed:@"CardBack"] forState:UIControlStateNormal];
    [sender setTitle:@"" forState:UIControlStateNormal];
    } else {
    Card *card = [self.deck drawRandomCard];
    if(card) {
    [sender setBackgroundImage:[UIImage imageNamed:@"CardFront"] forState:UIControlStateNormal];
    [sender setTitle:@"A♣️" forState:UIControlStateNormal];
    }
    }
    self.flipCount++;
    }
  3. 重构属性flipCountsetter函数。

    1
    2
    3
    4
    5
    - (void)setFlipCount:(int)flipCount
    {
    _flipCount = flipCount;
    self.flipsLabel.text = [NSString stringWithFormat:@"Flips: %d",self.flipCount];
    }

此时App完成,代码如下:

1
2
3
4
5
6
7
8
// ViewController.h

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
- (IBAction)touchCardButton:(UIButton *)sender;

@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ViewController.m
#import "ViewController.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UILabel *flipsLabel;
@property (nonatomic) int flipCount;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}

- (IBAction)touchCardButton:(UIButton *)sender {
if ([sender.currentTitle length]) {
[sender setBackgroundImage:[UIImage imageNamed:@"CardBack"] forState:UIControlStateNormal];
[sender setTitle:@"" forState:UIControlStateNormal];
} else {
[sender setBackgroundImage:[UIImage imageNamed:@"CardFront"] forState:UIControlStateNormal];
[sender setTitle:@"A♣️" forState:UIControlStateNormal];
}
self.flipCount++;
}

- (void)setFlipCount:(int)flipCount
{
_flipCount = flipCount;
self.flipsLabel.text = [NSString stringWithFormat:@"Flips: %d",self.flipCount];
}
@end

最终效果如下:

加载失败

对该App改进

上节课完成的App只能实现纸牌的翻转,每次翻转显示的纸牌都是同一张。那么如何让每次翻转,随机显示牌堆中的一张纸牌呢?其实也很简单,方法如下:

  1. ViewController.m中引入Deck.h

  2. ViewController.m@interface ViewController() @end之间添加Deck*属性deck

  3. ViewController.m中引入PlayingCardDeck.h

  4. 修改属性deckgetter,当_deck为空时,通过[[PlayingCardDeck alloc]init]创建牌堆,然后再返回Deck。

    此时ViewController.m的代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    //  ViewController.m

    #import "ViewController.h"
    #import "Deck.h"
    #import "PlayingCardDeck.h"

    @interface ViewController ()
    @property (weak, nonatomic) IBOutlet UILabel *flipsLabel;
    @property (nonatomic) int flipCount;
    @property (strong, nonatomic) Deck *deck;
    @end

    @implementation ViewController

    - (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    }

    - (Deck *)deck
    {
    if(!_deck)
    _deck = [self createDeck];
    return _deck;
    }

    - (Deck *)createDeck
    {
    return [[PlayingCardDeck alloc]init];
    }


    - (IBAction)touchCardButton:(UIButton *)sender {
    if ([sender.currentTitle length]) {
    [sender setBackgroundImage:[UIImage imageNamed:@"CardBack"] forState:UIControlStateNormal];
    [sender setTitle:@"" forState:UIControlStateNormal];
    } else {
    [sender setBackgroundImage:[UIImage imageNamed:@"CardFront"] forState:UIControlStateNormal];
    [sender setTitle:@"A♣️" forState:UIControlStateNormal];
    }
    self.flipCount++;
    }

    - (void)setFlipCount:(int)flipCount
    {
    _flipCount = flipCount;
    self.flipsLabel.text = [NSString stringWithFormat:@"Flips: %d",self.flipCount];
    }
    @end
  5. touchCardButton函数中,利用deckdrawRandomCard函数随机抽取一张纸牌。

  6. 将随机抽取出的Card的内容显示出来。需要注意的是,牌堆里的牌抽完以后要停止抽牌。

完成后ViewController.m的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// ViewController.m

#import "ViewController.h"
#import "Deck.h"
#import "PlayingCardDeck.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UILabel *flipsLabel;
@property (nonatomic) int flipCount;
@property (strong, nonatomic) Deck *deck;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}

- (Deck *)deck
{
if(!_deck)
_deck = [self createDeck];
return _deck;
}

- (Deck *)createDeck
{
return [[PlayingCardDeck alloc]init];
}

- (IBAction)touchCardButton:(UIButton *)sender {
if ([sender.currentTitle length]) {
[sender setBackgroundImage:[UIImage imageNamed:@"CardBack"] forState:UIControlStateNormal];
[sender setTitle:@"" forState:UIControlStateNormal];
self.flipCount++;
} else {
Card *card = [self.deck drawRandomCard]; // 从牌堆抽随机取一张Card
if(card) { // 牌堆里的牌抽完以后停止抽牌
[sender setBackgroundImage:[UIImage imageNamed:@"CardFront"] forState:UIControlStateNormal];
[sender setTitle:card.contents forState:UIControlStateNormal]; // 将Card的内容展示出来
self.flipCount++;
}
}
}

- (void)setFlipCount:(int)flipCount
{
_flipCount = flipCount;
self.flipsLabel.text = [NSString stringWithFormat:@"Flips: %d",self.flipCount];
}
@end

效果如下:

加载失败

使该游戏变得真正可玩

接下来我们让游戏变得真正可玩儿。在MVC开发模型中,游戏的逻辑属于Model,Model和UI是完全独立的,所以在编写过程中不需要考虑UI。

Model开发

首先添加一个新的类CardMatchingGame,这个类就是MVC模型中的Model,在编码CardMathingGame的过程中,需要思考需要哪些公开的API。

  1. 首先是构造函数,因为需要传进一些参数,比如牌的张数,所以新建构造函数initWithCardCount,指定构造函数(designated initializer)
  2. 公开的属性 score,但同时我们不希望别人能够随意修改socre属性,因此需要在公开API中将其设置为readonly,同时在.m文件中的私密API中声明为readwrite。其实readwrite用的不多,因为默认情况下就是readwrite,只有在公开API只读的时候才会用到。
  3. 允许用户通过index选中纸牌chooseCardAtIndex
  4. 通过index从牌堆中获取Card,CardAtIndex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CardMatchingGame.h

#import <Foundation/Foundation.h>
#import "Deck.h"

NS_ASSUME_NONNULL_BEGIN

@interface CardMatchingGame : NSObject

// designated initializer
- (instancetype)initWithCardCount:(NSUInteger)count usingDeck:(Deck *)deck;
- (void)chooseCardAtIndex:(NSUInteger)index;
- (Card *)cardAtIndex:(NSUInteger)index;
@property (nonatomic, readonly) NSInteger score;
@end

NS_ASSUME_NONNULL_END

接下来开始在CardMatchingGame.m中开始实现:

  1. 利用关键字readwrite使得属性score在CardMatchingGame.m中可写。

    1
    2
    3
    @interface CardMatchingGame()
    @property (nonatomic, readwrite) NSInteger score;
    @end
  2. 新建属cards,用于存储游戏中的纸牌。

    1
    2
    3
    4
    @interface CardMatchingGame()
    @property (nonatomic, readwrite) NSInteger score;
    @property (nonatomic, strong) NSMutableArray *cards; // of Card
    @end
  3. 修改属性cardsgetter函数

    属性的默认初始值为nil,当属性cards的指针为nil时,创建一个NSMutableArray

    1
    2
    3
    4
    5
    6
    7
    -(NSMutableArray *)cards
    {
    if(!_cards) {
    _cards = [[NSMutableArray alloc] init];
    }
    return _cards;
    }
  4. 实现构造函数initWithCardCount

    这个构造函数是designated initializer,也就是说使用这个类的用户必须调用这个构造函数,否则类无法被正确的初始化。使用构造函数init会返回nil。这个信息要传递给用户,所以以注释的形式写在公开API中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    -(instancetype) initWithCardCount:(NSUInteger)count usingDeck:(Deck *)deck
    {
    self = [super init];
    if(self) {
    for(int i = 0;i < count;i++) {
    Card *card = [deck drawRandomCard];
    if(card) {
    [self.cards addObject:card];
    } else {
    self = nil;
    break;
    }
    }
    }
    return self;
    }
  5. 实现函数cardAtIndex

    1
    2
    3
    4
    - (Card *)cardAtIndex:(NSUInteger)index
    {
    return index < [self.cards count] ? self.cards[index] : nil;
    }
  6. 实现函数chooseCardAtIndex

    这个函数是游戏的关键。表示某个card被选中后的处理方式,包括匹配过程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    static const int PENALTY_SCORE = 1;
    static const int COST_OF_CHOOSE = 1;

    -(void)chooseCardAtIndex:(NSUInteger)index
    {
    Card *card = [self cardAtIndex:index];
    if(!card.isMatched) {
    // if choosen card is not matched
    if(card.isChosen) { // if the card is already chosen
    card.chosen = NO; // filp the card back
    } else { // match it against another card
    // here, only match two cards, but may make it match multiple cards in later
    for(Card *otherCard in self.cards) {
    if(otherCard.isChosen && !otherCard.isMatched) { // Bingo, find the card we want to match
    int matchScore = [card match:@[otherCard]]; // try to match it
    if(matchScore == 0) { // not match
    self.score -= PENALTY_SCORE; // penalty of un match
    otherCard.chosen = NO; // flip the other card back
    } else { // match
    self.score += matchScore;
    card.matched = YES;
    otherCard.matched = YES;
    }
    break;
    }
    }
    card.chosen = YES;
    self.score -= COST_OF_CHOOSE;
    }
    } else {
    // if choosen card is already matched
    // do nothing
    }
    }

UI开发

到目前,完成了App中Model的实现,接下来是实现App的UI。

  1. 在UI中创建多张纸牌,可以通过复制粘贴的方式完成

  2. 创建OutletCollections,添加所有纸牌。

    创建方法,右键点击某个button,按住“New Referencing Outlet Collections“,拖到ViewController.m中。

    添加其他button的方法,按住Ctrl键,点击button并拖到ViewController.m

加载失败
  1. ViewController.m中引入CardMatchingGame.h

  2. 新增CardMatchingGame的属性game

    @property (strong, nonatomic) CardMatchingGame *game;

  3. 重写属性gamegetter

    1
    2
    3
    4
    5
    6
    - (CardMatchingGame *)game{
    if(!_game) {
    _game = [[CardMatchingGame alloc] initWithCardCount:[self.cardButtons count] usingDeck:[self createDeck]];
    }
    return _game;
    }
  4. ViewController.m中创建函数titleForCard

    1
    2
    3
    4
    - (NSString *)titleForCard:(Card *)card
    {
    return card.isChosen ? card.contents : @"";
    }
  5. ViewController.m中创建函数backgroundImageForCard

    1
    2
    3
    4
    - (UIImage *)backgroundImageForCard:(Card *)card
    {
    return [UIImage imageNamed:card.isChosen ? @"CardFront" : @"CardBack"]; // CardFront & CardBack are name of card image
    }
  6. ViewController.m中创建函数updateUI用于同步Model和UI。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    - (void)updateUI
    {
    for (UIButton *cardButton in self.cardButtons) {
    int cardIndex = [self.cardButtons indexOfObject:cardButton];
    Card *card = [self.game cardAtIndex:cardIndex];
    [cardButton setTitle:[self titleForCard:card] forState:UIControlStateNormal];
    [cardButton setBackgroundImage:[self backgroundImageForCard:card] forState:UIControlStateNormal];
    cardButton.enabled = !card.isMatched;
    }
    }
  7. 创建Touch Up Inside事件,命名为touchCardButton,并将所有button加入其中,方法类似于步骤2.

  8. .m文件中实现touchCardButton

    1
    2
    3
    4
    5
    - (IBAction)touchCardButton:(UIButton *)sender {
    int cardIndex = [self.cardButtons indexOfObject:sender];
    [self.game chooseCardAtIndex:cardIndex];
    [self updateUI];
    }

到这个时候App已经可以运行了,运行效果如下:

加载失败

可以正常运行,但是两张黑桃却没有正常匹配,问题出在哪里?

问题在我们在课程2中实现的Card类中的匹配函数:

1
2
3
4
5
6
7
8
9
10
// Card.m
- (int)match:(NSArray *)otherCards
{
int score = 0;
for(Card *card in otherCards) {
if([card.contents isEqualToString:self.contents])
score = 1;
}
return score;
}

这显然不是一个完整的匹配逻辑。

所以需要在PlayingCard类的.m文件中重构match函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
- (int)match:(NSArray *)otherCards
{
int score = 0;
if([otherCards count] == 1) {
PlayingCard *otherCard = [otherCards firstObject];
if([self.suit isEqualToString:otherCard.suit]) { // 如果花色匹配
score = 1;
} else if ([self.rank == otherCard.rank]) { // 如果数字匹配
score = 4;
}
}
return score;
}

现在完成了匹配规则,最后一个任务是添加一个Label用于展示分数。

  1. 在UI中增加一个Label

  2. 为Label增加一个Outlet

  3. updateUI中更新score Label。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    - (void)updateUI
    {
    for (UIButton *cardButton in self.cardButtons) {
    int cardIndex = [self.cardButtons indexOfObject:cardButton];
    Card *card = [self.game cardAtIndex:cardIndex];
    [cardButton setTitle:[self titleForCard:card] forState:UIControlStateNormal];
    [cardButton setBackgroundImage:[self backgroundImageForCard:card] forState:UIControlStateNormal];
    cardButton.enabled = !card.isMatched;
    }
    self.scoreLabel.text = [NSString stringWithFormat:@"Score: %d", self.game.score];
    }
加载失败

至此,一个可玩的纸牌匹配游戏App已经完成,大家可以试着让App可以同时匹配三张及以上的纸牌。