cs193p – Assignment #4 Task #2

Please note, this blog entry is from a previous course. You might want to check out the current one.

Cards must have a “standard” look and feel (i.e. for Set, 1, 2 or 3 squiggles, diamonds or ovals that are solid, striped or unfilled, and are either green, red or purple; for Playing Cards, pips and faces). You must draw these using UIBezierPath and Core Graphics functions. You may not use images or attributed strings for Set cards. The drawings on the card must scale appropriately to the card’s bounds. You can use the PlayingCardView from the in-class demo to draw your Playing Card game cards.

… the cleaning continues: Remove all card buttons from your storyboard, as well as their outlet collection property and every reference to it in your code. Because the cards will now drawn remove also the methods to update the button title and background images … like before, you can leave the code parts where the are, but it might get crowded …

In storyboard add a new view to both card-game view controllers and link them to a common outlet in the common-card-game-view-controller class:

cs193p – assignment #4 task #2 – new view
cs193p – assignment #4 task #2 – new view

… because the new views will hold the grid of cards call the new outlet accordingly:

@property (weak, nonatomic) IBOutlet UIView *gridView;

Before the outlet collection was used to link to the storyboard, and also to derive the number of cards to show. Now we use a new array to store the cards, and a public property to set the number of initial cards, which is used when instantiating the array lazily, and for instantiating the game property. The card array is reset when a new deck is dealt:

// CardGameViewController.h
@property (nonatomic) NSUInteger numberOfStartingCards;

// CardGameViewController.m
@property (strong, nonatomic) NSMutableArray *cardViews;
...
- (NSMutableArray *)cardViews
{
    if (!_cardViews) _cardViews = [NSMutableArray arrayWithCapacity:self.numberOfStartingCards];
    return _cardViews;
}

- (CardMatchingGame *)game
{
    ...
        _game = [[CardMatchingGame alloc] initWithCardCount:self.numberOfStartingCards
                                                  usingDeck:[self createDeck]];
        ...
}

- (IBAction)touchDealButton:(UIButton *)sender {
    ...
    self.cardViews = nil;
    ...
}

Set the initial cards number when the view load (e.g. 35 for playing cards and 12 for set cards):

// PlayingCardGameViewController.m
- (void)viewDidLoad
{
    ...
    self.numberOfStartingCards = 35;
    ...
}

// SetCardGameViewController.m
- (void)viewDidLoad
{
    ...
    self.numberOfStartingCards = 12;
    ...
}

To calculate the size and the positions of the cards use the grid class provided by Stanford, store it in a property and instantiate it lazily. The minimum number of cells is defined by the initial card number, the size of the grid equals the size of the grid view from the storyboard. To calculate the aspect ratio of the cards add a new public property which are set accordingly in the child classes:

// CardGameViewController.h
@property (nonatomic) CGSize maxCardSize;

// CardGameViewController.m
#import "Grid.h"
...
@property (strong, nonatomic) Grid *grid;
...
- (Grid *)grid
{
    if (!_grid) {
        _grid = [[Grid alloc] init];
        _grid.cellAspectRatio = self.maxCardSize.width / self.maxCardSize.height;
        _grid.minimumNumberOfCells = self.numberOfStartingCards;
        _grid.maxCellWidth = self.maxCardSize.width;
        _grid.maxCellHeight = self.maxCardSize.height;
        _grid.size = self.gridView.frame.size;
    }
    return _grid;
}

// PlayingCardGameViewController.m
- (void)viewDidLoad
{
    ...
    self.maxCardSize = CGSizeMake(80.0, 120.0);
    ...
}

// SetCardGameViewController.m
- (void)viewDidLoad
{
    ...
    self.maxCardSize = CGSizeMake(120.0, 120.0);
    ...
}

Before we looped over all buttons to updated the user interface. Now we actually do not know how many cards there dealt. We could derive it from the initial card number and when adding or removing cards in future tasks update that number. But it is much easier to ask the game model. Add a public property and let it return that number:

// CardMatchingGame.h
@property (nonatomic, readonly) NSUInteger numberOfDealtCards;

// CardMatchingGame.m
- (NSUInteger)numberOfDealtCards {
    return [self.cards count];
}

Now loop over all cards in the game. To know if a view for a card does already exist, store the index of the card in the tag property of the view. If does not exist, create a new view for that card (the used method will be overwritten by the children classes), set the tag value, add a tap gesture to the view, and finally add the button to the grid view and store it in the cards array. If the view does already exist, update the view (again using a method which will be overwritten by children classes). … and for now mark already matched cards (a future task asks to remove them). Finally calculate the frame of the card using the grid class. Because the grid class does not leave any space between cards, inset them slightly:

#define CARDSPACINGINPERCENT 0.08

- (void)updateUI
{
    for (NSUInteger cardIndex = 0;
         cardIndex < self.game.numberOfDealtCards;
         cardIndex++) {
        Card *card = [self.game cardAtIndex:cardIndex];        
        NSUInteger viewIndex = [self.cardViews indexOfObjectPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) {
            if ([obj isKindOfClass:[UIView class]]) {
                if (((UIView *)obj).tag == cardIndex) return YES;
            }
            return NO;
        }];
        UIView *cardView;
        if (viewIndex == NSNotFound) {
            cardView = [self createViewForCard:card];
            cardView.tag = cardIndex;            
            UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self
                                                                                  action:@selector(touchCard:)];
            [cardView addGestureRecognizer:tap];            
            [self.cardViews addObject:cardView];
            viewIndex = [self.cardViews indexOfObject:cardView];
            [self.gridView addSubview:cardView];
        } else {
            cardView = self.cardViews[viewIndex];
            [self updateView:cardView forCard:card];
            cardView.alpha = card.matched ? 0.6 : 1.0;
        }
        CGRect frame = [self.grid frameOfCellAtRow:viewIndex / self.grid.columnCount
                                          inColumn:viewIndex % self.grid.columnCount];
        frame = CGRectInset(frame, frame.size.width * CARDSPACINGINPERCENT, frame.size.height * CARDSPACINGINPERCENT);
        cardView.frame = frame;
    }
    ...
}

To test the code above provide dummy methods to create and update the card views (mainly drawing blue rectangles) – and because the will be overwritten by the children classes, don’t forget to make them public:

- (UIView *)createViewForCard:(Card *)card
{
    UIView *view = [[UIView alloc] init];
    [self updateView:view forCard:card];
    return view;
}

- (void)updateView:(UIView *)view forCard:(Card *)card
{
    view.backgroundColor = [UIColor blueColor];
}

When touching send the chosen card index to the game model and update the user interface:

- (void)touchCard:(UITapGestureRecognizer *)gesture
{
    if (gesture.state == UIGestureRecognizerStateEnded) {
        [self.game chooseCardAtIndex:gesture.view.tag];
        [self updateUI];
    }
}

Add the playing-card-view class from the lectures to matchismo and use it to create and update the playing card views:

#import "PlayingCardView.h"
#import "PlayingCard.h"
...
- (UIView *)createViewForCard:(Card *)card
{
    PlayingCardView *view = [[PlayingCardView alloc] init];
    [self updateView:view forCard:card];
    return view;
}

- (void)updateView:(UIView *)view forCard:(Card *)card
{
    if (![card isKindOfClass:[PlayingCard class]]) return;
    if (![view isKindOfClass:[PlayingCardView class]]) return;    
    PlayingCard *playingCard = (PlayingCard *)card;
    PlayingCardView *playingCardView = (PlayingCardView *)view;
    playingCardView.rank = playingCard.rank;
    playingCardView.suit = playingCard.suit;
    playingCardView.faceUp = playingCard.chosen;
    
}

Please note that the code from the lecture does have a slight problem with aligning the rotated pips in some cases …

The set-card view controller overrides the same methods, but uses set cards and a new a new set-card-view class which does not exist yet:

#import "SetCardView.h"
#import "SetCard.h"
...
- (UIView *)createViewForCard:(Card *)card
{
    SetCardView *view = [[SetCardView alloc] init];
    [self updateView:view forCard:card];
    return view;
}

- (void)updateView:(UIView *)view forCard:(Card *)card
{
    if (![card isKindOfClass:[SetCard class]]) return;
    if (![view isKindOfClass:[SetCardView class]]) return;
    
    SetCard *setCard = (SetCard *)card;
    SetCardView *setCardView = (SetCardView *)view;
    setCardView.color = setCard.color;
    setCardView.symbol = setCard.symbol;
    setCardView.shading = setCard.shading;
    setCardView.number = setCard.number;
    setCardView.chosen = setCard.chosen;    
}

The public interface the new class provides access to the properties of a set card:

@property (strong, nonatomic) NSString *color;
@property (strong, nonatomic) NSString *symbol;
@property (strong, nonatomic) NSString *shading;
@property (nonatomic) NSUInteger number;
@property (nonatomic) BOOL chosen;

When any of them is set, the view needs to be redrawn:

- (void)setColor:(NSString *)color
{
    _color = color;
    [self setNeedsDisplay];
}

- (void)setSymbol:(NSString *)symbol
{
    _symbol = symbol;
    [self setNeedsDisplay];
}

- (void)setShading:(NSString *)shading
{
    _shading = shading;
    [self setNeedsDisplay];
}

- (void)setNumber:(NSUInteger)number
{
    _number = number;
    [self setNeedsDisplay];
}

- (void)setChosen:(BOOL)chosen
{
    _chosen = chosen;
    [self setNeedsDisplay];
}

The basic setup is equal to the playing cards:

- (void)setup
{
    self.backgroundColor = nil;
    self.opaque = NO;
    self.contentMode = UIViewContentModeRedraw;
}

- (void)awakeFromNib
{
    [self setup];
}

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    [self setup];
    return self;
}

First draw a rounded rectangle, adjust its border if the card is selected or not, and then draw the symbols:

#define CORNER_RADIUS 12.0

- (void)drawRect:(CGRect)rect
{
    UIBezierPath *roundedRect = [UIBezierPath bezierPathWithRoundedRect:self.bounds
                                                           cornerRadius:CORNER_RADIUS];
    [roundedRect addClip];
    [[UIColor whiteColor] setFill];
    UIRectFill(self.bounds);    
    if (self.chosen) {
        [[UIColor blueColor] setStroke];
        roundedRect.lineWidth *= 2.0;
    } else {
        [[UIColor colorWithWhite:0.8 alpha:1.0] setStroke];
        roundedRect.lineWidth /= 2.0;
    }
    [roundedRect stroke];    
    [self drawSymbols];
}

Set the color of the symbols and draw the individual symbols at appropriate positions:

#define SYMBOL_OFFSET 0.2;
#define SYMBOL_LINE_WIDTH 0.02;

- (void)drawSymbols
{
    [[self uiColor] setStroke];
    CGPoint point = CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2);
    if (self.number == 1) {
        [self drawSymbolAtPoint:point];
        return;
    }
    CGFloat dx = self.bounds.size.width * SYMBOL_OFFSET;
    if (self.number == 2) {
        [self drawSymbolAtPoint:CGPointMake(point.x - dx / 2, point.y)];
        [self drawSymbolAtPoint:CGPointMake(point.x + dx / 2, point.y)];
        return;
    }
    if (self.number == 3) {
        [self drawSymbolAtPoint:point];
        [self drawSymbolAtPoint:CGPointMake(point.x - dx, point.y)];
        [self drawSymbolAtPoint:CGPointMake(point.x + dx, point.y)];
        return;
    }
}

To pick the color of the symbols a helper class makes the code more readable:

- (UIColor *)uiColor
{
    if ([self.color isEqualToString:@"red"]) return [UIColor redColor];
    if ([self.color isEqualToString:@"green"]) return [UIColor greenColor];
    if ([self.color isEqualToString:@"purple"]) return [UIColor purpleColor];
    return nil;
}

Depending on the kind of symbol use another helper method:

- (void)drawSymbolAtPoint:(CGPoint)point
{
    if ([self.symbol isEqualToString:@"oval"]) [self drawOvalAtPoint:point];
    else if ([self.symbol isEqualToString:@"squiggle"]) [self drawSquiggleAtPoint:point];
    else if ([self.symbol isEqualToString:@"diamond"]) [self drawDiamondAtPoint:point];
}

The circles are rectangles with really round corners:

#define OVAL_WIDTH 0.12
#define OVAL_HEIGHT 0.4

- (void)drawOvalAtPoint:(CGPoint)point;
{
    CGFloat dx = self.bounds.size.width * OVAL_WIDTH / 2.0;
    CGFloat dy = self.bounds.size.height * OVAL_HEIGHT / 2.0;
    UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(point.x - dx, point.y - dy, 2.0 * dx, 2.0 * dy)
                                                    cornerRadius:dx];
    path.lineWidth = self.bounds.size.width * SYMBOL_LINE_WIDTH;
    [self shadePath:path];
    [path stroke];
}

The squiggles are a combination of curves:

#define SQUIGGLE_WIDTH 0.12
#define SQUIGGLE_HEIGHT 0.3
#define SQUIGGLE_FACTOR 0.8

- (void)drawSquiggleAtPoint:(CGPoint)point;
{
    CGFloat dx = self.bounds.size.width * SQUIGGLE_WIDTH / 2.0;
    CGFloat dy = self.bounds.size.height * SQUIGGLE_HEIGHT / 2.0;
    CGFloat dsqx = dx * SQUIGGLE_FACTOR;
    CGFloat dsqy = dy * SQUIGGLE_FACTOR;
    UIBezierPath *path = [[UIBezierPath alloc] init];
    [path moveToPoint:CGPointMake(point.x - dx, point.y - dy)];
    [path addQuadCurveToPoint:CGPointMake(point.x + dx, point.y - dy)
                 controlPoint:CGPointMake(point.x - dsqx, point.y - dy - dsqy)];
    [path addCurveToPoint:CGPointMake(point.x + dx, point.y + dy)
            controlPoint1:CGPointMake(point.x + dx + dsqx, point.y - dy + dsqy)
            controlPoint2:CGPointMake(point.x + dx - dsqx, point.y + dy - dsqy)];
    [path addQuadCurveToPoint:CGPointMake(point.x - dx, point.y + dy)
                 controlPoint:CGPointMake(point.x + dsqx, point.y + dy + dsqy)];
    [path addCurveToPoint:CGPointMake(point.x - dx, point.y - dy)
            controlPoint1:CGPointMake(point.x - dx - dsqx, point.y + dy - dsqy)
            controlPoint2:CGPointMake(point.x - dx + dsqx, point.y - dy + dsqy)];
    path.lineWidth = self.bounds.size.width * SYMBOL_LINE_WIDTH;
    [self shadePath:path];
    [path stroke];
}

The diamonds are just four lines:

#define DIAMOND_WIDTH 0.15
#define DIAMOND_HEIGHT 0.4

- (void)drawDiamondAtPoint:(CGPoint)point;
{
    CGFloat dx = self.bounds.size.width * DIAMOND_WIDTH / 2.0;
    CGFloat dy = self.bounds.size.height * DIAMOND_HEIGHT / 2.0;
    UIBezierPath *path = [[UIBezierPath alloc] init];
    [path moveToPoint:CGPointMake(point.x, point.y - dy)];
    [path addLineToPoint:CGPointMake(point.x + dx, point.y)];
    [path addLineToPoint:CGPointMake(point.x, point.y + dy)];
    [path addLineToPoint:CGPointMake(point.x - dx, point.y)];
    [path closePath];
    path.lineWidth = self.bounds.size.width * SYMBOL_LINE_WIDTH;
    [self shadePath:path];
    [path stroke];
}

Fill solid symbols with the stroke color, and open ones with no color. Stripping workes by drawing parallel lines which are clipped by the path of the symbol:

#define STRIPES_OFFSET 0.06
#define STRIPES_ANGLE 5

- (void)shadePath:(UIBezierPath *)path
{
    if ([self.shading isEqualToString:@"solid"]) {
        [[self uiColor] setFill];
        [path fill];
    } else if ([self.shading isEqualToString:@"striped"]) {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextSaveGState(context);
        [path addClip];
        UIBezierPath *stripes = [[UIBezierPath alloc] init];
        CGPoint start = self.bounds.origin;
        CGPoint end = start;
        CGFloat dy = self.bounds.size.height * STRIPES_OFFSET;
        end.x += self.bounds.size.width;
        start.y += dy * STRIPES_ANGLE;
        for (int i = 0; i < 1 / STRIPES_OFFSET; i++) {
            [stripes moveToPoint:start];
            [stripes addLineToPoint:end];
            start.y += dy;
            end.y += dy;
        }
        stripes.lineWidth = self.bounds.size.width / 2 * SYMBOL_LINE_WIDTH;
        [stripes stroke];
        CGContextRestoreGState(UIGraphicsGetCurrentContext());
    } else if ([self.shading isEqualToString:@"open"]) {
        [[UIColor clearColor] setFill];
    }
}

The complete code is available on github.

Facebooktwittergoogle_plusredditpinterestlinkedintumblrmailFacebooktwittergoogle_plusredditpinterestlinkedintumblrmail

Flattr this!

9 thoughts on “cs193p – Assignment #4 Task #2”

  1. Hi there!

    I’ve been following your code on this assignment, but I can’t seem to get anything to appear when I run, the grid view dragged out at the start is completely blank…

    1. Did you try to run the code from I posted on github? Do your views get called? Did you add the dummy code for the “blue rectangles”? Set a breakpoint their to see if it gets called …

      1. My goodness, made a stupid small mistake somewhere else. Thanks for your quick reply, am thoroughly enjoyed going through all your stuff. 🙂

    1. … it actually does what it says, it saves the current state of the context and restores it afterwards 😉

      … in the code above, we do some things with the context (add clipping, move the current position quite a lot), which we need to undo afterwards because the rest of the code does not know about these changes … we could either remove the clipping, and move to the starting coordinates manually … or we just restore the previously saved context …

  2. Hi,
    Once you add all subviews to the self.cardViews, the viewIndexes are exactly the same as the cardIndexes, right?

    For example, at the beginning, there is nothing in self.cardViews, so when we cycle the cardIndex in loop, each cardIndex will create a cardView. Then the addObject method of self.cardViews will add the created cardView to the end of self.cardViews, so the cardIndex is always added in order. Am I right?

    1. You are right that afterwards all cards are associated with views at the end. However viewIndex is only a temporary variable used as helper within the loop. The views themselves do not have an index associated, and I do not store an special array/dictionary holding the views. To be able to find a card later on, I store the cardIndex in the tag of an view … better said, at the end we have a punch of views (however the master view wants to store them), which have an tag equal to the cardIndex of the associated card.

Leave a Reply

Your email address will not be published. Required fields are marked *