Live4Sales, a Plant vs Zombies clone

Edit : Nape version

One week after the Osmos demo, I offer a new game made with the Citrus Engine based on an other popular game : Plant vs. Zombies!

If you have never played this game, try it now!

This is my clone made with the CE working as a browser game, air application, and on mobile devices :
The web demo. I didn’t make a preloader, to have the same source code between each version.
There is no end in the game to be used as a stress test!
Sources are available on the CE’s GitHub.


The story part
I was in Paris last weekend with Pierrick. We planned to make the Ludum Dare contest. The theme was evolution. We aren’t graphics designer, it’s (always) a problem. So we used graphics from a previous Pierrick’s project, the graphic designer was Lou Drouet. We thought to evolve ours shoppers into zombies to criticize brands addiction. Salers would throw later smartphones, etc… Nothing original, but funny. We didn’t finish the game because on Saturday’s night, my macbook pro died. It was baptised by beer and didn’t survive. After 4-5 years of excellent services, it was the best end that could happen to it : death on the battlefield! R.I.P. Since I need a laptop, I’ve bought a cheap Acer which do the job, I planned to buy an iMac later. No more Objective-C & Haxe NME for mobile at the moment. That’s it for my life part.

The case study
Plant vs Zombies is an interesting programming lesson since there are different ways to approach it. Some years ago
Emanuele Feronato has made several tutorials to develop this game. I really enjoy informations on his blog but don’t like his way of programming, not enough OOP. Anyway, I invite you to read his blog.

To develop Live4Sales, I used as usual the Citrus Engine. To detect collision I used Box2D physics engine ; Nape would work better but I wanted to test Box2D’s performances on mobile! Also you would probably not use a physics engine at all to make only simple collision detection. And finally I used Starling for the view renderer.

The physics engine is very useful for collision detection, and don’t forget : graphics always lie! The physic body is smaller than the graphics so we don’t have to manage/ignore layers superposition. Even if we use a physics engine, we need a grid to prevent two objects on the same case and to know if baddies are on a line. So basically we use 2 array.

Since there are lots of codes and class, I wouldn’t explain them all. We will focus on the game loop, a part of the hud (the drag & drop), the IA class and finally Box2D’s objects classes with some simple optimizations you have to take care!

Our GameState :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package games.live4sales {
 
	import Box2DAS.Common.V2;
 
	import games.live4sales.assets.Assets;
	import games.live4sales.characters.SalesWoman;
	import games.live4sales.events.MoneyEvent;
	import games.live4sales.objects.Block;
	import games.live4sales.objects.Cash;
	import games.live4sales.runtime.BaddiesCreation;
	import games.live4sales.runtime.CoinsCreation;
	import games.live4sales.ui.Hud;
	import games.live4sales.utils.Grid;
 
	import starling.core.Starling;
	import starling.display.Image;
 
	import com.citrusengine.core.CitrusEngine;
	import com.citrusengine.core.StarlingState;
	import com.citrusengine.objects.CitrusSprite;
	import com.citrusengine.physics.Box2D;
	import com.citrusengine.view.starlingview.AnimationSequence;
	import com.citrusengine.view.starlingview.StarlingArt;
 
	/**
	 * @author Aymeric
	 */
	public class Live4Sales extends StarlingState {
 
		private var _ce:CitrusEngine;
		private var _hud:Hud;
		private var _coinsCreation:CoinsCreation;
		private var _baddiesCreation:BaddiesCreation;
 
		public function Live4Sales() {
 
			super();
 
			_ce = CitrusEngine.getInstance();
		}
 
		override public function initialize():void {
 
			super.initialize();
 
			Assets.contentScaleFactor = Starling.current.contentScaleFactor;
 
			var box2D:Box2D = new Box2D("box2D", {gravity:new V2()});
			//box2D.visible = true;
			add(box2D);
 
			_hud = new Hud();
			addChild(_hud);
			_hud.onIconePositioned.add(_createObject);
 
			_coinsCreation = new CoinsCreation();
			addChild(_coinsCreation);
 
			StarlingArt.setLoopAnimations(["stand", "fire", "attack", "walk"]);
 
			var background:CitrusSprite = new CitrusSprite("background", {view:Image.fromBitmap(new Assets.BackgroundPng())});
			add(background);
 
			_baddiesCreation = new BaddiesCreation();
		}
 
		private function _createObject(name:String, posX:uint, posY:uint):void {
 
			if (Hud.money >= 50) {
 
				var casePositions:Array = Grid.getCaseCenter(posX, posY);
 
				if (casePositions[0] != 0 && casePositions[1] != 0) {
 
					if (name == "SalesWoman") {
 
						var saleswomanAnim:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["fire", "stand"], "fire", 30, true);
						var saleswoman:SalesWoman = new SalesWoman("saleswoman", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-saleswomanAnim.height * 0.3, fireRate:1000, missileExplodeDuration:0, missileFuseDuration:3000, view:saleswomanAnim});
						add(saleswoman);
 
					} else if (name == "Block") {
 
						var blockAnimation:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["block1", "block2", "block3", "blockDestroyed"], "block1");
						var block:Block = new Block("block", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-15, view:blockAnimation});
						add(block);
 
					} else if (name == "Cash") {
 
						var cash:Cash = new Cash("cash", {x:casePositions[0], y:casePositions[1], group:casePositions[2], offsetY:-15, view:new Image(Assets.getAtlasTexture("cash", "Objects"))});
						add(cash);
					}
 
					_ce.dispatchEvent(new MoneyEvent(MoneyEvent.BUY_ITEM));
 
				} else trace('case not empty');
 
			} else trace('no money');
		}
	}
}

We create the Hud, the CoinsCreation & BaddiesCreation classes to create coins/baddies at runtime and we will create there our objects if we have money and if the case is empty.

Two importants CE’s properties
– the group property is used as a z-index sorter.
– thanks to the offsetX & offsetY properties even if your graphics is registred at the center point, you can move around!

The Hud :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package games.live4sales.ui {
 
	import games.live4sales.events.MoneyEvent;
	import games.live4sales.assets.Assets;
	import games.live4sales.utils.Grid;
 
	import starling.display.Image;
	import starling.display.Sprite;
	import starling.events.Event;
	import starling.text.BitmapFont;
	import starling.text.TextField;
	import starling.textures.Texture;
 
	import com.citrusengine.core.CitrusEngine;
 
	import org.osflash.signals.Signal;
 
	import flash.display.Bitmap;
 
	/**
	 * @author Aymeric
	 */
	public class Hud extends Sprite {
 
		[Embed(source="../embed/ArialFont.fnt", mimeType="application/octet-stream")] private var _fontConfig:Class;
		[Embed(source="../embed/ArialFont.png")] private var _fontPng:Class;
 
		static public var money:uint = 400;
 
		public var onIconePositioned:Signal;
 
		private var _ce:CitrusEngine;
 
		private var _grid:Grid;
		private var _vectorIcon:Vector.<Icon>;
 
		private var _backgroundMenu:Image;
		private var _iconSaleswoman:Icon;
		private var _iconCash:Icon;
		private var _iconBlock:Icon;
 
		private var _score:TextField;
 
		public function Hud() {
 
			_ce = CitrusEngine.getInstance();
 
			onIconePositioned = new Signal(String, uint, uint);
 
			_grid = new Grid();
 
			_vectorIcon = new Vector.<Icon>(3, true);
 
			_backgroundMenu = new Image(Assets.getAtlasTexture("background-menu", "Menu"));
			_iconSaleswoman = new Icon(Assets.getAtlasTexture("icon-saleswoman", "Menu"));
			_iconCash = new Icon(Assets.getAtlasTexture("icon-cash", "Menu"));
			_iconBlock = new Icon(Assets.getAtlasTexture("icon-block", "Menu"));
 
			_vectorIcon[0] = _iconSaleswoman;
			_vectorIcon[1] = _iconCash;
			_vectorIcon[2] = _iconBlock;
 
			var bitmap:Bitmap = new _fontPng();
			var texture:Texture = Texture.fromBitmap(bitmap);
			var xml:XML = XML(new _fontConfig());
			TextField.registerBitmapFont(new BitmapFont(texture, xml));
			_score = new TextField(50, 20, "0", "ArialMT");
 
			addEventListener(Event.ADDED_TO_STAGE, _addedToStage);
		}
 
		public function destroy():void {
 
			onIconePositioned.removeAll();
 
			removeChild(_grid);
 
			for each (var icon:Icon in _vectorIcon) {
				icon.destroy();
				removeChild(icon);
			}
 
			_vectorIcon = null;
 
			TextField.unregisterBitmapFont("ArialMT");
			removeChild(_score);
 
			_ce.removeEventListener(MoneyEvent.BUY_ITEM, _changeMoney);
		}
 
		private function _addedToStage(evt:Event):void {
 
			removeEventListener(Event.ADDED_TO_STAGE, _addedToStage);
 
			addChild(_grid);
			_grid.visible = false;
 
			addChild(_backgroundMenu);
			_backgroundMenu.x = (480 - _backgroundMenu.width) / 2;
 
			addChild(_iconSaleswoman);
			_iconSaleswoman.name = "SalesWoman";
			_iconSaleswoman.x = (480 - _iconSaleswoman.width) / 2 - 30;
 
			addChild(_iconCash);
			_iconCash.name = "Cash";
			_iconCash.x = (480 - _iconCash.width) / 2 + 20;
 
			addChild(_iconBlock);
			_iconBlock.name = "Block";
			_iconBlock.x = (480 - _iconBlock.width) / 2 + 70;
 
			for each (var icon:Icon in _vectorIcon) {
				icon.onStartDrag.add(_showGrid);
				icon.onStopDrag.add(_hideGridAndCreateObject);
			}
 
			_score.x = 150;
			_score.y = 3;
			addChild(_score);
			_score.text = String(money);
 
			_ce.addEventListener(MoneyEvent.BUY_ITEM, _changeMoney);
			_ce.addEventListener(MoneyEvent.PICKUP_MONEY, _changeMoney);
		}
 
		private function _changeMoney(mEvt:MoneyEvent):void {
 
			if (mEvt.type == "BUY_ITEM")
				money -= 50;
			else if (mEvt.type == "PICKUP_MONEY")
				money += 50;
 
			_score.text = String(money);
		}
 
		private function _showGrid():void {
			_grid.visible = true;
		}
 
		private function _hideGridAndCreateObject(name:String, posX:uint, posY:uint):void {
			_grid.visible = false;
			onIconePositioned.dispatch(name, posX, posY);
		}
	}
}

The Icon :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package games.live4sales.ui {
 
	import starling.display.Image;
	import starling.events.Touch;
	import starling.events.TouchEvent;
	import starling.events.TouchPhase;
	import starling.textures.Texture;
 
	import org.osflash.signals.Signal;
 
	/**
	 * @author Aymeric
	 */
	public class Icon extends Image {
 
		public var onStartDrag:Signal;
		public var onStopDrag:Signal;
 
		private var _dragging:Boolean = false;
 
		private var _posX:uint, _posY:uint;
 
		public function Icon(texture:Texture) {
 
			super(texture);
 
			onStartDrag = new Signal();
			onStopDrag = new Signal(String, uint, uint);
 
			addEventListener(TouchEvent.TOUCH, _iconTouched);
		}
 
		public function destroy():void {
 
			removeEventListener(TouchEvent.TOUCH, _iconTouched);
 
			onStartDrag.removeAll();
			onStopDrag.removeAll();
		}
 
		private function _iconTouched(tEvt:TouchEvent):void {
 
			var touchBegan:Touch = tEvt.getTouch(this, TouchPhase.BEGAN);
			var touchMoved:Touch = tEvt.getTouch(this, TouchPhase.MOVED);
			var touchEnded:Touch = tEvt.getTouch(this, TouchPhase.ENDED);
 
			if (touchBegan) {
 
				_posX = this.x;
				_posY = this.y;
 
				_dragging = true;
 
				onStartDrag.dispatch();
 
			} else if (touchMoved && _dragging) {
 
				this.x = touchMoved.globalX - this.width * 0.5;
				this.y = touchMoved.globalY - this.height * 0.5;
 
			} else if (touchEnded && _dragging) {
 
				_dragging = false;
 
				this.x = _posX;
				this.y = _posY;
 
				onStopDrag.dispatch(name, uint(touchEnded.globalX), uint(touchEnded.globalY));
			}
		}
	}
}

In a game like Plant vs Zombies, the difficulty is progressive, there are more and more enemies. We use a master timer which will change the enemy’s speed creation after each 10s. It will be reduced of 500ms.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package games.live4sales.runtime {
 
	import games.live4sales.assets.Assets;
	import games.live4sales.characters.ShopsWoman;
	import games.live4sales.utils.Grid;
 
	import com.citrusengine.core.CitrusEngine;
	import com.citrusengine.view.starlingview.AnimationSequence;
 
	import flash.events.TimerEvent;
	import flash.utils.Timer;
 
	/**
	 * @author Aymeric
	 */
	public class BaddiesCreation {
 
		private var _ce:CitrusEngine;
 
		private var _timerProgression:Timer;
 
		private var _timerShopsWomen:Timer;
 
		public function BaddiesCreation() {
 
			_ce = CitrusEngine.getInstance();
 
			_timerProgression = new Timer(10000);
			_timerProgression.start();
			_timerProgression.addEventListener(TimerEvent.TIMER, _progressionDifficulty);
 
			_timerShopsWomen = new Timer(4000);
			_timerShopsWomen.start();
			_timerShopsWomen.addEventListener(TimerEvent.TIMER, _tick);
		}
 
		public function destroy():void {
 
			_timerProgression.stop();
			_timerProgression.removeEventListener(TimerEvent.TIMER, _progressionDifficulty);
 
			_timerShopsWomen.stop();
			_timerShopsWomen.removeEventListener(TimerEvent.TIMER, _tick);
		}
 
		private function _progressionDifficulty(tEvt:TimerEvent):void {
 
			_timerShopsWomen.removeEventListener(TimerEvent.TIMER, _tick);
 
			var delay:uint = _timerShopsWomen.delay - 500;
			if (delay < 500)
				delay = 500;
 
			_timerShopsWomen = new Timer(delay);
			_timerShopsWomen.start();
			_timerShopsWomen.addEventListener(TimerEvent.TIMER, _tick);
		}
 
		private function _tick(tEvt:TimerEvent):void {
 
			var casePosition:Array = Grid.getBaddyPosition(0, Grid.getRandomHeight());
 
			var shopsWomanAnim:AnimationSequence = new AnimationSequence(Assets.getTextureAtlas("Objects"), ["walk", "attack"], "walk");
			var shopswoman:ShopsWoman = new ShopsWoman("shopswoman", {x:480, y:casePosition[1], group:casePosition[2],  offsetY:-shopsWomanAnim.height * 0.3, view:shopsWomanAnim});
			_ce.state.add(shopswoman);
			shopswoman.onTouchLeftSide.add(_endGame);
 
			Grid.tabBaddies[casePosition[2]] = true;
		}
 
		private function _endGame():void {
 
			trace('game over');
		}
	}
}

Now let’s start some Box2D stuff. The Citrus Engine has already lots of Box2D objects pre-built, we will extend them.
Example the SalesWoman which is just similar to a Cannon :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
package games.live4sales.characters {
 
	import Box2DAS.Dynamics.ContactEvent;
 
	import games.live4sales.assets.Assets;
	import games.live4sales.utils.Grid;
	import games.live4sales.weapons.Bag;
 
	import starling.display.Image;
 
	import com.citrusengine.objects.platformer.box2d.Cannon;
 
	import flash.events.TimerEvent;
	import flash.utils.Timer;
 
	/**
	 * @author Aymeric
	 */
	public class SalesWoman extends Cannon {
 
		public var life:uint = 2;
 
		private var _timerHurt:Timer;
 
		public function SalesWoman(name:String, params:Object = null) {
 
			super(name, params);
 
			_timerHurt = new Timer(1000);
			_timerHurt.addEventListener(TimerEvent.TIMER, _removeLife);
		}
 
		override public function destroy():void {
 
			_fixture.removeEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact);
			_fixture.removeEventListener(ContactEvent.END_CONTACT, handleEndContact);
 
			_timerHurt.removeEventListener(TimerEvent.TIMER, _removeLife);
			_timerHurt = null;
 
			super.destroy();
		}
 
		override public function update(timeDelta:Number):void {
 
			super.update(timeDelta);
 
			if (life == 0) {
				kill = true;
				var tab:Array = Grid.getCaseId(x, y);
 
				Grid.tabObjects[tab[1]][tab[0]] = false;
			}
 
			if (Grid.tabBaddies[group])
				_firing = true;
			else
				_firing = false;
		}
 
		override protected function createFixture():void {
 
			_fixture = _body.CreateFixture(_fixtureDef);
			_fixture.m_reportBeginContact = true;
			_fixture.m_reportEndContact = true;
			_fixture.addEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact);
			_fixture.addEventListener(ContactEvent.END_CONTACT, handleEndContact);
		}
 
		protected function handleBeginContact(cEvt:ContactEvent):void {
 
			if (cEvt.other.GetBody().GetUserData() is ShopsWoman) {
 
				if (!_timerHurt.running)
					_timerHurt.start();
			}
		}
 
		protected function handleEndContact(cEvt:ContactEvent):void {
 
			if (cEvt.other.GetBody().GetUserData() is ShopsWoman) {
 
				if (_timerHurt.running)
					_timerHurt.stop();
			}
		}
 
		private function _removeLife(tEvt:TimerEvent):void {
			life--;
		}
 
		override protected function _fire(tEvt:TimerEvent):void {
 
			if (_firing) {
 
				var missile:Bag;
 
				if (startingDirection == "right")
					missile = new Bag("Missile", {x:x + width, y:y, group:group, width:missileWidth, height:missileHeight, offsetY:-30, speed:missileSpeed, angle:missileAngle, explodeDuration:missileExplodeDuration, fuseDuration:missileFuseDuration, view:new Image(Assets.getAtlasTexture("bag", "Objects"))});
				else
					missile = new Bag("Missile", {x:x - width, y:y, group:group, width:missileWidth, height:missileHeight, offsetY:-30, speed:-missileSpeed, angle:missileAngle, explodeDuration:missileExplodeDuration, fuseDuration:missileFuseDuration, view:new Image(Assets.getAtlasTexture("bag", "Objects"))});
 
				_ce.state.add(missile);
			}
		}
 
		override protected function _updateAnimation():void {
 
			if (_firing)
				_animation = "fire";
			else
				_animation = "stand";
		}
 
	}
}

Now let’s start talking about some Box2D optimizations :
A missile should only collide with an enemy, we can do it easily :

protected function handleBeginContact(cEvt:ContactEvent):void {
 
	var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData();
 
	if (!(other is ShopsWoman))
		cEvt.contact.Disable();
}

However disabling a contact this way is expensive if we have lots of missiles & contacts. Imagine, a missile crosses through an other SalesWoman, Cash and Block… So we use categoryBits & maskBits filters! Then they will never collide! With the Citrus Engine we can set up filters easily without bits manipulation. Also ShopsWoman shoudln’t collide between them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
package games.live4sales.characters {
 
	import Box2DAS.Common.V2;
	import Box2DAS.Dynamics.ContactEvent;
 
	import games.live4sales.objects.Block;
	import games.live4sales.objects.Cash;
	import games.live4sales.utils.Grid;
	import games.live4sales.weapons.Bag;
 
	import com.citrusengine.objects.Box2DPhysicsObject;
	import com.citrusengine.physics.Box2DCollisionCategories;
 
	import org.osflash.signals.Signal;
 
	/**
	 * @author Aymeric
	 */
	public class ShopsWoman extends Box2DPhysicsObject {
 
		public var speed:Number = 0.7;
		public var life:uint = 4;
 
		public var onTouchLeftSide:Signal;
 
		private var _fighting:Boolean = false;
 
		public function ShopsWoman(name:String, params:Object = null) {
 
			super(name, params);
 
			onTouchLeftSide = new Signal();
		}
 
		override public function destroy():void {
 
			_fixture.removeEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact);
			_fixture.removeEventListener(ContactEvent.END_CONTACT, handleEndContact);
 
			onTouchLeftSide.removeAll();
 
			super.destroy();
		}
 
		override public function update(timeDelta:Number):void {
 
			super.update(timeDelta);
 
			if (!_fighting) {
 
				var velocity:V2 = _body.GetLinearVelocity();
 
				velocity.x = -speed;
 
				_body.SetLinearVelocity(velocity);
			}
 
			if (x < 0) {
				onTouchLeftSide.dispatch();
				kill = true;
			}
 
			if (life == 0) {
				kill = true;
				Grid.tabBaddies[group] = false;
			} else {
				Grid.tabBaddies[group] = true;
			}
 
			updateAnimation();
		}
 
		override protected function defineBody():void {
 
			super.defineBody();
 
			_bodyDef.fixedRotation = true;
		}
 
		override protected function defineFixture():void {
 
			super.defineFixture();
 
			_fixtureDef.friction = 0;
			_fixtureDef.filter.categoryBits = Box2DCollisionCategories.Get("BadGuys");
			_fixtureDef.filter.maskBits = Box2DCollisionCategories.GetAllExcept("BadGuys");
		}
 
		override protected function createFixture():void {
 
			super.createFixture();
 
			_fixture.m_reportBeginContact = true;
			_fixture.m_reportEndContact = true;
			_fixture.addEventListener(ContactEvent.BEGIN_CONTACT, handleBeginContact);
			_fixture.addEventListener(ContactEvent.END_CONTACT, handleEndContact);
		}
 
		protected function handleBeginContact(cEvt:ContactEvent):void {
 
			var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData();
 
			if (other is SalesWoman || other is Block || other is Cash)
				_fighting = true;
 
			else if (other is Bag) {
				life--;
				cEvt.contact.Disable();
			}
		}
 
		protected function handleEndContact(cEvt:ContactEvent):void {
 
			var other:Box2DPhysicsObject = cEvt.other.GetBody().GetUserData();
 
			if (other is SalesWoman || other is Block || other is Cash)
				_fighting = false;
		}
 
		protected function updateAnimation():void {
 
			_animation = _fighting ? "attack" : "walk";
		}
	}
}

Now the bags. They are just a missile with categoryBits & maskBits filters. However the CE is prebuilt as a platformer engine, it means that Missiles remove Box2D gravity this way :

override public function update(timeDelta:Number):void {
 
	var removeGravity:V2 = new V2();
	removeGravity.subtract(_box2D.world.GetGravity());
	removeGravity.multiplyN(body.GetMass());
 
	_body.ApplyForce(removeGravity, _body.GetWorldCenter());
}

In our game we don’t have gravity so we just remove this unnecessary & expensive calculation! And finally Missile are defined as bullet and use Continous Collision Detection, this is very expensive. Since our missiles aren’t fast we don’t need that.

The Bag class :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package games.live4sales.weapons {
 
	import Box2DAS.Common.V2;
	import Box2DAS.Dynamics.ContactEvent;
 
	import com.citrusengine.objects.platformer.box2d.Missile;
	import com.citrusengine.physics.Box2DCollisionCategories;
 
	/**
	 * @author Aymeric
	 */
	public class Bag extends Missile {
 
		public function Bag(name:String, params:Object = null) {
			super(name, params);
		}
 
		override public function update(timeDelta:Number):void {
 
			if (!_exploded)
				_body.SetLinearVelocity(_velocity);
			else
				_body.SetLinearVelocity(new V2());
 
			if (x > 480)
				kill = true;
		}
 
		override protected function handleBeginContact(cEvt:ContactEvent):void {
			explode();
		}
 
		override protected function defineBody():void {
 
			super.defineBody();
 
			_bodyDef.bullet = false;
			_bodyDef.allowSleep = true;
		}
 
		override protected function defineFixture():void {
 
			super.defineFixture();
 
			_fixtureDef.filter.categoryBits = Box2DCollisionCategories.Get("Level");
			_fixtureDef.filter.maskBits = Box2DCollisionCategories.GetAllExcept("Level");
		}
 
	}
}

I’m pretty sure that Box2D ninja user will find other optimizations, please share them.

The example is over now. Again the hardest part is to find good parameters & balance to have the best user experience. To do that you have to try your game again & again and when you think it is ok : let’s an other guy try it… then restart your balancing!

That was an other non platformer game example for the CE! Cheers!

19 thoughts on “Live4Sales, a Plant vs Zombies clone

  1. Hey, performance are ok if there are lots of enemies but run near 30 fps if we have lot of missiles! Absolutely, using Nape we will have better performance, I just wanted to test the Box2D’s performance on a small/serious project! I will try to make it running on Nape next week.

  2. Salut Aymeric
    Je suis entrain de bosser sur un projet de jeu de plate-forme pour un grand client , et j’ai pris comme engine citrus pour toute ces aventages , et ces fonctionnalités que j’estime inexistantes sur n’import quel autre framework ,
    Cependant mon gameplay se base sur le fait de changer le view du hero , suivant une action de Clavier en exécution , comme suivant
    if( _ce.input.isDown(Keyboard.D)){
    _hero.view="Patch2.swf";
    }

    le problème c’est qu’il gard le premier view et il rajoute le nouveau ( le 1er view reste sur la deriere frame ) , j’ai bien compris pourquoi ça arrive , parceque le content sur StarlingArt , et toujours ajouté
    ma question s’il aura un moyen pour détruire ou effacer ce content , sans toucher a la Classe StarlingArt

    j’espere que vous avez bien compris ma demande Thank you !!

  3. Hey, la solution la plus simple est d’utiliser un seul swf pour le héros avec toutes les différentes vues qu’il peut avoir ! Il suffit de rajouter un état de transition pour faire ça proprement 😉
    N’hésite pas à me demander pour des compléments d’informations !
    Merci d’avoir choisi le Citrus Engine !

  4. Merci Aymeric pour ta réponse , j’ai réfléchi à ça au début , je vais essayer et voir que ce-que ça va donner , mais sincèrement bravo et merci pour ce magnifique Engine , avec tous ces implémentations ( Starling , Signal , Box2D et Nape ) j’attends la finalisation de l’implémentation de Nape pour attaquer le mobile !

  5. Je suis entrain de bosser sur la BETA 3, probablement la dernière. Nape utilise désormais le même système de coordonnées que Box2D, ce qui n’était pas le cas auparavant (ma faute). La transition de l’un à l’autre est désormais très rapide ! Il me reste plus qu’à trouver un moyen robuste pour gérer les collisions afin que cette partie soit achevée.
    Bon courage avec ton jeu, tiens moi au courant !

  6. Bon d’apres mon essai , apparement j’ai pas compris comment rajouter un état de transition ( c’est un StarlingState qu’on utilise pour definir un level , ou c’est relative au Hero ) ,
    Bref sur mon Patch.swf , il y aura les clips d’animation suivants :
    – walk_1
    – walk_2
    – walk_3
    – walk_4
    – walk_5

    -idle_1
    -idle_2
    -idle_3
    -idle_4
    -idle_5

    -hurt_1
    -hurt_2
    -hurt_3
    -hurt_4
    -hurt_5

    -run_1
    -jump_2
    -duck_3
    -push_4
    -hit_5

    les numeros 1 ,2 ,3, 4, 5 , sont les ids des personnage choisis par le player ,
    donc pour moi faut absolument étendre la Classe Hero , et surcharger les deux methodes
    update(timeDelta:Number) et protected function updateAnimation():void
    {
    var prevAnimation:String = _animation;

    var velocity:V2 = _body.GetLinearVelocity();
    if (_hurt)
    {
    _animation = "hurt";
    }
    else if (!_onGround)
    {
    _animation = "jump";
    }
    else if (_ducking)
    {
    _animation = "duck";
    }
    else
    {
    var walkingSpeed:Number = getWalkingSpeed();
    if (walkingSpeed acceleration)
    {
    _inverted = false;
    _animation = "walk";
    }
    else
    {
    _animation = "idle";
    }
    }

    if (prevAnimation != _animation)
    {
    onAnimationChange.dispatch();
    }
    }

    et donc selon le personnage choisi
    _animation = "walk_"+idPerson ;

    et je pour update , je dois modifier le comportement une fois le bouton espace est appuyé , dans mon cas faut déclencher le pouvoir spécial ( run pour le 1 “run_1” , jump pour le 2 “jump_2” .

    j’espère que je suis sur la bonne voix et qu’il s’agit de la meilleure solution et je veux surtout qu’il soit propre, sans toucher au framework sauf si le voyez , Merci

  7. Tu utilises Starling ou la display list classique de Flash. Si c’est le cas ce n’est pas StarlingArt qui est utilisé mais SpriteArt. Un State/StarlingState est un level, en général un Héros est simplement ajouté à celui-ci.
    L’état de transition est simplement une animation de ton Patch.swf avec un nouveau label, example : transition1_4, pour une transition du perso 1 au 4.
    Tu as tout à fait raison pour la surcharge des 2 méthodes.

    Attention si tu utilises Starling, tu dois utiliser un Texture Atlas pour contenir toutes les animations du héros. Il faudrait que toutes puissent tenir dans un PNG de 2048*2048, sinon tu devras en faire plusieurs. Si c’est le cas, à toi de créer plusieurs AnimationSequence pour ton héros et de switcher en fonction de celle nécessaire !

  8. Oui en fait j’utilise Starling , mais si je veux utiliser un Texture Atlas , faudra que je passe une AnimationSequence au view du Hero

    _hero.view = new AnimationSequence(sTextureAtlas, ["walk", "duck", "idle", "jump", "hurt"], "idle");

    je peux quand meme passer path.swf , comme view ? je crois que TextureAtlas s’occupe d’extraire les differente textures de l’animation a partir du swf , mais j’aurai un prb de taille de bitmap generée vu que j’ai beaucoup d’animations .

    Question : est ce que on peut switcher entre les AnimationSequence par exemple :


    _hero.view = new AnimationSequence(sTextureAtlas1, ["walk", "idle", "jump", "hurt"], "idle");

    suivant un évènement je peux mettre

    _hero.view = new AnimationSequence(sTextureAtlas2, ["walk", "idle", "duck", "hurt"], "idle");

    en fait c’est ce-que j’voulais au départ

  9. j’ai testé et ça n’a pas marché c’est le même probleme , je crois qu’il faut toucher la classe StarlingArt , pour supporter cette fonctionnalité ! ?

  10. Il semblerait bien, il suffirait de supprimer une précédente vue si existante dans le setter de la propriété view. En appelant la fonction destroy peut-être ?

  11. yes c’est ceque j’ai fais 🙂 , faudra aussi toucher la fonction destroy , par-ce-que j’ai un probleme d’aligment de content il reste toujours ( 0, 0) et non centrer ! voila ce-que j’ai fais et ça marche nikel 🙂


    public function set view(value:*):void {

    if (_view == value)
    return;

    _view = value;

    if (_view) {
    if(content && content.parent){
    destroy();
    }

    if (_view is String) {
    // view property is a path to an image?
    var classString:String = _view;
    var suffix:String = classString.substring(classString.length - 4).toLowerCase();
    if (suffix == ".swf" || suffix == ".png" || suffix == ".gif" || suffix == ".jpg") {
    loader = new Loader();
    loader.contentLoaderInfo.addEventListener(Event.COMPLETE, handleContentLoaded);
    loader.contentLoaderInfo.addEventListener(IOErrorEvent.IO_ERROR, handleContentIOError);
    loader.load(new URLRequest(classString));
    }
    // view property is a fully qualified class name in string form.
    else {
    var artClass:Class = getDefinitionByName(classString) as Class;
    content = new artClass();
    addChild(content);
    }
    } else if (_view is Class) {
    // view property is a class reference
    content = new citrusObject.view();
    addChild(content);

    } else if (_view is DisplayObject) {
    // view property is a Display Object reference
    content = _view;
    addChild(content);
    } else {
    throw new Error("SpriteArt doesn't know how to create a graphic object from the provided CitrusObject " + citrusObject);
    return;
    }

    if (content && content.hasOwnProperty("initialize"))
    content["initialize"](_citrusObject);
    }
    }

    et j’ai réinitialisé _registration = “” ; dans destroy() , comme ça il va etre centré quand update() va appeler set registration

  12. J’ai testé d’ajouter dans StarlingArt et SpriteArt :
    if(content && content.parent){
    destroy();
    }
    Mais j’obtiens la même erreur lorsque je change la view : Exception fault: Error: StarlingArt doesn’t know how to create a graphic object from the provided CitrusObject [object Hero]
    J’ai essayé avec des png/swf, tu n’as pas modifié autre chose ?

Leave a Reply

Your email address will not be published. Required fields are marked *