On coroutines
One of my experiments for this pyweek was to make use of Python's new(ish) coroutine support to implement the behavior of the player, baddies and some other scripted things in the game.In short this looks something like this:
class Player
def update(self):
while 1:
dt = yield
self.x += self.dx * dt
self.y += self.dy * dt
player = Player()
update = player.update()
update.send(None)
...
update.send(dt)
OK, so this is neat in theory. You could have multiple loops in there with multiple entry points (yield statements) depending on the current "state" of the player, and so on. I've seen some nice implementations of byte-chomper network servers using this sort of approach.
Only it didn't seem to work. Eventually I was back at square one with a single yield statement and a really, really long update() code body.
I've just refactored it to use separate update() methods depending on the current state of the player:
class Player
def update(self, dt):
self.current_action(dt)
def do_jump(self, dt):
# do jump stuff
if landed:
self.current_action = self.do_land
player = Player()
player.current_action = player.stand_still
I'll note that the simpler coroutines I implemented for the baddies and other things are still coroutines - but I'll probably re-implement them as regular functions as well.
So, was I just doing it wrong, or are coroutines just not that useful for a time-based player update?
(log in to comment)
Comments
It may work better for things like enemies with fairly simple behaviour, e.g. a guard that follows a rigid patrol route. Even then it may need to be able to break out of the routine at unexpected places, e.g. when the player shoots it.
Maybe a hybrid approach would work best -- a number of states, each state represented by a coroutine that goes through a sequence. So the guard would spend most of its time running the patrol_route coroutine, then when he gets shot the whole coroutine would get replaced by shoot_back_at_player until the threat had passed, then patrol_route would resume.
If the player's behaviour is sufficiently state dependent then it may be best to encapsulate states in objects with methods called by the player object:
class State(object):
can_jump = False
def __init__(self, actor):
self.actor = actor
def update(self, dt):
pass
class Jumping(State):
def update(self, dt):
self.actor.pos.z += 20 * dt
class Standing(State):
can_jump = True
def update(self, dt):
pass
class Player(object):
def __init__(self, ...):
self.state = Standing(self)
...
def update(self, dt):
self.state.update(dt)
if self.state.can_jump:
...
This has its flaws too, but I find when you ignore an implicit state machine and use a single method to do everything that it doesn't take long before you're completely baffled about all the transitions.
def SneakyEnemy():
while True:
move_to_ambush_point()
attack_player()
hide()
if (player_has_seen_me):
move_somewhere_else()
... which would allow you to present the higher-level logic of the enemy's behaviour in one script which runs in a coroutine and calls subroutines such as attack_player() which can update physics and so on and then yield out of the coroutine back to your main loop.
Unfortunately, Python's coroutines aren't quite powerful enough to support this: you can't explicitly invoke a function as a coroutine so that it can yield all the way back to the coroutine caller, you can only allow functions to yield to their immediate caller. You could work around this by scattering yield statements all over your scripts and moving any looping logic out of the innermost functions into the coroutine's caller (Robot Underground does this) but it makes them somewhat more unwieldy and removes most of the aesthetic appeal of doing this in the first place.
@adam, I'm still trying to get my head around how that form of yield would work/is useful. Could you please mock up a simple pseudocode of how it could work?
def talk_to_baker():
say("Baker", "Hello!")
say("Me", "Hi!")
while give_choice("Baker", "What kind of bread would you like?", ["Ciabatta", "French stick"]) != "Ciabatta":
say("Baker", "Sorry, we're out of that kind.")
say("Me", "No problem!")
say("Baker", "That'll be five pounds")
if player.money < 5:
say("Me", "I don't have that, sorry")
else:
say("Me", "Here you go")
give_me(bread)
say("Baker", "Goodbye now!")
Obviously say and give_choice both need to leap out to the main loop, wait for player input, and then jump back in when the conversation should proceed. You can do this by scattering yields everywhere, but it's pretty ugly, especially if you want to call sub-conversations (other functions).
(... in main loop ...)
for enemy in enemies:
resume_coroutine(enemy.doThings)
... other stuff ...
(... in definition of a specific enemy class ...)
def doThings(self):
while True:
self.moveToPlace(self.nest)
self.wait(50)
self.moveToPlace(somewhereNice())
self.killEverythingNearby()
(... in definition of enemy ...)
def moveToPlace(self, place):
while not self.at(place):
self.pos = self.aBitCloserTo(place) # replace with real movement logic
coroutine_yield()
def wait(self, time):
start = getCurrentTicks()
while getCurrentTicks() < start + time:
coroutine_yield()
(... and so on ...)
The attraction of this is that you can write the specific enemy behaviours in a very natural style without having to worry about the workings of the main loop. The coroutine resume/yield calls take care of moving you from the main loop to the inner levels of the scripting system (which you only need write once, on the common enemy superclass, or even your actor or entity class) without the doThings() function being any the wiser about what's really going on.
def doThings(self):
self.current_action()
def moveToPlace(self, place):
while not self.at(place):
self.pos = self.aBitCloserTo(place)
coroutine_yield()
self.current_action = self.wait(); self.wait.send(None)
or similar? Which is pretty much how I currently write my code now.
def doThings(self):
self.moveToPlace(somewhere)
self.attackPlayer()
and another to have ...
def doThings(self):
self.moveToPlace(somewhere)
self.huntForEasterEggs()
Unless I'm misunderstanding your implementation, your moveToPlace() has to explicitly set current_action to some specific next state, which makes it less useful as a building block for a bunch of different enemy behaviours.
In his presentation A Curious Course on Coroutines and Concurrency David Beazley looks into this stack issue and apparently proposes "trampolining" as a solution. I've not had a chance to actually look at the presentation in detail yet though.
This is why the Python world desperately needs the yield from statement (see PEP 380, Syntax for Delegating to a Subgenerator),
cyhawk on 2009/09/07 08:09:
I think they may be more useful for enemy AI (that for example approaches the player first, then fires at the player until the clip lasts, and hides and reloads after that) — in generally it is useful when you have a state machine.