Accept watches for a "toggle pause" message but condition is met over multiple frames

I have an action called 'toggle_pause' which pauses a sequence if it is playing, and plays the sequence if it is paused. My accept statement looks like

    def update_key_map(self, action_name, action_state):
        self.keymap[action_name] = action_state

    def other_func(self):
        ...
        self.accept('space', self.update_key_map, 'toggle_pause', True)
        self.accept('space-up', self.update_key_map, 'toggle_pause', False)

Meanwhile I have a task that watches the state of 'toggle_pause' and if True, it pauses the sequence if its playing and it plays the sequence if its pausing:

    def shot_animation_task(self, task):
        if self.keymap['toggle_pause']:
            if self.sequence.isPlaying():
                self.sequence.pause_animation()
            else:
                self.sequence.resume_animation()

        ...
        return task.cont

In a sense, this works exactly as I would expect. But the problem is that between space and space-up, the task is called multiple times. So the effect is that the toggle undoes itself if the task is called an even number of times, and only functions if the task is called an odd number of times.

Does anyone have advice for solving this? I have considered creating a quarter second timeout whenever toggle_pause is activated, but that doesn’t seem very elegant. I’m sure there is a better way.

Thanks in advance.


EDIT: Perhaps setting a timeout isn’t that inelegant after all? I just started messing around with task.again. For example, I can do this:

    def pause_task(self, task):
        if self.keymap['toggle_pause']:
            if self.sequence.isPlaying():
                self.sequence.pause_animation()
            else:
                self.sequence.resume_animation()

            task.delayTime = 0.25
            return task.again

        return task.cont

This will give the user 0.25 to release the spacebar. All this really requires is making this task a dedicated method so no other code in my original task method will be affected by this delay.

The simplest solution might be, instead of having the events that you “accept” call “update_key_map”, to have them call some other function that runs your logic. That way said logic should only be run when the key is pressed or released.

(And if you do go with the above, I would suggest using either the key-down or the key-up event, but not both. After all, you presumably want the logic to run once per use of the key, not once per key-press and once per key-release, thus resulting in the logic being run twice per use of the key!)

Ok that makes sense I think. So are you suggesting something like this, for example?

    def other_func(self):
        ...
        self.accept('space', toggle_pause)

    def toggle_pause(self):
        if self.sequence.isPlaying():
                self.sequence.pause_animation()
        else:
            self.sequence.resume_animation()

I see the advantage of this. I sort of created a framework where all keypresses are managed through update_key_map so I’m trying to hold to that standard, but maybe I’m trying to fit a square into a circle. I’m basically following the framework you laid out in your panda3d tutorial, except instead of one keymap, I have a dictionary of keymaps that I swap in and out depending on the game mode. This kind of shows a taste of what I mean:

        self.modes = {
            'menu': {
                'enter': self.menu_enter,
                'exit': self.menu_exit,
                'keymap': {
                    action.exit: False,
                    action.new_game: False,
                }
            },
            'aim': {
                'enter': self.aim_enter,
                'exit': self.aim_exit,
                'keymap': {
                    action.fine_control: False,
                    action.quit: False,
                    action.shoot: False,
                    action.view: False,
                    action.zoom: False,
                },
            },
            'view': {
                'enter': self.view_enter,
                'exit': self.view_exit,
                'keymap': {
                    action.aim: False,
                    action.fine_control: False,
                    action.move: True,
                    action.quit: False,
                    action.zoom: False,
                },
            },
            'shot': {
                'enter': self.shot_enter,
                'exit': self.shot_exit,
                'keymap': {
                    action.aim: False,
                    action.fine_control: False,
                    action.move: False,
                    action.toggle_pause: False,
                    action.quit: False,
                    action.zoom: False,
                },
            },
        }

This has been my solution for dealing with changing game conditions that require different sets of controls. I like it currently, but it may become unwieldy as my game grows in complexity. Do you have any advice for managing controls when the game starts to grow?

(And if you do go with the above, I would suggest using either the key-down or the key-up event, but not both. After all, you presumably want the logic to run once per use of the key, not once per key-press and once per key-release, thus resulting in the logic being run twice per use of the key!)

Totally. That makes sense why you would only need to register key-down with your solution.

Exactly, yes. :slight_smile:

Indeed–the “key-map” approach is specific to the case in which a given button is intended to be held down over time; attempting to force more-immediate, more-binary functionality into it likely is

Ah, perhaps I should have made a point to include at least one control that doesn’t use the key-map, to illustrate that it’s not intended to be a universal approach. I’ll think about updating the tutorial, I intend!

Ah, that’s an interesting approach!

Hmm… The only real problem that I see with this approach is that, if the player presses one of the keymap-keys, and the game-state changes to one that doesn’t use that key before the player releases said key, then the keymap for the old state will presumably still indicate the key as being pressed, should the game return to that state. (Unless you have code to clear key-map values when changing states, of course.)

That is, by example: Let’s say that the game is in the “aim” state. The player now presses and holds the “fine_control” key. Then, before they release that key, they’re killed and a menu is automatically opened, putting the game into the “menu” state. Naturally, with the menu open, the player releases the key. Next they re-start the game, and enter the “aim” state again–and find that the game is responding as though the “fine_control” key was still held!

But overall, I think that your approach is sensible, and should work.

For myself, I think that I tend to keep a single set of keys, and then either differentiate their behaviour in the relevant methods, or have separate classes for various game-states, each of which responds to the keys internally.

There is also the question of allowing the player to remap keys. If you intend to implement this, then it might be worth thinking about how you intend to go about it.

You can aim to implement this functionality yourself, but there should also be at least one key-remapping module to be found on the forum, and there may be more. The official samples also include a key-remapping example, I believe.

Perhaps you might benefit from using the state system that I developed for my Panda3D Studio project, as its purpose is precisely to bind specific events to a particular “state” that the application is in:

state.py:

from direct.showbase.DirectObject import DirectObject


# The following class allows predefining specific bindings of events to their
# handlers.
# Multiple bindings can be set active at once, with the option to stop listening
# for all other events.
class EventBinder:

    def __init__(self):

        self._listener = DirectObject()
        self._bindings = {}
        self._active_bindings = {}

    def bind(self, binding_id, event_props, handler, arg_list=None, once=False):
        """
        Predefine a specific binding of an event to a handler by id.
        The event in this binding can be set to be listened for later on by simply
        using this id instead of having to set the details of the binding every
        single time.

        """

        args = (arg_list,) if arg_list else ()
        self._bindings[binding_id] = (once, event_props, handler) + args

    def unbind(self, binding_id):
        """
        Remove the given binding.
        Note that, if the binding is currently active, this also stops the event in
        that binding being listened for!

        Returns True if successful or False if binding_id was not found.

        """

        if binding_id not in self._bindings:
            return False

        event_props = self._bindings[binding_id][1]
        del self._bindings[binding_id]

        if event_props in self._active_bindings:
            self.ignore(event_props)

        return True

    def accept(self, binding_ids, exclusive=False):
        """
        Listen for the events in the bindings whose ids are given.

        If "exclusive" is True, the events in all of the predefined bindings other
        than the ones given will be ignored.

        Returns True if successful or False if not all binding_ids were found.

        """

        if exclusive:
            self.ignore_all()

        binding_ids_found = True

        for binding_id in binding_ids:

            if binding_id not in self._bindings:
                binding_ids_found = False
                continue

            binding = self._bindings[binding_id]
            event_props = binding[1]
            self._active_bindings[event_props] = binding_id
            method = self._listener.accept_once if binding[0] else self._listener.accept
            method(*binding[1:])

        return binding_ids_found

    def ignore(self, *event_data):
        """
        Stop listening for the events whose ids are given.

        """

        for event_props in event_data:

            self._listener.ignore(event_props)

            if event_props in self._active_bindings:
                del self._active_bindings[event_props]

    def ignore_all(self):
        """
        Stop listening for all events.

        """

        self._listener.ignore_all()
        self._active_bindings = {}

    def get_active_bindings(self):
        """
        Return a list of the ids of the bindings of the events currently being
        listened for.

        """

        return list(self._active_bindings.values())


class StateObject:

    def __init__(self, state_id, persistence, on_enter=None, on_exit=None):

        self.id = state_id
        self.persistence = persistence
        self.enter_command = on_enter if on_enter else lambda prev_state_id, active: None
        self.exit_command = on_exit if on_exit else lambda next_state_id, active: None
        self.active = False
        self._prev_state = None

    @property
    def previous_state(self):

        prev_state = self._prev_state

        while prev_state and not prev_state.active:
            prev_state = prev_state.previous_state

        return prev_state

    @previous_state.setter
    def previous_state(self, prev_state):

        while prev_state and prev_state.persistence <= self.persistence:
            prev_state = prev_state.previous_state

        self._prev_state = prev_state

    def enter(self, prev_state_id):

        self.enter_command(prev_state_id, self.active)
        self.active = True

    def exit(self, next_state_id):

        self.exit_command(next_state_id, self.active)


# The following class manages the different states that the application
# can be in.
class StateManager:

    def __init__(self):

        self._states = {}
        self._default_state_id = ""
        self.current_state_id = ""
        self._changing_state = False

    def add_state(self, state_id, persistence, on_enter=None, on_exit=None):
        """
        Define a new state, optionally with commands to be called on entering and/or
        exiting that state.

        """

        self._states[state_id] = StateObject(state_id, persistence, on_enter, on_exit)

    def set_state_enter_command(self, state_id, on_enter):

        self._states[state_id].enter_command = on_enter

    def set_state_exit_command(self, state_id, on_exit):

        self._states[state_id].exit_command = on_exit

    def has_state(self, state_id):
        """ Check if the state with the given id has been previously defined """

        return state_id in self._states

    def is_state_binder(self):
        """ Check if this object is a StateBinder instance """

        return False

    def set_default_state(self, state_id):
        """
        Set the default state for the application.

        Returns True if successful or False if the state with the given id has not
        been previously defined or is already the current state.
        Also returns False if a state change is already in progress.

        """

        if self._changing_state:
            return False

        if state_id not in self._states:
            return False

        current_state_id = self.current_state_id

        if state_id == current_state_id:
            return False

        self._changing_state = True
        state = self._states[state_id]
        state.enter(current_state_id)

        if self.is_state_binder():
            self._set_state_bindings(state_id)

        self._default_state_id = state_id
        self.current_state_id = state_id
        self._changing_state = False

        return True

    def enter_state(self, state_id):
        """
        Change from the current state to the one with the given id.

        Returns True if successful or False if the state with the given id has not
        been previously defined or is already the current state.
        Also returns False if a state change is already in progress.

        """

        if self._changing_state:
            return False

        if state_id not in self._states:
            return False

        current_state_id = self.current_state_id

        if state_id == current_state_id:
            return False

        self._changing_state = True
        current_state = self._states[current_state_id]
        state = self._states[state_id]
        state.previous_state = current_state
        persistence = state.persistence

        if current_state.persistence <= persistence:
            current_state.active = False

        current_state.exit(state_id)
        prev_state = current_state.previous_state

        while prev_state and prev_state is not state and prev_state.persistence <= persistence:
            prev_state.active = False
            prev_state.exit(state_id)
            prev_state = prev_state.previous_state

        state.enter(self.current_state_id)

        if self.is_state_binder():
            self._set_state_bindings(state_id)

        self.current_state_id = state_id
        self._changing_state = False

        return True

    def exit_state(self, state_id):
        """
        Exit the state with the given id. If it is not the current state, it will
        merely get deactivated (its exit command will still be called and it will
        be marked as inactive, but its previous state will not be restored).

        Returns True if successful or False if the state with the given id has not
        been previously defined, is inactive or is the default state.
        Also returns False if a state change is already in progress.

        """

        if self._changing_state:
            return False

        if state_id not in self._states:
            return False

        state = self._states[state_id]

        if not state.active:
            return False

        prev_state = state.previous_state

        if not prev_state:
            # the default state has no previous state and thus cannot be exited
            return False

        self._changing_state = True
        current_state_id = self.current_state_id
        state.active = False

        if state_id == current_state_id:

            prev_state_id = prev_state.id
            state.exit(prev_state_id)
            prev_state.enter(state_id)

            if self.is_state_binder():
                self._set_state_bindings(prev_state_id)

            self.current_state_id = prev_state_id

        else:

            state.exit(current_state_id)

        self._changing_state = False

        return True

    def exit_states(self, min_persistence=None):
        """
        Exit all states with a persistence lower than or equal to min_persistence.
        If no persistence is specified, start by exiting the current state.
        Returns False if a state change is already in progress, True otherwise.

        """

        if self._changing_state:
            return False

        self._changing_state = True
        current_state_id = self.current_state_id
        default_state_id = self._default_state_id
        current_state = self._states[current_state_id]
        default_state = self._states[default_state_id]
        persistence = current_state.persistence if min_persistence is None else min_persistence
        prev_state = current_state

        while prev_state and prev_state is not default_state:

            if prev_state.persistence >= persistence:
                prev_state.active = False
                prev_state.exit(default_state_id)

            prev_state = prev_state.previous_state

        if current_state.persistence >= persistence:

            default_state.enter(current_state_id)

            if self.is_state_binder():
                self._set_state_bindings(default_state_id)

            self.current_state_id = default_state_id

        self._changing_state = False

        return True

    def get_state_persistence(self, state_id):

        return self._states[state_id].persistence

    def is_state_active(self, state_id):

        return self._states[state_id].active


# The following class associates a particular state with a selection of event
# bindings.
class StateBinder(StateManager):

    def __init__(self):

        StateManager.__init__(self)

        self._state_bindings = {}
        self.event_binder = EventBinder()

    def is_state_binder(self):
        """ Confirm that this object is a StateBinder instance """

        return True

    def add_state(self, state_id, persistence, on_enter=None, on_exit=None):
        """
        Define a new state, optionally with commands to be called on entering and/or
        exiting that state.

        """

        StateManager.add_state(self, state_id, persistence, on_enter, on_exit)

        self._state_bindings[state_id] = set()

    def bind(self, state_id, binding_id, event_props, event_handler):
        """
        Define an event binding and associate it with a state.

        """

        self.event_binder.bind(binding_id, event_props, event_handler)
        self._state_bindings[state_id].add(binding_id)

    def unbind(self, state_id, binding_id):
        """
        Remove the given binding.
        Note that, if the binding is currently active, this also stops the event in
        that binding being listened for!

        Returns True if successful or False if binding_id was not found.

        """

        if state_id in self._state_bindings:
            self._state_bindings[state_id].discard(binding_id)
            return self.event_binder.unbind(binding_id)

        return False

    def update_active_bindings(self):
        """
        Call this after changing any of the bindings associated with the current
        state, so they take effect immediately.

        """

        self.event_binder.accept(self._state_bindings[self.current_state_id], exclusive=True)

    def accept(self, binding_ids, exclusive=False):
        """
        Listen for the events in the bindings whose ids are given.

        If "exclusive" is True, the events in all of the predefined bindings other
        than the ones given will be ignored.

        Returns True if successful or False if not all binding_ids were found.

        """

        return self.event_binder.accept(binding_ids, exclusive)

    def _set_state_bindings(self, state_id):
        """
        Change from the current state to the one with the given id.

        """

        self.event_binder.accept(self._state_bindings[state_id], exclusive=True)

main script:

from direct.showbase.ShowBase import ShowBase
from panda3d.core import *
from state import *


class MyApp(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        # set up the different states that the application can be in
        self.state_binder = binder = StateBinder()
        notify = lambda *args, **kwargs: self.__notify_state_change("default",
            *args, **kwargs)
        binder.add_state("default", persistence=0, on_enter=notify)
        binder.bind("default", "action1", "space", self.__perform_action1)
        binder.bind("default", "action2", "control-space", self.__perform_action2)
        enter_state1 = lambda: self.state_binder.enter_state("state1")
        binder.bind("default", "enter_state1", "1", enter_state1)
        notify = lambda *args, **kwargs: self.__notify_state_change("state1",
            *args, **kwargs)
        binder.add_state("state1", persistence=-1, on_enter=notify)
        # any additional state should have a persistence lower than that of the 
        # default state
        binder.bind("state1", "action3", "enter", self.__perform_action3)
        binder.bind("state1", "action4", "control-enter", self.__perform_action4)
        binder.bind("state1", "change_binding", "c", self.__change_binding)
        exit_state1 = lambda: self.state_binder.exit_state("state1")
        binder.bind("state1", "exit_state1", "escape", exit_state1)
        binder.set_default_state("default")

    def __notify_state_change(self, state_id, prev_state_id, active):

        print(f'Entered state "{state_id}"!')
        print(f'The previous state was "{prev_state_id}".')

    def __change_binding(self):

        self.state_binder.bind("state1", "action3", "e", self.__perform_action3)
        self.state_binder.update_active_bindings()

    def __perform_action1(self):

        print(f'    Performing action1!')

    def __perform_action2(self):

        print(f'    Performing action2!')

    def __perform_action3(self):

        print(f'    Performing action3!')

    def __perform_action4(self):

        print(f'    Performing action4!')


app = MyApp()
app.run()

This system probably does a lot more than you need right now, but as your project grows, its additional functionality might still prove useful. For example, each state has a certain “persistence” associated with it. Right now you don’t have to worry about that; it helps the system to figure out what the previous state was when you exit a state explicitly.

If it’s all a bit too much for your current needs, fair enough. But should the need arise for a more sophisticated way of dealing with (key-) event bindings when your code becomes more complex, then you’re certainly welcome to make use of the code above if you find it suits your purposes :slight_smile: .

2 Likes

Yeah right now I have something that resets all keymap values to defaults whenever a game-state is exited. But as a consequence, this means there is no tear-down or build-up operations that care about which game-state transition is occurring. Every time a game-state is exited or entered, the same key mappings are set/reset. These operations can in theory withstand some conditional statements like

def aim_enter(self):
    # standard stuff
    ...

    # transition-specific logic
    if self.prev_mode == 'view':
        ....
        

But I’m realizing these ad-hoc conditionals will spiral out of control eventually.

Like everything else, I think any potential users will have to live with my design choices :lollipop:


This is amazing @Epihaius. I ran the example and it’s working very well. This could really simplify and empower my current design. If you don’t mind, I’d like to explain to you what I’m thinking. Right now, I have about 10 task methods that get added and removed from taskMgr depending on the game-state. For example, one of them looks like this:

    def shot_view_task(self, task):
        if self.keymap[action.aim]:
            self.change_mode('aim')
        elif self.keymap[action.zoom]:
            self.zoom_camera()
        elif self.keymap[action.move]:
            self.move_camera()

        return task.cont

This task is added to the taskMgr when the game-state is set to "shot", and removed when the game-state is set to anything else. Yet with StateBinder, I think I could remove the need for task methods that check for the state of keypresses. For example, I think I could do something like this:

        # Suppose Shot is some class or context manager to deal with the "shot" state
        shot = Shot()

        # set up the different states that the application can be in
        self.state_binder = binder = StateBinder()
        
        binder.add_state("shot", persistence=0, on_enter=shot.__enter__, on_exit=shot.__exit__)
        binder.bind("shot", action.zoom, "mouse-repeat", shot.zoom_camera)
        binder.bind("shot", action.move, "v-repeat", shot.move_camera)
        binder.bind("shot", "enter_aim", "a", lambda: self.state_binder.enter_state("aim"))
        ....

Is this implementing StateBinder in the way you envisioned?

2 Likes

That’s great to hear :slight_smile: .

Yeah that looks like it should work (assuming that the shot.__enter__ and shot.__exit__ methods have prev_state_id/next_state_id and active parameters).

If you plan to allow the same action for different states, you will have to use different binding IDs for each state, though. For instance, if you also want to allow zooming the camera when in another state than "shot", you’d need a different binding ID than action.zoom. That’s because under the hood I’m using only a single DirectObject to listen for all bound events. If that’s annoying, I could change the implementation to make use of a different DirectObject for each state, although that seems a bit less efficient.

I’ve edited the code to make it easy to re-bind events, even for the current state:

        ...
        binder.bind("state1", "change_binding", "c", self.__change_binding)

    def __change_binding(self):

        self.state_binder.bind("state1", "action3", "e", self.__perform_action3)
        self.state_binder.update_active_bindings()

Let me know if you need any more help :slight_smile: .

1 Like

Good to know Epihaius. I will be returning to this thread once I summon the energy for a refactor. Thanks again.