07 June 2013

Objective-C, Day 5

(Warning: non-FP content). (I've been including this for the benefit of any "Planet X" aggregators who are including my feed, where X is a functional language like Haskell. I'm assuming you've got that by now and either don't care or are unsubscribing/ignoring this series if it bothers you. I expect to get back to more functional language code at some point, maybe even implementing the same problem. But I dabble in different languages and sometimes the digressions go on for a while...)

So, today's topic is dispatch. I'm starting to design and implement the logic for pieces interacting on the board. The key in object-oriented designs is always to determine who (which object) manages which state. The design I've come up with is a sort of hybrid design, where position on the board is not actually a property of the board tiles. Instead, there's a board which keeps track of them. The instances of, say, a tree on the board don't have any unique state, so I'm not instantiating different objects for each one; they all point to a singleton (which I should properly enforce with a singleton factory method at some point).

There are trade-offs. On paper, my design involved adding a "push" method to the classes for tile pieces. In a language like Dylan, this "push" method would be a generic function, and dispatch on multiple parameter types, so that I could write some very short methods and use the method dispatcher, instead of explicit if-then or switch logic to find the right bit of code at run-time according to the types of the interacting objects (either their literal types or an enum, or some such). I could even do this in C++ because it has method overloading based on the parameters -- as long as this is based on their static type known at compile-time. Which... isn't true in this case. I miss Dylan's generic function dispatch! Objective-C is a underpowered in this respect even compared to C++. For example, I'd like to be able to write methods like this for the bomb class (this is pseudocode):

bomb::push(mountain)
{
    // blow up the mountain
}

bomb::push(empty)
{
    // slide the bomb onto the tile
}

But I can't. I can't just use the class construct to organize methods to call without an instance -- there doesn't seem to be the equivalent of C++ static methods. There also is no equivalent of a pure virtual function; I can't declare the need for a push() method in the common base class of the tile pieces and have the compiler demand that I implement in any subclasses to make them instantiable. The closest I can come, I think, is to create a method that generates an error if it itself is called instead of being overridden in a subclass. That seems to lack semantic clarity. So maybe I could do this, with double dispatch, but it doesn't seem worth the trouble for a small number of collision behaviors, when the behavior isn't simply supported by the language. I keep telling myself "thin layer on top of C... thin layer on top of C..."

So last night I had my Mac running upstairs in the office, and I used my iPad downstairs to connect to it via VNC, and write some code, running it on the iPad simulator, which I then viewed and controlled with a real iPad (mad scientist laugh). It needs a little tweaking this morning but here's more-or-less what I came up with. Note that I have started using some different naming conventions for file-scope and local variables. I'm not sure they are very standard Objective-C but they are closer to what I've grown comfortable with over the years in C and C++.

typedef struct {
    int x_idx;
    int y_idx;
} pos_t;

typedef enum {
    dir_east,
    dir_west,
    dir_south,
    dir_north
} dir_e;

// A straight C function to return a pos_t updated with a dir_e;
// the result may be out of bounds
pos_t getUpdatedPos( pos_t original_pos, dir_e dir );
BOOL posValid( pos_t pos );

static const int board_width = 24, board_height = 4;
// The short board design is part of what makes it so
// easy to get sliding objects stuck against the edges
// of the world or in corners where you can't get the
// penguin avatar around to the other side to push them.
// We could consider a bigger board later and maybe
// implement the original puzzles surrounded by water,
// or something like that.

// Equivalent of C++ class forward declaration
@class ArcticSlideTile;
@class ArcticSlideBomb;
@class ArcticSlideEmpty;
@class ArcticSlideHeart;
@class ArcticSlideHouse;
@class ArcticSlideIceBlock;
@class ArcticSlideMountain;
@class ArcticSlideTree;

@interface ArcticSlideModel : NSObject
{
    ArcticSlideTile* board[board_height][board_width];
}

- (id)init;
- (id)initWithLevelIndex:(int)level_idx;
- (NSString*) description;
- (ArcticSlideTile*)getTileFromPosition:(pos_t)pos
                            inDirection:(dir_e)dir;
- (void)setTileAtPosition:(pos_t)pos
                       to:(ArcticSlideTile*)type;

@end

@interface ArcticSlideTile : NSObject

- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;

@end


@interface ArcticSlideBomb : ArcticSlideTile
// Bombs can be pushed and will slide until they hit an
// object and stop. If the object is a mountain, both bomb
// and mountain are destroyed. If another object hits a bomb
// it stops (I think -- I'm not sure it is possible to set 
// up a board such that you can slide something into a bomb).

// push is called when the penguin pushes against a tile.
// It returns YES if the penguin can move onto the tile with
// this action. This is only ever the case for a tree or empty
// tile.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideEmpty : ArcticSlideTile
// The penguin can always step onto an empty tile
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (BOOL)slideFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideHeart : ArcticSlideTile
// When a heart hits a house the heart disappears (getting
// all the hearts into the house is how you win the game).
// Otherwise they cannot be destroyed, and slide like other
// slidable items.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideHouse : ArcticSlideTile
// Houses cannot be pushed and stop other objects except
// hearts. When a heart hits a house the heart disappears
// (getting the hearts into the house is how you win the game).
// So the model should keep track of the number of hearts
// on the board and trigger a "win the level" behavior when
// the last heart is removed.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideIceBlock : ArcticSlideTile
// Ice blocks can be pushed and will slide until they hit
// an object and stop. If they are pushed directly against
// an object they will be crushed (there should be an animation)
// and disappear.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideMountain : ArcticSlideTile
// Mountains cannot be moved and are destroyed by bombs.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

@interface ArcticSlideTree : ArcticSlideTile
// Trees cannot be pushed or destroyed and stop all sliding
// objects, but the penguin avatar character can walk through
// them.
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir;
- (NSString*) description;
@end

Here's part of the implementation. It seems way too wordy; I need to rethink the amount of code required for each step. At the least, some refactoring seems to be in order. As I mentioned earlier, I'm not sure the tile classes are really earning their keep. I got rid of the singleton instantiation machinery but now there are order of initialization dependencies. I'll need to do further thinking as I consider what communication needs to happen between the "model" part and "controller" part -- how to interact with the GUI, how to indicate that tiles need to be redrawn, or animated transitions should play, or sound effects should play, or that the score is changed, and what to do when a level is completed. There is lots more to think about for such a simple little game! And of course I haven't really even begun to implement the "view" parts.

static ArcticSlideEmpty* empty_p;
static ArcticSlideTree* tree_p;
static ArcticSlideMountain* mountain_p;
static ArcticSlideHouse* house_p;
static ArcticSlideIceBlock* ice_block_p;
static ArcticSlideHeart* heart_p;
static ArcticSlideBomb* bomb_p;

static ArcticSlideModel* model_p;

pos_t getUpdatedPos( pos_t original_pos, dir_e dir )
{
    pos_t updated_pos = original_pos;
    int x_offset = 0;
    int y_offset = 0;
    if ( dir_east == dir )
    {
        x_offset = 1;
        y_offset = 0;
    }
    else if ( dir_west == dir )
    {
        x_offset = -1;
        y_offset = 0;
    }
    else if ( dir_north == dir )
    {
        x_offset = 0;
        y_offset = -1;
    }
    else if ( dir_south == dir )
    {
        x_offset = 0;
        y_offset = +1;
    }
    updated_pos.x_idx += x_offset;;
    updated_pos.y_idx += y_offset;
    return updated_pos;
}

BOOL posValid( pos_t pos )
{
    return ( ( ( pos.x_idx >= 0 ) ||
               ( pos.x_idx < board_width  ) ) ||
             ( ( pos.y_idx >= 0 ) ||
               ( pos.y_idx < board_height ) ) );
}

@implementation ArcticSlideTile

- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir
{
    // Should be implemented in subclass
    return NO;
}

@end

@implementation ArcticSlideBomb

- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir
{
    // Penguin has pushed bomb in the given direction.
    // Get our own position:
    pos_t bomb_pos = getUpdatedPos( pos, dir );
    // What are we being pushed into?
    ArcticSlideTile *target_tile_p =
    [model_p getTileFromPosition:bomb_pos
                     inDirection:dir];
    
    if ( nil == target_tile_p )
    {
        // Edge of the world. TODO:
        // queue a "boop" sound effect
    }
    else if ( mountain_p == target_tile_p )
    {
        // bomb pushed into mountain
        // TODO: queue animation of bomb moving onto
        // mountain, animate explosion
        // remove bomb and mountain
        pos_t new_bomb_pos = getUpdatedPos( bomb_pos, dir );
        [model_p setTileAtPosition:new_bomb_pos
                                to:empty_p];
        new_bomb_pos = getUpdatedPos( new_bomb_pos, dir );
        [model_p setTileAtPosition:new_bomb_pos
                                to:empty_p];
    }
    else if ( empty_p == target_tile_p )
    {
        // TODO: queue bomb moving into space
        pos_t new_bomb_pos = getUpdatedPos( bomb_pos, dir );
        // Set bomb at new position
        [model_p setTileAtPosition:new_bomb_pos
                                to:bomb_p];
        // Remove bomb from old position
        [model_p setTileAtPosition:bomb_pos
                                to:empty_p];

        // Bombs will continue to slide until stopped
        ArcticSlideTile *target_tile_p =
        [model_p getTileFromPosition:new_bomb_pos
                         inDirection:dir];

        while ( empty_p == target_tile_p )
        {
            // TODO: animate bomb moving into space
            pos_t new_bomb_pos = getUpdatedPos( bomb_pos, dir );
            // set bomb at new position
            [model_p setTileAtPosition:new_bomb_pos
                                    to:bomb_p];
            // remove bomb from old position
            [model_p setTileAtPosition:bomb_pos
                                    to:empty_p];
        }

        if ( mountain_p == target_tile_p )
        {
            // bomb pushed into mountain
            // TODO: queue animation of bomb moving
            // onto mountain, animate explosion
            // remove bomb and mountain
            [model_p setTileAtPosition:new_bomb_pos
                                    to:empty_p];
            new_bomb_pos = getUpdatedPos( new_bomb_pos, dir );
            [model_p setTileAtPosition:new_bomb_pos
                                    to:empty_p];
        }
    }
    // The penguin cannot actually move in this turn
    return NO;
}

- (NSString*) description
{
    return @"Bomb  ";
}

@end

@implementation ArcticSlideEmpty
- (BOOL)pushFromPosition:(pos_t)pos inDirection:(dir_e)dir
{
    // If the penguin pushes onto an empty tile, he can always
    // move there
    return YES;
}

- (NSString*) description
{
    return @"      ";
}
@end

I'm leaving out unfinished tile classes for clarity, but here is the model implementation:

@implementation ArcticSlideModel

- (id)init
{
    // Initialize the global tile objects. I messed around
    // with singleton factory methods for creating a single
    // instance of each of these and accessing it everywhere
    // but the resulting code was too wordy to justify this.

    empty_p = [[ArcticSlideEmpty alloc] init];
    tree_p = [[ArcticSlideTree alloc] init];
    mountain_p = [[ArcticSlideMountain alloc] init];
    house_p = [[ArcticSlideHouse alloc] init];
    ice_block_p = [[ArcticSlideIceBlock alloc] init];
    heart_p = [[ArcticSlideHeart alloc] init];
    bomb_p = [[ArcticSlideBomb alloc] init];

    self = [super init];

    for ( unsigned int idx_y = 0;
         idx_y < board_height; idx_y++ )
    {
        for ( unsigned int idx_x = 0;
             idx_x < board_width; idx_x++ )
        {
            board[idx_y][idx_x] = empty_p;
        }
    }

    return self;
}

- (id)initWithLevelIndex:(int)level_idx
{
    self = [self init];

    // Lookup table to decode the original Polar resource
    // data as strings
    ArcticSlideTile *
        polar_data_tile_map[POLAR_DATA_NUM_TILE_VALS] =
    {
        empty_p, tree_p, mountain_p, house_p, ice_block_p,
        heart_p, bomb_p
    };

    if ( level_idx > ( num_polar_levels - 1) )
    {
        NSLog(@"initWithLevelIndex: bad level_idx %d!\n",
              level_idx);
    }
    else
    {
        unsigned int level_data_idx = 0;
        for ( unsigned int idx_y = 0;
             idx_y < board_height; idx_y++ )
        {
            for ( unsigned int idx_x = 0;
                 idx_x < board_width; idx_x++ )
            {
                int polar_data_tile_val =
                    polar_levels[level_idx]
                                [level_data_idx] - '0';
                if ( ( polar_data_tile_val < 0 ) ||
                     ( polar_data_tile_val > 
                       polar_data_max_tile_val ) )
                {
                    NSLog(@"tile value %d out of range!\n",
                          polar_data_tile_val );
                    self = nil;
                }
                else
                {
                    board[idx_y][idx_x] =
                        polar_data_tile_map[polar_data_tile_val];
                    level_data_idx++;
                }
            }
        }
    }

    return self;

}

- (ArcticSlideTile*)getTileFromPosition:(pos_t)pos 
                            inDirection:(dir_e)dir
{
    pos_t updated_pos = getUpdatedPos(pos, dir);
    if ( posValid( updated_pos ) )
    {
        return board[updated_pos.y_idx]
                    [updated_pos.x_idx];
    }
    else
    {
        return nil;
    }
}

- (NSString*)description
{
    NSMutableString *desc_str =[[NSMutableString alloc]init];
    
    [desc_str appendString:@"ArcticSlideModel board state:\n"];
    for ( unsigned int idx_y = 0;
         idx_y < board_height; idx_y++ )
    {
        for ( unsigned int idx_x = 0;
             idx_x < board_width; idx_x++ )
        {
            [desc_str appendString:[board[idx_y][idx_x] 
                                    description]];
        }
        [desc_str appendString:@"\n"];
    }
    return desc_str;
}

- (void)setTileAtPosition:(pos_t)pos to:(ArcticSlideTile*)type
{
    board[pos.y_idx][pos.x_idx] = type;
}

@end

It's not much yet, and it doesn't have any kind of user interface outside of NSLog, but my code will successfully respond to moving the penguin through trees, through open space, and pushing a bomb, which then moves into an open space, continues to slide until it comes up against a mountain, and destroys the mountain. I'm driving this with a test method like this:

NSLog(@"%@\n", model_p);
// Penguin starts at 0,0, on a tree tile
pos_t penguin_pos = { 0, 0 };

// Walk the penguin south onto another tree tile
ArcticSlideTile* tile_p =
[model_p getTileFromPosition:penguin_pos 
                 inDirection:dir_south];
NSLog(@"Penguin is facing: %@\n", tile_p);
BOOL allowed = [tile_p pushFromPosition:penguin_pos
                            inDirection:dir_south];
NSLog(@"Penguin allowed: %s\n", ( allowed ? "YES" : "NO" ) );
tile_p = [model_p getTileFromPosition:penguin_pos
                              inDirection:dir_south];
penguin_pos = getUpdatedPos(penguin_pos, dir_south);
NSLog(@"Penguin is facing: %@\n", tile_p);
    
// Walk the penguin east onto an empty space
tile_p = [model_p getTileFromPosition:penguin_pos
                              inDirection:dir_east];
NSLog(@"Penguin is facing: %@\n", tile_p);
allowed = [tile_p pushFromPosition:penguin_pos
                       inDirection:dir_east];
NSLog(@"Penguin allowed: %s\n", ( allowed ? "YES" : "NO" ) );
tile_p = [model_p getTileFromPosition:penguin_pos
                          inDirection:dir_east];
penguin_pos = getUpdatedPos(penguin_pos, dir_east);

// Try walking into a bomb, which should slide
// and blow up a mountain
tile_p = [model_p getTileFromPosition:penguin_pos
                          inDirection:dir_east];
NSLog(@"Penguin is facing: %@\n", tile_p);
allowed = [tile_p pushFromPosition:penguin_pos
                           inDirection:dir_east];
NSLog(@"Penguin allowed: %s\n", ( allowed ? "YES" : "NO" ) );

NSLog(@"%@\n", model_p);

I'll think on this whole model of updates some more. Maybe it can be even simpler. And I have to consider how the penguin state will be managed, including its orientation (in the original, the penguin can face in the cardinal directions). Should I preserve that in a touch-driven game user interface?

No comments: