senäh

17senäh und so…

Flash/AS3
11. Jul 2011
Kommentare: 0

GameStateManager, GameFactory und GameObjects

Kategorien: Flash/AS3 | 11. Jul 2011 | Kommentare: 0

Serie: How to develop a Doodle Jump clone in 30 minutes

Rapid Prototyping ist ein wichtiger Bestandteil in der Entwicklung von Videospielen, der gerne  übersehen wird. Dabei ist er so wichtig, dass er als eigene Disziplin an sich gesehen werden kann. Ein erfahrener Programmierer muss nicht zwangsläufig ein guter Prototype Engineer sein und andersherum genauso. Jeder gute Programmierer weiß wie wichtig aufgeräumter und wiederverwendbarer Code ist, aber diese Merkmale sind beim Rapid Prototyping eher zweitrangig, weil dort vor allem Schnelligkeit in der Entwicklung zählt. Insofern fällt es vielen Programmierern schwer gewohnte und angelernte Arbeitsweisen einfach über Bord zu werfen, nur um schneller zu entwickeln. Genau vor  diesem Problem stand ich auch, als ich Ginger Jump entwickelte.

Eigentlich hatte ich für die Entwicklung nur einen Tag veranschlagt, aber so bald ich eine neue Funktion eingebaut hatte, wollte ich sie irgendwie in einer eigenen Klasse kapseln, damit ich sie für spätere Prototypen wiederverwenden kann. Herausgekommen ist eine viel längere Entwicklungszeit als ursprünglich geplant (~5 Tage), aber dafür auch die zweite Version eines kleinen persönlichen Frameworks zur Entwicklung von Spieleprototypen. Wieso zweite? Weil die erste Version quasi aus allen Projekten besteht, die ich zuvor bereits entwickelt hatte und diese zweite Version ist ein erster Versuch, alle nützlichen Klassen aus diesen Projekten zusammenzuführen. Puuh… etwas verwirrend…

Dieses Framework ist noch hochexperimentell und an vielen Stellen unvollständig, reicht aber bereits aus um erste einfache Prototypen wie einen Doodle Jump Klon zu entwickeln. Denn auch wenn man aus Zeitgründen bei der Prototypisierung größtenteils auf sauberen, wiederverwendbaren Code verzichten kann/soll, habe ich eins gelernt: mit den richtigen Frameworks zur Hand kann man die Prototypisierung sehr stark beschleunigen!

Und wie das abläuft, zeige ich euch nun am Beispiel von Ginger Jump.

Hinweis: Da ihr euch anscheinend für Rapid Prototyping interessiert, gehe ich davon aus, dass ihr bereits etwas Erfahrung in der Videospielprogrammierung habt. Ich werde deswegen viele Punkte wie GameLoops und GameStates nicht näher erklären.

Die richtigen Frameworks

Für die Entwicklung von Ginger Jump benötigen wir folgende Frameworks:

  • TweenMax: Mit TweenMax könnt ihr sehr komplexe Tweens erstellen. Wir benötigen TweenMax hauptsächlich für die Bewegungen der Gegner.
  • Box2D 2.1a: Box2D ist eine Physik-Engine. In unserem Fall missbrauchen wir Box2D hauptsächlich als Kollisionserkennungsframework, aber auch um die Spielfigur zu bewegen.
  • PIPO Framework: Das PIPO Framework in Version 0.002, welches auf diesen Artikel zugeschnitten ist. Es hilft einem bei der schnellen Entwicklung von Spieleprototypen. Das Akronym PIPO steht für… äh… prototype, iterate, play, optimize. Ja, das klingt ganz gut.

Zu TweenMax und Box2D werde ich nichts näher sagen. Es gibt zig  Tutorials zu diesen Themen. Für Box2D kann ich bspw. die Seite von Emanuele Feronato empfehlen! Ansonsten setze ich voraus, dass ihr bereits etwas Erfahrung mit diesen Frameworks habt.

Dies kann ich bei PIPO natürlich nicht voraussetzen, weil dies der erste öffentliche Auftritt des Frameworks ist. *Trommelwirbel* Da es sich noch um eine sehr frühe Version des Frameworks handelt und stetigem Wandel unterliegt, werde ich euch aber jetzt nicht en détail die Funktionsweise erklären, sondern euch nur einen groben Überblick verschaffen.

Das PIPO Framework besteht im wesentlichen aus zwei Teilen: einem game Package und einem utils Package. Das game Package stellt Hilfsklassen für die Realisierung einer rudimentären Spielumgebung mit View, GameLoops, GameObjects, etc. Das utils Package stellt Hilfsklassen für Spiele (z.B. eine spezielle Math-Klasse für in Spielen benötigte mathematische Formeln), aber auch für andere Anwendungsfälle (z.B. eine Klasse die einen Screenshot von eurer Stage macht).

Die Funktionsweise des game Packages ist etwas an PureMVC orientiert. Ihr habt eine GameFacade, welche Dreh- und Angelpunkt ist um Grafiken zu eurem View hinzuzufügen oder Sounds abzuspielen. Der View ist in unserem Beispiel gleichzusetzen mit der Stage. Die GameFacade hält außerdem eine Instanz des GameStateManagers. Hier wird u.a. festgelegt wie die Übergänge vom Spielmenü zum Spiel und zurück ablaufen. Die GameFacade stellt zudem eine GameLoop bei der man verschiedene Systems registrieren kann. Diese Systems enthalten GameObject übergreifende Spiellogiken (z.B. für die Physiksimulation). Natürlich stellt das game Package auch Basisklassen für GameObjects für unsere späteren Gegner und Spielfiguren. Systems und GameObjects werden über eine GameFactory erstellt. Die Kommunikation zwischen als diesen Klassen wird über Notifications gelöst.

Verwirrt? Kann ich mir vorstellen. Eine etwas einfachere Erklärung:

  • Im GameStateManager wird festgelegt wann etwas erstellt werden soll.
  • In der GameFactory wird festgelegt was erstellt werden.
  • Es können Systems (objektübergreifende Logik/Daten) und GameObjects (objektbezogene Logik/Daten) erstellt werden.
  • Diese Klassen kommunizieren untereinander über Notifications.
  • Zusammengehalten werden sie von der GameFacade über die man außerdem Grafiken anzeigen und Sounds abspielen kann. Der Zugriff der GameFacade erfolgt über „this._facade“. (Ausnahme: GameObjects)

So viel zur Theorie. Jetzt fehlen nur noch ein paar Assets und dann wird es praktisch:

  • Ginger Jump Assets: Ladet euch die Assets herunter. Sie enthalten Grafiken und Sounds, die wir für den Prototypen benötigen.

GameStateManager – Part I

Legt ein neues FlashDevelop-Projekt (oder das IDE eurer Wahl) an und bindet die oben erwähnten Frameworks und Assets ein. Die Größe der .swf beträgt 480×800 Px und hat 30 fps.

Zunächst erstellen wir den GJStateManager (GJ = Ginger Jump).

package de.senaeh.pipo.gingerjump
{
	import de.senaeh.pipo.game.facade.IGameFacade;
	import de.senaeh.pipo.game.statemanager.GameStateManager;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class GJStateManager extends GameStateManager
	{
		public static const STATE_INIT:String = "STATE_INIT";
		public static const STATE_MENU:String = "STATE_MENU";
		public static const STATE_GAME:String = "STATE_GAME";

		override public function init(facade:IGameFacade):void
		{
			super.init(facade);
			// TODO: fill with code
		}

		override protected function onDeactivationState(currentState:String, futureState:String):void
		{
			trace("deactivate this state: " + currentState);
			switch(currentState)
			{
				// TODO: fill with code
			}
		}

		override protected function onActivationState(oldState:String, currentState:String):void
		{
			trace("activate this state: " + currentState);
			switch(currentState)
			{
				// TODO: fill with code
			}
		}
	}
}

Der GJStateManager erbt von GameStateManager. Jeder GameStateManager besteht im wesentlichen aus drei Teilen: einer init-Funktion, sowie den Funktionen onDeactivationState und onActivationState, welche das De- und Aktivieren von GameStates handhaben. Für Ginger Jump benötigen wir drei States, welche wir als public static const Strings festlegen:

  • STATE_INIT: Wird nur einmal zu Beginn aufgerufen und enthält einmalige Inititalisierungsvorgänge. Dazu gehören die Voreinstellung für den Sound und das Erstellen der Systems.
  • STATE_MENU: Ruft die Erstellung des Spielmenüs auf.
  • STATE_GAME: Ruft die Erstellung der GameObjects auf und startet GameLoop.

In der init-Funktion erstellen wir eine Instanz der GJFactory, welche wir für die Wechsel zwischen den States benötigen. Erstellt nach den States die Variable private var _factory:GJFactory; und ersetzt die Zeile // TODO: fill with code in init(); mit: _factory = new GJFactory(facade);.

Die GJFactory erhält eine Instanz der GameFacade, um später Grafiken und Sounds zum Spiel hinzuzufügen. Wenden wir uns jetzt erst einmal der GJFactory zu bevor wir onActivationState(); und onDeactivationState(); mit Logik füllen.

GJFactory – Part I

Die GJFactory erstellt alle Systems und GameObjects, die wir benötigen. In unserem speziellen Fall erstellt sie außerdem zwei Sprites, die wir als Container für die Spiel- und die UI-Grafiken nutzen werden. Erstellt die GJFactory folgendermaßen:

package de.senaeh.pipo.gingerjump
{
	import de.senaeh.pipo.game.facade.IGameFacade;
	import de.senaeh.pipo.game.factory.GameFactory;
	import de.senaeh.pipo.game.systems.logic.LogicSystem;
	import de.senaeh.pipo.game.systems.physic.Box2DSystem;
	import de.senaeh.pipo.gingerjump.systems.LevelSystem;
	import de.senaeh.pipo.utils.events.SoundEvent;
	import flash.display.Sprite;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class GJFactory extends GameFactory
	{
		private var _levelContainer:Sprite;
		private var _uiContainer:Sprite;

		private var _box2DSystem:Box2DSystem;
		private var _logicSystem:LogicSystem;
		private var _levelSystem:LevelSystem;

		public function GJFactory(facade:IGameFacade):void
		{
			super(facade);
		}

		override protected function init():void
		{
			// graphic container
			_levelContainer = new Sprite();
			_facade.addSprite(_levelContainer);
			_uiContainer = new Sprite();
			_facade.addSprite(_uiContainer);
			// systems
			_box2DSystem = new Box2DSystem(_facade, 0, 17);
			_logicSystem = new LogicSystem();
			_levelSystem = new LevelSystem(this, _levelContainer, _facade.view.stageWidth, _facade.view.stageHeight, _uiContainer);
		}

		/*
		 * Systems
		 */
		public function get box2DSystem():Box2DSystem
		{
			return _box2DSystem;
		}

		public function get logicSystem():LogicSystem
		{
			return _logicSystem;
		}

		public function get levelSystem():LevelSystem
		{
			return _levelSystem;
		}
	}
}

Wie ihr sehen könnt, erbt GJFactory von GameFactory. Überschreibt init(); und erstellt dort die zwei Sprite-Container. Mit „_facade.addSprite(sprite);“ fügt ihr diese Sprites zum View hinzu. (Die Container sind ab dann immer Childs des Views. Da sie im Augenblick selbst noch keine Grafiken enthalten sind sie quasi unsichtbar. Darum stören sie auch nicht, wenn wir sie jetzt schon hinzufügen, obwohl wir sie noch nicht brauchen.) Erstellt anschließend Instanzen des LogicSystems und des Box2DSystems, welche mit dem game Package mitgeliefert werden. Das Box2DSystem benötigt eine GameFacade-Instanz, sowie zwei Parameter, die die Gravitations in der Spielwelt beschreiben. In unserem Fall 0 in x-Richtung und 17 in y-Richtung. Dadurch fällt die Spielfigur später. Warum 17? Das hat kein speziellen Grund. Je höher, desto schneller fällt die Spielfigur. 17 hat sich beim Testen der Spielgeschwindigkeit richtig angefühlt. War ja irgendwie klar… Das LogicSystem ruft pro GameLoop-Update eine Update-Funktion in ausgewählten GameObjects auf.

Wie ihr vielleicht bemerkt habt, existiert das LevelSystem noch nicht im game Package. Ihr müsst es selbst noch erstellen. Später wird das LevelSystem dafür zuständig sein, dass Levelscrolling zu berechnen und um Gegner und Plattformen zu spawnen. Es wird die komplexeste Klasse in diesem Tutorial, weswegen ich sie erst zum Schluss behandeln werde. Fürs erste reicht es aus, wenn ihr das LevelSystem so erstellt:

package de.senaeh.pipo.gingerjump.systems
{
	import de.senaeh.pipo.game.systems.System;
	import de.senaeh.pipo.gingerjump.GJFactory;
	import flash.display.Sprite;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class LevelSystem extends System
	{
		private var _factory:GJFactory;
		private var _levelContainer:Sprite;
		private var _uiContainer:Sprite;

		private var _levelWidth:int;
		private var _levelHeight:int;

		public function LevelSystem(factory:GJFactory, levelContainer:Sprite, levelWidth:int, levelHeight:int,
			uiContainer:Sprite):void
		{
			_factory = factory;

			_levelContainer = levelContainer;
			_levelWidth = levelWidth;
			_levelHeight = levelHeight;

			_uiContainer = uiContainer;
		}
	}
}

LevelSystem erbt von System und speichert eine Instanz der  GJFactory, die UI- und Level-Container, sowie die Höhe und Breite des Views. Später tauchen wir tiefer in die Funktionalitäten des LevelSystems ein.
Die GJFactory kann jetzt Systems erzeugen, auf die wir per Getter zugreifen können. Ein tolle Sache. Aber damit erfüllt sie erst eine Hälfte ihrer Aufgaben. Die andere Hälfte besteht daraus GameObjects zu erzeugen. Diese müssen wir aber zunächst näher beschreiben.

GameObjects ganz allgemein erklärt

Ein GameObject besteht im Wesentlichen aus einem MovieClip (= this.graphic), aus Funktionen um diesen MovieClip zu manipulieren (set position();) und aus einem type-String. Der type-String ist im Grunde der Name des GameObjects (= Enemy, Platform, Item, etc.). Außerdem stellt GameObject drei wichtige Funktionen:

  • init();: Überschreibt diese Funktion für spezielle Initialisierungsvorgänge.
  • update(delta:uint);: Überschreibt diese Funktion für Spiellogiken, die pro GameLoop-Update ausgeführt werden sollen. Der Parameter delta gibt die Zeitdifferenz seit dem letzten Update an. Um diese Funktion nutzen zu können, muss man das GameObject beim LogicSystem hinzufügen.
  • kill();: Überschreibt diese Funktion, um bestimmte Aufgaben beim Löschen des GameObjects vorzunehmen. Ihr könnt hier bspw. spezielle EventListener entfernen.

Das game Package stellt noch eine erweiterte Form des GameObjects bereit: das PhysicalGameObject. Dieses besteht zusätzlich zum MovieClip und dem type-String aus einem Box2D-Body. Außerdem wurde die Funktionen des PhysicalGameObjects so verändert, dass sie den MovieClip und den Box2D-Body beeinflussen. So verändert eine neue Positionsangabe nicht nur den MovieClip, sondern gleichzeitig auch den Box2D-Body. Außerdem stellt das PhysicalGameObject vier spezielle Box2D-Contact-Funktionen bereit, welche – gesteuert von einem speziellen ContactListener – aufgerufen werden:

  • onBeginContact(contact:b2Contact, gameObject:IPhysicalGameObject): Wird aufgerufen, wenn ein PhysicalGameObject anfängt ein anderes PhysicalGameObject zu berühren.
  • onEndContact(contact:b2Contact, gameObject:IPhysicalGameObject): Wird aufgerufen, wenn sich der Contact zwischen zwei PhysicalGameObjects löst.
  • onPreSolveContact(contact:b2Contact, gameObject:IPhysicalGameObject, oldManifold:b2Manifold): Wird aufgerufen nachdem eine Kollision festgestellt, aber bevor sie abgearbeitet wurde.
  • onPostSolveContact(contact:b2Contact, gameObject:IPhysicalGameObject, impulse:b2ContactImpulse): Wird nach einer Kollision aufgerufen. Gibt Auskunft über die Impulse, die in der Kollision wirken.

Für mehr Infos zu den speziellen Kollisionen empfehle ich euch das Box2D-Manual. Der Parameter gameObject in den Funktionen ist übrigens immer das zweite GameObject durch das die Kollision entsteht.

Kümmern wir uns nun um die einzelnen GameObjects und gehen von den einfachen zu den komplizierteren.

Platform

Die Platform-Klasse ist ganz simpel, weil sie keine weitere Logik enthält. Beim Erzeugen werden ihr einfach ein MovieClip und ein Body übergeben – dies macht übrigens die GJFactory. Ansonsten wird nur der type-String festgelegt.

package de.senaeh.pipo.gingerjump.gameobjects
{
	import Box2D.Dynamics.b2Body;
	import de.senaeh.pipo.game.gameobjects.physic.PhysicalGameObject;
	import flash.display.MovieClip;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class Platform extends PhysicalGameObject
	{
		public static const TYPE:String = "Platform";

		public function Platform(graphic:MovieClip, body:b2Body):void
		{
			super(TYPE, graphic, body);
		}
	}
}

Enemy

Die Gegner bieten im Prinzip drei zusätzliche Funktionalitäten.

Zum einen wird per Zufall ein Frame des übergebenen MovieClips ausgewählt, damit die Gegner unterschiedliche Gesichter haben (jedes Frame stellt ein Gesicht dar). Zum anderen bewegen sich alle Gegner zwischen zwei Punkten hin und her. Wo die Punkte liegen wird später vom LevelSystem berechnet. Wie sich die Gegner zwischen diesen Punkten aber bewegen wird in der Enemy-Klasse selbst festgelegt. Dafür erstelle ich einen neuen Tween, wähle für den Tween eine zufällige Dauer zwischen 0,5 und 4 Sekunden, um unterschiedliche Geschwindigkeiten zu erzeugen und wähle per Zufall eine der von TweenMax gestellten Easing-Funktionen aus, um die Bewegungsart zu variieren (z.B. anfangs beschleunigt und dann langsamer werdend). Anschließend lasse ich diese Tween unendlich wiederholen.

Außerdem erstelle ich noch eine öffentliche Funktion attackedByGinger();, welche aufgerufen wird, wenn die Spielfigur auf einen Gegner springt. In diesem Fall soll der Box2D-Body des Gegners inaktiv geschalten werden und der Gegner in einer Schrumpfanimation verschwinden.

Anschließend überschreibe ich die kill()-Funktion, um den Bewegungstween beim Löschen des Gegners zu beenden.

Eure Gegnerklasse sieht dann so aus:

package de.senaeh.pipo.gingerjump.gameobjects
{
	import Box2D.Common.Math.b2Vec2;
	import Box2D.Dynamics.b2Body;
	import com.greensock.easing.*;
	import de.senaeh.pipo.utils.tweening.EasingFunctions;

	import com.greensock.TweenMax;
	import de.senaeh.pipo.game.gameobjects.physic.PhysicalGameObject;
	import de.senaeh.pipo.utils.interaction.KeyboardInput;
	import de.senaeh.pipo.utils.math.GameMath;
	import de.senaeh.pipo.utils.math.Vector2D;
	import flash.display.MovieClip;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class Enemy extends PhysicalGameObject
	{
		public static const TYPE:String = "Enemy";

		private var _moveTween:TweenMax;

		public function Enemy(graphic:MovieClip, body:b2Body,endPos:Vector2D):void
		{
			super(TYPE, graphic, body);
			graphic.gotoAndStop(GameMath.randomInt(1, graphic.totalFrames));
			_moveTween = new TweenMax(
				this,
				GameMath.randomNumber(0.5, 4.0),
				{
					repeat: -1, yoyo: true, x: endPos.x, y: endPos.y,
					ease: GameMath.randomElementOfArray(EasingFunctions.ALL_STANDARD_EASING_FUNCTIONS)
				} );
		}

		public function attackedByGinger():void
		{
			body.SetActive(false);
			TweenMax.to(graphic, 0.5, { scaleX: 0, scaleY: 0 } );
		}

		override public function kill():void
		{
			_moveTween.kill();
			_moveTween = null;
			super.kill();
		}
	}
}

Menu

Ja, auch das Spielmenü ist in meinem Fall ein GameObject. Dies ist nicht unbedingt eine gängige Vorgehensweise und hängt eher mit dem Ziel des Rapid Prototypings möglichst schnell zu entwickeln zusammen. Wenn ich schon Klassen habe, die ausreichen um mein Spielmenü zu beschreiben, warum sollte ich nochmals spezielle Spielmenü-Klassen entwickeln?

Das Menu ist kein PhysicalGameObject, sondern nur ein einfaches GameObject. Zusätzlich zum eigentlichen MovieClip besteht es noch aus zwei Buttons: einem Start-Button und einem Audio-Button. Diese Buttons werden mit einem Tween animiert und bekommen einen MouseClick-Handler, sodass bei einem Klick auf ein Button eine entsprechende Notification versendet wird. PureMVC-Nutzer kennen das Prinzip Flash-Events in Notifications umzuwandeln. Außerdem besteht die Möglichkeit den Status des Audio-Buttons pro Klick zu verändern („on“, „off“). In der überschriebenen kill()-Funktion werden die Tweens und MouseClick-Handler dann wieder verändert.

package de.senaeh.pipo.gingerjump.gameobjects
{
	import com.greensock.TweenMax;
	import de.senaeh.pipo.game.gameobjects.GameObject;
	import de.senaeh.pipo.gingerjump.notifications.GJNotificationList;
	import de.senaeh.pipo.utils.events.GameEvent;
	import flash.display.MovieClip;
	import flash.events.Event;
	import flash.events.MouseEvent;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class Menu extends GameObject
	{
		public static const TYPE:String = "Menu";

		private var _startBtn:MovieClip;
		private var _audioBtn:MovieClip;
		private var _startBtnTween:TweenMax;
		private var _audioBtnTween:TweenMax;

		public function Menu(graphic:MovieClip, startBtn:MovieClip, audioBtn:MovieClip):void
		{
			_startBtn = startBtn;
			_audioBtn = audioBtn;
			super(TYPE, graphic);
		}

		override protected function init():void
		{
			setButtonBehaviourOfGraphic(_audioBtn, true, onAudioBtnClick);
			setAudioBtnState(false);
			setButtonBehaviourOfGraphic(_startBtn, true, onStartBtnClick);
			_startBtnTween = new TweenMax(_startBtn, 1, { repeat: -1, yoyo: true, scaleX: 0.8, scaleY: 0.8 } );
			_audioBtnTween = new TweenMax(_audioBtn, 1, { repeat: -1, yoyo: true, scaleX: 0.8, scaleY: 0.8 } );
		}	

		private function onStartBtnClick(e:MouseEvent):void
		{
			sendNotification(GJNotificationList.CLICK_START_BTN);
		}

		private function onAudioBtnClick(e:MouseEvent):void
		{
			sendNotification(GJNotificationList.CLICK_AUDIO_BTN);
		}

		public function setAudioBtnState(on:Boolean):void
		{
			if (on)
				_audioBtn.gotoAndStop("on");
			else
				_audioBtn.gotoAndStop("off");
		}

		public function getAudioBtnState():Boolean
		{
			if (_audioBtn.currentLabel == "on")
				return true;
			else
				return false;
		}

		override public function kill():void
		{
			setButtonBehaviourOfGraphic(_startBtn, false, onStartBtnClick);
			setButtonBehaviourOfGraphic(_audioBtn, false, onAudioBtnClick);
			_startBtnTween.kill();
			_startBtnTween = null;
			_audioBtnTween.kill();
			_audioBtnTween = null;
			_startBtn = null;
			_audioBtn = null;
			super.kill();
		}
	}
}

Die Funktion setButtonBehaviourOfGraphic(); wird übrigens von der GameObject-Klasse gestellt. Jedes GameObject kann also in einen Button verwandelt werden. Dies könnte zum Beispiel für Spiele mit Touch-Interfaces sehr nützlich sein.

Einschub: Notifications

Bevor ich schon zu unserem letzten GameObject komme – die Spielfigur-, muss ich noch kurz das Notification-System erklären. Dieses besteht aus zwei Teilen: dem Senden und dem Empfangen von Notifications. Diese Funktionen stehen im GameStateManager, in der GameFactory, in den Systems und in den GameObjects zur Verfügung.

Das Senden von Notifications erfolgt über „this.sendNotification(name, gameObject, body);“. Die Parameter name stehen für den Namen der Notification, gameObject ist ein mitgeschicktes GameObject (kann auch null sein, wenn nicht benötigt) und body sind alle weitere ggf. benötigten Daten. Die Namen der Notifications können als public static const entweder in dazugehörigen Klassen oder in einer separaten Liste festgehalten werden. Ich benutze eine Hybridvariante. Ganz wenig Notificationnamen habe ich Klassen zugeordnet. So besitzt der GameStateManager die public static const NOTIFICATION_CHANGE_STATE, um zwischen GameStates zu wechseln. Der body-Parameter enthält in dem Fall den neuen GameState, zu dem gewechselt werden soll. Alle anderen – meist projektspezifischen Notification-Namen – halte ich in einer eigenen Liste fest, welche ihr euch schon einmal übernehmen könnt. Wann welche Notification warum zum Einsatz kommt, erkläre ich, wenn es soweit ist:

package de.senaeh.pipo.gingerjump.notifications
{
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class GJNotificationList
	{
		public static const CLICK_AUDIO_BTN:String = "CLICK_AUDIO_BTN";
		public static const CLICK_START_BTN:String = "CLICK_START_BTN";
		public static const CLICK_RETRY_BTN:String = "CLICK_RETRY_BTN";

		public static const REMOVE_PHYSICAL_GAME_OBJECT:String = "REMOVE_PHYSICAL_GAME_OBJECT";
		public static const REMOVE_GINGER:String = "REMOVE_GINGER";

		public static const GINGER_IS_DEAD:String = "GINGER_IS_DEAD";

		public static const KILL_EVERYTHING:String = "KILL_EVERYTHING";
	}
}

Das Empfangen von Notifications ist ebenfalls zweistufig aufgebaut. Zuerst müsst ihr festlegen an welchen Notifications euer GameObject (oder System, etc.) interessiert ist. Dazu gibt es den public Getter notificationInterests, welches ein Array mit den relevanten Notification-Namen zurück gibt.  Die Verwendung könnte so aussehen:

		override public function get notificationInterests():Array
		{
			return [
				GJNotificationList.GINGER_IS_DEAD,
				GJNotificationList.CLICK_RETRY_BTN
				]
		}

In diesem Beispiel geben wir an, dass das GameObject (oder System, etc.) an den zwei Notifications GINGER_IS_DEAD und CLICK_RETRY_BTN interessiert ist. Sollte irgendeine Klasse nun eine Notification mit diesen Namen verschicken, wird unser GameObject benachrichtigt. Die Benachrichtigung erfolgt über die Funktion „receiveNotification(notification);“ in welcher auch auf die Notification reagiert werden kann. Die Reaktion auf eine Notification könnte so gehandhabt werden:

		override public function receiveNotification(notification:INotification):void
		{
			switch(notification.name)
			{
				case GJNotificationList.GINGER_IS_DEAD:
					// TODO: do something
					break;
				case GJNotificationList.CLICK_RETRY_BTN:
					// TODO: do something
					break;
			}
		}

So verwendet man Notifications. Und nun zum letzten GameObject!

Ginger

Das Ginger-GameObject ist unsere Spielfigur und als solche – wie in nahezu jedem Spiel – die komplexeste GameObject-Klasse. Deswegen werde ich sie in mehreren Teilschritten erklären.

Übernehmt als erstes die allgemeine Struktur der Klasse mit den NotificationInterests als erste Funktionalität:

package de.senaeh.pipo.gingerjump.gameobjects
{
	import Box2D.Dynamics.b2Body;
	import Box2D.Dynamics.Contacts.b2Contact;
	import de.senaeh.pipo.game.gameobjects.physic.IPhysicalGameObject;
	import de.senaeh.pipo.game.gameobjects.physic.PhysicalGameObject;
	import de.senaeh.pipo.game.notifications.INotification;
	import de.senaeh.pipo.gingerjump.notifications.GJNotificationList;
	import flash.display.MovieClip;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class Ginger extends PhysicalGameObject
	{
		public static const TYPE:String = "Ginger";

		private var _cloneGraphic:MovieClip;
		private var _levelWidth:Number;

		public function Ginger(graphic:MovieClip, body:b2Body, cloneGraphic:MovieClip, levelWidth:Number):void
		{
			_cloneGraphic = cloneGraphic;
			_levelWidth = levelWidth;
			super(TYPE, graphic, body);
		}

		/*
		 * NotificationInterest.
		 */

		override public function get notificationInterests():Array
		{
			return [
				GJNotificationList.GINGER_IS_DEAD,
				GJNotificationList.CLICK_RETRY_BTN
				]
		}

		override public function receiveNotification(notification:INotification):void
		{
			switch(notification.name)
			{
				case GJNotificationList.GINGER_IS_DEAD:
					// TODO: fill with code
					break;
				case GJNotificationList.CLICK_RETRY_BTN:
					// TODO: fill with code
					break;
			}
		}

		/*
		 * GameObject logic.
		 */

		override public function update(delta:uint):void
		{
			// TODO: fill with code
			super.update(delta);
		}

		override public function onBeginContact(contact:b2Contact, gameObject:IPhysicalGameObject):void
		{
			// TODO: fill with code
		}

		override public function kill():void
		{
			// TODO: fill with code
			super.kill();
		}
	}
}

Im Konstruktor seht ihr, dass Ginger neben den üblichen graphic- und body-Parameter noch zwei weitere Parameter besitzt: cloneGraphic und levelWidth. Der Parameter cloneGraphic steht für eine weitere Instanz der  Grafik für die Spielfigur. Wieso benötigt die Spielfigur zwei Grafiken? Wie bei Doodle Jump kann die Spielfigur in Ginger Jump das eigentliche Level auf einer Seite verlassen und auf der anderen Seite wieder herauskommen. Wenn die Spielfigur nun über einen der Levelränder hinausragt, muss sie auf der anderen Seite bereits zu sehen sein. Dies wird durch eine zweite Grafik gelöst, die versetzt zur eigentlich Spielfigur angezeigt wird. Beachtet bitte, dass es sich NICHT um eine weitere Spielfigur handelt, auch wenn man zwei Spielfiguren sieht. Es handelt sich nur um zwei Spielfigurgrafiken. Nur eine der Spielfiguren ist echt, d.h. dass zum Beispiel nur eine der Spielfiguren zur Kollisionserkennung mit den Gegner und Plattformen in Betracht gezogen wird. Dies fällt jedoch im laufenden Spielbetrieb kaum auf und war vor allem – ihr habt es bereits erraten – schneller zu programmieren. Und darum geht es beim Rapid Prototyping. Testen, ob eine Spielidee Spaß macht. Und das geht auch mit nur einer echten Spielfigur. Würde man das Spiel fertig programmieren wollen, könnte man eventuell zwei echte Spielfiguren einbauen. Allerdings müsste man auch hier überlegen, ob dies überhaupt Sinn macht. Lohnt sich der Synchronisierungsaufwand von zwei echten Spielfiguren, obwohl die meisten Nutzer den Unterscheid vielleicht gar nicht merken? Der zweite Parameter levelWidth wird übrigens benötigt, um die cloneGraphic auf den richtigen Abstand von der eigentlichen graphic zu halten – nämlich genau eine Displaybreite.

In den NotificationInterests seht ihr, dass die Spielfigur auf zwei Notifications achtet. Dies ist zum einen die Notification, wenn die Spielfigur stirbt. Diese wird später vom LevelSystem versendet, wenn die Spielfigur herunterfällt. Daraufhin soll die Spielfigur u.a. auf den Kopf gedreht werden. Die andere Notification CLICK_RETRY_BTN wird ebenfalls vom LevelSystem versendet werden und zwar dann, wenn das Spiel neugestartet wird. In diesem Fall wollen wir die Spielfigur löschen. Zu den genauen Funktionalitäten kommen wir gleich noch. Schauen wir aber erst einmal weiter in den noch leeren Aufbau.

Die update()-Funktion wird überschrieben, um Funktionen aufzurufen, die pro GameLoop-Update ausgeführt werden sollen. Dazu zählt das Steuern der Spielfigur, das Animieren und das Bewegen der cloneGraphic.

Die onBeginContact()-Funktion wird überschrieben, damit wir auf  den Kontakt mit Gegnern und Plattformen reagieren können.

Die kill()-Funktion wird überschrieben, damit wir spezielle Löschvorgänge durchführen können.

Kümmern wir uns zuerst um die Steuerung der Spielfigur. Diese kann bei Box2D-Bodies auf drei Arten erfolgen:

  • Veränderung der Beschleunigung: Über body.GetLinearVelocity() und body.SetLinearVelocity(velocity) können die Beschleunigungen, die auf einen Körper wirken, ausgelesen und festgelegt werden. Auf diese Weise kann man direkt die Bewegungsrichtung und -geschwindigkeit eines Körpers verändern. Die Bewegungen werden direkt ausgelöst. Diese Bewegungsart verwende ich beim Springen der Spielfigur.
  • Hinzufügen von Impulsen: Durch Impulse können Körper in eine bestimmte Richtung gedrückt werden. Der Impuls addiert sich dabei quasi zur eigentlich Beschleunigung des Körpers. Die Bewegungsänderung wird also nicht direkt durchgeführt. Dadurch entstehen eher gleitende Bewegungen mit denen man zum Beispiel das Rutschen auf einer Eisoberfläche ganz gut simulieren kann. Diese Bewegungsart nutze ich für die Bewegung auf der x-Achse, um eine Art Schwebebewegung zu erhalten.
  • Direktes Ändern der Position: Es ist auch möglich die x- und y-Koordinaten manuell zu ändern. Dies kann allerdings zu nicht vorhergesehenen Verhalten führen. Als Physikengine beruht Box2D natürlich auf physikalischen Grundlagen. Das direkte Manipulieren der Position verändert die Position allerdings von einen Moment auf den anderen und wäre zum Beispiel mit einer Teleportation zu vergleichen. So eine Bewegung ist in der realen physikalischen Welt (noch 😉 ) nicht möglich und für so etwas ist auch Box2D nicht vorgesehen. Nur in Ausnahmefällen verwenden oder wenn man sicher ist, dass keine Probleme auftauchen! Ich verwende diese Bewegungsart nur bei den Gegner.

Erweitert nun eure update()-Funktion folgendermaßen:

		override public function update(delta:uint):void
		{
			move();
			super.update(delta);
		}

		private function move():void
		{

		}

Um die Bewegung erfassen und beschreiben zu können benötigen wir noch ein paar klassenweite Variablen. Zunächst einmal müssen wir schauen, ob und welche Taste gedrückt wurde. Dies könnt ihr mit der KeyboardInput-Klasse aus dem utils Package herausfinden. (Hinweis: Wie bereits gesagt ist das PIPO Framework noch in einer Anfangsphase. Im Augenblick werden deswegen nur die Pfeiltasten abgefragt.)

private var _keyboardInput:KeyboardInput = KeyboardInput.getInstance();

Als nächstes benötigen wir Variablen für den Geschwindigkeitszuwachs (= _speed), die Reibung (= _friction) und die maximale Beschleunigung in x-Richtung ( = _velocityMaxX). Außerdem benötigen wir noch einen Boolean um herauszufinden, ob die Spielfigur tot ist oder lebt, da wir sie nur steuern können sollen, so lange sie noch keinen Gegner berührt hat (= _dead).

		private var _speed:int = 17;
		private var _friction:Number = 0.7;
		private var _velocityMaxX:int = 20;

		private var _dead:Boolean = false;

Die Bedeutung der Variablen sollte weitestgehend selbsterklärend sein. Die Reibung benötigen wir, damit die Spielfigur einmal nach rechts bewegt nicht unendlich nach rechts weiter fliegt.  Die Werte sind durch Ausprobieren zustande gekommen bis mir die Steuerung und die Geschwindigkeit gefallen haben. Wenn ihr das Spiel schneller oder langsamer machen möchtet, verändert einfach diese Werte.

Die eigentliche move()-Funktion sieht dann so aus:

		private function move():void
		{
			var currentVelocity:b2Vec2 = body.GetLinearVelocity();
			// move by input
			if (_keyboardInput.arrowLeft && currentVelocity.x > -_velocityMaxX && !_dead)
				body.ApplyImpulse(new b2Vec2( -_speed, 0), new b2Vec2());
			else if (_keyboardInput.arrowRight && currentVelocity.x < _velocityMaxX && !_dead)
				body.ApplyImpulse(new b2Vec2( _speed, 0), new b2Vec2());
			// friction
			else
			{
				currentVelocity.x *= _friction;
				body.SetLinearVelocity(currentVelocity);
			}
		}

Zuerst rufen wir die derzeitige Beschleunigung der Spielfigur ab. Anschließend bewegen wir die Spielfigur mit Impulsen nach links oder rechts, wenn eine Pfeiltaste gedrückt wurde, die maximale Beschleunigung noch nicht überschritten wurde und die Spielfigur noch lebt. Ist dies nicht der Fall, verringern wir die Beschleunigung auf der x-Achse, damit die Spielfigur stehen bleibt. Spitze! Das war es schon. Kommen wir zum Animieren der Spielfigur.

Das Ginger-Asset besteht aus drei Frames, die mit drei Labels versehen sind: „normal“, „happy“, „dead“. „normal“ ist natürlich der Standardframe, „happy“ wird aufgerufen, wenn die Spielfigur gerade springt und „dead“ wird beim Tod der Spielfigur ausgewählt. Legt nun diese Konstanten für jedes Label fest:

		private const ANI_NORMAL:String = "normal";
		private const ANI_HAPPY:String = "happy";
		private const ANI_DEAD:String = "dead";

Erweitert dann die Update Funktion:

		override public function update(delta:uint):void
		{
			move();
			animate();
			super.update(delta);
		}

Und fügt die animate()-Funktion ein:

		private function animate():void
		{
			// dead?
			if (_dead)
				graphic.gotoAndStop(ANI_DEAD);
			else
			{
				// normal == falling
				if (body.GetLinearVelocity().y > 0)
					graphic.gotoAndStop(ANI_NORMAL);
				// happy jumping
				else
					graphic.gotoAndStop(ANI_HAPPY);
			}
		}

Der Wechsel zwischen ANI_NORMAL und ANI_HAPPY basiert auf der y-Beschleunigung des Körpers. Ist sie negativ, springt die Spielfigur und ANI_HAPPY wird ausgewählt.

Fein. Kommen wir zur letzten Änderung der update()-Funktion, in welcher wir cloneGraphic mit der eigentlichen graphic synchronisieren. Erweitert ein letztes Mal die update()-Funktion():

		override public function update(delta:uint):void
		{
			move();
			animate();
			super.update(delta);
			manageClone();
		}

Der Funktionsaufruf manageClone() folgt nach super.update(delta), weil dort der Box2D-Body mit dem eigentlichen MovieClip des GameObjects abgeglichen wird (sprich: body und graphic werden synchronisiert). Wenn ihr mehr darüber wissen wollt, ruft einfach den entsprechenden Quellcode auf. Nun zu manageClone():

		private function manageClone():void
		{
			// set _cloneGraphic
			if (x <= graphic.width)
				_cloneGraphic.x = graphic.x + _levelWidth;
			else
				_cloneGraphic.x = graphic.x - _levelWidth;
			_cloneGraphic.y = graphic.y;
			_cloneGraphic.gotoAndStop(graphic.currentFrame);
			// swap?
			if (x < 0)
			{
				x += _levelWidth;
				graphic.x -= _levelWidth;
			}
			else if (x > _levelWidth)
			{
				x -= _levelWidth;
				graphic.x += _levelWidth;
			}
		}

Je nachdem, ob sich cloneGraphic links oder rechts von graphic befindet, wird cloneGraphic nach links bzw. rechts auf einen Abstand von _levelWidth gehalten. Die y-Position und der ausgewählte Frame von cloneGraphic sind immer gleich zu graphic. Überschreitet die Spielfigur einen Rand komplett, so werden die Positionen der Spielfigur und der cloneGraphic getauscht (= swap).

Unsere Spielfigur ist damit fast komplett. Sie kann gesteuert werden, hat eine synchrone copyGraphic und kann von einer Levelseite auf die andere wechseln. Fehlt (fast) nur noch die Interaktion mit anderen GameObjects. Diese findet in onBeginContact() statt.

		override public function onBeginContact(contact:b2Contact, gameObject:IPhysicalGameObject):void
		{
			// dead
			if (_dead)
			{
				contact.SetSensor(true);
				return;
			}
			// platform
			if (gameObject.type == Platform.TYPE)
			{
				if (y + graphic.height / 4 > gameObject.y)
					contact.SetSensor(true);
				else
					jump();
			}
			// enemy
			if (gameObject.type == Enemy.TYPE)
			{
				// ginger dead
				if (y > gameObject.y)
					sendNotification(GJNotificationList.GINGER_IS_DEAD);
				// enemy dead
				else
				{
					Enemy(gameObject).attackedByGinger();
					jump(2.5);
				}
				dispatchEvent(new SoundEvent(SoundEvent.PLAY, GJSoundList.HURT_SOUND));
			}
		}

In onBeginContact() werden drei Zustände betrachtet: eine tote Spielfigur, die Berührung mit einer Plattform und die Berührung mit einem Gegner.

  • Tod: Ist die Spielfigur tot, wird jeder Kontakt mit einem anderen Körper ignoriert und die weitere Ausführung der Funktion abgebrochen. Berührt die Spielfigur einen Gegner und stirbt, fällt sie anschließend durch bestehende Plattformen durch.
  • Plattform: Bewegt sich die Spielfigur von unten nach oben durch eine Plattform wird der Kontakt ignoriert. Kommt die Spielfigur von oben auf eine Plattform zu und berührt sie, wird die jump()-Funktion aufgerufen (Erklärung folgt gleich).
  • Gegner: Bewegt sich die Spielfigur von unten nach oben auf einen Gegner zu und berührt ihn, stirbt die Spielfigur. Die Notification GINGER_IS_DEAD wird abgeschickt. Die Reaktion auf diese Notification folgt ebenfalls gleich. Springt die Spielfigur jedoch auch einen Gegner drauf, so wird der Gegner verletzt und die attackedByGinger()-Funktion der Enemy-Klasse aufgerufen. In der Ginger-Klasse wird die Funktion jump() mit dem Parameter 2.5 aufgerufen. Egal ob die Spielfigur stirbt oder den Gegner tötet – es wird in beiden Fällen ein SoundEvent aus dem utils Package ausgelöst, durch das ein entsprechender Soundeffekt abgespielt wird.

Dieser Soundeffekt ist in der Klasse GJSoundList abgelegt, welche wir aber noch nicht erstellt haben. Holen wir das also schnell nach:

package de.senaeh.pipo.gingerjump.sounds
{
	import de.senaeh.pipo.sofluffy.assets.*;
	import flash.media.Sound;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class GJSoundList
	{
		public static const BACKGROUND_MUSIC:Sound = new SGingerJumpMusic();
		public static const JUMP_SOUND:Sound = new SJump();
		public static const HURT_SOUND:Sound = new SHurt();
		public static const FALLING_SOUND:Sound = new SFalling();
	}
}

GJSoundList tut nichts besonderes außer alle SoundAssets in Konstanten festzuhalten. Ganz ähnlich wie die GJNotificationList.

Nun zur jump()-Funktion, welche aufgerufen wird, wenn man auf eine Plattform oder einen Gegner springt:

		private function jump(multiplier:Number = 1.0):void
		{
			var currentVelocity:b2Vec2 = body.GetLinearVelocity();
			currentVelocity.y = -_speed * multiplier;
			body.SetLinearVelocity(currentVelocity);
			dispatchEvent(new SoundEvent(SoundEvent.PLAY, GJSoundList.JUMP_SOUND));
		}

Wird jump() aufgerufen, wird die y-Beschleunigung des Körpers direkt verändert, damit die Spielfigur springt. Jetzt macht auch der Parameter 2.5 Sinn, der übergeben wird, wenn man auf einen Gegner springt. In diesem Fall erhält man nämlich einen Extraboost: das 2.5fache der eigentlichen Sprungkraft. Anschließend wird wieder ein SoundEvent ausgelöst – dieses Mal mit dem Sprung-Soundeffekt.

Lasst euch von den SoundEvents nicht verwirren. Im Augenblick haben sie noch keine Bedeutung. Später werden sie aber von der GJFactory abgefangen, welche sie an die GameFacade weiterleitet, um die Sounds auch wirklich abzuspielen.

Damit sind wir fast fertig. Es fehlen nur noch das Reagieren auf Notifications. Verändert dazu receiveNotification folgendermaßen:

		override public function receiveNotification(notification:INotification):void
		{
			switch(notification.name)
			{
				case GJNotificationList.GINGER_IS_DEAD:
					_dead = true;
					break;
				case GJNotificationList.CLICK_RETRY_BTN:
					sendNotification(GJNotificationList.REMOVE_GINGER, this);
					break;
			}
		}

Wenn GINGER_IS_DEAD erhalten wird, wird die Variable _dead einfach auf  true gesetzt. Warum haben wir das nicht bereits gemacht, wenn die Spielfigur einen Gegner berührt? Warum gehen wir den Umweg über die Notification? Das hängt damit zusammen, dass es zwei Todesarten für die Spielfigur gibt: die Berührung eines Gegners und das Fallen. Wann die Spielfigur fällt, wird später vom LevelSystem berechnet, weil die Spielfigur das nicht selbst herausfinden kann. Das LevelSystem versendet dann ebenfalls eine GINGER_IS_DEAD-Notification, damit auch die Spielfigur weiß, dass sie fällt.

Bei einer CLICK_RETRY_BTN-Notification erfährt die Spielfigur, dass das Spiel neugestartet wurde und versendet eine REMOVE_GINGER-Notification, um sich von der GJFactory entfernen zu lassen. Auch hier ist der Umweg über die Notifications nötig, damit die Ginger-Klasse einen Verweis zu sich selbst in die REMOVE_GINGER-Notification übergeben kann.

Wenn die Spielfigur entfernt werden soll, wird natürlich auch die dazugehörige kill()-Funktion aufgerufen. Diese müssen wir noch entsprechend anpassen:

		override public function kill():void
		{
			_cloneGraphic.parent.removeChild(_cloneGraphic);
			_cloneGraphic = null;
			_keyboardInput = null;
			super.kill();
		}

Wird kill() aufgerufen, entfernen wir die cloneGraphic und setzen sie und den _keyboardInput null.

Habt ihr alles richtig mitgemacht, sollte eure fertige Klasse ungefähr so aussehen:

package de.senaeh.pipo.gingerjump.gameobjects
{
	import Box2D.Common.Math.b2Vec2;
	import Box2D.Dynamics.b2Body;
	import Box2D.Dynamics.Contacts.b2Contact;
	import de.senaeh.pipo.game.gameobjects.physic.IPhysicalGameObject;
	import de.senaeh.pipo.game.gameobjects.physic.PhysicalGameObject;
	import de.senaeh.pipo.game.notifications.INotification;
	import de.senaeh.pipo.gingerjump.GJStateManager;
	import de.senaeh.pipo.gingerjump.notifications.GJNotificationList;
	import de.senaeh.pipo.gingerjump.sounds.GJSoundList;
	import de.senaeh.pipo.sofluffy.assets.SHurt;
	import de.senaeh.pipo.sofluffy.assets.SJump;
	import de.senaeh.pipo.utils.events.GameEvent;
	import de.senaeh.pipo.utils.events.SoundEvent;
	import de.senaeh.pipo.utils.interaction.KeyboardInput;
	import flash.display.MovieClip;
	/**
	 * You can use this code as you wish,
     * but please recommend our site http://www.senaeh.de.
	 * @author Philipp Zins/Donald Pipowitch
	 */
	public class Ginger extends PhysicalGameObject
	{
		public static const TYPE:String = "Ginger";

		private const ANI_NORMAL:String = "normal";
		private const ANI_HAPPY:String = "happy";
		private const ANI_DEAD:String = "dead";

		private var _cloneGraphic:MovieClip;

		private var _levelWidth:Number;

		private var _keyboardInput:KeyboardInput = KeyboardInput.getInstance();

		private var _speed:int = 17;
		private var _friction:Number = 0.7;
		private var _velocityMaxX:int = 20;

		private var _dead:Boolean = false;

		public function Ginger(graphic:MovieClip, body:b2Body, cloneGraphic:MovieClip, levelWidth:Number):void
		{
			_cloneGraphic = cloneGraphic;
			_levelWidth = levelWidth;
			super(TYPE, graphic, body);
		}

		/*
		 * NotificationInterest.
		 */

		override public function get notificationInterests():Array
		{
			return [
				GJNotificationList.GINGER_IS_DEAD,
				GJNotificationList.CLICK_RETRY_BTN
				]
		}

		override public function receiveNotification(notification:INotification):void
		{
			switch(notification.name)
			{
				case GJNotificationList.GINGER_IS_DEAD:
					_dead = true;
					break;
				case GJNotificationList.CLICK_RETRY_BTN:
					sendNotification(GJNotificationList.REMOVE_GINGER, this);
					break;
			}
		}

		/*
		 * GameObject logic.
		 */

		override public function update(delta:uint):void
		{
			move();
			animate();
			super.update(delta);
			manageClone();
		}

		private function move():void
		{
			var currentVelocity:b2Vec2 = body.GetLinearVelocity();
			// move by input
			if (_keyboardInput.arrowLeft && currentVelocity.x > -_velocityMaxX && !_dead)
				body.ApplyImpulse(new b2Vec2( -_speed, 0), new b2Vec2());
			else if (_keyboardInput.arrowRight && currentVelocity.x < _velocityMaxX && !_dead)
				body.ApplyImpulse(new b2Vec2( _speed, 0), new b2Vec2());
			// friction
			else
			{
				currentVelocity.x *= _friction;
				body.SetLinearVelocity(currentVelocity);
			}
		}

		private function animate():void
		{
			// dead?
			if (_dead)
				graphic.gotoAndStop(ANI_DEAD);
			else
			{
				// normal == falling
				if (body.GetLinearVelocity().y > 0)
					graphic.gotoAndStop(ANI_NORMAL);
				// happy jumping
				else
					graphic.gotoAndStop(ANI_HAPPY);
			}
		}

		private function manageClone():void
		{
			// set _cloneGraphic
			if (x <= graphic.width)
				_cloneGraphic.x = graphic.x + _levelWidth;
			else
				_cloneGraphic.x = graphic.x - _levelWidth;
			_cloneGraphic.y = graphic.y;
			_cloneGraphic.gotoAndStop(graphic.currentFrame);
			// swap?
			if (x < 0)
			{
				x += _levelWidth;
				graphic.x -= _levelWidth;
			}
			else if (x > _levelWidth)
			{
				x -= _levelWidth;
				graphic.x += _levelWidth;
			}
		}

		override public function onBeginContact(contact:b2Contact, gameObject:IPhysicalGameObject):void
		{
			// dead
			if (_dead)
			{
				contact.SetSensor(true);
				return;
			}
			// platform
			if (gameObject.type == Platform.TYPE)
			{
				if (y + graphic.height / 4 > gameObject.y)
					contact.SetSensor(true);
				else
					jump();
			}
			// enemy
			if (gameObject.type == Enemy.TYPE)
			{
				// ginger dead
				if (y > gameObject.y)
					sendNotification(GJNotificationList.GINGER_IS_DEAD);
				// enemy dead
				else
				{
					Enemy(gameObject).attackedByGinger();
					jump(2.5);
				}
				dispatchEvent(new SoundEvent(SoundEvent.PLAY, GJSoundList.HURT_SOUND));
			}
		}

		private function jump(multiplier:Number = 1.0):void
		{
			var currentVelocity:b2Vec2 = body.GetLinearVelocity();
			currentVelocity.y = -_speed * multiplier;
			body.SetLinearVelocity(currentVelocity);
			dispatchEvent(new SoundEvent(SoundEvent.PLAY, GJSoundList.JUMP_SOUND));
		}

		override public function kill():void
		{
			_cloneGraphic.parent.removeChild(_cloneGraphic);
			_cloneGraphic = null;
			_keyboardInput = null;
			super.kill();
		}
	}
}

Das war es jetzt aber wirklich mit der Ginger-Klasse 😀

Was haben wir geschafft?

Schauen wir noch einmal kurz zurück. Wir haben den GJStateManager und die GJFactory erschaffen. Die GJFactory erstellt bereits unsere benötigten Systems. Außerdem haben wir folgende GameObjects erstellt und mit Logik versehen: Menu, Platform, Enemy, Ginger. Ein beachtliches Stück Arbeit!

Was müssen wir als nächstes tun?

Im nächsten Teil dieses Tutorials füllen wir GJStateManager und GJFactory mit Logik, damit wir zwischen verschiedenen GameStates hin- und herspringen können und damit die GJFactory auch unsere GameObjects erstellt. Dann müssen wir nur noch LevelSystem fertig programmieren. Das LevelSystem wird die UI kontrollieren, das Scrolling des Levels handhaben und Gegner und Plattformen spawnen lassen. Dann ist das Spiel fertig.

Ich hoffe, ihr konntet bei dem Tutorial etwas lernen. Wenn ihr noch Fragen oder Anregungen habt, schreibt einfach etwas in die Kommentare.

Wenn ihr nicht bis zum nächsten und letzten Teil des Tutorials warten wollt und mehr zum Thema Rapid Prototyping lesen möchtet, kann ich euch in der Zwischenzeit den Artikel How to Prototype a Game in under 7 Days auf Gamasutra empfehlen.

Bis zum nächsten Mal 😀

Autor: Pipo

...kommt ursprünglich aus der Designerecke, ist aber im Laufe seines Studiums in die Webentwicklung gestolpert. Kann sich seit dem nicht so richtig entscheiden wo er hingehört und wagt deswegen vielleicht die Flucht nach vorne in ein komplett neues Gebiet. Vielleicht Management? Niemand weiß es. Auch er nicht.