Skip to content

Actionable Items

Arnaud Dupuis edited this page Oct 8, 2022 · 8 revisions

Actionable items

Requirements

This tutorial is written for the pygamelib 1.3.0 and greater.

Please install the pygamelib before starting.

Introduction

Actionable items are a type of board items that can be triggered by movable items (like the player or an NPC for example).

In the pygamelib, you will find 3 actionable items:

  • Actionable: the base class for all actionable.
  • GenericActionableStructure: a GenericStructure that is also an Actionable.
  • ActionableTile: a complex (multi-characters) GenericActionableStructure.

In this tutorial we will discuss the concept of actionable items and we'll see examples of implementations.

Generic concepts

At its core an actionable item is an immovable board item. In term of inheritance, the graph looks like that:

                                        GernericActionableStructure
                                      /
  BoardItem -> Immovable -> Actionable
                                      \
                                        ActionableTile

The only meaningful difference between GenericActionableStructure and ActionableTile is the ability for the later to be represented with a Sprite.

Otherwise all Actionable objects share the same base behavior: you give them a callback function when you construct the object and this function will be called automatically by the Board when an item collides with it.

In general, you want to use an Actionable item when you want something to be done when a player or NPC collide with it.

A couple simple examples are:

  • A door or a portal to another level,
  • A trap that needs to inflict damages upon activation.
  • A healing spot (that needs to heal the user upon activation).
  • A mechanism that needs to be activated for further progression.
  • A zone that activate a cut scene.
  • etc.

A more convoluted example could be:

If you have a companion system and some pickable items should go to their inventory and not the player's one. Instead of instantiating a Treasure, you use an Actionable to trigger the correct actions (the engine does not support these use cases by default). In that case, the item to put in the companion inventory will be store in the Actionable.action_parameters list.

In any case it is a simple but versatile system.

Depending on when you read this document, the callback system might be optional as Actionable might already be using the observer system and send a signal when it's activated.

In that case, just think of the handle_notification() as the callback method that we are using in the rest of this tutorial. Just be careful of the method's parameters as handle_notification() is a class method not a stand-alone function.

Now let's see some code!

Coding with actionables

Actionable

Base Portal

As we talked about earlier, Actionable is the base class of all this system. But in reality, you will rarely use it directly. Its main use is to be subclassed for specific purpose.

Anyway, let's code a simple portal system with it. The code is very straightforward: when the portal is activated, the level change. We will set 2 boards with different colors for easy identification.

Here is the code:

from pygamelib import engine, constants, board_items
from pygamelib.gfx import core


# Here is our callback
def change_level_callback(action_parameters):
    # We set the game object as the first action_parameters, so let's get it back
    game = action_parameters[0]
    # Now we just oscillate between two levels
    if game.current_level == 1:
        game.change_level(2)
    else:
        game.change_level(1)

    # Once, the level is changed, we need to update the screen
    game.screen.delete(0, 0)
    game.screen.place(game.current_board(), 0, 0)


# This is the update function that will be called every frame.
# The parameters are: a reference to the game object, the last input (can be None) and
# the elapsed time since the last frame.
def main_game_update(game: engine.Game, input_key, elapsed_time: float):

    # Here we just handle the inputs (Q for quit and arrows to move)
    if input_key == "Q":
        game.stop()
    elif input_key.name == "KEY_RIGHT":
        game.move_player(constants.RIGHT)
    elif input_key.name == "KEY_LEFT":
        game.move_player(constants.LEFT)
    elif input_key.name == "KEY_DOWN":
        game.move_player(constants.DOWN)
    elif input_key.name == "KEY_UP":
        game.move_player(constants.UP)

    # And we need to update the screen at each frame.
    # update() will only redraw the screen if something changed, whereas force_update()
    # will redraw the screen regardless of its state.
    game.screen.update()


if __name__ == "__main__":
    # Now let's create a game object.
    game = engine.Game(
        # MODE_RT tells the game to run in real time.
        mode=constants.MODE_RT,
        # The player will be a red "@"
        player=board_items.Player(
            sprixel=core.Sprixel("@", fg_color=core.Color(255, 0, 0))
        ),
        # Finally we set the update function. It will be called every frame.
        user_update=main_game_update,
    )
    # Now let's create our 2 boards.
    b1 = engine.Board(
        # We set the size of the board to be 20 columns by 10 rows.
        size=[20, 10],
        # This controls the background color of the board. This one will be blue-ish
        ui_board_void_cell_sprixel=core.Sprixel(
            " ", bg_color=core.Color(125, 125, 200)
        ),
    )
    b2 = engine.Board(
        # We set the size of the board to be 20 columns by 10 rows.
        size=[20, 10],
        # This controls the background color of the board. This one will be green-ish
        ui_board_void_cell_sprixel=core.Sprixel(
            " ", bg_color=core.Color(125, 200, 125)
        ),
    )

    # Now we add the boards to the game. One board per level.
    game.add_board(1, b1)
    game.add_board(2, b2)

    # Change level to level 1
    game.change_level(1)

    # The only thing remaining is to set up the Actionable objects.
    a1 = board_items.Actionable(
        # The actionable will be a black "A"
        sprixel=core.Sprixel("A", fg_color=core.Color(0, 0, 0)),
        # The callback action will be the change_level_callback function
        action=change_level_callback,
        # The action_parameters will be the game object
        action_parameters=[game],
    )
    # We add the actionable to the board at row 2 and column 10
    b1.place_item(a1, 2, 10)

    # And the second one
    a2 = board_items.Actionable(
        # The actionable will be a white "A"
        sprixel=core.Sprixel("A", fg_color=core.Color(255, 255, 255)),
        # The callback action will be the change_level_callback function
        action=change_level_callback,
        # The action_parameters will be the game object
        action_parameters=[game],
    )
    # We add the actionable to the board at row 9 and column 2
    b2.place_item(a2, 9, 2)

    # Place the board on screen (the Game object automatically creates a screen of the
    # size of the terminal). You can create your own screen if you want.
    game.screen.place(b1, 0, 0)  # Top left corner

    # And finally run the game
    game.run()

The result looks like that:

Actionable example 01 (GIF)

Saving positions

But there's an issue: we always teleport the player to the top left corner. Depending on your game mechanic it can be an issue, so let's slightly change our callback to remember the last position of the player on each board.

This is done by simply using the player_starting _position property of the board object. It's just one line of code to add:

game.current_board().player_starting_position = game.player.pos

The change_level_callback function should now be:

def change_level_callback(action_parameters):
    # We set the game object as the first extra_param, so let's get it back
    game = action_parameters[0]
    # Let's remember where the player was before changing level
    game.current_board().player_starting_position = game.player.pos
    # Now we just oscillate between two levels
    if game.current_level == 1:
        game.change_level(2)
    else:
        game.change_level(1)

    # Once, the level is changed, we need to update the screen
    game.screen.delete(0, 0)
    game.screen.place(game.current_board(), 0, 0)

And voilà:

Actionable example 02 (GIF)

Permissions

Now let's explore the permission system by adding a NPC to the game.

First, by default, the permission on the Actionable is to interact only with a Player object. Let's check that.

Adding an NPC is fairly easy:

# Let's add an NPC represented by a yellow "N"
npc = board_items.NPC(sprixel=core.Sprixel("N", fg_color=core.Color(255, 255, 0)))
# We will place the NPC on the right and it will move to the left.
npc.actuator = actuators.UnidirectionalActuator(direction=constants.LEFT)
game.add_npc(1, npc, 2, 19)

The result is an NPC that starts on the right of the board and moves to the left towards the Actionable.

Actionable example 03 (GIF)

And then the NPC is blocked by the Actionable and cannot move further. It is not interacting with the Actionable because the default permission is constants.PLAYER_AUTHORIZED.

The Actionable object can take a perm parameter that can take one of the following constant (from pygamelib.constants):

  • PLAYER_AUTHORIZED
  • NPC_AUTHORIZED
  • ALL_CHARACTERS_AUTHORIZED
  • ALL_PLAYABLE_AUTHORIZED
  • ALL_MOVABLE_AUTHORIZED
  • NONE_AUTHORIZED

Setting the right permission will allow the NPC to use the actionable object:

# The only thing remaining is to set up the Actionable objects.
    a1 = board_items.Actionable(
        # The actionable will be a black "A"
        sprixel=core.Sprixel("A", fg_color=core.Color(0, 0, 0)),
        # The callback action will be the change_level_callback function
        action=change_level_callback,
        # The action_parameters will be the game object
        action_parameters=[game],
        # Set the permission so the Player and NPC can use the Actionable
        perm=constants.ALL_CHARACTERS_AUTHORIZED,
    )

And the result will be:

Actionable example 04 (GIF)

The permissions can be set dynamically, for example with:

a1.perm = constants.PLAYER_AUTHORIZED

GenericActionableStructure

In substance, the GenericActionableStructure is an actionable with a value. This allows for slightly different behavior. Let's see an example.

from pygamelib import engine, constants, board_items
from pygamelib.gfx import core


# Here is our callback
def destroy_callback(action_parameters):
    # The first action parameter is the actionable object.
    act = action_parameters[0]
    # We set the game object as the second action parameter, so let's get it back
    game = action_parameters[1]

    # Then let's increase the score with the actionable's value.
    game.score += act.value

    # And we remove the actionable from the board.
    game.current_board().remove_item(act)

    # And update the score
    game.screen.place(f"Score: {game.score}", 0, game.current_board().width + 1)


def projectile_hit(projectile, targets, extra):
    # If the projectile hits a target, we trigger the actionable callback.
    if isinstance(targets[0], board_items.Actionable):
        targets[0].activate()


# This is the update function that will be called every frame.
# The parameters are: a reference to the game object, the last input (can be None) and
# the elapsed time since the last frame.
def main_game_update(game: engine.Game, input_key, elapsed_time: float):

    # Here we just handle the inputs (Q for quit and arrows to move)
    if input_key == "Q":
        game.stop()
    elif input_key.name == "KEY_RIGHT":
        game.move_player(constants.RIGHT)
    elif input_key.name == "KEY_LEFT":
        game.move_player(constants.LEFT)
    elif input_key == " ":
        # If the player hits the space bar, we fire a projectile.
        p = board_items.Projectile(
            direction=constants.UP,
            range=10,
            sprixel=core.Sprixel("|", fg_color=core.Color(255, 0, 0)),
            hit_callback=projectile_hit,
        )
        # And place the projectile just above the player.
        game.add_projectile(1, p, game.player.row - 1, game.player.column)

    # And we need to update the screen at each frame.
    # update() will only redraw the screen if something changed, whereas force_update()
    # will redraw the screen regardless of its state.
    game.screen.update()


if __name__ == "__main__":
    # Now let's create a game object.
    game = engine.Game(
        # MODE_RT tells the game to run in real time.
        mode=constants.MODE_RT,
        # The player will be a red "^"
        player=board_items.Player(
            sprixel=core.Sprixel("^", fg_color=core.Color(255, 0, 0))
        ),
        # Finally we set the update function. It will be called every frame.
        user_update=main_game_update,
    )
    # Let's add an extra variable to the game object to hold the score.
    setattr(game, "score", 0)

    # Now let's create our board.
    board = engine.Board(
        # We set the size of the board to be 20 columns by 10 rows.
        size=[20, 10],
        # This controls the background color of the board. This one will be black
        ui_board_void_cell_sprixel=core.Sprixel(" ", bg_color=core.Color(0, 0, 0)),
        # The player will be at the bottom center of the board.
        player_starting_position=[9, 10],
    )

    # Now we add the boards to the game.
    game.add_board(1, board)

    # Change level to level 1
    game.change_level(1)

    # Place the score counter on the screen next to the board.
    game.screen.place(f"Score: {game.score}", 0, board.width + 1)

    # Let's put lines of GenericActionableStructure as destructible walls.
    for c in range(board.width):
        a1 = board_items.GenericActionableStructure(
            # The actionable will be a green "#"
            sprixel=core.Sprixel("#", fg_color=core.Color(0, 255, 0)),
            # The callback action will be the destroy_callback function
            action=destroy_callback,
            # Set the permission so movable objects can use the Actionable
            perm=constants.ALL_MOVABLE_AUTHORIZED,
            value=100,
        )
        # We want the callback to know about ourselves.
        a1.action_parameters = [a1, game]
        # We add the actionable to the board
        board.place_item(a1, 0, c)
    # And another line of less valuable items.
    for c in range(board.width):
        a1 = board_items.GenericActionableStructure(
            # The actionable will be an orange "#"
            sprixel=core.Sprixel("#", fg_color=core.Color(255, 155, 0)),
            # The callback action will be the destroy_callback function
            action=destroy_callback,
            # Set the permission so movable objects can use the Actionable
            perm=constants.ALL_MOVABLE_AUTHORIZED,
            value=50,
        )
        # We want the callback to know about ourselves.
        a1.action_parameters = [a1, game]
        # We add the actionable to the board
        board.place_item(a1, 1, c)

    # Place the board on screen (the Game object automatically creates a screen of the
    # size of the terminal). You can create your own screen if you want.
    game.screen.place(board, 0, 0)  # Top left corner

    # And finally run the game
    game.run()

And we just coded ourselves a (very) basic shooter game:

Actionable example 05 (GIF)

As you can see, the use of GenericActionableStructure is really similar to Actionable.

ActionableTile

ActionableTiles have almost exactly the same behavior than the GenericActionableStructure. The only 2 differences are that it is a BoardComplexItem, and therefor can use a Sprite instead of a simple sprixel. The second one, is that by default all Tile objects are overlappable.

Let's see an example by replacing the '#' representation by an ASCII art wall:

Full:
[#]

And after 1 hit:
[X]

Needless to say that Art (ASCII or any other form) is not my strong point...

We have 2 representations of a wall because the wall will now require 2 shots to be destroyed. After the first shot, we'll show the damaged sprite instead of the full one.
On that topic, there is currently a limitation on Immovable board items that uses sprites: they need to be removed and added back to the board for its visual to update. This is due to a limitation that will be gone in future version (this warning apply to version 1.3.0).

Then we need to create 2 sprites. Since they are very small we can do that by hand, but usually we would use the pgl-sprite-editor to do that.

We will also set the foreground color of the full wall to green and when it's damaged, to a shade of orange.

green = core.Color(0, 255, 0)
full_wall = core.Sprite(
    sprixels=[
        [
            core.Sprixel("[", fg_color=green),
            core.Sprixel("#", fg_color=green),
            core.Sprixel("]", fg_color=green),
        ]
    ]
)
damaged_wall = core.Sprite(
    sprixels=[
        [
            core.Sprixel("[", fg_color=green),
            core.Sprixel("X", fg_color=core.Color(255, 155, 0)),
            core.Sprixel("]", fg_color=green),
        ]
    ]
)

WARNING: When you build a sprite by hand, do not forget that the sprixels' array needs to be 2 dimensions.

Now let's populate the top row with actionable tiles. The following code replaces the 2 for loop in the previous example.

    # Let's put lines of ActionableTile as destructible walls.
    c = 0  # This will be used to place the walls. The column index.
    while True:
        # First let's check if there's enough room to place the item.
        if c >= board.width or c + full_wall.width > board.width:
            break
        # Then create the actionable tile.
        a1 = board_items.ActionableTile(
            # The actionable will be represented by our full wall.
            sprite=full_wall,
            # The callback action will be the destroy_callback function
            action=destroy_callback,
            # Set the permission so movable objects can use the Actionable
            perm=constants.ALL_MOVABLE_AUTHORIZED,
            value=100,
            # Warning: Tiles are overlappable by default.
            overlappable=False,
        )
        # Add an attribute to the tile to say if it's been hit before.
        setattr(a1, "was_hit", False)
        # We want the callback to know about ourselves.
        a1.action_parameters = [a1, game]
        # We add the actionable to the board
        board.place_item(a1, 0, c)
        # Increase the column index by the width of the sprite.
        c += full_wall.width

The result looks like that:

Actionable example 06 (PNG)

Each wall is now 3 characters wide and can be damaged from any of these characters.

Finally, we need to change the destroy_callback to take in consideration that a wall now needs 2 shots to be destroyed.

def destroy_callback(action_parameters):
    # The first action parameter is the actionable object.
    act = action_parameters[0]
    # We set the game object as the second action parameter, so let's get it back
    game = action_parameters[1]
    if act.was_hit:
        # If the actionable was hit, we remove it and score using the
        # actionable's value.
        game.score += act.value

        # And we remove the actionable from the board.
        game.current_board().remove_item(act)

        # And update the score
        game.screen.place(f"Score: {game.score}", 0, game.current_board().width + 1)
    else:
        # If the tile was never hit we update its sprite and update the was_hit flag.
        # INFO: It is a known limitation of the current implementation of complex items
        #       that we need to remove the item from the board before updating it, and
        #       then add it back again.
        game.current_board().remove_item(act)
        act.sprite = damaged_wall
        act.was_hit = True
        game.current_board().place_item(act, act.row, act.column)

Before having a look at the whole code, let's see the final result:

Actionable example 06 (GIF)

And the complete code for that example:

from pygamelib import engine, constants, board_items
from pygamelib.gfx import core

green = core.Color(0, 255, 0)
full_wall = core.Sprite(
    sprixels=[
        [
            core.Sprixel("[", fg_color=green),
            core.Sprixel("#", fg_color=green),
            core.Sprixel("]", fg_color=green),
        ]
    ]
)
damaged_wall = core.Sprite(
    sprixels=[
        [
            core.Sprixel("[", fg_color=green),
            core.Sprixel("X", fg_color=core.Color(255, 155, 0)),
            core.Sprixel("]", fg_color=green),
        ]
    ]
)


# Here is our callback
def destroy_callback(action_parameters):
    # The first action parameter is the actionable object.
    act = action_parameters[0]
    # We set the game object as the second action parameter, so let's get it back
    game = action_parameters[1]
    if act.was_hit:
        # If the actionable was hit, we remove it and score using the
        # actionable's value.
        game.score += act.value

        # And we remove the actionable from the board.
        game.current_board().remove_item(act)

        # And update the score
        game.screen.place(f"Score: {game.score}", 0, game.current_board().width + 1)
    else:
        # If the tile was never hit we update its sprite and update the was_hit flag.
        # INFO: It is a known limitation of the current implementation of complex items
        #       that we need to remove the item from the board before updating it, and
        #       then add it back again.
        game.current_board().remove_item(act)
        act.sprite = damaged_wall
        act.was_hit = True
        game.current_board().place_item(act, act.row, act.column)


def projectile_hit(projectile, targets, extra):
    # If the projectile hits a target, we trigger the actionable callback.
    if isinstance(targets[0], board_items.ActionableTile):
        targets[0].activate()


# This is the update function that will be called every frame.
# The parameters are: a reference to the game object, the last input (can be None) and
# the elapsed time since the last frame.
def main_game_update(game: engine.Game, input_key, elapsed_time: float):

    # Here we just handle the inputs (Q for quit and arrows to move)
    if input_key == "Q":
        game.stop()
    elif input_key.name == "KEY_RIGHT":
        game.move_player(constants.RIGHT)
    elif input_key.name == "KEY_LEFT":
        game.move_player(constants.LEFT)
    elif input_key == " ":
        # If the player hits the space bar, we fire a projectile.
        p = board_items.Projectile(
            direction=constants.UP,
            range=10,
            sprixel=core.Sprixel("|", fg_color=core.Color(255, 0, 0)),
            hit_callback=projectile_hit,
        )
        # And place the projectile just above the player.
        game.add_projectile(1, p, game.player.row - 1, game.player.column)

    # And we need to update the screen at each frame.
    # update() will only redraw the screen if something changed, whereas force_update()
    # will redraw the screen regardless of its state.
    game.screen.update()


if __name__ == "__main__":
    # Now let's create a game object.
    game = engine.Game(
        # MODE_RT tells the game to run in real time.
        mode=constants.MODE_RT,
        # The player will be a red "^"
        player=board_items.Player(
            sprixel=core.Sprixel("^", fg_color=core.Color(255, 0, 0))
        ),
        # Finally we set the update function. It will be called every frame.
        user_update=main_game_update,
    )
    # Let's add an extra variable to the game object to hold the score.
    setattr(game, "score", 0)

    # Now let's create our board.
    board = engine.Board(
        # We set the size of the board to be 21 columns by 10 rows.
        size=[21, 10],
        # This controls the background color of the board. This one will be black
        ui_board_void_cell_sprixel=core.Sprixel(" ", bg_color=core.Color(0, 0, 0)),
        # The player will be at the bottom center of the board.
        player_starting_position=[9, 10],
    )

    # Now we add the boards to the game.
    game.add_board(1, board)

    # Change level to level 1
    game.change_level(1)

    # Place the score counter on the screen next to the board.
    game.screen.place(f"Score: {game.score}", 0, board.width + 1)

    # Let's put lines of ActionableTile as destructible walls.
    c = 0  # This will be used to place the walls. The column index.
    while True:
        if c >= board.width or c + full_wall.width > board.width:
            break
        a1 = board_items.ActionableTile(
            # The actionable will be represented by our full wall.
            sprite=full_wall,
            # The callback action will be the destroy_callback function
            action=destroy_callback,
            # Set the permission so movable objects can use the Actionable
            perm=constants.ALL_MOVABLE_AUTHORIZED,
            value=100,
            # Warning: Tiles are overlappable by default.
            overlappable=False,
        )
        # Add an attribute to the tile to say if it's been hit before.
        setattr(a1, "was_hit", False)
        # We want the callback to know about ourselves.
        a1.action_parameters = [a1, game]
        # We add the actionable to the board
        board.place_item(a1, 0, c)
        # Increase the column index by the width of the sprite.
        c += full_wall.width

    # Place the board on screen (the Game object automatically creates a screen of the
    # size of the terminal). You can create your own screen if you want.
    game.screen.place(board, 0, 0)  # Top left corner

    # And finally run the game
    game.run()

Conclusion

The Actionable system is very versatile and allow for a wide variety of usage. From items that do something immediately (damage, heal, change visual, turn on a mechanism) to destructible environments or portals and many more, the possibilities are really wide.

A benefit of that system is that you can write callback functions for all your specific needs. This is much more efficient (therefor a lot faster) than having a huge handling function with a lot of ifs. This is because a callback is only executed on purpose.

Depending on when you are reading that tutorial, all the actionable objects might already support the observer system introduced in 1.3.0. In that case, it is better to use it over callbacks.

Do not hesitate to comment on this if you have interesting use cases or issues that you want help with!

Documentation & Help

he full documentation for the Actionable objects is available on readthedocs:

If you have issues and want some help, you can either ask in the Discussion tab of the Github repository or come ask your question on Discord.

Next

You want more? Maybe we can interest you in learning more about the Board object or the Game object.