Se liikkuu sittenkin!

Ensimmäinen varsinainen harjoitustyöni, viihdepainotteinen aurinkokuntasimulaattori, on lähtenyt ihan mukavasti käyntiin. Perusosat – kosketusnäytön tapahtumiin reagointi, säännöllisten tilannepäivitysten laskeminen ja ruutuun piirtäminen toimivat jotenkuten, joskaan eivät aivan bugeitta, tuskin ihan oikeaoppisesti, eivätkä taatusti ainakaan optimoidusti.

Helpoin osuus oli odotetusti Planet-luokan laatiminen, koska MVC-mallin model-osio toimii täysin itsenäisesti, eikä itselleni vaikeita kytkyjä iOS-käyttöliittymään tarvinnut siis vielä miettiä.  Xcoden new filestä vaan Objective-C class > Subclass of NSObject (joka on iOS-ohjelmoinnissa jonkinlainen ”kaikkien olioiden äiti”) ja tallennetaan nimillä Planet.h ja Planet.m. Tämän luokan muuttujien ja metodien määrittely muistutti eniten sitä ohjelmointia jota opettelin silloin joskus kun Ritari Ässä oli vielä kova juttu.

Planet.h

Header-tiedostossa oli #import <Foundation/Foundation.h> joten jätin sen siihen ja naputtelin perään muutamia vakioita kuten:

#define PLANET_GROWTH_RATIO 0.5
#define MAX_PLANET_RADIUS 50.0

Tämä on simppeli tapa määritellä nimettyjä vakioita. Muitakin tapoja näytti olevan, mutta tämä vaikutti tarpeisiini sopivalta.

Sitten määrittelin @interfacen, jossa esitellään luokan julkiset muuttujat, esimerkiksi nämä:

@interface Planet : NSObject {
    BOOL tooFar;
    float radius;
    float mass;
    NSTimeInterval birthday;
    // jne.
}

Noiden floatien sijaan suositeltiin käytettäväksi CGFloatia, kuulemma helpottaa 64-bittisyyteen siirtymistä, mutta kaipasin hämmentävän koodin joukkoon jotain ennestään tuttua, joten käytin float-määritystä. Tuo NSTimeInterval taisi olla sekin vain normaali float tms., mutta koska sitä käytetään nimenomaan ajan mittaamiseen se on ymmärrettävyyden vuoksi nimetty noin. En tiedä, olisiko jotenkin parempi käyttää näissäkin pointereita, mutta olen jotenkin vielä arka käyttämään niitä muuten kuin pakon edessä (olioihin viitatessa).

Sitten @propertyt (eli ne getter/setter-metodin automatisointikäskyt) samoille muuttujille tähän tapaan:

@property(nonatomic, readwrite, getter=isTooFar) BOOL tooFar;
@property(nonatomic, readwrite) float radius;
@property(nonatomic, readwrite) float mass;

Ja vielä metodien nimet:

-(id) initWithLocation: (CGPoint)theLocation birthday:(NSTimeInterval)theBirthday;
-(void) calculateGravityWithSolarSystem: (NSDictionary *)theSolarSystem;
//jne.

Tuo eka on alustusmetodi, joten siinä on paluuarvona (id), eli tässä tapauksessa planeetta-luokan instanssi. Tuo painovoima-metodi ei palauta mitään lopputulosta vaan muuttaa vain sen planeetta-instanssin sisäisiä arvoja, josta sitä kutsutaan,  joten paluuarvona on (void). Samaan tapaan kaikki tarvittavat metodit ja sitten loppuun vielä @end, niin päästään varsinaiseen metodien määrittelyyn.

Planet.m

Tässä jonkin verran karsittu listaus tiedoston alusta:

#import "Planet.h"
@implementation Planet

@synthesize tooFar;
@synthesize radius;
@synthesize mass;
// jne.

-(id) initWithLocation:(CGPoint)theLocation birthday:(NSTimeInterval)theBirthday
{    self = [super init]; //tämä rivi piti olla, se liittyi jotenkin periytymishommiin mutten nyt muista tarkemmin
    if (self) { //pitää varmistaa että edellisen rivin komento onnistui ennen kun aloitetaan alustelu
        self.xCenter = theLocation.x;
        self.yCenter = theLocation.y;
        self.birthday = theBirthday;
        self.tooFar = NO;
        self.radius = BABY_PLANET_RADIUS;
        self.diameter = self.radius*2;
        self.mass = (self.radius * self.radius * self.radius) / MASS_DIVIDER;
        self.xSpeed = 0;
        //jne.
    }
    return self;
}

-(void) calculateGravityWithSolarSystem: (NSDictionary *)theSolarSystem {
    float xDistance, yDistance, distance = 0.0;
    double multipliedMasses, distanceSqr, gravity, xGravity, yGravity = 0.0;
    // seuraavat kolme riviä käyvät läpi theSolarSystem -Dictionaryn
    // kaikki planeetat, eräänlainen looppi siis, kiitti vinkistä www.hopeinenomena.net
    NSEnumerator *enumerator = [theSolarSystem keyEnumerator];
    id key;
    while ((key = [enumerator nextObject])) {
        xDistance = ([[theSolarSystem objectForKey:key] xCenter] - self.xCenter);
        yDistance = ([[theSolarSystem objectForKey:key] yCenter] - self.yCenter);
        distance = sqrtf((xDistance * xDistance) + (yDistance * yDistance));
        if (distance != 0) {
            if (distance < (self.radius + [[theSolarSystem objectForKey:key] radius])){
                // Ympyröiden törmäystunnistus, hillitsee älytöntä kiihtymistä pakottamalla
                // etäisyysmuuttujan vähintään planeettojen säteiden summan suuruiseksi.
                // Ei kimpoamislaskelmia tms. vielä, väri vain muuttuu punaiseksi
                distance = (self.radius + [[theSolarSystem objectForKey:key] radius]);
                NSLog(@"Hit!");
                self.red = 1;
            }
            multipliedMasses = (self.mass * [[theSolarSystem objectForKey:key] mass]);
            distanceSqr = (distance * distance);
            gravity = multipliedMasses / distanceSqr;
            xGravity = gravity * xDistance / distance;
            yGravity = gravity * yDistance / distance;
            self.xForce += (xGravity);
            NSLog(@"distance: %f, gravity: %f, multipliedMasses: %f, distanceSqr: %f", distance, gravity, multipliedMasses, distanceSqr);
            NSLog(@"xDist: %f, xForce: %f, xGravity: %f", xDistance, self.xForce, xGravity);
            self.yForce += (yGravity);                    
            NSLog(@"yDist: %f, yForce: %f, yGravity: %f", yDistance, self.yForce, yGravity);

        }
    }
}
// jne.

Tuo calculateGravity… oli melko kinkkinen, mutta ei varsinaisten gravitaatiolaskelmien takia, vaan koska en meinannut millään osata tehdä luuppia. Samaan ongelmaan törmäsin myös myöhemmin, kun ajastinmetodin sisällä piti kutsua kaikkien planeettojen päivitystoimintoja vuorotellen. Onneksi  hopeinenomena.netin avuliaat toverit tulivat hätiin ja neuvoivat tuon enumerator-objektin käytön. Tästä tarkemmin vähän jäljempänä. Tuo Dictionary joka koodissa paljon toistuu, on muuten kuten taulukko, mutta siihen tallennetaan objektin kaveriksi aina key-arvo, jota käyttämällä päästään aina tarvittaessa käsiksi oikeaan objektiin.

SolarSystemView.h ja .m

Planeetat piirretään view-luokan drawRect-metodissa, jonka käyttöä aiemmin jo vähän opeteltiinkin. View-luokassa tarkkaillaan kuitenkin myös kosketusnäyttöä ja luodaan kosketusten perusteella uusia planeettoja. Header on lyhyt, kokonaisuudessaan tässä:

#import <UIKit/UIKit.h>
#import "Planet.h"
#import "SolarSystemViewController.h"
#import "SolarSystemAppDelegate.h"

@class SolarSystemViewController;

@interface SolarSystemView : UIView {
    SolarSystemViewController *solarSystemViewController;
    float dashes[2];
    NSMutableDictionary *solarSystem;
}
@property(nonatomic, retain) SolarSystemViewController *solarSystemViewController;
@property(nonatomic, readwrite, retain) NSMutableDictionary *solarSystem;

@end

Siinä pohjustetaan Viewin paritusta ViewControllerin kanssa, josta tarkemmin kohta. Ekaksi kurkataan kuitenkin pari juttua View.m:stä. Se alkaa näin:

#import "SolarSystemView.h"
@implementation SolarSystemView
@synthesize solarSystemViewController;
@synthesize solarSystem;

- (id)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];
    if (self) {
        self.backgroundColor = [UIColor blackColor];
        dashes[0] = 2;
        dashes[1] = 5;

        solarSystem = [[NSMutableDictionary alloc] initWithCapacity:20];
    }

    return self;
}
//jatkuu...

Tuossa heti alussa on taas ilmiö, joka tulee Cocoan kanssa usein vastaan: tuota initWithFrameahan ei määritelty headerissa lainkaan, silti sen toiminta tuossa selvästi määritellään. Se on yksi näistä lukuisista vakiomuotoisista metodeista, joita iOS kutsuu automaattisesti sopivissa tilanteissa, ja jotka koodaaja uudelleenmäärittelee tekemään mitä sitten haluaakaan. Tässä tapauksessa määrittelemään taustavärin sekä vähän nolosti tuon dashes-taulukon tuossa, koska en osannut tehdä taulukkoa vakiona (tuota dashes-taulukkoa tarvitaan myöhemmin planeettojen ääriviivoissa käytetyn katkoviivan määrittelyyn). Ja sitten alustetaan tuo solarSystem -dictionary, johon kaikki luotavat planeetat tullaan laittamaan talteen.

Saman tiedoston drawRect-metodissa loopataan läpi kaikki kirjastoon tallennetut planeetat ja piirretään niiden perustella sopivankokoiset ympyrät oikeille paikoilleen, tähän tapaan:

// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
    CGContextRef solarSystemContext = UIGraphicsGetCurrentContext();
    //CGContextClearRect (solarSystemContext,CGRectMake(0, 0, 320, 480));
    CGContextSetLineWidth(solarSystemContext, 2);
    CGContextSetLineDash (solarSystemContext, 0, dashes, 2);
    CGContextSetLineCap(solarSystemContext, kCGLineCapRound);
    NSString *key;
    for(key in solarSystem){
        CGContextSetRGBFillColor (solarSystemContext, [[solarSystem objectForKey: key] red], [[solarSystem objectForKey: key] green], [[solarSystem objectForKey: key] blue], [[solarSystem objectForKey: key] alphaValue]);
        CGContextSetRGBStrokeColor (solarSystemContext, [[solarSystem objectForKey: key] red], [[solarSystem objectForKey: key] green], [[solarSystem objectForKey: key] blue], [[solarSystem objectForKey: key] alphaValue]);    
        CGContextAddEllipseInRect (solarSystemContext, CGRectMake ([[solarSystem objectForKey: key] leftEdge], [[solarSystem objectForKey: key] topEdge], [[solarSystem objectForKey: key] diameter], [[solarSystem objectForKey: key] diameter]));
        CGContextDrawPath(solarSystemContext, kCGPathFillStroke);
    }
    [key release];
}

Tuossa olin näköjään osannut käyttää luuppia Dictionaryn läpikäyntiin noin yksinkertaisesti, miksihän en muistanut tuota tapaa siellä Planet-luokan koodissa tai myöhemmin siellä Timer-metodissa… no, tulipa treenattua erilaisia luuppaustapoja!

Edelleen, täällä SolarSystemView.m:ssä on määritelty uudestaan kosketusnäytön tapahtumiin reagointimetodit. Nämä voisivat ymmärtääkseni olla View Controllerinkin puolella kuten ajastinmetoditkin (oho, spoileri!), ja minulla on sellainen fiilis, että ehkä niiden tyylikkyyden nimissä pitäisikin, jotta View-objektille jäisi selkeästi pelkkä ruudun piirto. Mutta en ole ihan varma, joten ne nyt jäivät tähän – kumpikaan tapa ei siis ole varsinaisesti laiton. Mutta ne touches-metodit, tässä:

// Handles the start of a touch
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{    
    //NSUInteger numTaps = [[touches anyObject] tapCount];
    // Enumerate through all the touch objects.
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInView:self];
        NSString *addressToTouchThatCreatedAPlanet = [[NSString alloc] initWithFormat:@"%u", touch];
        Planet *aPlanet = [[Planet alloc] initWithLocation:touchLocation birthday:[touch timestamp]];

        [solarSystem setObject: aPlanet forKey: addressToTouchThatCreatedAPlanet];
        [aPlanet release];
        [addressToTouchThatCreatedAPlanet release];

        /*
        Tällä palautetaan osoite takaisin integeriksi
        unsigned int addressAsInteger = [addressToTouchThatCreatedAPlanet intValue];
        UITouch *testTouch = addressAsInteger;*/
        [self setNeedsDisplay];
    }    
}

// Handles the continuation of a touch.
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{  
    // Enumerates through all touch objects
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInView:self];

        Planet *aPlanet = [solarSystem objectForKey: [NSString stringWithFormat:@"%u", touch]];
        [aPlanet movePlanetToLocation:touchLocation];
    }

}

// Handles the end of a touch event.
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // Enumerates through all touch object
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInView:self];
        CGPoint previousTouchLocation = [touch previousLocationInView:self];

        Planet *aPlanet = [solarSystem objectForKey: [NSString stringWithFormat:@"%u", touch] ];
        [aPlanet setPlanetFreeWithXSpeed: (touchLocation.x - previousTouchLocation.x) ySpeed:(touchLocation.y - previousTouchLocation.y)];
    }
}

-(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    // Enumerates through all touch object
    for (UITouch *touch in touches) {
    // täällä pitäisi varautua siihen, jos touch-tapahtuma keskeytyy esim. puhelimen soidessa, mutta en ole viitsinyt :)
    }
}

Tuossa ekassa metodissa touchesBegan käy ilmi myös minkä takia päädyin Dictionaryyn planeettojen tallentamiseksi enkä settiin tai arrayhin. Kaunis ajatukseni oli tallentaa planeetan key-pariksi sen kosketuksen pointteriosoitteen, jolla kyseinen planeetta on luotu, jotta multitouch-tilanteessa planeetta osaisi pysytellä juuri oikean kosketuksen mukana. Itse asiassa tämä toimiikin, mutta samalla syntyi koodin toistaiseksi järein bugi. iOS nimittäin kierrättää kosketusten pointteriosoitteita aika tehokkaasti vanhojen kosketusten päätyttyä,  joten uudet planeetat alkavat ennen pitkää tallentua vanhojen päälle. Tämä pitäisi korjata käyttämällä dictionaryä vain kosketusten ajan, tai tallentaa koko kosketuksen osoitetieto vaikka tuonne planeetta-luokan muuttujaan ja tarkistaa touchesMoved-metodissa sieltä käsin. Tämä on vielä vähän hakusessa🙂

SolarSystemViewController.h

Tämä on hyvin simppeli:

#import <UIKit/UIKit.h>
#import "SolarSystemView.h"
#import "Planet.h"

@class SolarSystemView;

@interface SolarSystemViewController : UIViewController {
    SolarSystemView *solarSystemView;
    NSTimer *solarSystemTimer;
}

@property (nonatomic, assign) SolarSystemView *solarSystemView;

-(void) updateSolarSystem:(NSTimer*)theTimer;

@end

Tuossa määriteltiin solarSystemView-instanssi, jotta näkymää päästään kontrolloimaan. Ja sitten tuo tärkeä NSTimer *solarSystemTimer -ajastinobjekti, jota tarvitaan säännöllisen tilannepäivitysrutiinin käynnistämiseen. ViewControllerin ainoa oma metodi onkin tuo updateSolarSystem, jota tuolta ajastimesta sitten säännöllisesti kutsutaan.

SolarSystemViewController.m

Täällä ei oikeastaan olekaan muuta kuin tuo updateSolarSystem-metodi sekä ViewController-templatessa valmiina tulleet metodit, joista tarvitsi uudelleenmääritellä vain tuo loadView

#import "SolarSystemViewController.h"
@implementation SolarSystemViewController
@synthesize solarSystemView;

-(void) updateSolarSystem: (NSTimer*)theTimer {
     NSMutableArray *keysForPlanetsToRemove = [NSMutableArray array];
     NSEnumerator *enumerator = [[solarSystemView solarSystem] keyEnumerator];
     id key;
     while ((key = [enumerator nextObject])) {

         if ([[[solarSystemView solarSystem] objectForKey:key] isTooFar])
         { [keysForPlanetsToRemove addObject:key];
         NSLog(@"%@", keysForPlanetsToRemove);}

         else {
             [[[solarSystemView solarSystem] objectForKey:key] growPlanetUnderConstruction];
             [[[solarSystemView solarSystem] objectForKey:key] calculateGravityWithSolarSystem:[solarSystemView solarSystem]];
             [[[solarSystemView solarSystem] objectForKey:key] movePlanet];    
         }
     }
    [[solarSystemView solarSystem] removeObjectsForKeys:keysForPlanetsToRemove];

    NSLog(@"Planeettoja: %u", [[solarSystemView solarSystem] count]);
    [solarSystemView setNeedsDisplay];
}

// Implement loadView to create a view hierarchy programmatically, without using a nib.
- (void)loadView {
    self.wantsFullScreenLayout = YES;
    SolarSystemView *view = [[SolarSystemView alloc] initWithFrame:[UIScreen mainScreen].applicationFrame];
    view.solarSystemViewController = self;
    self.view = view;
    self.solarSystemView = view;
    self.view.multipleTouchEnabled = YES;
    [view release];
    // Timerin ohjelmointi
    solarSystemTimer = [[NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(updateSolarSystem:) userInfo:nil repeats: YES] retain];

}

/*
// Implement viewDidLoad to do additional setup after loading the view, typically from a nib.
- (void)viewDidLoad {
    [super viewDidLoad];
}
*/

/*
// Override to allow orientations other than the default portrait orientation.
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation {
    // Return YES for supported orientations.
    return (interfaceOrientation == UIInterfaceOrientationPortrait);
}
*/

- (void)didReceiveMemoryWarning {
    // Releases the view if it doesn't have a superview.
    [super didReceiveMemoryWarning];

    // Release any cached data, images, etc. that aren't in use.
}

- (void)viewDidUnload {
    [super viewDidUnload];
    // Release any retained subviews of the main view.
    // e.g. self.myOutlet = nil;
}

- (void)dealloc {
    [super dealloc];
}

@end

Tämä rivi oli itselleni ehkä koko harjoituksen valaisevin:

solarSystemTimer = [[NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(updateSolarSystem:) userInfo:nil repeats: YES] retain];

Tuolla siis laitetaan käyntiin ajastin, joka lähettää kerran sadasosasekunnissa (jos pystyy) kohteelle self, eli tälle ViewControllerille itselleen metodikutsun updateSolarSystem. Tuossa siis vihdoin on se ”pääluuppi” tai ainakin niin lähellä sitä kuin Cocoa-maailmassa pääsee ilman todellisia hc-kikkoja. Ja tuolta loadView-metodin lopusta sille löytyi mielestäni ihan looginen paikkakin – ajastinta tarvitaan ekan kerran siinä vaiheessa kun eka näkymä on latautunut loppuun.

Jaha, kello on 4:22

Ehkä pitää muuten mainita, että tässä harjoituksessa jätin tarkoituksella kaikki Interface Builderilla wysiwyg-tyyliin laaditut näkymät kokonaan pois, ja kokeilin näkymän luomista ohjelmallisesti – osittain harjoittelun vuoksi ja osittain, koska IB-elementtien linkitys koodiin aiheuttaa vielä toistaiseksi enemmän hämmennystä kuin helpotusta. Kannattaa kuitenkin ehdottomasti käyttää Interface Builderia, jos tarvitset lähimainkaan Apple-tyylistä vakionavigointia – vakioelementit ovat aika pitkälle tuunattavissa, joten omaakin ilmettä niillä kyllä saa. Tähänkin projektiin varmaan käyttäisin IB-näkymiä esimerkiksi preferences-valikkoon ja planeettanäkymän päälle tuleviin pistelaskureihin tms.; pitää vain ensin opetella se linkitys paremmin.

Tuo NSLog on muuten aika välttämätön komento työstövaiheessa. Xcoden Run-valikosta löytyvä Console kannattaa pitää auki ja NSLogilla lähetellä työstövaihessa tarvittavaa tietoa sinne, jotta pääsee kärryille esim. ohjelmakoodin bugeista.

Korostan siis vielä, että näitä lähdekoodeja ei missään tapauksessa kannata pitää esimerkkitapauksina oikeaoppisesta Cocoa-ohjelmoinnista. Nämä ovat harjoitusvaiheen koodeja ja täynnä tehottomuuksia ja epäloogisuuksia. Otan mielelläni vastaan kaikenlaisia vinkkejä edistyneemmiltä iOS-koodaajilta!

Testatessa on kyllä ilmennyt harmillinen juttu, jota en osannut ihan ennakoida, nimittäin fysiikan lait. Planeetan saaminen toisen kiertoradalle onkin muuten aika pirun tarkkaa hommaa, eikä se oikein onnistu näppituntumalta, kuten olin mielessäni alunperin hahmotellut. Jos näkymän laittaisi zoomailemaan ulospäin tarvittaessa, jotta pystyisi hahmottamaan isomman palan avaruutta kerralla, voisi tämä helpottua, mutta varsinaisen aurinkokunnan kasaaminen edellyttäisi kyllä koodiin jonkinlaista tekoälyä, joka vaivihkaa avustaisi planeettoja toisiaan kiertäville radoille. Se on ehkä tässä vaiheessa turhan monimutkaista, joten tämä harjoitus taitaa nyt  jäädä odottelemaan taitojeni karttumista. Monenlaista muuta on kuitenkin kehitteillä, ei tämä tähän lopu!

Kurkista tästä applikaatiota toiminnassa.

Vastaa

Täytä tietosi alle tai klikkaa kuvaketta kirjautuaksesi sisään:

WordPress.com-logo

Olet kommentoimassa WordPress.com -tilin nimissä. Log Out / Muuta )

Twitter-kuva

Olet kommentoimassa Twitter -tilin nimissä. Log Out / Muuta )

Facebook-kuva

Olet kommentoimassa Facebook -tilin nimissä. Log Out / Muuta )

Google+ photo

Olet kommentoimassa Google+ -tilin nimissä. Log Out / Muuta )

Muodostetaan yhteyttä palveluun %s

%d bloggers like this: