Tuto tiny-ecs 2d platformer Part 9 Systems

From GiderosMobile
Revision as of 03:56, 6 November 2025 by MoKaLux (talk | contribs) (→‎Next?)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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.

There are a few 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:process(entity, dt) Called once on init and every frames.
*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

Don't worry, we won't use all the callback functions :-)

sDrawable.lua

Our first System will add/remove sprites from layers. 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.pos)
	ent.spritelayer:addChild(ent.sprite)
end

function SDrawable:onRemove(ent) -- tiny function
--	print("SDrawable:onRemove(ent)")
--	if ent.isplayer1 then return end
	if ent.ispersistent then return end
	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 unless it has a ispersistent id

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
	-- listeners
	self.player1inputlayer:addEventListener(Event.KEY_DOWN, function(e)
		if ent.currlives > 0 then
			if e.keyCode == KeyCode.LEFT or e.keyCode == g_keyleft then -- left
				ent.isleft = true
			elseif e.keyCode == KeyCode.RIGHT or e.keyCode == g_keyright then -- right
				ent.isright = true
			end
			if e.keyCode == KeyCode.UP or e.keyCode == g_keyup then -- up
				ent.isup = true
				ent.wasup = false -- allows initial jump
				ent.body.currinputbuffer = ent.body.inputbuffer
			elseif e.keyCode == KeyCode.DOWN or e.keyCode == g_keydown then -- down
				ent.isdown = true
			end
			-- ACTIONS:
			if e.keyCode == g_keyaction1 then -- shoot
				if ent.shield.currtimer <= 0 then -- no shoot while shield active, you choose!
					ent.isaction1 = true
				end
			elseif e.keyCode == g_keyaction2 then -- shield
				ent.isaction2 = true
			elseif e.keyCode == g_keyaction3 then -- dash
				if ent.body.currdashcooldown <= 0 then
					ent.isaction3 = 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 -- left
				ent.isleft = false
			end
			if e.keyCode == KeyCode.RIGHT or e.keyCode == g_keyright then -- right
				ent.isright = false
			end
			if e.keyCode == KeyCode.UP or e.keyCode == g_keyup then -- up
				ent.isup = false
				ent.wasup = false -- prevent constant jumps
				if ent.body.vy < ent.body.upspeed*0.5 then -- variable jump height
					local function lerp(a,b,t) return a + (b-a) * t end
--					ent.body.vy = lerp(ent.body.vy, ent.body.upspeed*0.5, 0.5)
					ent.body.vy = lerp(ent.body.vy, ent.body.upspeed*0.8333, 0.25)
				end
			end
			if e.keyCode == KeyCode.DOWN or e.keyCode == g_keydown then -- down
				ent.isdown = false
				ent.wasdown = false -- prevent constant going down ptpf
			end
			if e.keyCode == g_keyaction1 then ent.isaction1 = false end
			if e.keyCode == g_keyaction2 then ent.isaction2 = false end
			if e.keyCode == g_keyaction3 then ent.isaction3 = false 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 collision System we will add.

sPlayer1.lua

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

SPlayer1 = Core.class()

function SPlayer1:init(xtiny, xbump, 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
	self.bworld = xbump
	-- fx
	self.camera = xcamera -- camera shake
	self.camcurrzoom = self.camera:getZoom()
	-- sfx
	self.snd = { sound=Sound.new("audio/sfx/sfx_deathscream_human14.wav"), time=0, delay=0.2, }
end

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

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

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

function SPlayer1:process(ent, dt) -- tiny function
	if ent.isaction1 then -- shoot
		ent.isaction1 = false
		local projectilespeed = 60*8 -- 54*8
		local xangle = ^<0
		if ent.flip == -1 then xangle = ^<180 end
		local vx, vy = projectilespeed * math.cos(xangle), projectilespeed * math.sin(xangle)
		--EProjectiles:init(xid, xmass, xangle, xspritelayer, xpos, xvx, xvy, dx, dy, xpersist)
		local p = EProjectiles.new(
			1, 0, xangle, ent.spritelayer,
			ent.pos + vector(ent.collbox.w*0.5, ent.collbox.h*0.4),
			vx, vy, 36*8, 40*8, false
		)
		p.body.vx = vx
		p.body.vy = vy
		self.tiny.tworld:addEntity(p)
		self.bworld:add(p, p.pos.x, p.pos.y, p.collbox.w, p.collbox.h)
	end
	if ent.isaction2 then -- shield
		ent.isaction2 = false
		ent.shield.currtimer = ent.shield.timer
		ent.shield.sprite:setVisible(true)
	end
	if ent.isaction3 and ent.body.candash then -- dash
		ent.isaction3 = false
		ent.body.currdashtimer = ent.body.dashtimer
		ent.body.currdashcooldown = ent.body.dashcooldown
	end
	-- shield collision
	if ent.shield.currtimer > 0 then
		local function collisionfilter2(item) -- only one param: "item", return true, false or nil
			if item.isnme or (item.isprojectile and item.eid > 1) then
				return true
			end
		end
		ent.shield.sprite:setScale(ent.shield.sprite.sx*ent.flip, ent.shield.sprite.sy)
		ent.shield.sprite:setPosition(
			ent.pos + vector(ent.collbox.w/2, 0) +
			ent.shield.offset*vector(ent.shield.sprite.sx*ent.flip, ent.shield.sprite.sy)
		)
		local pw, ph = ent.shield.sprite:getWidth(), ent.shield.sprite:getHeight()
		--local items, len = world:queryRect(l, t, w, h, filter)
		local items, len2 = self.bworld:queryRect(
			ent.pos.x+ent.shield.offset.x*ent.flip-pw*0.5+ent.collbox.w*0.5,
			ent.pos.y+ent.shield.offset.y-ph*0.5,
			pw, ph,
			collisionfilter2
		)
		for i = 1, len2 do
			local item = items[i]
			if ent.shield.currtimer > 0 then
				item.damage = ent.shield.damage
				item.isdirty = true
			end
		end
	end
	if ent.shield and ent.shield.currtimer > 0 then
		ent.shield.currtimer -= 1
		if ent.shield.currtimer <= 0 then
			ent.shield.sprite:setVisible(false)
		end
	end
	-- hurt
	if ent.washurt > 0 and ent.wasbadlyhurt <= 0 and ent.currlives > 0 then -- lose 1 nrg
		ent.washurt -= 1
		ent.isdirty = false
		if ent.washurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1) -- reset color transform
			self.camera:setZoom(self.camcurrzoom) -- zoom
		elseif ent.washurt < ent.recovertimer*0.5 then
			ent.hitfx:setVisible(false)
		end
	elseif ent.wasbadlyhurt > 0 and ent.currlives > 0 then -- lose 1 life
		ent.wasbadlyhurt -= 1
		ent.isdirty = false
		if ent.wasbadlyhurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1)
			self.camera:setZoom(self.camcurrzoom) -- zoom
		elseif ent.wasbadlyhurt < ent.recoverbadtimer*0.5 then
			ent.hitfx:setVisible(false)
			ent.animation.curranim = g_ANIM_STANDUP_R
			if ent.iswallcontacts then
				ent.animation.curranim = g_ANIM_WALL_IDLE_R
			end
			ent.animation.frame = 0 -- start animation at frame 0
		end
	end
	if ent.body.currdashtimer > 0 then -- invicible while dashing
		ent.isdirty = false
	end
	-- hit
	if ent.isdirty then
		local snd = self.snd -- sfx
		local curr = os.timer()
		local prev = snd.time
		if curr - prev > snd.delay then
			local channel = snd.sound:play()
			if channel then channel:setVolume(g_sfxvolume*0.01) end
			snd.time = curr
		end
		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
		ent.sprite:setColorTransform(1, 1, 0, 2)
		ent.hitfx:setVisible(true)
		ent.hitfx:setPosition(ent.pos.x+ent.collbox.w*0.5, ent.pos.y)
		ent.spritelayer:addChild(ent.hitfx)
		ent.currhealth -= ent.damage
		-- hud
		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.animation.curranim = g_ANIM_HURT_R
		ent.animation.frame = 0 -- start animation at frame 0
		ent.isdirty = false
		if ent.currhealth <= 0 then
			ent.sprite:setColorTransform(1, 0, 0, 2)
			ent.currlives -= 1
			-- hud
			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
			ent.wasbadlyhurt = ent.recoverbadtimer -- timer for player1 to stand back up
			ent.animation.curranim = g_ANIM_LOSE1_R
			if ent.iswallcontacts then ent.animation.curranim = g_ANIM_HURT_R end
			self.camera:shake(0.8, 64) -- (duration, distance), you choose
		end
	end
	-- deaded
	if ent.currlives <= 0 then
		-- stop all movements
		ent.isleft = false
		ent.isright = false
		ent.isup = false
		ent.isdown = false
		-- play dead sequence
		ent.isdirty = false
		ent.washurt = 0
		ent.wasbadlyhurt = 0
		ent.animation.curranim = g_ANIM_LOSE1_R
		ent.sprite:setColorTransform(1, 1, 1, 5)
		self.camera:setZoom(self.camcurrzoom) -- zoom
		ent.body.currmass = -0.15 -- soul leaves body!
		ent.isup = true
		local timer = Timer.new(2000, 1)
		timer:addEventListener(Event.TIMER_COMPLETE, function(e)
			ent.restart = true
		end)
		timer:start()
	end
end

This System deals with the player1 actions:

  • 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
  • when an action button is pressed we execute it (action1 = shoot, action2 = shield, action3 = dash)
  • 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, atan2, cos, sin = math.random, math.atan2, math.cos, math.sin

function SNmes:init(xtiny, xbump, xplayer1) -- tiny function
	xtiny.processingSystem(self) -- called once on init and every update
	self.tiny = xtiny -- class ref so we can remove entities from tiny world
	self.bworld = xbump
	self.player1 = xplayer1
	-- sfx
	self.snd = { sound=Sound.new("audio/sfx/sfx_deathscream_human14.wav"), time=0, delay=0.2, }
end

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

function SNmes:onAdd(ent) -- tiny function
--	print("SNmes:onAdd")
	ent.flip = 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.curractiontimer = ent.actiontimer
end

function SNmes:onRemove(ent) -- tiny function
--	print("SNmes:onRemove")
	local function fun()
		ent.shield.sprite:setVisible(false)
		ent.shield.sprite = nil
		if ent.collectible then
			if ent.collectible:find("door") then -- append "key" to eid
				ent.collectible = "key"..tostring(ent.collectible)
			end
			--ECollectibles:init(xid, xspritelayer, xpos, xspeed, xdx, xdy)
			local el = ECollectibles.new(
				ent.collectible, ent.spritelayer,
				ent.pos+vector(ent.collbox.w/4, 0*ent.collbox.h),
				0.8*8, 6*8, 8*8
			)
			self.tiny.tworld:addEntity(el)
			self.bworld:add(el, el.pos.x, el.pos.y, el.collbox.w, el.collbox.h)
		end
		Core.yield(1)
	end
	Core.asyncCall(fun)
	self.bworld:remove(ent) -- remove collision box from cbump world here!
end

function SNmes:process(ent, dt) -- tiny function
	if ent.isaction1 then -- shoot
		ent.isaction1 = false
		local projectilespeed = 28*8
		local xangle = atan2(self.player1.pos.y-ent.pos.y, self.player1.pos.x-ent.pos.x)
		local offset = vector(ent.collbox.w*0.5, ent.collbox.h*0.4)
		if ent.eid == 100 then -- shoot straight
			if ent.flip == 1 then xangle = ^<0 -- shoot right
			else xangle = ^<180 -- shoot left
			end
			projectilespeed = 26*8
		end
		local vx, vy = projectilespeed*cos(xangle), projectilespeed*sin(xangle)
		--EProjectiles:init(xid, xmass, xangle, xspritelayer, xpos, xvx, xvy, dx, dy, xpersist)
		local p = EProjectiles.new(
			100, 0.1, xangle, ent.spritelayer, ent.pos+offset, vx, vy, 32*8, 32*8
		)
		p.body.vx = vx
		p.body.vy = vy
		self.tiny.tworld:addEntity(p)
		self.bworld:add(p, p.pos.x, p.pos.y, p.collbox.w, p.collbox.h)
	end
	if ent.isaction2 then -- shield
		ent.isaction2 = false
		if ent.shield then
			ent.shield.currtimer = ent.shield.timer
			ent.shield.sprite:setVisible(true)
		end
	end
	if ent.isaction3 then -- dash
		ent.isaction3 = false
		ent.body.currdashtimer = ent.body.dashtimer
		ent.body.currdashcooldown = ent.body.dashcooldown
	end
	-- shield collision
	if ent.shield and ent.currlives > 0 then
		local function collisionfilter2(item) -- only one param: "item", return true, false or nil
			if item.isplayer1 or (item.isprojectile and item.eid == 1) then
				return true
			end
		end
		local shiledsprite = ent.shield.sprite -- faster
		shiledsprite:setScale(shiledsprite.sx*ent.flip, shiledsprite.sy)
		shiledsprite:setPosition(
			ent.pos +
			vector(ent.collbox.w/2, 0) +
			ent.shield.offset*vector(shiledsprite.sx*ent.flip, shiledsprite.sy)
		)
		local pw, ph = shiledsprite:getWidth(), shiledsprite:getHeight()
		--local items, len = world:queryRect(l,t,w,h, filter)
		local items, len2 = self.bworld:queryRect(
			ent.pos.x+ent.shield.offset.x*ent.flip-pw*0.5+ent.collbox.w*0.5,
			ent.pos.y+ent.shield.offset.y-ph*0.5,
			pw, ph,
			collisionfilter2)
		for i = 1, len2 do
			local item = items[i]
			if shiledsprite:isVisible() then
				item.damage = ent.shield.damage
				item.isdirty = true
			end
		end
	end
	if ent.shield and ent.shield.currtimer > 0 then
		ent.shield.currtimer -= 1
		if ent.shield.currtimer <= 0 then
			ent.shield.sprite:setVisible(false)
		end
	end
	-- hurt
	if ent.washurt > 0 and ent.wasbadlyhurt <= 0 and ent.currlives > 0 then -- lose 1 nrg
		ent.washurt -= 1
		ent.isdirty = false
		if ent.washurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1) -- reset color transform
		elseif ent.washurt < ent.recovertimer*0.5 then
			ent.hitfx:setVisible(false)
		end
	elseif ent.wasbadlyhurt > 0 and ent.currlives > 0 then -- lose 1 life
		ent.wasbadlyhurt -= 1
		ent.isdirty = false
		if ent.wasbadlyhurt <= 0 then
			ent.sprite:setColorTransform(1, 1, 1, 1) -- reset color transform
		elseif ent.wasbadlyhurt < ent.recoverbadtimer*0.5 then
			ent.hitfx:setVisible(false)
			ent.animation.curranim = g_ANIM_STANDUP_R
			ent.animation.frame = 0 -- start animation at frame 0
		end
	end
	if ent.body.currdashtimer > 0 then -- invicible while dashing
		ent.isdirty = false
	end
	-- hit
	if ent.isdirty then
		ent.sprite:setColorTransform(0, 1, 0, 2)
		ent.hitfx:setVisible(true)
		ent.hitfx:setPosition(ent.pos.x+ent.collbox.w*0.5, ent.pos.y)
		ent.spritelayer:addChild(ent.hitfx)
		ent.currhealth -= ent.damage
		ent.washurt = ent.recovertimer -- timer for a flash effect
		ent.animation.curranim = g_ANIM_HURT_R
		ent.animation.frame = 0 -- start animation at frame 0
		ent.isdirty = false
		if ent.currhealth <= 0 then
			ent.sprite:setColorTransform(0, 1, 1, 2)
			ent.currlives -= 1
			if ent.currlives > 0 then
				ent.currhealth = ent.totalhealth
			end
			ent.wasbadlyhurt = ent.recoverbadtimer -- timer for actor to stand back up
			ent.animation.curranim = g_ANIM_LOSE1_R
		end
	end
	-- deaded
	if ent.currlives <= 0 then
		local snd = self.snd -- sfx
		local curr = os.timer()
		local prev = snd.time
		if curr - prev > snd.delay then
			local channel = snd.sound:play()
			if channel then channel:setVolume(g_sfxvolume*0.01) end
			snd.time = curr
		end
		-- stop all movements
		ent.isleft = false
		ent.isright = false
		ent.isup = false
		ent.isdown = false
		-- play dead sequence
		ent.isdirty = false
		ent.washurt = 0 -- ent.recovertimer
		ent.wasbadlyhurt = 0 -- ent.recoverbadtimer
		if ent.readytoremove then
			-- blood
			ent.hitfx:setVisible(true)
			ent.hitfx:setColorTransform(3, 0, 0, random(1, 3)*0.1) -- red color modulate
			ent.hitfx:setPosition(ent.pos.x+ent.collbox.w*0.5, ent.pos.y+ent.h*0.5)
			ent.hitfx:setRotation(random(360))
			ent.hitfx:setScale(random(5, 8)*0.1)
			ent.bgfxlayer:addChild(ent.hitfx)
			ent.animation.curranim = g_ANIM_LOSE1_R
			ent.sprite:setColorTransform(0.5, 0.5, 0.5, 0.5)
			self.tiny.tworld:removeEntity(ent) -- sprite is removed in SDrawable
		end
	end
end

This System deals with the enemies actions:

  • 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 action is triggered, we execute it (action1 = shoot, action2 = shield, action3 = dash). Actions will be triggered by an AI System
  • when an enemy is hit, we play some sound
  • onRemove the enemy is dead, we check if it 'holds' an item (key, coin, ...) and we add the item to the game

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.

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
	-- abilities
	-- ent.abilities[#ent.abilities+1] = 1 -- jump/climb
	-- ent.abilities[#ent.abilities+1] = 10 -- action1, shoot
	-- ent.abilities[#ent.abilities+1] = 20 -- action2, shield
	-- ent.abilities[#ent.abilities+1] = 30 -- action3, dash
	ent.abilities = {}
	if ent.body.upspeed > 0 then
		ent.abilities[#ent.abilities+1] = 1 -- jump/climb up
	end
	if ent.eid == 200 then
		ent.abilities[#ent.abilities+1] = 20 -- action2, shield
		if ent.body.speed > 0 then
			ent.abilities[#ent.abilities+1] = 30 -- action3, dash
		end
	elseif ent.eid == 300 then -- nme boss1
		ent.abilities[#ent.abilities+1] = 10 -- action1, shoot
		ent.abilities[#ent.abilities+1] = 20 -- action2, shield
		if ent.body.speed > 0 then
			ent.abilities[#ent.abilities+1] = 30 -- action3, dash
		end
	else
		ent.abilities[#ent.abilities+1] = 20 -- action2, shield
	end
--	for k, v in pairs(ent.abilities) do print(k, v) end
end

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

local p1rangetoofarx = myappwidth*2 -- 0.7, disable systems to save some CPU, magik XXX
local p1rangetoofary = myappheight*2 -- 0.7, disable systems to save some CPU, magik XXX
local p1outofrangex = myappwidth*1 -- 0.5, magik XXX
local p1outofrangey = myappheight*1 -- 0.5, magik XXX
local p1actionrange = myappwidth*0.2 -- 3*8, 1*8, myappwidth*0.1, magik XXX
function SAI:process(ent, dt) -- tiny function
	if ent.currlives <= 0 then
		return
	end
	local function fun()
		-- some flags
		ent.doanimate = true -- to save some cpu
		-- OUTSIDE VISIBLE RANGE
		if (ent.pos.x > self.player1.pos.x + p1rangetoofarx or
			ent.pos.x < self.player1.pos.x - p1rangetoofarx) or
			(ent.pos.y > self.player1.pos.y + p1rangetoofary or
			ent.pos.y < self.player1.pos.y - p1rangetoofary) then
			ent.doanimate = false
			return
		end
		-- OUTSIDE ACTION RANGE
		if (ent.pos.x > self.player1.pos.x+p1outofrangex or
			ent.pos.x < self.player1.pos.x-p1outofrangex) or
			(ent.pos.y > self.player1.pos.y+p1outofrangey or
			ent.pos.y < self.player1.pos.y-p1outofrangey) then
			-- idle
			ent.isleft, ent.isright = false, false
			ent.isup, ent.isdown = false, false
			ent.body.currspeed = ent.body.speed
			ent.body.currupspeed = ent.body.upspeed
			ent.readyforaction = false
		else -- INSIDE ACTION RANGE
			-- x
			local rnd = random(100)
			if rnd > 1 and ent.body.speed > 0 then -- allow nme to stop walking, magik XXX
				if ent.pos.x > random(self.player1.pos.x+p1actionrange, self.player1.pos.x+p1outofrangex) then
					ent.isleft, ent.isright = true, false
					ent.body.currspeed = ent.body.speed*random(8, 12)*0.1 -- magik XXX
				elseif ent.pos.x < random(self.player1.pos.x-p1outofrangex, self.player1.pos.x-p1actionrange) then
					ent.isleft, ent.isright = false, true
					ent.body.currspeed = ent.body.speed*random(8, 12)*0.1 -- magik XXX
				end
			elseif ent.body.speed == 0 then -- nme always faces player1
				if ent.pos.x > self.player1.pos.x then
					ent.isleft, ent.isright = true, false
				else
					ent.isleft, ent.isright = false, true
				end
			else -- stop moving
				ent.isleft, ent.isright = false, false
				ent.body.currspeed = ent.body.speed
			end
			-- no going left or right on stairs/ladders
			if ent.isladdercontacts and not (ent.isfloorcontacts or ent.isptpfcontacts) then
				ent.isright, ent.isleft = false, false
			end
			-- bumping on a wall unless isleftofplayer
			ent.isleftofplayer = false
			if ent.pos.x < self.player1.pos.x then
				ent.isleftofplayer = true
			end
			-- y
			-- impulse on top of stairs/ladders and actor is going up
			if ent.isladdercontacts and ent.isptpfcontacts and ent.body.vy < 0 then
				ent.isup, ent.isdown = true, false
				ent.wasup = false
				ent.body.currinputbuffer = ent.body.inputbuffer
				ent.body.currupspeed = ent.body.upspeed*random(8, 12)*0.1 -- magik XXX
			end
			-- only act if entity has abilities
			if #ent.abilities > 0 then
				ent.readyforaction = true
			end
		end
		-- ACTION
		if ent.readyforaction then
			ent.curractiontimer -= 1
			-- don't let the entity get stuck in place when in range
			if ent.body.vx == 0 and ent.body.speed > 0 then
				if ent.pos.x > self.player1.pos.x then ent.isleft, ent.isright = true, false
				else ent.isleft, ent.isright = false, true
				end
				ent.body.currspeed = ent.body.speed*random(8, 12)*0.1 -- magik XXX
			end
			if ent.curractiontimer < 0 then
				-- actor above player1, go down
				if ent.pos.y < self.player1.pos.y and ent.body.upspeed > 0 then
					if ent.isptpfcontacts then
						local rnd = random(100)
						if rnd > 30 then -- 80, magik XXX
							ent.isup, ent.isdown = false, true
							ent.wasdown = false
							ent.body.currupspeed = ent.body.upspeed*random(8, 12)*0.1 -- magik XXX
						end
					elseif ent.body.upspeed > 0 then -- ladders/stairs
						ent.isup, ent.isdown = false, true
						ent.wasdown = false
						ent.body.currupspeed = ent.body.upspeed*random(8, 12)*0.1 -- magik XXX
					end
				end
				-- actor below player1, jump
				if ent.pos.y > self.player1.pos.y + 8 and ent.body.upspeed > 0 then
					local rnd = random(100)
					if rnd > 40 then -- 80, magik XXX
						ent.isup, ent.isdown = true, false
						ent.wasup = false
						ent.body.currinputbuffer = ent.body.inputbuffer
						ent.body.currupspeed = ent.body.upspeed*random(8, 12)*0.1 -- magik XXX
					end
				end
				-- pick a random available action
				local rndaction = ent.abilities[random(#ent.abilities)]
				-- movements
				if rndaction == 1 and ent.body.upspeed > 0 then -- jump
					local rnd = random(100)
					if rnd > 70 then -- 80, jump
						ent.isup, ent.isdown = true, false
						ent.wasup = false
						ent.body.currinputbuffer = ent.body.inputbuffer
						ent.body.currupspeed = ent.body.upspeed*random(8, 12)*0.1 -- magik XXX
					end
				end
				-- actions
				local rnd = random(100)
				if rndaction == 10 then -- shoot
					ent.isaction1 = false
					if rnd > 99 then -- rate
						ent.isaction1 = true
					end
				elseif rndaction == 20 then -- shield
					ent.isaction2 = false
					if rnd > 98 then -- rate
						ent.isaction2 = true
					end
				elseif rndaction == 30 then -- dash
					ent.isaction3 = false
					if rnd > 95 then -- rate
						ent.body.currspeed = ent.body.speed*0.5 -- magik XXX
						ent.isaction3 = true
					end
				end
			end
			-- extra attack on jumping peak
			if ent.body.vy > 0 and ent.body.vy < 30 and ent.body.upspeed > 0 then -- magik XXX
				if ent.curractiontimer < 0 then
					local rndaction = ent.abilities[random(#ent.abilities)]
					local rnd = random(100)
					if rnd > 70 then -- rate
						if rndaction == 10 then -- shoot
							ent.isaction1 = true
						elseif rndaction == 20 then -- shield
							ent.isaction2 = true
						end
					else -- dash/shield
						local rnd2 = random(100)
						if rnd2 > 30 then
							if rndaction == 30 then -- dash
								ent.isaction3 = true
							end
						else
							ent.body.currspeed = ent.body.speed*0.5 -- magik XXX
							if rndaction == 20 then -- shield
								ent.isaction2 = true
							end
						end
					end
					ent.curractiontimer = ent.actiontimer
				end
			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:

  • runs once on init and every game loop (process)
  • onAdd() we assign various abilities depending on the type of enemy id
	-- abilities
	ent.abilities = {}
	-- ent.abilities[#ent.abilities+1] = 1 -- jump/climb
	-- ent.abilities[#ent.abilities+1] = 10 -- action1, shoot
	-- ent.abilities[#ent.abilities+1] = 20 -- action2, shield
	-- ent.abilities[#ent.abilities+1] = 30 -- action3, dash
  • process() when the enemy is outside the visible screen we don't process it
  • process() when the enemy is in action range with the player1 we randomly trigger an action (ability)

sAnimation.lua

The animation System. "sAnimation.lua" in the "_S" folder. The code:

SAnimation = Core.class()

function SAnimation:init(xtiny)
	xtiny.processingSystem(self) -- called once on init and every frames
	self.sndstepgrass = { sound=Sound.new("audio/sfx/footstep/Grass02.wav"), time=0, delay=0.2, }
end

function SAnimation:filter(ent) -- tiny function
	return ent.animation
end

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

function SAnimation:onRemove(ent) -- tiny function
	ent.animation = nil -- free some memory?
end

local checkanim
--@native -- new in Gideros
function SAnimation:process(ent, dt) -- tiny function
	-- a little boost?
	local anim = ent.animation

--	checkanim = anim.curranim -- if you are sure all animations are set else use below ternary operator code
	-- luau ternary operator (no end at the end), it's a 1 liner and seems fast?
	checkanim = if anim.anims[anim.curranim] then anim.curranim else g_ANIM_DEFAULT
--	print("checkanim", checkanim)
--	print("#anim.anims[checkanim]", #anim.anims[checkanim])

	if not ent.doanimate then return end

	anim.animtimer -= dt
	if anim.animtimer < 0 then
		anim.frame += 1
		anim.animtimer = anim.animspeed
		if checkanim == g_ANIM_DEFAULT then
			if anim.frame > #anim.anims[checkanim] then
				anim.frame = 1
			end
		elseif checkanim == g_ANIM_HURT_R or checkanim == g_ANIM_LOSE1_R or checkanim == g_ANIM_STANDUP_R then
			if anim.frame >= #anim.anims[checkanim] then
				anim.frame = #anim.anims[checkanim]
			end
		elseif checkanim == g_ANIM_JUMPUP_R or checkanim == g_ANIM_JUMPDOWN_R then
			if anim.frame > #anim.anims[checkanim] then
				anim.frame = #anim.anims[checkanim]
			end
		else -- any looping animation
			-- player1 steps sound fx
			if ent.isplayer1 then
				if (anim.curranim == g_ANIM_WALK_R or anim.curranim == g_ANIM_RUN_R) and
					(anim.frame == 3 or anim.frame == 7) then
					local snd = self.sndstepgrass
					local curr = os.timer()
					local prev = snd.time
					if curr - prev > snd.delay then
						local channel = snd.sound:play()
						if channel then channel:setVolume(g_sfxvolume*0.01) end
						snd.time = curr
					end
				end
			end
			-- loop animations
			if anim.frame > #anim.anims[checkanim] then
				anim.frame = 1
			end
		end
		anim.bmp:setTextureRegion(anim.anims[checkanim][anim.frame])
	end
end

This is the System responsible for all the animations in the game:

  • runs once on init and every game loop (process)
  • it executes an animation or falls back to the default animation (g_ANIM_DEFAULT)
  • it only runs when the doanimate flag is set to true
  • then it increases the frame based on a timer
  • on certain animations, the frame loops back to 1
  • on other animations, the frame will stop at the end of the animation
  • we can also trigger events on specific frame number (eg. play sound)

Next?

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

We have covered the first set of systems, the next System is the heart of the game: the collision System.


Prev.: Tuto tiny-ecs 2d platformer Part 8 more entities
Next: Tuto tiny-ecs 2d platformer Part 10 Collision System


Tutorial - tiny-ecs 2d platformer