Actions

World

From Future Skill

This article goes through how to use the World library to create a topdown or isometric world.

Overview

The World library is split into two main parts:

  • Core functionality, focused on flexibility and reuse
  • Prefabricated content, focused on easy of use

This article will focus on the prefab content, but include enough core details to be able to take full advantage of the library.

The worlds created using this library are divided into descrete spaces, usually a square grid. So positions will be based on integer coordinates.

The prefabricated content supports both topdown and isometric perspectives, and it is very easy to switch between the two as needed. Additional perspectives are possible, but not supported by the prefab content.

All classes, types, and constants mentioned in this article can be imported from lib.world.

Easiest way to get started is to pick up one of the templates at the end of the article, and then jump to relevant sections as questions come up.

Setup

To use the prefab content you will have to use the WorldSetup class. The following sections will assume that you have an instance of this class called setup.

To start, just create the setup object like this setup = WorldSetup().

The class has the following attributes:

directions - list[tuple[int, int]]
The allowed movement directions, see directions, defaults to CARDINAL_DIRECTIONS
basic - bool
If True will draw colored geometric shapes rather than the image-based tiles and walls
perspective - "topdown" | "isometric"
The perspective to use for the prefab content, defautls to "topdown"
resolution - int
The "unit size", the size of a tile in topdown, will be used for all content
block_corners - bool
If True will block diagonal movement next to a wall, should generally be enabled when using SQUARE_DIRECTIONS
debug_mode - bool
If True adds additional debugging features to the world element, see debugging

Here is an example of creating the setup object:

setup = WorldSetup(
    directions=SQUARE_DIRECTIONS,
    perspective="isometric",
    block_corners=True,
)

Directions

You can specify which directions exist in the world, which will affect how actors can move.

By default only the cardinal directions exist:

  • Cardinal.LEFT = (-1, 0)
  • Cardinal.RIGHT = (1, 0)
  • Cardinal.UP = (0, -1)
  • Cardinal.DOWN = (0, 1)

But these can be extended with diagonals:

  • Ordinal.LEFT_UP = (-1, -1)
  • Ordinal.RIGHT_UP = (1, -1)
  • Ordinal.LEFT_DOWN = (-1, 1)
  • Ordinal.RIGHT_DOWN = (1, 1)

For convenience, the following direction list constants are available:

  • CARDINAL_DIRECTIONS
  • ORDINAL_DIRECTIONS
  • SQUARE_DIRECTIONS

You can specify any other set of directions you want, but it will seldom make sense to do so.

World

To create the world itself you can use world = setup.create_world().

This section will not go into detail of how the World object works, that will be covered throughout this article.

Rendering

The world library can be used without rendering, but generally you do want to show the results. When using the library you will initialize and make changes to the world in the setup_state and update_state methods. Then you will want to render and re-render the world in the setup_canvas/setup_view and update_canvas/update_view methods.

When doing the initial render you can choose how the rendered element will behave:

  • Use world.render_resizable() to get an element which dynamically scales the world to fit within the size of the element. Recommended for smaller worlds.
  • Use world.render_scrollable() to get a scroll area containing the world. The size of the world is based on the resolution. Recommended for larger worlds.

No matter which way you create the initial element, you need to call world.render_changes() when you want the world to be re-rendered. No need to do anything else.

See the templates section for examples of how to do this in practice.

Tiles

Worlds consist mainly of tiles, which are used to both define the walkable area of the world and the visual tiles the world consist of.

The library comes with prefabricated graphical tiles and has built-in support for plain colored tiles, but it is also possible to make custom tiles.

There are a few ways to add tiles to the world, which to use is partly up to need and partly up to preference.

First way is to include a tiles argument to setup.create_world(). This can take the form of a dictionary with coordinates as keys and tiles as values:

world = setup.create_world(tiles={
    (0, 0): "grass",
    (1, 0): "grass",
    (0, 1): "gravel",
})

Alternatively the tiles argument can be a string as described in tile map:

world = setup.create_world(tiles="gg\nr")

Second way is to use the World methods:

world.add_tile(coordinate, data)
Where data depends on what types of tiles you are using
For example adding a prefab tile: world.add_tile((1, 1), {"name": "grass"})
Or if using basic tiles: world.add_tile((1, 1), {"color": "black"})
world.fill(top_left, bottom_right, data)
Which will add the same tile to the area spanned by top_left and bottom_right
For example: world.fill((0, 0), (1, 1), {"name": "mud", "color": "tan"})

It is possible to get tile data for a coordinate using world.get_tile_data(coordinate), and it is possible to get all tile coordinates using world.get_tile_coordinates().

You can remove a tile using world.remove_tile(coordinate).

Prefab tiles

Constant Name Code
Tile.BLOCKED blocked b
Tile.GRASS grass g
Tile.GRASS_2 grass_2 G
Tile.GRAVEL gravel r
Tile.MUD mud m
Tile.WATER water w

Tile map

The tile map string is a conveninet way of specifying what tiles to use. Each tile is specified using a code (see table in previous section), use . to mark gaps. To be able to add line breaks you will have to use triple " for the strings. Or you can use the special sequence \n instead of adding a line break. Leading and trailing whitespace is ignored, which allows indenting the tile maps.

Here are some examples:

# Example from main section
ex1 = "gg\nr"
# Equivalent to ex1
ex2 = """
    gg
    r
"""
# An example of using gaps
ex3 = """
    .wr.
    mrrg
    mrgg
    .rg.
"""
# Equivalent to ex3, but hard to read
ex4 = """.wr
mrrg
    mrgg\n.rg"""

Walls

It is possible to place walls between tiles in the world, these will block movement between the tiles.

While tiles are identified using a single coordinate, walls are identified using a pair of coordinates. The pair is the coordinates of the tiles the wall is placed between. E.g. if the wall should be placed between tiles (0, 1) and (1, 1) then the coordinate pair is ((0, 1), (1, 1)).

Like tiles, the library comes with prefabricated graphical walls and support for plain colored walls. And it is possible to make custom walls.

Walls are added to the world in a similar way as tiles, except there is no equivalent to tile maps.

First way is to include a walls argument to setup.create_world(). This takes the form of a dictionary with coordinate pairs as keys and walls as values:

world = setup.create_world(tiles={
    ((0, 0), (1, 0)): "hedge",
    ((0, 0), (0, 1)): "stone",
})

Second way is to use the World method:

world.add_wall(coordinate_pair, data)
Where data depends on what types of walls you are using
For example adding a prefab wall: world.add_wall(((0, 1), (1, 1)), {"name": "hedge_2"})
Or if using basic walls: world.add_wall(((1, 0), (1, 1)), {"color": "orange", "height": 0.3})

It is possible to get wall data for a pair of coordinates using world.get_wall_data(coordinate_pair).

You can remove a wall using world.remove_wall(coordinate_pair).

Prefab walls

Constant Name
Wall.BRICK_FENCE brick_fence
Wall.HEDGE hedge
Wall.HEDGE_2 hedge_2
Wall.PICKET_FENCE picket_fence
Wall.STONE stone

Block corners

Due to the geometry of a square grid, you will generally only want to have visual walls between tiles in the cardinal directions. However, when allowing diagonal movement (e.g. using directions=SQUARE_DIRECTIONS) the characters can move diagonally across the corners. This will look incorrect and not make intuitive sense for the users.

To solve this you can set block_corners=True in the WorldSetup or World constructors. This will automatically add invisible walls at the corners next to walls so that movement is blocked as expected.

Note that these walls must be manually removed if needed.

Actors

Everything that is not a tile or a wall is an Actor. This includes characters, objects, and even props like flowers.

The easiest way to create actors is to use the WorldSetup methods (see following sections), but you can also create them manually if you need more flexibility.

The World class has the following Actor-related methods:

world.add_actor(coordinate, actor)
Places the actor in the world at the given coordinate.
The actor will be configured to follow the movement rules of the world.
Multiple actors can be placed on the same coordinate.
world.remove_actor(actor)
Removes the actor from the world.
world.find_actors()
Allows looking up actors based on various criteria, returns a list.
If given no arguments it returns all actors in the world.
The following arguments are supported:
at - tuple[int, int]
Coordinate (tile) to search at.
Example: world.find_actors(at=(0, 0))
tags - set[str]
Tags the actor must have.
Example: world.find_actors(tags={"my_tag"})
tags_match_any - bool
Only requires that one tag of tags matches.
Example: world.find_actors(tags={"player", "enemy"}, tags_match_any=True)

Attributes

All actors have a number of attributes that usually can be set when creating the actor:

actor.coordinate - tuple[int, int] | None
The current coordinate of the actor, or None if the actor is not in a world.
actor.offset - tuple[float, float] | None
A visual offset on the current tile, mainly used to add small props (such as rocks and flowers).
actor.tags - set[str]
A set of tags for the actor, which are used in world.find_actors or other features.
actor.block_tags - set[str]
A set of tags this actor is blocked by. If a space has an actor with any of these tags, then this actor can not move there.

Movement

This library has a pathing system which enables actors to find the quickest path from point a to point b.

In order for this system to work, you must call world.update_world_graph(). This should be done after finishing setting up the world, and when you have modified any tiles or walls.

Actors have a set of methods related to movement:

actor.can_move(direction)
Returns True if the actor currently can move in the given direction.
actor.move(direction)
Moves the actor one step in the given direction.
Does not require movement to be properly set up.
Example: actor.move(Cardinal.LEFT)
actor.move(direction, check=True)
Same as above, but includes additional checks (such as blocked check).
Returns False if the movement failed.
Requires movement to be properly set up.
Example: actor.move(Ordinal.LEFT_DOWN, check=True)
actor.possible_moves()
Returns the possible directions this actor can move (currently).
actor.next_move_towards(coordinate)
Returns the current direction the actor should move to get one step closer to the given coordinate along a shortest path.
Returns None if there is no path to to coordinate or if the actor is already there.
actor.move_towards(coordinate)
Moves the actor one step towards the given coordinate along a shortest path.
Returns False if the movement failed or the actor is already there.
Example: actor.move_towards((3, 4))

You can have a callback trigger whenever the actor performs a successful movement, by supplying the following argument when creating an actor:

on_move - (ActorMoveEventData) -> None
Callback to call when the actor successfully moves (one step)

The ActorMoveEventData object has the following attributes:

source - Actor
The actor that moved
direction - tuple[int, int]
The direction the actor moved
origin - tuple[int, int]
The coordinate the actor came from

Keyboard Input

This library has an keyboard input system which enables users to use the keyboard to move actors or perform other actions.

When creating an actor you can supply the following arguments:

key_map - dict[str, tuple[int, int] | (ActorKeyboardEventData) -> None] | None
Maps keys to movements and/or callbacks.
on_key_up - (ActorKeyboardEventData) -> None
Callback to call if the key is not found in the key map.

The ActorKeyboardEventData object has the following attributes:

source - Actor
The actor that received the key event
key - str
The key value

We supply a default keymap for cardinal movement called MOVE_KEY_MAP which supports arrow keys and WASD movement.

Example of using default movement for a main character:

world.add_actor((0, 0), setup.create_character("skilly", "blue", key_map=MOVE_KEY_MAP))

Example of adding an additional action when pressing "p":

world.add_actor((0, 0), setup.create_character("skilly", "orange", key_map=MOVE_KEY_MAP | {"p": lambda e: e.source.pick_up()}))

Example of just printing the value when a key is pressed:

world.add_actor((0, 0), setup.create_character("skilly", "orange", on_key_up=lambda e: print(e.key)))

Props

Prop actors are simply prefabricated actors that can be placed in the world. It is up to you to decide how to use them.

Props are created using setup.create_prop(name) which also accepts various Actor and Image attributes. The available prefab props are listed in the following table:

Constant Name Note
Prop.BARREL barrel Container
Prop.BARREL_SIDE barrel_side Container
Prop.BUSH bush
Prop.CHEST chest Container
Prop.CHEST_BAND chest_band Container
Prop.CRATE crate Container
Prop.CRATE_TALL crate_tall Container
Prop.FLOWER flower
Prop.FLOWER_2 flower_2
Prop.STONE stone
Prop.STONE_2 stone_2
Prop.TREE tree
Prop.TREE_STONES tree_stones

Smaller props such as flowers and stones can preferably be added as cosmetic scenary to world, with an offset from the center. Here is an example where a couple of stones and flowers are added:

scenary = [
    ("stone", (0, 0), (-0.3, 0)),
    ("flower", (0, 0), (0.3, 0.4)),
    ("stone", (1, 0), (0.2, -0.4)),
]
for name, coord, offset in scenary:
    world.add_actor(coord, setup.create_prop(name, offset=offset))

Bigger props should generally be used without an offset to avoid intersecting walls and characters. You would generally also want to give the bigger props a tag that can be used to block movement:

world.add_actor((0, 2), setup.create_prop("bush", tags={"obstacle"}))

The props marked as Container can be used as normal props, but if you want to have them be opened/broken, then you should take a look at the containers section.

Characters

Character actors have a few extra features and, most importantly, are animated. The characters will automatically get appropriate walking animations based on movement, and there are a couple of additional actions with dedicated animations.

When you call world.render_changes() the appropriate animation will be chosen. If you want to perform multiple movements or actions, you have to use canvas.split_step() and call world.render_changes() multiple times.

Like with props, there is a dedicated method to create a character setup.create_character(name, color). Available characters and colors are listed in the following tables:

Constant Name Description
Character.ADA ada a girl
Character.DOUGLAS douglas a dog
Character.KATNISS katniss a cat
Character.RUST rust a boy
Character.SKILLY skilly a robot
Constant Name
CharacterColor.BLACK black
CharacterColor.BLUE blue
CharacterColor.ORANGE orange
CharacterColor.RED red
CharacterColor.WHITE white
CharacterColor.YELLOW yellow

Character actors have an additional set of methods with animations:

actor.pick_up()
Plays a pick-up animation.
actor.gesture(direction)
Plays a gesturing animation in the given direction.
Example: actor.gesture(Cardinal.DOWN)

Containers

Container actors are created using the method setup.create_container(name) with one of the names marked as Container in |the props table.

Containers have the following extra attributes:

actor.opened - bool
Whether the container currently is opened/broken or not, readonly

And the following extra methods:

actor.open()
Opens/breaks the container
actor.close()
Closes/un-breaks the container

The containers have animations that play then they open/break, and then they will keep the open/broken appearence. They can then be closed, but there currently are not animations playing for this action.

Debugging

The world element has a subtle menu on the top left which can be used to activate various visualizations:

(x, y) - coordinates
Shows coordinates for all tiles, which can greatly help when placing actors or writing solutions.
Always available.
nav - navigation
Visualization of the pathfinding graph, does not take into account blocking tags.
Requires debug mode.

Templates

The following templates are a bit rough around the edges and might not always showcase best practices.

This template showcases how the world library can be used together with the GameChallenge skeleton:

"""
This is the module containing the challenge implementation.
"""
from lib.exceptions import SolutionException
from lib.skeletons import LevelInfo, GameChallenge, GameInfo, PlayerInfo, StatisticInfo
from lib.world import MOVE_KEY_MAP, WorldSetup


# NOTE: only needed due to a bug
class HumanAdapter:
    pass


class Challenge(GameChallenge):
    """
    This is a challenge implementation using the game skeleton and the world library.
    """
    
    def setup_game(self):
        """
        This method returns required game information for setting up the challenge.
        """
        # The info prompt shows up in the bottom left corner in interact mode
        self.info_prompt = "Move using WASD or arrow keys"
        # We don't use the turn system, this change is just cosmetic
        self.auto_finish_turn = True
        return GameInfo(
            title="My game",
            summary="Follow the path",
            description="My game description.<br>Which can use <em>html formatting</em>!",
        )
    
    def setup_players(self):
        """
        This method returns a list with required information for each player.
        """
        return [PlayerInfo(
            role="Player",
            name="Your solution",
            image="TODO",
        )]

    # Keep track of points using a statistic
    def setup_statistics(self):
        return [StatisticInfo("points", suffix="points", type=int, default=10)]

    # Disable the auto-finish turn setting
    def setup_settings(self):
        return []

    def setup_level(self, level):
        """
        This method returns required level information for the current level.
        The `level` parameter is the level index, starting at 0.
        """
        match level:
            case 0:
                name = "Right down the path"
                self.tiles = """
                    rrrrr
                    GGGGr
                    GGGGr
                    GGGGr
                    GGGGr
                """
                self.walls = {}
                self.path_length = 8
            case 1:
                name = "Not straight-farward"
                self.tiles = """
                    rrrGb
                    mmrGb
                    mrrGb
                    mrGGG
                    mrrrr
                """
                self.walls = {}
                self.path_length = 10
            case 2:
                name = "Walls as well"
                self.tiles = """
                    rrmmm
                    grrrm
                    grrrm
                    grrrm
                    wwwrr
                """
                self.walls = {
                    ((1, 1), (2, 1)): "stone",
                    ((1, 2), (2, 2)): "stone",
                    ((2, 2), (3, 2)): "hedge",
                    ((2, 3), (3, 3)): "hedge",
                    ((4, 3), (4, 4)): "picket_fence",
                }
                self.path_length = 12
            case 3:
                name = "Not so square"
                self.tiles = """
                    rGGGb
                    rrr.g
                    .mrrr
                    ..mrr
                    ...rr
                """
                self.walls = {
                    ((3, 2), (3, 3)): "hedge_2",
                    ((4, 3), (4, 4)): "brick_fence",
                }
                self.path_length = 10
            case _:
                raise Exception(f"level {level} not supported")
        return LevelInfo(name=name, max_score=10)

    def setup_state(self):
        """
        This method is where you should do all initial setup, except for graphics.
        """
        # We first need to create the world setup, in this case we just use the default
        self.setup = WorldSetup()
        # Create the world using the tiles and walls assigned for the level
        self.world = self.setup.create_world(tiles=self.tiles, walls=self.walls)
        # Needed to enable movement
        self.world.update_world_graph()
        # Create the player character with standard movement controls and a movement callback
        self.player_character = self.setup.create_character("rust", "red", key_map=MOVE_KEY_MAP, on_move=self.move_callback)
        # Add the player to the world at the starting position
        self.world.add_actor((0, 0), self.player_character)
        # Keep track of how many steps the player has taken
        self.step_count = 0

    def setup_view(self):
        """
        This method returns the main view for the challenge, which can be any graphics element.
        """
        # We want the world to be resized to fit the designated area
        # For a bigger world we would want to use "render_scrollable"
        return self.world.render_resizable()

    def update_state(self):
        """
        This method is where you should call solutions and update the current state.
        It is called continuously until `self.finished` is set to `True`.
        """
        # When calling a solution you need to handle any `SolutionException`
        try:
            solution = self.context.solutions[0]
            # TODO: call a solution method
        except SolutionException:
            # Code put here will run if the solution crashed
            pass

    def update_view(self):
        """
        This method is where you should update the view based on the current state.
        """
        # Need to trigger rendering of any changes
        self.world.render_changes()

    # We add this method to be called whenever the player character moves
    def move_callback(self, event):
        # If the player takes too many steps they will lose points
        self.step_count += 1
        if self.step_count > self.path_length:
            self.players[0].points -= 1
        # If the player moves off the path they will lose points
        tile_data = self.world.get_tile_data(self.player_character.coordinate)
        match tile_data["name"]:
            # On the path, do not deduct points
            case "gravel":
                pass
            # Walked on mud, deduct double points
            case "mud":
                self.players[0].points -= 2
            # Matches any other tile
            case _:
                self.players[0].points -= 1
        # If the player has reached the end, we stop the run
        if self.player_character.coordinate == (4, 4):
            self.finished = True
            self.info_prompt = "You made it to the end of the path!"
        # We can update the scores here
        self.scores[0] = self.players[0].points)