Tuto tiny-ecs beatemup Part 9 Systems

From GiderosMobile

The Systems

We have our entities, we have our components, now the systems. What is an ECS System?

A System is a wrapper around function callbacks for manipulating Entities. Systems are implemented as tables that contain at least one method; an update function that takes parameters like so:

function system:update(dt)

There are also a few other optional callbacks:
*function system:filter(entity) Returns true if this System should include this Entity, otherwise should return false. If this isn't specified, no Entities are included in the System.
*function system:onAdd(entity) Called when an Entity is added to the System.
*function system:onRemove(entity) Called when an Entity is removed from the System.
*function system:onModify(dt) Called when the System is modified by adding or removing Entities from the System.
*function system:onAddToWorld(world) Called when the System is added to the World, before any entities are added to the system.
*function system:onRemoveFromWorld(world) Called when the System is removed from the world, after all Entities are removed from the System.
*function system:preWrap(dt) Called on each system before update is called on any system.
*function system:postWrap(dt) Called on each system in reverse order after update is called on each system.

Please see Tiny-ecs#System_functions for more information

That looks scary but worry not we won't use all the callback functions :-)

To put it simple a System manipulates entities. Let's see our first System.

sDrawable.lua

Please create a file "sDrawable.lua" in the "_S" folder and the code:

SDrawable = Core.class()

function SDrawable:init(xtiny) -- tiny function
	xtiny.system(self) -- called only once on init (no update)
end

function SDrawable:filter(ent) -- tiny function
	return ent.spritelayer and ent.sprite
end

function SDrawable:onAdd(ent) -- tiny function
--	print("SDrawable:onAdd(ent)")
	ent.spritelayer:addChild(ent.sprite)
end

function SDrawable:onRemove(ent) -- tiny function
--	print("SDrawable:onRemove(ent)")
	ent.spritelayer:removeChild(ent.sprite)
	-- cleaning?
	ent.sprite = nil
	ent = nil
end

What it does:

  • runs only once when it is called
  • affects only entities which have a spritelayer variable (id) and a sprite variable (id)
  • when an Entity is added to tiny-ecs World, the System adds the Entity to its Sprite layer
  • when an Entity is removed from tiny-ecs World, the System removes the Entity from its Sprite layer

In other words, the System adds an Entity to a Sprite layer when the Entity is added to tiny-ecs World, and removes it from that Sprite layer when the Entity is destroyed.

sPlayer1Control.lua

I am adding systems in an order that seems logical and helps in understanding the making of the game. The next System we add is the player1 controller.

"sPlayer1Control.lua" in the "_S" folder. The code:

SPlayer1Control = Core.class()

function SPlayer1Control:init(xtiny, xplayer1inputlayer) -- tiny function
	xtiny.system(self) -- called only once on init (no update)
	self.player1inputlayer = xplayer1inputlayer
end

function SPlayer1Control:filter(ent) -- tiny function
	return ent.isplayer1
end

function SPlayer1Control:onAdd(ent) -- tiny function
	self.player1inputlayer:addEventListener(Event.KEY_DOWN, function(e)
		if ent.currlives > 0 then
			if e.keyCode == KeyCode.LEFT or e.keyCode == g_keyleft then ent.isleft = true end
			if e.keyCode == KeyCode.RIGHT or e.keyCode == g_keyright then ent.isright = true end
			if e.keyCode == KeyCode.UP or e.keyCode == g_keyup then ent.isup = true end
			if e.keyCode == KeyCode.DOWN or e.keyCode == g_keydown then ent.isdown = true end
			-- ACTIONS:
			-- isactionpunch1, isactionpunch2, isactionjumppunch1,
			-- isactionkick1, isactionkick2, isactionjumpkick1,
			-- isactionjump1
			if e.keyCode == g_keyaction1 then
				ent.animation.frame = 0
				ent.isactionpunch1 = true
			elseif e.keyCode == g_keyaction2 then
				ent.animation.frame = 0
				ent.isactionkick1 = true
			end
			if e.keyCode == g_keyaction3 then
				if ent.body.isonfloor then
					ent.animation.frame = 0
					ent.positionystart = ent.pos.y
					ent.body.isonfloor = false
					ent.body.isgoingup = true
					ent.isactionjump1 = true
				end
			end
		end
	end)
	self.player1inputlayer:addEventListener(Event.KEY_UP, function(e)
		if ent.currlives > 0 then
			if e.keyCode == KeyCode.LEFT or e.keyCode == g_keyleft then ent.isleft = false end
			if e.keyCode == KeyCode.RIGHT or e.keyCode == g_keyright then ent.isright = false end
			if e.keyCode == KeyCode.UP or e.keyCode == g_keyup then ent.isup = false end
			if e.keyCode == KeyCode.DOWN or e.keyCode == g_keydown then ent.isdown = false end
--			if e.keyCode == g_keyaction1 then ent.isactionpunch1 = false end
--			if e.keyCode == g_keyaction2 then ent.isactionkick1 = false end
--			if e.keyCode == g_keyaction3 then end
		end
	end)
end

What it does:

  • runs only once when it is called
  • affects only entities with the isplayer1 id
  • when the player1 Entity is added to tiny-ecs World, the System registers KEY_DOWN and KEY_UP events

The System processes the user keys input and sets various flags to be processed in a future sDynamicBodies System we will add.

sPlayer1.lua

"sPlayer1.lua" in the "_S" folder. The code:

SPlayer1 = Core.class()

function SPlayer1:init(xtiny, xcamera) -- tiny function
	self.tiny = xtiny -- ref so we can remove entities from tiny system
	self.tiny.processingSystem(self) -- called once on init and every update
	-- fx
	self.camera = xcamera -- camera shake
	-- sfx
	self.snd = Sound.new("audio/sfx/sfx_deathscream_human14.wav")
	self.channel = self.snd:play(0, false, true)
end

function SPlayer1:filter(ent) -- tiny function
	return ent.isplayer1
end

function SPlayer1:onAdd(ent) -- tiny function
end

function SPlayer1:onRemove(ent) -- tiny function
end

local resetanim = true
function SPlayer1:process(ent, dt) -- tiny function
	-- hurt fx
	if ent.washurt and ent.washurt > 0 and not (ent.wasbadlyhurt and ent.wasbadlyhurt > 0) then
		ent.washurt -= 1
		ent.animation.curranim = g_ANIM_HURT_R
		if ent.washurt < ent.recovertimer*0.5 then ent.hitfx:setVisible(false) end
		if ent.washurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1)
			self.camera:setZoom(1) -- zoom
		end
	elseif ent.wasbadlyhurt and ent.wasbadlyhurt > 0 then
		ent.hitfx:setVisible(false)
		ent.wasbadlyhurt -= 1
		ent.animation.curranim = g_ANIM_LOSE1_R
		if ent.wasbadlyhurt < ent.recoverbadtimer/2 then
			if resetanim then
				resetanim = false
				ent.animation.frame = 0
			end
			ent.animation.curranim = g_ANIM_STANDUP_R
		end
		if ent.wasbadlyhurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1)
			self.camera:setZoom(1) -- zoom
			resetanim = true
		end
	end
	if ent.isdirty then -- hit
		local function map(v, minSrc, maxSrc, minDst, maxDst, clampValue)
			local newV = (v - minSrc) / (maxSrc - minSrc) * (maxDst - minDst) + minDst
			return not clampValue and newV or clamp(newV, minDst><maxDst, minDst<>maxDst)
		end
		self.channel = self.snd:play()
		if self.channel then self.channel:setVolume(g_sfxvolume*0.01) end
		ent.hitfx:setVisible(true)
		ent.hitfx:setPosition(ent.pos.x+ent.collbox.w/2+(ent.headhurtbox.x*ent.flip), ent.pos.y+ent.headhurtbox.y-ent.headhurtbox.h/2)
		ent.spritelayer:addChild(ent.hitfx)
		ent.currhealth -= ent.damage
		local hudhealthwidth = map(ent.currhealth, 0, ent.totalhealth, 0, 100)
		self.tiny.hudhealth:setWidth(hudhealthwidth)
		if ent.currhealth < ent.totalhealth/3 then self.tiny.hudhealth:setColor(0xff0000)
		elseif ent.currhealth < ent.totalhealth/2 then self.tiny.hudhealth:setColor(0xff5500)
		else self.tiny.hudhealth:setColor(0x00ff00)
		end
		ent.washurt = ent.recovertimer -- timer for a flash effect
		ent.sprite:setColorTransform(2, 0, 0, 2) -- the flash effect (a bright red color)
		ent.isdirty = false
		self.camera:shake(0.6, 16) -- (duration, distance), you choose
		self.camera:setZoom(1.2) -- zoom
		if ent.currhealth <= 0 then
			ent.wasbadlyhurt = ent.recoverbadtimer -- timer for player1 to stand back up
			self.camera:shake(0.8, 64) -- (duration, distance), you choose
			ent.currlives -= 1
			for i = 1, ent.totallives do self.tiny.hudlives[i]:setVisible(false) end -- dirty but easy XXX
			for i = 1, ent.currlives do self.tiny.hudlives[i]:setVisible(true) end -- dirty but easy XXX
			if ent.currlives > 0 then
				ent.currhealth = ent.totalhealth
				hudhealthwidth = map(ent.currhealth, 0, ent.totalhealth, 0, 100)
				self.tiny.hudhealth:setWidth(hudhealthwidth)
				self.tiny.hudhealth:setColor(0x00ff00)
				if ent.currlives == 1 then self.tiny.hudlives[1]:setColor(0xff0000) end
			end
		end
	end
	if ent.currlives <= 0 then -- deaded
		-- stop all movements
		ent.isleft = false
		ent.isright = false
		ent.isup = false
		ent.isdown = false
		-- play dead sequence
		ent.isdirty = false
		resetanim = false
		ent.washurt = ent.recovertimer
		ent.wasbadlyhurt = ent.recoverbadtimer
		ent.animation.curranim = g_ANIM_LOSE1_R
		ent.sprite:setColorTransform(255*0.5/255, 255*0.5/255, 255*0.5/255, 1)
		self.camera:setZoom(1) -- zoom
		ent.animation.bmp:setY(ent.animation.bmp:getY()-1)
		if ent.animation.bmp:getY() < -200 then -- you choose
			switchToScene(LevelX.new())
		end
	end
end

This System deals with the player1 being hit or killed:

  • runs once on init and every game loop (process)
  • in init we add the camera and a sound to add some juice to the game
  • there are 2 kind of hurt animations depending on the player1 health (washurt and wasbadlyhurt)
  • when the player1 is hit, we add a camera shake and play some sound
  • we update the HUD
  • when the player1 is dead we play a death sequence and restart the current level

sNmes.lua

"sNmes.lua" in the "_S" folder. The code:

SNmes = Core.class()

local random = math.random

function SNmes:init(xtiny, xbump) -- tiny function
	self.tiny = xtiny -- class ref so we can remove entities from tiny world
	self.tiny.processingSystem(self) -- called once on init and every update
	self.bworld = xbump
	-- sfx
	self.snd = Sound.new("audio/sfx/sfx_deathscream_human14.wav")
	self.channel = self.snd:play(0, false, true)
end

function SNmes:filter(ent) -- tiny function
	return ent.isnme
end

function SNmes:onAdd(ent) -- tiny function
	ent.flip = math.random(100)
	if ent.flip > 50 then ent.flip = 1 else ent.flip = -1 end
	ent.currlives = ent.totallives
	ent.currhealth = ent.totalhealth
	ent.washurt = 0
	ent.wasbadlyhurt = 0
	ent.isdead = false
	ent.curractiontimer = ent.actiontimer
	ent.positionystart = 0
	-- abilities
	ent.abilities = {}
	if ent.headhitboxattack1 then ent.abilities[#ent.abilities+1] = 1 end -- punch1
	if ent.headhitboxattack2 then ent.abilities[#ent.abilities+1] = 2 end -- punch2
	if ent.spinehitboxattack1 then ent.abilities[#ent.abilities+1] = 3 end -- kick1
	if ent.spinehitboxattack2 then ent.abilities[#ent.abilities+1] = 4 end -- kick2
	if ent.headhitboxjattack1 then ent.abilities[#ent.abilities+1] = 5 end -- jumppunch1
	if ent.spinehitboxjattack1 then ent.abilities[#ent.abilities+1] = 6 end -- jumpkick1
--	for k, v in pairs(ent.abilities) do print(k, v) end
end

function SNmes:onRemove(ent) -- tiny function
	self.bworld:remove(ent) -- remove collision box from cbump world here!
end

local resetanim = true
function SNmes:process(ent, dt) -- tiny function
	-- hurt fx
	if ent.washurt and ent.washurt > 0 and not (ent.wasbadlyhurt and ent.wasbadlyhurt > 0) and not ent.isdead then
		ent.washurt -= 1
		ent.animation.curranim = g_ANIM_HURT_R
		if ent.washurt < ent.recovertimer*0.5 then ent.hitfx:setVisible(false) end
		if ent.washurt <= 0 then ent.sprite:setColorTransform(1, 1, 1, 1) end
	elseif ent.wasbadlyhurt and ent.wasbadlyhurt > 0 and not ent.isdead then
		ent.wasbadlyhurt -= 1
		ent.animation.curranim = g_ANIM_LOSE1_R
		if ent.wasbadlyhurt < ent.recoverbadtimer*0.5 then
			ent.hitfx:setVisible(false)
			if resetanim then
				resetanim = false
				ent.animation.frame = 0
			end
			ent.animation.curranim = g_ANIM_STANDUP_R
		end
		if ent.wasbadlyhurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1)
			resetanim = true
		end
	end
	if ent.isdirty then -- hit
		self.channel = self.snd:play()
		if self.channel then self.channel:setVolume(g_sfxvolume*0.01) end
		ent.hitfx:setVisible(true)
		ent.hitfx:setPosition(ent.pos.x+ent.collbox.w/2+(ent.headhurtbox.x*ent.flip), ent.pos.y+ent.headhurtbox.y-ent.headhurtbox.h/2)
		ent.spritelayer:addChild(ent.hitfx)
		ent.currhealth -= ent.damage
		ent.washurt = ent.recovertimer -- timer for a flash effect
--		ent.sprite:setColorTransform(0, 0, 2, 3) -- the flash effect (a bright red color)
		ent.isdirty = false
		if ent.currhealth <= 0 then
			ent.wasbadlyhurt = ent.recoverbadtimer -- timer for actor to stand back up
			ent.currlives -= 1
			if ent.currlives > 0 then ent.currhealth = ent.totalhealth end
		end
	end
	if ent.currlives <= 0 then -- deaded
		-- stop all movements
		ent.isleft = false
		ent.isright = false
		ent.isup = false
		ent.isdown = false
		-- play dead sequence
		ent.isdirty = false
		ent.washurt = ent.recovertimer
		ent.wasbadlyhurt = ent.recoverbadtimer
		-- blood
		if not ent.isdead then
			ent.hitfx:setVisible(true)
			ent.hitfx:setColorTransform(3, 0, 0, random(1, 3)/10) -- blood stain
			ent.hitfx:setPosition(ent.pos.x+ent.collbox.w/2, ent.pos.y)
			ent.hitfx:setRotation(random(360))
			ent.hitfx:setScale(random(5, 8)/10)
			ent.bgfxlayer:addChild(ent.hitfx)
			ent.isdead = true
		end
		ent.animation.curranim = g_ANIM_LOSE1_R
		resetanim = false -- ??? XXX
		ent.sprite:setColorTransform((-ent.pos.y<>ent.pos.y)/255, (-ent.pos.y<>ent.pos.y)/255, 0, 1)
		ent.shadow.sprite:setVisible(false)
		ent.pos -= vector(8*ent.flip, 8)
		ent.sprite:setPosition(ent.pos)
		ent.sprite:setScale(ent.sprite:getScale()+0.07)
		if ent.pos.y < -256 then
			self.tiny.tworld:removeEntity(ent) -- sprite is removed in SDrawable
			self.tiny.numberofnmes -= 1
		end
	end
end

This System deals with the enemies being hit or killed:

  • runs once on init and every game loop (process)
  • in init we add a sound to add some juice to the game
  • onAdd some explanation below
  • when an enemy is hit, we play some sound

In the onAdd function it is worth noting that instead of creating all the variables for the enemies in their Entity code, I found it 'clever' to put them in the enemy System as they all share the same variables.

The other thing worth noting is an enemy ability. Depending on the attacks an Entity has, they are stored in an abilities table. In a artificial intelligence System we will add shortly, the code will iterate the abilities table and pick a random attack an Entity can perform.

sAI.lua

"sAI.lua" in the "_S" folder. The code:

SAI = Core.class()

local random = math.random

function SAI:init(xtiny, xplayer1) -- tiny function
	xtiny.processingSystem(self) -- called once on init and every update
	self.player1 = xplayer1
end

function SAI:filter(ent) -- tiny function
	return ent.ai
end

function SAI:onAdd(ent) -- tiny function
end

function SAI:onRemove(ent) -- tiny function
end

local p1rangex = 192 -- 192 -- magik XXX
local p1hitrangex = 64 -- magik XXX
local p1rangey = 96 -- 96 -- magik XXX
local p1hitrangey = 16 -- magik XXX
local rndaction = 0 -- random punch/kick action
local p1rangetoofar = myappwidth -- disable system to save some CPU, magik XXX
function SAI:process(ent, dt) -- tiny function
	if ent.isdead then return end
	local function fun()
		-- some flags
		ent.doanimate = true -- to save some cpu
		ent.readytohit = false
		if (ent.pos.x > self.player1.pos.x + p1rangetoofar or ent.pos.x < self.player1.pos.x - p1rangetoofar) then
			ent.doanimate = false
			return
		end
		if (ent.pos.x > self.player1.pos.x + p1rangex or ent.pos.x < self.player1.pos.x - p1rangex) or
			(ent.pos.y > self.player1.pos.y + p1rangey or ent.pos.y < self.player1.pos.y - p1rangey) then -- OUTSIDE ATTACK RANGE
			-- idle
			ent.isleft, ent.isright = false, false
			ent.isup, ent.isdown = false, false
			ent.body.currspeed = ent.body.speed
			ent.body.currjumpspeed = ent.body.jumpspeed
		else -- INSIDE ATTACK RANGE
			-- x
			if ent.pos.x > random(self.player1.pos.x+p1hitrangex, self.player1.pos.x+p1rangex) then
				ent.isleft, ent.isright = true, false
				ent.body.currspeed = ent.body.speed * random(10, 15)*0.1 -- magik XXX
			elseif ent.pos.x < random(self.player1.pos.x-p1rangex, self.player1.pos.x-p1hitrangex) then
				ent.isleft, ent.isright = false, true
				ent.body.currspeed = ent.body.speed * random(10, 15)*0.1 -- magik XXX
			end
			-- y
			if ent.pos.y > random(self.player1.pos.y, self.player1.pos.y+p1hitrangey) then
				ent.isup, ent.isdown = true, false
				ent.body.currjumpspeed = ent.body.jumpspeed * random(10, 64) -- magik XXX
				ent.readytohit = true
			elseif ent.pos.y < random(self.player1.pos.y-p1hitrangey, self.player1.pos.y) then
				ent.isup, ent.isdown = false, true
				ent.body.currjumpspeed = ent.body.jumpspeed * random(10, 64) -- magik XXX
				ent.readytohit = true
			end
			-- nmes always face player1
			if not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
				if ent.pos.x > self.player1.pos.x then ent.flip = -1
				else ent.flip = 1
				end
			end
		end
		-- ATTACK
		if ent.readytohit then
			ent.curractiontimer -= 1
			if ent.curractiontimer < 0 then
				ent.animation.frame = 0
				rndaction = ent.abilities[random(#ent.abilities)] -- pick a random attack
				if rndaction == 1 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then 
					ent.isactionpunch1 = true
				elseif rndaction == 2 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
					ent.isactionpunch2 = true
				elseif rndaction == 3 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
					ent.isactionkick1 = true
				elseif rndaction == 4 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
					ent.isactionkick2 = true
				elseif rndaction == 5 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
					ent.positionystart = ent.pos.y
					ent.body.isonfloor = false
					ent.body.isgoingup = true
					ent.isactionjumppunch1 = true
					ent.body.currspeed *= 1+random(4)*0.1 -- randomize speed, you choose to add it and the params
					-- jump in the direction of the flip
					if ent.flip == 1 then ent.isleft = false ent.isright = true
					else ent.isleft = true ent.isright = false
					end
				elseif rndaction == 6 and not (ent.isactionjumppunch1 or ent.isactionjumpkick1) then
					ent.positionystart = ent.pos.y
					ent.body.isonfloor = false
					ent.body.isgoingup = true
					ent.isactionjumpkick1 = true
					ent.body.currspeed *= 1+random(3)*0.1 -- randomize speed, you choose to add it and the params
					-- jump in the direction of the flip
					if ent.flip == 1 then ent.isleft = false ent.isright = true
					else ent.isleft = true ent.isright = false
					end
				end
				ent.curractiontimer = ent.actiontimer
			end
		end
		Core.yield(1)
	end
	Core.asyncCall(fun) -- profiler seems to be faster without asyncCall (because of pairs traversing?)
end

This System controls all the entities with an artificial intelligence (ai) id. In this System an entity can be in an idle state, a move state or an attack state. Each states are applied relative to the distance between the Entity and the player1.

sShadow.lua

Let's quickly add the Shadow System. "sShadow.lua" in the "_S" folder. The code:

SShadow = Core.class()

function SShadow:init(xtiny) -- tiny function
	xtiny.processingSystem(self) -- called once on init and every update
end

function SShadow:filter(ent) -- tiny function
	return ent.shadow
end

function SShadow:onAdd(ent) -- tiny function
	ent.spritelayer:addChildAt(ent.shadow.sprite, 1) -- add shadow behind ent
end

function SShadow:onRemove(ent) -- tiny function
	ent.spritelayer:removeChild(ent.shadow.sprite)
end

function SShadow:process(ent, dt) -- tiny function
	local function fun()
		ent.shadow.sprite:setPosition(ent.pos+vector(ent.collbox.w/2, ent.collbox.h/2))
		if ent.body and not ent.body.isonfloor then
			ent.shadow.sprite:setPosition(ent.pos.x+ent.collbox.w/2, ent.positionystart+ent.collbox.h/2)
		end
		Core.yield(1)
	end
	Core.asyncCall(fun)
end

This System adds a shadow below an Entity. If the Entity is in a jump state, we update the shadow only on the x axis.

Next?

Systems are fairly straight forward and fairly short for what they can achieve.

We have covered the first set of systems, we have a couple more to add.


Prev.: Tuto tiny-ecs beatemup Part 8 Breakables
Next: Tuto tiny-ecs beatemup Part 10 Systems 2


Tutorial - tiny-ecs beatemup