Attempt at a (programmable) shoot'em up game based on an entity-component-system, requires Python3, PySDL2 and ecs.
Grab PySDL2 and ecs from PyPI (pip install ecs PySDL2
).
Then ./bin/tanks demo
should create a window with a bit of stuff going on.
There will be two turret shooting at things and a tank driving around doing
nothing in particular. You may have to squint a bit to recognize them as I may have
been a bit laz… abstract when designing the textures.
So the twist is that this behaviour is not hardcoded into the demo but controlled by (more or less) simple python coroutines. They are found in presets/ai/, feel free to play around with them once I have explained how they work.
You can also have a number routines battle each other with ./bin/tanks match path/script1.py path/script2.py …
.
I'll just assume you know about python's function generators for now. The cool thing about generators is that they allow us to start/stop execution at almost arbitrary points in functions.
The only constraints on you script files are:
-
they contain valid python (this is why you want to watch whose scripts you run, nothing stops a script from running
shutil.rmtree ("~")
) -
they have a generator function called
main
which takes two arguments (which I'll explain later) -
they yield functions taking no arguments which return either
True
orFalse
The general process followed by the game is this:
-
it calls
main
with an object representing the tank your script controls and an object that has some info about the battlefield (for now the total elapsed time and its size, aptly named.time
and.size
, thereby instantiating the generator -
each tick of the game the function last yielded by your generator is executed (
lambda: True
in the first tick), if it returnsTrue
execution in your generator is resumed, if not the game will just retry in the next tick -
your generator runs until you yield the next function, during that period the game does nothing else, i.e. between
yields
everything is static, but also that malicious scripts can halt the whole game -
goto 2.
So with that in mind let's look over presets/ai/demo_tank.py to get some of the details straight.
-
Line 19-21:
As I said previously the first argument is an interface to your tank. This is a reference so the exact value of e.g.
tank.position
will change over time, but only when your generator is currently not executing. -
Line 23-25
It's getting a little bit more interesting now. We're setting the tank to full speed and rotate by -90°, i.e. to the top. After that we yield a function that will return
False
until the tank moved up so the y coordinate of its position is smaller 50. Keep in mind that for some stupid reason the origin is in the top left corner in computer graphics. This is the important step, until now our generator ran undisturbed and the value oftank.position
didn't change (since it was the only thing running), now that we yielded the function the rest of the game will continue to run and 'wake' us when the condition encoded in the function is true. -
Line 27-38:
Now those are some minor variations of the former theme, just to drive the point home.
-
Line 40:
Just something to ease debugging of your scripts, check this if you're familiar with python
logging
module. -
Line 43:
This is just to show you that you can still encapsulate behaviour like you can with functions, just that you use sub generators and the
yield from
syntax now.game.time
as mentioned earlier is just a float telling you how much time has passed. -
Line 49:
So there is stuff mounted on you tank. Some more on that in the next example and next section. For now it's first its weapon and then its radar. You can always check it with
[print (m ["Class"]) for m in tank.mounts]
, though. Check next section with details on both. -
Line 52:
This will iterate over all entities visible on your radar, but there's a catch. The loop will only go over entities that were visible at the time the loop started. If you yield in the loop, as we do here, this might not be everything there is to see. I don't have an easy fix for this though, so meh™.
-
Line 53-54:
Positions are vectors, so you can add and subtract them and some more, check the next section for some details.
-
Line 54:
Interfaces of all types are comparable to each other will compare equal if they refer to the same entity, we use this here to not target ourselves.
-
Line 56
It's getting a bit complicated here to show that the function we return don't have to be without side effects, but are actually a pretty good place to do some periodic stuff as it is called every tick of the game. Note that you don't have to use partial function application to pull this off, you can also use closures and nested function definitions to achieve the same, I just don't like it as much.
You will notice that the tank also has a weapon and we will see in presets/ai/demo_turret.py how to use it. I will also explain a bit more about the second argument the generator function is call with.
-
Line 12:
So this is where weapons come in. Your tank has a attribute
.mounts
which is a list of all stuff mounted on top of it (only one weapon so far, but that may change in the future). The weapon has a very similar interface to the one of your tank, but obviously some attributes will be different or not existent, check the next section for details. -
Line 14:
This is line has two important points:
- the attribute
turret.visible
is an iterator over all living entities (including the your tank) that within eyeshot of your tank represented by similar interfaces as your tank or your weapon, but in a read-only fashion - the interfaces (except
RadarInterface
, see next section) act like dictionary storing some meta data about the entity in question, e.g. the "Class" key lets you distinguish between barriers, tanks, turret or bullets; if an entity is not tagged with a certain key it will raise aKeyError
just like an ordinary dictionary, there will be docs on the keys employed by the game later™
- the attribute
-
Line 5:
Every interface we talked about so far also has a attribute
.destroyed
which is exactly what it says on the tin. If an entity is destroyed all attempts to access attributes on its interface will result inAttributeError
.
There is also a slightly more sophisticated example, which you can check out
with ./bin/tanks match presets/ai{circles.py,defender.py}
.
Interfaces and their attributes give your script a way to interface neatly with entities and their components.
There are three different types of interfaces so far and each exhibits a couple
of attributes depending on which components the entity behind the interface
sports, RadarInterface
, ROInterface
and RWInterface
. The latter being
supersets of the respective former. RWInterface
is what you have for the
tank your script is controlling, everything you get from its .visible
attribute is wrapped in ROInterface
and everything coming from your radars
.visible
is RadarInterface
.
So what are components? Basically data about a entity, e.g. Position
is a
component and describes that an entity is positioned at a certain point.
At the end of this section I'll give you a list
which thing we encountered so far (tanks, weapons, radars) has which
components, but first have list of all attributes, what type of interface they
first appear on and what components are needed for them.
If you try to access an attribute on an interface and the entity lacks the
needed component an AttributeError
will be raised. Interfaces refering to
the same entity are the same, there is only one instance of a given interface
type for a given entity. From ROInterface
on interfaces can be indexed
(read-only) like dictionaries for some meta data (docs pending).
Attribute name | First on Interface | Component needed | Description |
---|---|---|---|
.destroyed |
RadarInterface |
False if the entity is alive, if this is True the entity is not in the game anymore and all components have been removed from it. |
|
.position |
RadarInterface |
Position |
This is a vector, you can treat it like a 2-tuple or use the .x , .y , .angle and length attributes, it also supports adding it to other vectors and scalar multiplication, but lacks full docs sofar, check the source for now. |
.hp |
ROInterface |
Health |
Current hit points of the entity. |
.max_hp |
ROInterface |
Health |
Maximum hit points of the entity. |
.size |
ROInterface |
Hitbox |
Size of the hit box of the entity, 2 tuple. |
.rotation |
ROInterface |
Movement |
Rotation of the entity, in radians, rotation 0 is parallel to the x-axis. |
.mounts |
ROInterface |
Mount |
List of either ROInterface of mounted entities or None if the respective mount point is empty. |
.root |
ROInterface |
Mountable |
The entity this entity is mounted on, e.g. your tank on the interface of its weapon. |
.mounts |
RWInterface |
Mount |
List of either RWInterface of mounted entities or None if the respective mount point is empty. Tricky, huh. |
.rotation |
RWInterface |
Movement |
Same as on ROInterface but you can set it now. |
.speed |
RWInterface |
Movement |
Speed of the entity as a scalar, in px/s. |
.max_speed |
RWInterface |
Movement |
Maximum speed of an entity as a scalar, in px/s |
.velocity |
RWInterface |
Movement |
Velocity of the entity as a vector (same kind as .position ), in px/s. |
.throttle |
RWInterface |
Movement |
Speed of the entity in percent, setter clamps the value between 0 and 1. |
.shoot |
RWInterface |
Weapon |
Attention, this one's a method no attribute. Calling this will shoot a bullet, if the weapon is reloaded. The bullet will fly in the direction of the .rotation attribute of the weapon. |
.till_reloaded |
RWInterface |
Weapon |
Seconds until this weapon can fire again. |
.reload_time |
RWInterface |
Weapon |
Minimum time between shots, in seconds. |
.bullet_damage |
RWInterface |
Weapon |
Damage done by this weapon's bullets of this weapon. |
.bullet_speed |
RWInterface |
Weapon |
Speed of this weapon's bullets, in px/s. |
.bullet_hp |
RWInterface |
Weapon |
Hit points of this weapon's bullets. |
.bullet_size |
RWInterface |
Weapon |
Similar to .size but for this weapon's bullets. |
.visible |
RWInterface |
Vision |
Iterator over all living entities that are visible to this entity. The type of interface they're wrapped depends on .vision . For "radar" it's RadarInterface , for "plain" ROInterface . |
.vision |
RWInterface |
Vision |
What kind of vision this entity has, currently either "plain" or "radar". |
.visualrange |
RWInterface |
Vision |
How far this entity can see, in px. |
I don't like half of the attribute names in here, so shoot me ideas if you have them.
Component | Tank | Weapon | Radar |
---|---|---|---|
Position |
x | x | x |
Hitbox |
x | ||
Health |
x | ||
Movement |
x | x | |
Mount |
x | ||
Mountable |
x | x | |
Weapon |
x | ||
Vision |
x | x |