Log In  

Cart #44917 | 2017-10-04 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
9

This is a tiny (47 token!) Entity Component System library.

Entity Component Systems are design patterns where entities are generic collections of components and logic is written for specific component types. It's popular for game design as we often have many 'things' that don't always fall within clear cut types.

There's different approaches to ECS, here we'll consider a table to be an entity, and the key/value pairs to be component names and values.

This library is basically a single function, system, which creates a function that selects every entity with the specified components and calls the user provided function on each one.

function _has(e, ks)
  for n in all(ks) do
    if not e[n] 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

To use it we'll need a collection of entities:

world = {}

add(world, {pos={x=32, y=64}, color=12})
add(world, {pos={x=64, y=64}, sprite=0})
add(world, {pos={x=96, y=64}, color=8, sprite=1})

Now we define a couple system functions, one for things with position and color components, and one for position and sprite.

circles = system({"pos", "color"}, 
  function(e)
    circfill(e.pos.x, e.pos.y, 8, e.color)
  end)

sprites = system({"pos", "sprite"}, 
  function(e)
    spr(e.sprite, e.pos.x-4, e.pos.y-4)
  end)

Finally, we call our systems on our entity collection:

function _draw()
  cls()
  circles(world)
  sprites(world)
end
P#44926 2017-10-05 09:17 ( Edited 2018-02-04 16:16)

Interesting. I'm digging it...I think.

Your example show just drawing objects. Using this same type of architecture, how would you do updating and drawing together?

Like I want my circle to change color when I press a button.

P#44933 2017-10-05 16:01 ( Edited 2017-10-05 20:01)
:: kozie

@morningtoast
I think you still want those two logics separated. You change certain states and properties of an entity in the update process (hence the name "update") and do the drawing using its current states and properties.

@selfsame
Interesting how you managed to put something still quite handy in such tiny codebase. Never really extensively used such an ECS. I might just dive deeper into this theory. Thanks tho

P#44940 2017-10-06 03:50 ( Edited 2017-10-06 07:50)

@morningtoast
Yeah you'd just design a separate system that changes the property

change_color = system({"color"}, 
  function(e)
    if btn(1) then
      e.color = 4
    end
  end)

function _update()
  change_color(world)
end
P#44943 2017-10-06 08:19 ( Edited 2017-10-06 12:25)
:: Felice

Heads-up: The for v in all(t) construct is verrry slow on PICO-8. Like, so slow that even with a small world to traverse, you might start noticing it.

I'd suggest using for k,v in pairs(t), at least if you can afford not to care about the order you walk over the table members. In your _has(), that appears to be the case.

P#44954 2017-10-06 16:47 ( Edited 2017-10-06 23:07)

How would you do a test that checks that no entity that has a component has the component set to a certain value? I mean:

go through every entity that has a component
    if a component has this value
        return false
return true

EDIT: Also, how do you do a function (system or otherwise) that takes in two entities?

P#48966 2018-02-04 11:16 ( Edited 2018-02-16 18:00)
:: alexr

Loving this and am trying to develop a proof of concept for a game using your system, @selfsame.

Current challenge is deciding how to deal with the concept of flipped sprites:

For my rendering system (sprites, in your example), I need a flipx boolean property to render some entities. This property reflects the value of the accel property of another component (motion).

Sample entity factory:

function mk_boat(x,y)
    return {
        spr={frames={19,20,21,20},ticks={12,12,12,12},idx=1,flp={x=false,y=false}},
        pos={x=x,y=y},
        motion={vx=0,vy=0,ax=0,ay=0,fx=0.2,fy=0.1}
    }
end

The problem is that while some entities can move, others might not be able to.

function mk_plant(x,y)
    return {
        spr={frames={1},ticks={100},idx=1,flp={x=false,y=false}},
        pos={x=x,y=y}
    }
end

Making one system (sprites) to render all, testing for the presence of a component and flipping the sprite accordingly feels incorrect.

sprites = draw_system({"spr", "pos"},
    function(e)
        if e.motion then
            e.spr.flp.x = e.motion.ax<0
            e.spr.flp.y = e.motion.ay<0
        end
        spr(e.spr.frames[e.spr.idx], e.pos.x, e.pos.y, 1, 1, e.spr.flp.x, e.spr.flp.y)
    end)

Another way would be to have 2 systems for rendering: sprites, and moving_sprites. But this feels wasteful (code duplication).

sprites = draw_system({"spr", "pos"},
    function(e)
        spr(e.spr.frames[e.spr.idx], e.pos.x, e.pos.y)
    end)

moving_sprites = draw_system({"spr", "pos", "motion"},
    function(e)
        e.spr.flp.x = e.motion.ax<0
        e.spr.flp.y = e.motion.ay<0
        spr(e.spr.frames[e.spr.idx], e.pos.x, e.pos.y, 1, 1, e.spr.flp.x, e.spr.flp.y)
    end)

A third way would be to add a heading property to the pos component (pos={x=0,y=0,h=4}. But while every visible entity has a position, not every entity needs a heading.

function mk_boat(x,y)
    return {
        spr={frames={19,20,21,20},ticks={12,12,12,12},idx=1,flp={x=false,y=false}},
        pos={x=x,y=y,h=2}, -- h is for heading
        motion={vx=0,vy=0,ax=0,ay=0,fx=0.1,fy=0.1}
    }
end

sprites = draw_system({"spr", "pos"},
    function(e)
        local flp_x=e.pos.h==1
        local flp_y=e.pos.h==3
        spr(e.spr.frames[e.spr.idx], e.pos.x, e.pos.y, 1, 1, flp_x, flp_y)
    end)

This is what I'm at right now. Just wanted to share in hope of getting ideas from others. It's quite the can of worms I have opened when I read your post. Thanks. ;)

P#67941 2019-09-20 15:28 ( Edited 2019-09-21 03:46)
:: dw817

This is quite the eye-opener for me in coding. Never seen anything like this. May take me a few weeks to understand all of it.

Gets my star for innovation and usefulness.

P#67947 2019-09-20 17:37

@alexr nice! i think you're on the right track.

I think you can just put a component that may or may not exist in those flip arguments (https://notabug.org/selfsame/carts/src/master/ecgame.p8#L252)

You could maybe try splitting up things like sprite, animation, and motion into their own components and systems too.

P#67964 2019-09-21 01:37
:: alexr

@selfsame thanks. Mulling over this. Here's a work in progress btw. btns 1 to 4 to move the sub. 5 simulates (for now) boat death.

That was the easy part. Now I'm aiming for torpedoes, targeting, mines, explosions, camera shake, and other components. Will see...

Cart #wumowetese-4 | 2019-09-21 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

P#67966 2019-09-21 02:48 ( Edited 2019-09-21 03:35)
:: zlg

ECS is a concept (also) used in Zelda: Breath of the Wild for entity interactions, like using a flame on a pepper, a bush, or an enemy. All tie into a 'burnable' component on an entity. Likewise for metallic objects with Magnesis, mobile objects and Stasis, etc.

So this is an easy way to get multiplicative gameplay elements in your game engine. One only needs to follow the pattern.

Thanks for sharing!

P#67988 2019-09-22 02:20 ( Edited 2019-09-22 02:24)
:: alexr
1

Cart #jenobumoti-6 | 2019-10-06 | Code ▽ | Embed ▽ | No License
1

Getting more comfortable with ECS. Brain rewiring almost complete.

After a couple of weeks of tinkering with this, it's become clear that adding some procedural (e.g., functions handle_map_collisions and handle_collisions) code to the setup simplifies things a lot.

I've added some comments to the code. Feel free if anything not clear.

Working with ECS goes like this, for me:

First, you list your actors, say, a submarine, a torpedo, a boat, a barrel bomb, and bubbles (subs release air sometimes, hence the bubbles). You then create basic factories for them, say:

function mk_submarine(x,y)
    return {
        type="sub",
        info={health=100},
        pos={x=x,y=y},
        spr={frame=1,fx=false,fy=false}
    }
end
function mk_torpedo(x,y)
    return {
        type="torpedo",
        pos={x=x,y=y},
        spr={frame=2,fx=false,fy=false}
    }
end
function mk_boat(x,y)
    return {
        type="boat",
        info={health=100},
        pos={x=x,y=y},
        spr={frame=3,fx=false,fy=false}
    }
end
function mk_barrel_bomb(x,y)
    return {
        type="bomb",
        pos={x=x,y=y},
        spr={frame=4,fx=false,fy=false}
    }
end
function mk_bubble(x,y)
    return {
        type="bubble",
        pos={x=x,y=y},
        spr={frame=5,fx=false,fy=false}
    }
end

But wait! Say you want to render bubbles as circles rather than sprites. You then remove the "spr" tag from the returned table, replacing it with a, say, shape tag.

function mk_bubble(x,y)
    return {
        type="bubble",
        pos={x=x,y=y},
        shape={type="circ",color=7}
    }
end

Then, you create (or reuse existing) systems that each specialize in something, say movement, or sprite animation rendering, or health tracking, or entity aging, etc. Each system will need a specific set of components to do its job. It's easier to code the system first, and then identify the components that that requires. The signature becomes obvious after that. Continuing on the previous example, you now need a renderer for sprites, and one for (bubble) shapes.

sprites = system({"pos","spr"}, function(e){
    spr(e.spr.frame, e.pos.x,e.pos.y, 1,1, e.spr.fx,e.spr.fy)
})
shapes = system({"pos","size","shape"}, function(e){
    local cx=e.pos.x+e.size.x/2
    local cy=e.pos.y+e.size.y/2
    local radius=e.size.x/2
    local col=e.shape.color
    circ(cx,cy,radius,col)
})

These rendering systems can now be added to your game _draw function:

function _draw()
    cls()
    map()
    sprites(world)
    shapes(world)
end

And that's it for the basics. Map collisions add some complexity. Entity to entity collisions even more. I found that, for simple outcomes, I could stay within the "confines" of a component table. Say, when a ball hits a wall, you give it a "rebound" tag (a new, corresponding, "rebounds" system will take care of the rest once you have written it):

function mk_ball(x,y)
    return {
        type="ball",
        pos={x=x,y=y},
        shape={type="circ",color=8,filled=true},
        mcoll={flag=1,on="rebound"}
    }
end
rebounds = system({"pos","move","rebound"}, function(e){
    -- reverse ball movement
    e.move.vx*=-1
    e.move.vy*=-1
})

Oops. Where is move coming from? We need to update our entity definition:

function mk_ball(x,y)
    return {
        type="ball",
        pos={x=x,y=y},
        move={vx=1,vy=1},
        shape={type="circ",color=8,filled=true},
        mcoll={flag=1,on="rebound"}
    }
end

We can now add the rebounds system to our update pipeline:

function _update()
  -- ...
    movement(world) -- we'll need to have a system for that too
    rebounds(world)
    -- ...
end

Things can get complicated. Say, when a bomb detects a nearby sub and detonates,
which results in destruction of the bomb, creation of an explosion entity (we'll
need a new function for that...), the effects of which will result in damage dealt
to nearby entities. More actors then are discovered as we follow the thread of our game narrative. And a point is reached when more complex interactions are needed that are not easily defined within one component. In such cases, rather than complicate things, I found it easier to use custom handlers.

-- custom entity collision handler,
-- invoked by the entity_collisions
-- system whenever the colliding 
-- component's ecoll tag does not
-- have an "on" attribute
function handle_collision(e1,e2)
    if e1.type=="alien" and e2.type=="missile" then
        -- play sound fx
        sfx(2)

        -- replace alien with dead_alien
        local x=e1.pos.x
        local y=e1.pos.y
        add(world, mk_dead_alien(x,y))

        -- tag alien for deletion
        tag(e1,"die",true)

        -- tag missile for deletion
        tag(e2,"die",true)

        -- up player (cannon) score
        local c=find(world,"type","cannon")
        c.info.hits+=1
    end
end

This is way longer than I wanted. I'll stop for now. Hoping it helped (anyone wondering about ECS and Pico-8).

P#68554 2019-10-06 01:37 ( Edited 2019-10-06 03:35)

This is really cool and useful, thanks for sharing! :)

P#68768 2019-10-11 17:45
:: alexr

Here's a snake game done with ECS:

Cart #piwegibaki-2 | 2019-11-07 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
.

P#69789 2019-11-10 22:50

[Please log in to post a comment]

Follow Lexaloffle:        
Generated 2020-06-06 07:57 | 0.069s | 4194k | Q:89