How To Make A Multi-Directional Scrolling Shooter – Part 1

A while back in the weekly tutorial vote, you guys said you wanted a tutorial on how to make a multi-directional scrolling shooter. Your wish is my command! :] In this tutorial series, we’ll make a tile-based game where you drive a tank around using the accelerometer. Your goal is to get to the exit, […] By Ray Wenderlich.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Adding the Tank

Time to add our hero into the mix!

Create a new file with the iOS\Cocoa Touch\Objective-C class template, enter Tank for the class, and make it a subclass of CCSprite. Then open Tank.h and replace it with the following:

#import "cocos2d.h"
@class HelloWorldLayer;

@interface Tank : CCSprite {
    int _type;
    HelloWorldLayer * _layer;
    CGPoint _targetPosition;
}

@property (assign) BOOL moving;
@property (assign) int hp;

- (id)initWithLayer:(HelloWorldLayer *)layer type:(int)type hp:(int)hp;
- (void)moveToward:(CGPoint)targetPosition;

@end

Let’s cover the instance variablers/properties inside this class:

  • type: We have two types of tanks, so this is either 1 or 2. Based on this we can select the proper sprites.
  • layer: We’ll need to call some methods in the layer later on from within the tank class, so we store a reference here.
  • targetPosition: The tank always has a position it’s trying to move toward. We store that here.
  • moving: Keeps track of whether the tank is currently trying to move or not.
  • hp: Keeps track of the tank’s HP, which we’ll be using later.

Next open Tank.m and replace it with the following:

#import "Tank.h"
#import "HelloWorldLayer.h"

@implementation Tank
@synthesize moving = _moving;
@synthesize hp = _hp;

- (id)initWithLayer:(HelloWorldLayer *)layer type:(int)type hp:(int)hp {
    
    NSString *spriteFrameName = [NSString stringWithFormat:@"tank%d_base.png", type];    
    if ((self = [super initWithSpriteFrameName:spriteFrameName])) {
        _layer = layer;
        _type = type;
        self.hp = hp;     
        [self scheduleUpdateWithPriority:-1];
    }
    return self;
}

- (void)moveToward:(CGPoint)targetPosition {    
    _targetPosition = targetPosition;                    
}

- (void)updateMove:(ccTime)dt {
    
    // 1
    if (!self.moving) return;
    
    // 2
    CGPoint offset = ccpSub(_targetPosition, self.position);
    // 3
    float MIN_OFFSET = 10;
    if (ccpLength(offset) < MIN_OFFSET) return;
    
    // 4
    CGPoint targetVector = ccpNormalize(offset);    
    // 5
    float POINTS_PER_SECOND = 150;
    CGPoint targetPerSecond = ccpMult(targetVector, POINTS_PER_SECOND);
    // 6
    CGPoint actualTarget = ccpAdd(self.position, ccpMult(targetPerSecond, dt));
    
    // 7
    CGPoint oldPosition = self.position;
    self.position = actualTarget;  
        
}

- (void)update:(ccTime)dt {    
    [self updateMove:dt];        
}

@end

The initializer is pretty straightforward - it just squirrels away the variables passed in, and schedules an update method to be called. You might not have known that you can schedule an update method on any CCNode - but now you do! :] And note the priority is set to -1, because we want this update to run BEFORE the layer's update (which is run at the default priority of 0).

moveToward just updates the target position - updateMove is where all the action is, and this is called once per frame. Let's go over what this method does bit by bit:

  1. If moving is false, just bail. Moving will be false when the app first begins.
  2. Subtract the current position from the target position, to get a vector that points in the direction of where we're going.
  3. Check the length of that line, and see if it's less than 10 points. If it is, we're "close enough" and we just return.
  4. Make the directional vector a unit vector (length of 1) by calling ccpNormalize. This makes it easy to make the line any length we want next.
  5. Multiply the vector by however fast we want the tank to travel in a second (150 here). The result is a vector in points/1 second the tank should travel.
  6. This method is being called several times a second, so we multiply this vector by the delta time (around 1/60 of a second) to figure out how much we should actually travel.
  7. Set the position of the tank to what we figured out. We also keep track of the old position in a local variable, which we'll use soon.

Now let's put our new tank class to use! Make the following changes to HelloWorldLayer.h:

// Before the @interface
@class Tank;

// After the @interface
@property (strong) Tank * tank;
@property (strong) CCSpriteBatchNode * batchNode;

And the following changes to HelloWorldLayer.m:

// At the top of the file
#import "Tank.h"

// Right after the @implementation
@synthesize batchNode = _batchNode;
@synthesize tank = _tank;

// Inside init
_batchNode = [CCSpriteBatchNode batchNodeWithFile:@"sprites.png"];
[_tileMap addChild:_batchNode];
[[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:@"sprites.plist"];

self.tank = [[Tank alloc] initWithLayer:self type:1 hp:5];
self.tank.position = spawnPos;
[_batchNode addChild:self.tank];

self.isTouchEnabled = YES;
[self scheduleUpdate];

// After init
- (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    
    UITouch * touch = [touches anyObject];
    CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch];
    
    self.tank.moving = YES;
    [self.tank moveToward:mapLocation];
    
}

- (void)ccTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    
    UITouch * touch = [touches anyObject];
    CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch];
    
    self.tank.moving = YES;
    [self.tank moveToward:mapLocation];
    
}

- (void)update:(ccTime)dt {
        
    [self setViewpointCenter:self.tank.position];
    
}

Nothing too fancy here - we create a batch node for the sprites and add it as a child of the tile map (so that we can scroll the tile map and have the sprites in the batch node scroll along with it).

We then create a tank and add it to the batch node. We set up touch routines to call the moveToward method we wrote earlier, and on each update keep the view centered on the tank.

Build and run, and now you can tap the screen to scroll your tank all around the map, in any direction!

Checking for Walls

So far so good, except there's one major problem - our tank can roll right across the water! This tank does not have the submersive upgrade yet, so we have to nerf him a bit :]

To do this we need to add a couple more helper methods to HelloWorldLayer.h. Add these methods right above init:

-(BOOL)isProp:(NSString*)prop atTileCoord:(CGPoint)tileCoord forLayer:(CCTMXLayer *)layer {
    if (![self isValidTileCoord:tileCoord]) return NO;
    int gid = [layer tileGIDAt:tileCoord];
    NSDictionary * properties = [_tileMap propertiesForGID:gid];
    if (properties == nil) return NO;   
    return [properties objectForKey:prop] != nil;
}

-(BOOL)isProp:(NSString*)prop atPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer {
    CGPoint tileCoord = [self tileCoordForPosition:position];
    return [self isProp:prop atTileCoord:tileCoord forLayer:layer];
}

- (BOOL)isWallAtTileCoord:(CGPoint)tileCoord {
    return [self isProp:@"Wall" atTileCoord:tileCoord forLayer:_bgLayer];
}

- (BOOL)isWallAtPosition:(CGPoint)position {
    CGPoint tileCoord = [self tileCoordForPosition:position];
    if (![self isValidPosition:tileCoord]) return TRUE;
    return [self isWallAtTileCoord:tileCoord];
}

- (BOOL)isWallAtRect:(CGRect)rect {
    CGPoint lowerLeft = ccp(rect.origin.x, rect.origin.y);
    CGPoint upperLeft = ccp(rect.origin.x, rect.origin.y+rect.size.height);
    CGPoint lowerRight = ccp(rect.origin.x+rect.size.width, rect.origin.y);
    CGPoint upperRight = ccp(rect.origin.x+rect.size.width, rect.origin.y+rect.size.height);
    
    return ([self isWallAtPosition:lowerLeft] || [self isWallAtPosition:upperLeft] ||
            [self isWallAtPosition:lowerRight] || [self isWallAtPosition:upperRight]);
}

These are just helper methods we'll use to check if a given tile coordinate/position/rectangle has the "Wall" property. I'm not going to go over these because they are just review from our earlier tile-based game tutorial.

Open up HelloWorldLayer.h and predeclare all of these methods so we can access them from outside the class if we want:

- (float)tileMapHeight;
- (float)tileMapWidth;
- (BOOL)isValidPosition:(CGPoint)position;
- (BOOL)isValidTileCoord:(CGPoint)tileCoord;
- (CGPoint)tileCoordForPosition:(CGPoint)position;
- (CGPoint)positionForTileCoord:(CGPoint)tileCoord;
- (void)setViewpointCenter:(CGPoint) position;
- (BOOL)isProp:(NSString*)prop atTileCoord:(CGPoint)tileCoord forLayer:(CCTMXLayer *)layer;
- (BOOL)isProp:(NSString*)prop atPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer;
- (BOOL)isWallAtTileCoord:(CGPoint)tileCoord;
- (BOOL)isWallAtPosition:(CGPoint)position;
- (BOOL)isWallAtRect:(CGRect)rect;

Then make the following changes to Tank.m:

// Add right before updateMove
- (void)calcNextMove {
    
}

// Add at bottom of updateMove
if ([_layer isWallAtRect:[self boundingBox]]) {
    self.position = oldPosition;
    [self calcNextMove];
}

The new code in updateMove checks to see if we've moved into a position that is colliding with a wall. If it does, it moves back to the old position and calls calcNextMove. Right now this method does absolutely nothing, but later on we'll override this in a subclass.

Build and run, and now you should no longer be able to sail across the sea!

Contributors

Over 300 content creators. Join our team.