ToriEngine - ECS Metroidvania Engine
I'm building an Entity-Component-System engine-thing to make platforming metroidvania games like Cave Story on Pico-8. I was initially building it for a Jam game, Tori Tower, but the engine fell out of scope x.x
I have run into some issues as I was building the code, and I'd really appreciate if anyone would like to help me here and there ^^
I'll start by explaining how the engine works, so it all becomes easier to digest later:
Architecture
An ECS (Entity-Component-System) is an architecture for games in which the World State is populated by Entities, which are simply containers (tables) who store values. These values are called Components, and each entity has its own set of components, which its own values assigned.
Entities hold no functions. The logic is handled by Systems, which are functions that make changes in the World State every frame. A system does so by filtering out the entities in the world who have components relevant to the system, and executes the function upon each entity selected.
Example: Moving system
A moving system moves every entity that is movable each frame.
So, the system _move
will filter out the world
table, so that it only selects the entities who have the move
and pos
component. Those components look a bit like this:
entity_that_moves={ pos = {x=30,y=64} --position component move = {vel_x=1,vel_y=0,max_vel_x=3,max_vel_y=2,acc_x=1, acc_y=3, friction=0.8} --movement component } |
Filtering out these systems, it'll run the logic for movement:
function(ent) ent.move.vel_x=mid(-ent.move.max_vel_x,ent.move.vel_x+ent.move.acc_x,ent.move.max_vel_x) ent.move.vel_y= --yadda yadda yadda you get the point end |
With that said, I'll explain how my engine is working.
The Core
The core is simple and compact, and credit goes to @selfsame for creating the system, and @alexr for building upon it:
https://www.lexaloffle.com/bbs/?tid=30039
-- basic function _has(e, ks) for n in all(ks) do if e[n]==nil then return false end end return true end function system(ks, f) return function(es) for e in all(es) do if _has(e, ks) then f(e) end end end end --extras function tag(e,k,v) if (not v) v=true e[k]=v end function untag(e,k) e[k]=nil end function filter(es,k,v) local res={} for e in all(es) do if e[k]==v then add(res,e) end end return res end function find(es,k,v) local res=filter(es,k,v) if (#res>0) return res[1] return nil end function filter_tagged(es,t) local res={} for e in all(es) do if e[t]!=nil then add(res,e) end end return res end |
Basically, the function system(ks, f)
returns a system function, that when called passing the world
as the argument, executes function f
upon the entities in the world that have all the components dictated in ks
(a table of strings)
The other extra functions are pretty self explanatory. Tagging just means adding a component to an entity that didnt have it before.
Entities and Components
Entities are all stored in the world
table, and are made in factory functions, such as mk_player(x,y)
, that returns a table with all the components of a player, to be added to the world
.
Systems
Update systems are called in the _update60()
function and rendering systems are called in the _draw
function. These are the ones currently implemented:
(credit to @matthughson for the collision system, on this cart: https://www.lexaloffle.com/bbs/?tid=28793)
function _update60() -- these are the updater -- systems we need (for now) _animate(world) --_behaviour_system(world) _ai_behaviour(world) _move_input(world) _jump_input(world) _atk_input(world) _gravity(world) _grounded(world) _movement(world) _map_collisions(world) _ent_collisions(world) _damaged(world) _death(world) end function _draw() palt(11,true) palt(0,false) cls(5) colorize(128,133,141,134) map() -- these are the render systems -- we need (for now) colorize(129,2,15,7) _animspr(world) _camera(world) pal() _ui(world) -- just debug stuff if debug then _debug_vectors(world) for p in all(debug_collision_pixels) do pset(p[1],p[2],p[3]) end end end |
Custom Handlers
Collisions are handled in separate functions, because their logic was too extensive to handle in a single system function.
They're separated into two types: colisions with the map tiles and collisions with other entities.
-- update systems _map_collisions = system({"pos","size","mcoll","jump"}, function(e) collide_side(e) collide_roof(e) if collide_floor(e) then tag(e,"grounded") else --self:set_anim("jump") untag(e,"grounded") end end) _ent_collisions = system({"pos","move","ecoll"}, function(e) for typ,tg in pairs(e.ecoll.all) do local es=filter(world,"type",typ) for e2 in all(es) do if e2!=e and overlaps_box_box( e.pos,e.size, e2.pos,e2.size) then --entity collided will become --e[tg] if (not e[tg]) tag(e,tg,{by=e2}) end end end end ) -- custom handlers function collide_side(e) local offset=e.size.x/3 for i=-(e.size.x/3),(e.size.x/3),2 do --if e.move.vx>0 then if fget(mget((e.pos.x+(offset))/8,(e.pos.y+i)/8),0) then e.move.vx=0 e.pos.x=(flr(((e.pos.x+(offset))/8))*8)-(offset) return true end --elseif e.move.vx<0 then if fget(mget((e.pos.x-(offset))/8,(e.pos.y+i)/8),0) then e.move.vx=0 e.pos.x=(flr((e.pos.x-(offset))/8)*8)+8+(offset) return true end -- end end --didn't hit a solid tile. return false end --check if pushing into floor tile and resolve. --requires e.move.vx,e.pos.x,e.pos.y,self.grounded,self.airtime and --assumes tile flag 0 or 1 == solid function collide_floor(e) --only check for ground when falling. if e.move.vy<0 then return false end local landed=false --check for collision at multiple points along the bottom --of the sprite: left, center, and right. for i=-(e.size.x/3),(e.size.x/3),2 do local tile=mget((e.pos.x+i)/8,(e.pos.y+(e.size.y/2))/8) if fget(tile,0) or (fget(tile,1) and e.move.vy>=0) then e.move.vy=0 e.pos.y=(flr((e.pos.y+(e.size.y/2))/8)*8)-(e.size.y/2) landed=true end end return landed end --check if pushing into roof tile and resolve. --requires e.move.vy,e.pos.x,e.pos.y, and --assumes tile flag 0 == solid function collide_roof(e) --check for collision at multiple points along the top --of the sprite: left, center, and right. for i=-(e.size.x/3),(e.size.x/3),2 do if fget(mget((e.pos.x+i)/8,(e.pos.y-(e.size.y/2))/8),0) then e.move.vy=0 e.pos.y=flr((e.pos.y-(e.size.y/2))/8)*8+8+(e.size.y/2) e.jump.jump_hold_time=0 end end end |
Databases
These are for data sets that are repeated among many entity constructors, or those that are unique
to each entity. Things such as animations and enemy behaviours.
There's a better description of each in the code :3
Backlog
[X] ECS
Gamemodes
[X] Overworld
[ ] Cutscene
[ ] Boss Fight
[ ] Title Screen
[ ] Game Over Screen
Entities
[X] Player
[X] Enemy
[ ] Item
[ ] Moving Blocks/Platforms
[ ] Breakable Block
[ ] Boss
Update Systems
[X] Movement
[X] Solid Collisions
[X] One-Way Platform Collisions
[X] Entity Collisions
[X] Walk Input
[X] Jump Input
[ ] Get Item
[X] Take Damage (any entity with HP)
[ ] Knockback
[~] Die
[~] Enemy Behaviour
[ ] Gameover
[ ] Scriptable Overworld Cutscenes (Toriscript)
Render Systems
[X] Sprites
[X] Sprite Animations
[X] Camera
[X] HP UI
[ ] Current Item UI
[ ] GFX
[ ] Textbox
Custom Handlers
[ ] Change/Load Room
[ ] Story Event Handler
[ ] Save Progress
Current Progress [Aug 07 2023]
I'm currently having issues with implementing the unique entity behaviour. If you see the _ai_behaviour() system and the db_bhvr table, you'll see what I'm talking about.
There's been weird behaviour like this one - the enemy is only supposed to avoid walls and pits; I'm not sure what's causing it .w.
Any help will be sincerily appreciated~
Cheers!
[Please log in to post a comment]