Log In  

Cart #49906 | 2018-03-04 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
3

Hey y'all! I'm so excited to share an object-oriented component system I've been working on for PICO-8. I'm still super new to the platform, so I'm looking for any and all feedback you might have -- how to save tokens, smarter ways to loop over updates, cooler rendering techniques, anything!

I built it out as a toolset for the game I'm working on right now, so please forgive me if I've forgotten to take out anything too specialized. Let me know if you're able to use it on a project or if you have any feedback at all!

Thanks for reading! Below is the readme from the github page, linked below.

GITHUB: https://github.com/walt-er/compos

compos: reusable components for object-oriented PICO-8

compos: like "components", but with fewer characters!

compos are independent, reusable objects that can be added to your game's actors to give them certain behaviors. compos manage their own state, initialization, updating, and drawing. The only thing you might need to do is set some intitial values.

There's a fair amount of overhead for defining so many components right out of the gate. But hopefully the savings come down the line: it's easy to attach behaviors to actors independently, so defining large numbers of actors with similar behaviors is simple and doesn't require messy class inheritance. This system is build with procedural generation in mind -- it's easy to spawn complex actors on the fly, mixing and matching qualities without spending tokens.

The compos include:

  • Position
  • Size
  • Velocity
  • Gravity
  • Collider
  • Age
  • Patrol

This library also includes a number of helper functions, including methods for drawing sprites and primitives with outlines, integrated logging, generating vectors, copying tables, tiling sprites, and more.

More importantly, the methods for adding and removing actors from the active list, used in conjunction with the compos update pool (where actors and compos register to run their updates), mean that once you've defined an init(), update() or draw() function to an actor, they'll act just as you expect as they are added and removed from the global list of actors.

Starting with actors

compos loops over an "actors" array and runs the functions those actors and their components have registered for. For your entities to use the compos lifecycle, they will need to copy over the desired components and then be added to the global list of actors.

Here's an example of an object that draws an animating sprite in the middle of the screen:

local thingy = {
    physical = true, -- this inits the x, y, w, and y properties
    sprite = copy(sprite), -- this copies in the compo sprite component
    init = function(self) -- this runs on compo_init (or on demand if this actor is added with add_actor()
        translate(self, 60, 60) -- translate moves an actor to an x and y vector
        local spritesheet = split'0, 1, 2' -- split saves tokens by turning comma separated lists into arrays
        self.sprite:animate(self, spritesheet, 15, true) -- the third parameter is sprites-per-seocnd, the fourth is looping
    end
}
-- add to list of actors to be initialized and updated
add(actors, thingy)

Notice that the actor does not need to register any update or draw functions -- the sprite compo, when initialized, will register for all the lifecycle methods it requires.

If you're adding actors on the fly, use add_actor(). This method will run the required initialization and event registration before the actor is added to the scene.

Lifecycle and the update pool

compos will handle their own updates, but you'll need to add compos functions somewhere for them to run. If nothing else, add the three basic functions to your cart:

function _init()
    compos_init()
end

function _update()
    compos_update()
end

function _draw()
    cls() -- compos doesn't clear for you!
    compos_draw()
end

Behind the scenes, within those compos_* functions, there are various "pools" of actors and compos that have registered to run each frame. The update functions availible are early_update, update, late_update, and fixed_update, and drawing is done in early_update and update.

When an actor is initialized, it's update functions are registered in those pools and run in the order they are added. Keep that in mind for drawing -- actors added later will be drawn on top. (Note that I want to add an optional override for this soon! For now you can use early_draw to make sure things are drawn in first.)

It's important to remove actors by using remove_actor(), as opposed to, say, del(actors, thingy), because the remove_actor function also unregisters all events. Failing to use it could mean a memory leak as more and more actors are registered and none are rmeoved.

Integrating compos into your project

The most direct way to integrate compos into your project is simply copy pasting all of compos.lua into your cart, then deleting unwanted components and functions. There are a lot of functions included here, and cherrypicking what you want will save a ton of tokens. You probably don't need it all!

Integration can also be achieved using picotool, with some extra work. Just require('compos.lua') in your source pico8 file to include compos inside a "require" function. Just note that you'll need to delete the function wrapping the compo definitions for your code to reference them without errors. (NOTE: if you think I could get around this, let me know!)

You could also just hack the compo.p8 cart, using that as a jumping off point!

Demo: Bouncy Blobs

Here's some code that uses compos to draw hundreds of actors with positions, sizes, colors, and gravity:

SOURCE: https://github.com/walt-er/compos/blob/develop/demo.lua

Compos in action

P#49908 2018-03-03 19:38 ( Edited 2018-03-06 01:34)

Nice!

You should be able to fix the require() bit just by adding a return statement at the end of compos.lua. The return value should be a table with all of the things you want to export in it:

return {
  copy=copy,
  idel=idel
  -- (and so on)
}

The user of the code would then get that object as the return value from require(), and use it to access the exported elements:

compos = require('compos.lua')

orig = { x=1, y=2 }
new = compos.copy(orig)

Let me know if that doesn't work and I can take a closer look.

P#49909 2018-03-03 20:11 ( Edited 2018-03-04 01:11)

Thanks dan! That's a great suggestion and one I would absolutely take -- my only concern is for the token count. In defining a lot of actors, the health = copy(health) lines start to add up. I wouldn't want to add a token for each copy.

I imagine that I could get it working with require, if only by using global variables for the compos. Would you have any other ways to get around it, or other ways I might save tokens? Thanks!

P#49911 2018-03-03 22:59 ( Edited 2018-03-04 03:59)

One simple way is to put the entire library inside the table from the beginning:

return {
 win_w=128,
 win_h=128,
 win_l=0,
 -- ...
 copy=function(o)
  -- ...
 end,
 idel=function(t,i)
  -- ...
 end,
 -- ...
}

This gets a little fussy if the library methods call each other. I actually don't know what the Lua standard way of doing that is. (Suggestions welcome! Maybe I'll ask around...) I would probably just:

function outline_print(s, x, y, color, outline)
 -- ...
end

return {
 outline_print=outline_print,
 compos_draw=function()
  -- ...
  outline_print(...)
 end,
}

I see you're using multi-assign to reduce the token count in the global consts. I don't think there's a way to do that if you need to export them in a table. Module-locals can remain local outside of the export table and can still use that technique.

I don't see why you'd need to copy() anything to get it into the export table. References are fine, unless I'm missing something.

P#49912 2018-03-03 23:22 ( Edited 2018-03-04 04:22)

Ah I think we're talking past each other a bit! :D So I actually don't need to use copy() within the library much at all. It's not the use of copy() in the export I would worry about, it's on the game itself.

Copy is used in the game logic to clone and attach a component to an actor. To with compos included, it would be

player = {
  health = copy(health),
  sprite = copy(sprite),
  gravity = copy(gravity),
  ...
}

And so forth. The components need to be copied so they can track unique values as children of the new actor (rather than referencing the original definition). So I was worried about exporting all components/functions as properties of the library, because then each parameter gets an extra token (or two if I include the helper functions in the exported object):

player = {
  health = compos.copy(compos.health),
  sprite = compos.copy(compos.sprite),
  gravity = compos.copy(compos.gravity),
  ...
}

So yeah, I'd rather everything just be accessible in the global scope.

P#49913 2018-03-03 23:39 ( Edited 2018-03-04 04:39)

I guess I'm confused as to why it doesn't just work as is then. require('compos.lua') should put all of the file in a function, then call the function. If the file is assigning to globals, it should still be assigning to globals when used with require(). Normally you'd use the module return value thing to avoid polluting the global namespace, but if that's what you want, then it should still be doing it.

... Just did a quick test. It works with one tiny exception: I had to remove the semicolon at the end of line 189. (Looks like a bug in picotool's semicolon handling. The code looks fine.) I had a file named "compos_test.lua":

require('compos.lua')

function _init()
    compos_init()
end

function _update()
    compos_update()
end

function _draw()
    cls() -- compos doesn't clear for you!
    compos_draw()
end

And built the cart with:

p8tool build compos_test.p8 --lua compos_test.lua

I get a working cart displaying stats.

P#49914 2018-03-03 23:58 ( Edited 2018-03-04 05:05)

@dddaaannn

If I just want to inline another file, rather than wrapping it with a function and calling that function, is there a way to do that with picotool? Basically just traditional C/C++ #include behavior, but preferably with built-in redundant-inclusion checks.

P#49917 2018-03-04 06:58 ( Edited 2018-03-04 12:00)

Some might start to assume I have something against sqrt but your distance function should be using square distance.

CPU usage for your demo goes from 80-90% to 60-70% with this simple fix :]

function sqrdist(obj1, obj2)
    local dx,dy=obj1.x-obj2.x,obj1.y-obj2.y
    return dx*dx+dy*dy
end

Usage:

...
        return sqrdist(obj1, obj2) < (r1+r2)*(r1+r2)
...
local overlap_tl = r*r > sqrdist(circle, vec(rect_l, rect_t))
        local overlap_tr = r*r > sqrdist(circle, vec(rect_r, rect_t))
        local overlap_bl = r*r > sqrdist(circle, vec(rect_l, rect_b))
        local overlap_br = r*r > sqrdist(circle, vec(rect_r, rect_b))
P#49926 2018-03-04 11:53 ( Edited 2018-03-04 16:53)

@dddaaannn

You're right! If I'm using globals correctly, it should just work. But in trying to require() the library my other project, where I have already added a lot of components and other functions aside from the likes of compos_update(), there are a number of errors. I think I just need to clean it up a bit and make sure I'm always using globals where appropriate.

But like @Felice mentioned, I'd rather just inline it wholesale. Using locals can make a big difference for performance; I would love for that to be an option with picotool.

@freds72

You're a god! I think I might have actually looked at your sqrdist function in another thread, but didn't understand how you would use it to compare with values like r. But now I see that avoiding sqrt() makes a big difference! Thanks much!

P#49927 2018-03-04 12:09 ( Edited 2018-03-04 17:09)

@Felice p8tool build does not currently support directives that can inline files directly. I'm not opposed to the idea, but I wanted real module support for require(), because it's what I'd want as both a library user and as a library developer. I want multiple files to be able to require('math') and have only one copy of 'math' added to my cart. I want to use multiple libraries from separate sources and not worry about name collisions. Etc.

For people who want to concatenate Lua files I've been suggesting just using two steps:

cat f1.lua f2.lua f3.lua >all.lua
p8tool build mygame.p8 --lua all.lua

You could do something similar with an off-the-shelf preprocessor. I did a couple of quick tests with cpp and m4 and got mixed results, but maybe they could be wrangled to do the right thing. It'd fun to add a cpp-like macro preprocessor to p8tool build. Personally I'd prefer a macro meta-syntax over expanding inline Lua (as require() does necessarily, and Pico-8 language extensions do invisibly) so I can visualize how the file will be transformed. Filed a feature request.

@WaltCodes I'd be curious to know more about cases where require() didn't just work as expected. Maybe we can chat offline or in another thread. You shouldn't have to sacrifice any features, including locals.

Congrats on the Compos launch!

P#49940 2018-03-04 16:56 ( Edited 2018-03-04 21:56)

@dddaaannn

Yeah, a full pre-processor would be nice, so I could use C-like defines for constants. That way I could avoid taking the token hit for having global constants, or the ugly hit for having magic numbers all over the place. I could also avoid extraneous unary minus tokens on negative constants, meh.

-- -9.80665
$define gravity 0xfff6.318

As for concatenation, hrm, no, that's not what I'm looking for. I want files to be able to depend on other files, like #include or require, so that depending on them will implicitly bring in anything they need. With concatenation, I need to do that manually. I mean, yeah, it's part of what I'm looking for, and it's better than none of what I'm looking for, but only just.

P#49954 2018-03-05 08:50 ( Edited 2018-03-05 13:50)

Static preprocessors like cpp are a weird fit for dynamic languages like Lua. For example, cpp-style includes have no order-dependent side effects in the languages they're used for (other than in the preprocessor macro language itself, I think?), so they can more simply insert code at first mention. This is not the case in Lua. Lua modules make the handling of side effects in the code explicit, so there's no confusion as to what a require() is expected to do. I'm very interested to know why a Pico-8 developer would prefer an #include-like to a require() because I can't think of a reason, as long as require() is implemented correctly.

Re: defines and such, I think what we actually want, especially in the context of Pico-8, is build-time constant folding and dead code elimination.

But we should take build tool discussion somewhere else, so people can use this thread to discuss Compos. :)

P#49968 2018-03-05 12:18 ( Edited 2018-03-05 17:18)

Haha thanks @dddaaannn! I'll look into what I'd need to fix to get things working with require(), I'm encouraged by your confidence!

The other thing about this library is that I want to opt for more features over too few. So in my current project, I really don't need circle colliders, and maybe in some other project I won't need gravity, velocity, or health... and so forth. So thus far the easier way to pick and choose has been to copy things over manually.

That being said, that's super unsustainable. Maybe I could break out each component into their own .lua file, so you could require what you need...

P#49998 2018-03-05 19:05 ( Edited 2018-03-06 00:05)

I'd do it as multiple small libraries, at least until my dead code elimination fantasies come true, and probably even after then so you'd have nicely organized modules that people can use separately.

I'd also provide an "everything cart" for non-picotool users. One way to do this is to have a .lua file that require()'s everything, then use "p8tool build" to make a cart from it. People can load the everything cart directly into Pico-8 without having to mess with picotool. (Add a "-->8" comment at the end and the cart will have Compos in tab 0 and an empty tab 1 for the user's code! :) )

P#50000 2018-03-05 20:34 ( Edited 2018-03-06 01:34)

[Please log in to post a comment]

Follow Lexaloffle:          
Generated 2024-03-28 20:04:01 | 0.033s | Q:33