Thursday, 28 July 2011

How to scroll and zoom in/out of a map using motions

So for my first cocos2d game I'm making a tiled map based game. I wanted the player to be able to zoom in and out freely, and to drag and move the level around the camera if it was too big to fit all on the screen.

There was little google help on this, and after days of torture I've got the code working for it, which I hope will help someone.

The code is fully commented and hopefully people can read through it and understand it. The player can pinch-zoom and scroll across the map, as long as the map isn't smaller than the actual screen (otherwise the map would show black spacing).

Everything is just maths to do with the scaling of the original size of the layer and the scaled layer. However when the layer is fully scaled out, there is a slight off set, which I think is just down to fliddly bits in xcode and it can't be helped. To solve this, I did an offset which should fix the offset and make the level have correct boundries: if (bottomLeft.x + mapWidth*mapScale +differenceX/2 < 440+ (1-mapScale)*33) {

where "+ (1-mapScale)*33)" is the fiddly bit.
http://twitter.com/#!/ant_run


//
//
//  Created by petemyster on 21/07/2011.
//  Copyright petemyster. All rights reserved.
//

// When you import this file, you import all the cocos2d classes
#import "cocos2d.h"

@interface easyLevel1 : CCLayer
{
    //map
    CCTMXTiledMap * testMap;
    CCTMXLayer * testLayer;
    CCLayer * mapLayer;
    //ui
    CCLayer * UILayer;
    CCSprite* UIBackground;
    
    //declarations for return to main menu
    CCMenu * returnMenu;
    CCMenuItemImage * returnMenuImage;
    
}
+(CCScene *) scene;
-(void) goBackToMain;
-(void) update:(ccTime) dt;

@property (nonatomic, retain) CCSprite * testSprite;

//map
@property (nonatomic, retain) CCTMXTiledMap * testMap;
@property (nonatomic, retain) CCTMXLayer * testLayer;
@property (nonatomic, retain) CCLayer * mapLayer;
@property int mapWidth;
@property int mapHeight;
@property float mapScale;
@property float distance;
@property float lastGoodDistance;
//UI
@property (nonatomic, retain) CCLayer * UILayer;
@property (nonatomic, retain) CCSprite* UIBackground;

//properties for the return to main menu screen
@property (nonatomic, retain) CCMenu * returnMenu;
@property (nonatomic, retain) CCMenuItemImage * returnMenuImage;

@end


// Import the interfaces
#import "easyLevel1.h"
#import "easyLevelSelect.h"
@implementation easyLevel1
@synthesize testMap,mapLayer, testLayer;
@synthesize  UILayer, UIBackground;
@synthesize returnMenu, returnMenuImage;
@synthesize mapWidth, mapHeight, mapScale, distance, lastGoodDistance;
+(CCScene *) scene;
{
    // 'scene' is an autorelease object.
    CCScene *scene = [CCScene node];
    
    // 'layer' is an autorelease object.
    easyLevel1 *layer = [easyLevel1 node];
    
    // add layer as a child to scene
    [scene addChild: layer];
    
    // return the scene
    return scene;
}
// on "init" you need to initialize your instance
-(id) init
{
    // always call "super" init
    // Apple recommends to re-assign "self" with the "super" return value
    if( (self=[super init])) {
        
        self.isTouchEnabled = YES;
        
        //declare the map and add to a layer
        mapLayer = [CCLayer node];
        testMap = [CCTMXTiledMap tiledMapWithTMXFile:@"testMap.tmx"];
        testLayer = [testMap layerNamed:@"Test Layer 1"];
        [mapLayer addChild:testMap];
        [self addChild:mapLayer];
        
        //get map size in pixels
        mapHeight = testMap.contentSize.height;
        mapWidth = testMap.contentSize.width;
        //set up defaults
        mapLayer.position= ccp(0, 0);
        //this value works well for the calculation later, trial and error really
        distance = 150;
        lastGoodDistance = 150;
        mapScale = 1;
        
        //set up a layer for the UI
        UILayer = [CCLayer node];
        UIBackground = [CCSprite spriteWithFile:@"UIbackgroundImage.png"];
        NSLog(@"%@", UIBackground);
        UIBackground.position= ccp(460, 160);
        [UILayer addChild:UIBackground];
        
        //set up labels for return menu
        returnMenuImage = [CCMenuItemImage itemFromNormalImage:@"backNavigationArrow.png" selectedImage:@"backNavigationArrowPressed.png" target:self selector:@selector(goBackToMain)];
        returnMenu = [CCMenu menuWithItems:returnMenuImage, nil];
        [self addChild:UILayer];
        [UILayer addChild:returnMenu];
        [returnMenu alignItemsVerticallyWithPadding:1];
        [returnMenu setPosition:ccp(460, 300)];
        
    }
    return self;
}
-(void) goBackToMain{
    
    [[CCDirector sharedDirector] replaceScene:[CCTransitionFade transitionWithDuration:0.5 scene:[easyLevelSelect scene:gameMode]]];
    
}
- (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
}
// Override the "ccTouchesMoved:withEvent:" method to add your own logic
- (void)ccTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    //finds the difference in the original map and the scaled map
    int differenceX = mapWidth - (mapWidth*mapScale);
    int differenceY = mapHeight - (mapHeight*mapScale);    
    
    // This method is passed an NSSet of touches called (of course) "touches"
    // "allObjects" returns an NSArray of all the objects in the set
    NSArray *touchArray = [touches allObjects];
    
    // Only run the following code if there is more than one touch, wanting to resize
    if ([touchArray count] > 1)
    {
        // We're going to track the first two touches (i.e. first two fingers)
        // Create "UITouch" objects representing each touch
        UITouch *fingerOne = [touchArray objectAtIndex:0];
        UITouch *fingerTwo = [touchArray objectAtIndex:1];
        
        // Convert each UITouch object to a CGPoint, which has x/y coordinates we can actually use
        CGPoint pointOne = [fingerOne locationInView:[fingerOne view]];
        CGPoint pointTwo = [fingerTwo locationInView:[fingerTwo view]];
        
        CGPoint pointOnePrev = [fingerOne previousLocationInView:[fingerOne view]];
        CGPoint pointTwoPrev = [fingerTwo previousLocationInView:[fingerTwo view]];
        
        // The touch points are always in "portrait" coordinates
        // You will need to convert them if in landscape (which we are)
        pointOne = [[CCDirector sharedDirector] convertToGL:pointOne];
        pointTwo = [[CCDirector sharedDirector] convertToGL:pointTwo];
        
        pointOnePrev = [[CCDirector sharedDirector] convertToGL:pointOnePrev];
        pointTwoPrev = [[CCDirector sharedDirector] convertToGL:pointTwoPrev];
        
        //this statement finds the difference in the pinches that the player is doing now, and the pinch the player was doing
        
        //if they were pinching in, subtract the distance of the pinch from the distance of the previous pinch
        
        if ((sqrt(pow(pointOnePrev.x - pointTwoPrev.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))) > sqrt(pow(pointOne.x - pointTwo.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))) {
            distance -= sqrt(pow(pointOne.x - pointTwo.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))/100;
            distance = fabsf(distance);
        }
        //otherwise they are pinching out and so add on more to the distance
        else  if ((sqrt(pow(pointOnePrev.x - pointTwoPrev.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))) < sqrt(pow(pointOne.x - pointTwo.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))) {
            distance += sqrt(pow(pointOne.x - pointTwo.x, 2.0) + pow(pointOne.y - pointTwo.y, 2.0))/100;
            distance = fabsf(distance);
        }
        
        // Get the distance between the touch points
        
        // Scale the distance based on the overall width of the screen (multiplied by a constant, just for effect)
        mapScale = distance / [CCDirector sharedDirector].winSize.width * 3;
        
        //we don't want the player to be able to zoom out to the point where the map is smaller than the screen
        float minFactor = MAX(440.0/mapWidth, 320.0/mapHeight);
        
        if (mapScale < minFactor) {
            mapScale = minFactor;
            //set this here, so if the player keeps zooming out out out, even though it doesn't change the scale factor, the distance is always decremented. this line keeps it to the last distance that had any effect, stops jumping camera
            distance = lastGoodDistance;
        }
        //we don't want him zooming in larger than the original image
        if (mapScale> 1.0){
            mapScale = 1.0;
            distance = lastGoodDistance;
            
        }    
        
        //set the last distance which had any affect to be the current one
        lastGoodDistance= distance;
        //allow the zoom to take place
        mapLayer.scale=mapScale;
        
        CGPoint bottomLeft = mapLayer.position;
        
        //check to see if the layer is drifting off the screen while the zoom is taking place
        
        if (bottomLeft.x - differenceX/2 > 0 - (mapWidth * (1-mapScale))) {
            bottomLeft.x = differenceX/2- (mapWidth * (1-mapScale));
        }
        
        if (bottomLeft.y - differenceY/2 > 0 -(mapHeight * (1-mapScale))) {
            bottomLeft.y = differenceY/2-(mapHeight * (1-mapScale));
            
        }
        
        if (bottomLeft.x + mapWidth*mapScale +differenceX/2 < 440) {
            bottomLeft.x = -differenceX/2 - mapWidth*mapScale + 440;
            
        }
        if (bottomLeft.y + mapHeight*mapScale +differenceY/2 < 320) {
            bottomLeft.y =  -differenceY/2 - mapHeight*mapScale + 320;
            
        }
        //set the position of the layer
        mapLayer.position = bottomLeft;
        
    }
    
    if ([touchArray count] ==1){
        
        UITouch * fingerOne = [touchArray objectAtIndex:0];
        
        CGPoint newTouchLocation = [fingerOne locationInView:[fingerOne view]];
        newTouchLocation = [[CCDirector sharedDirector] convertToGL:newTouchLocation];
        
        CGPoint oldTouchLocation = [fingerOne previousLocationInView:fingerOne.view];
        oldTouchLocation = [[CCDirector sharedDirector] convertToGL:oldTouchLocation];
        
        //get the difference in the finger touches when the player was dragging
        CGPoint difference = ccpSub(newTouchLocation, oldTouchLocation);
        
        //adds this on to the layers current position, effectively moving it
        CGPoint newPosition = ccpAdd(mapLayer.position, difference);
        
        CGPoint bottomLeft = newPosition;
        
        //check to see if the map edges of the map are showing in the screen, if so bringing them back on the view so no black space can be seen
        if (bottomLeft.x - differenceX/2 > 0 - (mapWidth * (1-mapScale)) + (1-mapScale)*33) {
            bottomLeft.x = differenceX/2- (mapWidth * (1-mapScale))+(1-mapScale)*33;
        }
        
        if (bottomLeft.y - differenceY/2 > 0 -(mapHeight * (1-mapScale))) {
            bottomLeft.y = differenceY/2-(mapHeight * (1-mapScale));
            
        }
        
        if (bottomLeft.x + mapWidth*mapScale +differenceX/2 < 440+ (1-mapScale)*33) {
            bottomLeft.x = -differenceX/2 - mapWidth*mapScale + 440 + (1-mapScale)*33;
            
        }
        if (bottomLeft.y + mapHeight*mapScale +differenceY/2 < 320) {
            bottomLeft.y =  -differenceY/2 - mapHeight*mapScale + 320;
            
        }
        
        mapLayer.position = bottomLeft;
    }
}
- (void)ccTouchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
}
// on "dealloc" you need to release all your retained objects
- (void) dealloc
{
    // in case you have something to dealloc, do it in this method
    // in this particular example nothing needs to be released.
    // cocos2d will automatically release all the children (Label)
    
    // don't forget to call "super dealloc"
    
    [super dealloc];
}
@end

1 comment:

  1. Awesome. Just what i needed.
    Question though, 2 fingers are only being picked up as one (on simulator, haven't done phone cause it's 5.0 right now)

    Any ideas?

    ReplyDelete