Press Any Button (Twice) To Continue

Overview:

This snippet demonstrates an implementation of “press a button, then press it again to do something (e.g. skip or continue)”.

Having the user press the key in question twice is intended to reduce the risk that the user will unintentionally enact the something in question by virtue of pressing a key during the seconds after a change of context. For example, a player coming from gameplay to a cutscene may still press a game-control or two just after the transition.

This code should work with keyboard, mouse, and other input devices, and should handle device -connection and -disconnection.

Behaviour:

The code allows the user to press (almost) any key or other input-device button.

When they do so, a short countdown is initiated; if they press the same key within that countdown, the code to perform the desired action is executed.

If, conversely, they press another key during the countdown, then the countdown starts again from the start, and the new key becomes the one that will perform the action.

If no keys are pressed within the countdown, then the code resets to its initial state.

Notes:

The code uses its own DirectObject, ButtonThrowers, and InputNodes, separate from whatever the rest of the program might be using. This allows it to act without interfering with other elements–for example, to register a “button down event” without usurping it from some other piece of code.

The code doesn’t include any UI elements–after all, I don’t know how a developer desiring to use this might want their UI to be set up or behave, or even what UI toolkit they’re using. UI elements are thus left to the developer!

However, the intended places for UI-related code have been indicated by comments starting with “UI”–i.e. like this:
# UI

Similarly, the actual logic of the action to be performed on pressing a key twice–continuing/skipping/whatever–may well vary from program to program, and so is not included. The place for it is marked by a comment that starts with “LOGIC” (all-caps)–i.e. like this:
# LOGIC

Finally, the code has been excerpted from my Cutscene class, and the excerpt hasn’t been tested by itself, so there may be mistakes present!

Code:

Initialisation:

        self.buttonHandler = DirectObject()

        # We store out input-nodes and button-throwers,
        # storing them in dictionaries keyed by device.
        # This allows us to clean them up per device
        # should a device be disconnected.
        self.inputNodes = {}

        # We start out with a basic button-thrower
        # for the keyboard and mouse.
        self.buttonThrowers = {
            None: base.mouseWatcher.attachNewNode(ButtonThrower("keyboardAndMouse"))
        }

        # Set up the events for keyboard and mouse
        # This could perhaps be folded into the
        # event-setup method below. If you want to do so,
        # then that's left an an exercise for the reader.
        thrower = self.buttonThrowers[None]
        thrower.node().setButtonDownEvent("keyInterception_" + "KandM")
        thrower.node().setButtonUpEvent("keyRelease_" + "KandM")

        self.buttonHandler.accept("keyInterception_KandM", self.buttonDown, extraArgs = ["keyboardAndMouse"])
        self.buttonHandler.accept("keyRelease_KandM", self.buttonUp, extraArgs = ["keyboardAndMouse"])

        # Next we run through the various 
        # non-keyboard-and-mouse device-classes
        # known to Panda, and setup event-handling for them.
        for deviceType in InputDevice.DeviceClass:
            if deviceType is not InputDevice.DeviceClass.keyboard and \
                deviceType is not InputDevice.DeviceClass.mouse:

                self.buttonHandler.accept("keyInterception_" + str(deviceType), self.buttonDown, extraArgs = [deviceType])
                self.buttonHandler.accept("keyRelease_" + str(deviceType), self.buttonUp, extraArgs = [deviceType])

        # Now we iterate through the currently-connected
        # devices and set up events for them.
        for device in base.devices.getDevices():
            self.setupEventsForDevice(device)

        # These variables keep track of which
        # keys was the last pressed and which
        # is currently being held, respectively.
        self.lastButtonReleased = None
        self.lastButtonPressed = None

        # We want to know when a device is
        # connected or disconnected, so we
        # register for those events.
        self.buttonHandler.accept("connect-device", self.connectController)
        self.buttonHandler.accept("disconnect-device", self.disconnectController)

        # And finally, we set up our timer.
        self.advanceButtonTimer = 0
        self.advanceButtonDuration = 5
        self.advanceButtonThreshold = 0.5

Device connection/disconnection and event-setup/cleanup:

    def connectController(self, controller):
        self.setupEventsForDevice(controller)

    def disconnectController(self, controller):
        self.clearEventsForDevice(controller)

    def setupEventsForDevice(self, device):
        deviceTypeString = str(device.device_class)

        # Create a button-thrower and an input-node
        thrower = NodePath(ButtonThrower(deviceTypeString))
        inputNP = base.dataRoot.attachNewNode(InputDeviceNode(device, device.name))
        inputNP.attachNewNode(thrower.node())

        # Set the button -down and -up events 
        # in the button-thrower
        thrower.node().setButtonDownEvent("keyInterception_" + deviceTypeString)
        thrower.node().setButtonUpEvent("keyRelease_" + deviceTypeString)

        # And store the button-thrower and the input-node
        self.buttonThrowers[device] = thrower
        self.inputNodes[device] = inputNP

    def clearEventsForDevice(self, device):
        # If the device in question is found in
        # the button-thrower and input-node
        # dictionaries, clean up the respective
        # elements.
        if device in self.buttonThrowers:
            np = self.buttonThrowers[device]
            self.destroyThrowerNP(np)
            del self.buttonThrowers[device]
        if device in self.inputNodes:
            np = self.inputNodes[device]
            self.destroyInputNode(np)
            del self.inputNodes[device]

Button-handling:

    def buttonDown(self, device, key):
        # Keep note of which button is currently being pressed
        self.lastButtonPressed = key

    def buttonUp(self, device, key):
        # If the key that has just been released
        # is the key that was just pressed, run
        # our logic. This prevents keys pressed
        # in previous contexts from generating
        # key-release events that confuse matters.
        if key == self.lastButtonPressed:
            # Furthermore, if the key that has just been
            # released is the button that was previously
            # pressed, then perform the intended action!
            # (e.g. skip the cutscene, continue on, etc.)
            if key == self.lastButtonReleased:
                # LOGIC: Perform the relevant action
                # (e.g. skipping) here
                pass
            else:
                # Conversely, if the key is not the most recent
                # (whether because it's a different key or
                # because no key was previously pressed),
                # then start the timer
                self.advanceButtonTimer = self.advanceButtonDuration
                # UI: Perhaps show a prompt to the user here

            # Now store this key as the most-recently pressed
            self.lastButtonReleased = key

        # And finally, clear the note that
        # a key has been pressed.
        self.lastButtonPressed = None

Update (intended to be in a task):

        # Note: this code assumes that you have a delta-time,
        # called "dt". If not, adjust the below accordingly.

        # If no button is being held down,
        # and the timer is active...
        if self.lastButtonPressed is None and self.advanceButtonTimer > 0:
            # ... reduce the timer by the delta-time ...
            self.advanceButtonTimer -= dt
           
            # ... and if that ends the timer ...
            if self.advanceButtonTimer <= 0:
                # Then the window for performing the action
                # (e.g. skipping or continuing) has ended.
                # Thus, clear the most-recently-pressed
                # button and zero the timer.
                self.lastButtonReleased = None
                self.advanceButtonTimer = 0

            # UI: This might be a good place to 
            # perform any UI-fades that might be intended.

Cleanup methods:

    def destroyThrowerNP(self, throwerNP):
        throwerNP.node().setButtonDownEvent("")
        throwerNP.node().setButtonUpEvent("")
        throwerNP.removeNode()

    def destroyInputNode(self, np):
        np.removeNode()

In class-destruction code:

        for np in self.inputNodes.values():
            self.destroyInputNode(np)
        self.inputNodes = {}

        for throwerNP in self.buttonThrowers.values():
            self.destroyThrowerNP(throwerNP)

        self.buttonThrowers = {}