The code for this project relies heavily on CircuitPython displayio fundamentals. If you're not familiar with displayio TileGrid and Group basics you should take a moment to bush up in the displayio guide and then come back here.

Configuration

There are a few variables that you can set to modify certain things about the appearance and behavior of the cat and background.

Application behavior:

  • BACKGROUND_COLOR - Hex color code to be used for the background of the display. Default is a light blue color, change to whatever color you like!
  • ANIMATION_TIME - This is the number of seconds between animation sprites. The Default is 0.3 seconds. You can leave it as is, or experiment with faster or slower times.
  • USE_TOUCH_OVERLAY - Boolean value that enables the touch interaction. Set this to False if your device doesn't have a touch screen. Leave it True on PyPortal.
  • TOUCH_COOLDOWN - The time in seconds to wait before registering new touch events. Default is 0.1 seconds, so it will allow up to 10 touch events per second.
  • LASER_DOT_COLOR - Hex color code for the color of the laser dot when you touch the display. Default is red, feel free to change it.

Neko Cat Behavior:

  • CONFIG_STEP_SIZE - How many pixels the cat will move with each step. Default is 10, you can speed up or slow down if you like.
  • CONFIG_STOP_CHANCE_FACTOR -  How likely the cat is to stop moving and clean its paws or sleep while it's walking. Default is 30. Lower numbers make the cat more likely to stop walking.
  • CONFIG_START_CHANCE_FACTOR - How likely the cat is to start moving after scratching at a wall. Default is 10. Lower numbers make it more likely to stop scratching and start walking.
  • CONFIG_MIN_SCRATCH_TIME - The minimum time in seconds the cat will spend scratching the wall. Default is 2.

Main Program Loop

Inside the main loop of the program two primary tasks occur.

  1. call neko.update() to process all animation and behavior logic on the cat sprite.
  2. Check the touch screen for any incoming touch events. If there is one, place the laser dot circle at the touch location and set the neko.moving_to property to the same location.

NekoAnimatedSprite Class

This class contains all of the logic that controls Neko's behavior. It is extending the displayio.TileGrid class so it can control which sprite from the spritesheet is showing when it's time to change them for animating. The core mechanism within this class is a state machine.

State Objects

There are tuple variables representing each state that Neko can be in. Each one contains:

  • A number ID
  • A tuple containing 1 or more sprites to show for this state. If multiple it will animate between them.
  • A tuple containing the delta values for x and y movements for this state.

There is a state object for each possible moving direction, each possible scratching direction, plus sleeping, cleaning paws, and sitting.

The current_state property on the NekoAnimatedSprite class allows you to get or set which state Neko is in. Ordinarily this will happen inside of the update() function based on the behavior logic and what Neko is doing at the time.

Update Function

This is the highest level behavior function. It gets called once per iteration inside of the main loop. Within this function these main tasks will occur:

  • If we are moving to the specific location of the laser dot (from user touch event), check where we are currently at in relation to the target location and change the moving direction to head towards it if needed. Changing directions is done by changing the current_state property.
  • Attempt an animation frame. If it has been long enough since the previously drawn animation frame, then it will change the currently showing sprite to the next one in the animation list and advance the index for next time.
  • Decide on the next behavior. If we are currently walking, we could decide to stop and sleep, or clean our paws. If we are currently scratching a wall, we could decide to stop scratching and start walking. These behaviors are random chance based on a modifier from the configuration. If we are done sleeping or cleaning, we will decide to start walking. The most likely chance is that we will keep doing whatever we already are.
  • Process movement. If there is room in the direction we are heading to take a step, then do so. If we run into a wall, then change into the scratching state that matches the direction of the wall we hit.

Animation

The animation()function is what handles the animation logic. It uses the LAST_ANIMATION_TIME variable and the ANIMATION_TIME configuration to determine whether it's time to change sprites or not. When it is time to change sprites it's done by changing the index from within the source spritesheet. The NekoAnimatedSprite class extends TileGrid so this occurs by sub-scripting self with square brackets like:
self[0] = 5

except instead of 5 it would pick whatever index is currently up in the list of sprites for the current animation based on the state. Our sprite is only a single tile so we always use self[0]. If we had more tiles, we might use higher numbers inside the square brackets. Learn more about TileGrids In the DisplayIO Guide.

The indexes represent the position of the desired sprite within the full sprite sheet. Starting with 0 in the top left and moving right, then down in the same order and directions as you would read English words on a page.

Further Detail

The source code is thoroughly commented to explain what the statements are doing. You can read through the code and comments to gain a deeper understanding of how it functions, or modify parts of it to suit your needs.

# SPDX-FileCopyrightText: 2022 TimCocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import time
import random
import board
import displayio
import vectorio
import adafruit_imageload

try:
    import adafruit_touchscreen
except ImportError:
    # Touch screen is optional
    pass

# display background color hex notation
BACKGROUND_COLOR = 0x00AEF0

# how long to wait between animation frames in seconds
ANIMATION_TIME = 0.3

# whether to use a touch overlay
USE_TOUCH_OVERLAY = True

# how long to wait for next valid touch event in seconds
TOUCH_COOLDOWN = 0.1

# laser dot color in hex notation
LASER_DOT_COLOR = 0xFF0000


class NekoAnimatedSprite(displayio.TileGrid):
    # how many pixels the cat will move for each step
    CONFIG_STEP_SIZE = 10

    # how likely Neko is to stop moving to clean or sleep.
    # lower number means more likely to happen
    CONFIG_STOP_CHANCE_FACTOR = 30

    # how likely Neko is to start moving after scratching a wall.
    # lower number means more likely to happen
    CONFIG_START_CHANCE_FACTOR = 10

    # Minimum time to stop and scratch in seconds. larger time means scratch for longer
    CONFIG_MIN_SCRATCH_TIME = 2

    TILE_WIDTH = 32
    TILE_HEIGHT = 32

    # State object indexes
    _ID = 0
    _ANIMATION_LIST = 1
    _MOVEMENT_STEP = 2

    # last time an animation occurred
    LAST_ANIMATION_TIME = -1

    # index of the sprite within the currently running animation
    CURRENT_ANIMATION_INDEX = 0

    # last time the cat changed states
    # used to enforce minimum scratch time
    LAST_STATE_CHANGE_TIME = -1

    # State objects
    # Format: (ID, (Animation List), (Step Sizes))
    STATE_SITTING = (0, (0,), (0, 0))

    # Moving states
    STATE_MOVING_LEFT = (1, (20, 21), (-CONFIG_STEP_SIZE, 0))
    STATE_MOVING_UP = (2, (16, 17), (0, -CONFIG_STEP_SIZE))
    STATE_MOVING_RIGHT = (3, (12, 13), (CONFIG_STEP_SIZE, 0))
    STATE_MOVING_DOWN = (4, (8, 9), (0, CONFIG_STEP_SIZE))
    STATE_MOVING_UP_RIGHT = (
        5,
        (14, 15),
        (CONFIG_STEP_SIZE // 2, -CONFIG_STEP_SIZE // 2),
    )
    STATE_MOVING_UP_LEFT = (
        6,
        (18, 19),
        (-CONFIG_STEP_SIZE // 2, -CONFIG_STEP_SIZE // 2),
    )
    STATE_MOVING_DOWN_LEFT = (
        7,
        (22, 23),
        (-CONFIG_STEP_SIZE // 2, CONFIG_STEP_SIZE // 2),
    )
    STATE_MOVING_DOWN_RIGHT = (
        8,
        (10, 11),
        (CONFIG_STEP_SIZE // 2, CONFIG_STEP_SIZE // 2),
    )

    # Scratching states
    STATE_SCRATCHING_LEFT = (9, (30, 31), (0, 0))
    STATE_SCRATCHING_RIGHT = (10, (26, 27), (0, 0))
    STATE_SCRATCHING_DOWN = (11, (24, 25), (0, 0))
    STATE_SCRATCHING_UP = (12, (28, 29), (0, 0))

    # Other states
    STATE_CLEANING = (13, (0, 0, 1, 1, 2, 3, 2, 3, 1, 1, 2, 3, 2, 3, 0, 0, 0), (0, 0))
    STATE_SLEEPING = (
        14,
        (
            0,
            0,
            4,
            4,
            4,
            0,
            0,
            4,
            4,
            4,
            0,
            0,
            5,
            6,
            5,
            6,
            5,
            6,
            5,
            6,
            5,
            6,
            7,
            7,
            0,
            0,
            0,
        ),
        (0, 0),
    )

    # these states count as "moving"
    # used to alternate between moving and non-moving states
    MOVING_STATES = (
        STATE_MOVING_UP,
        STATE_MOVING_DOWN,
        STATE_MOVING_LEFT,
        STATE_MOVING_RIGHT,
        STATE_MOVING_UP_LEFT,
        STATE_MOVING_UP_RIGHT,
        STATE_MOVING_DOWN_LEFT,
        STATE_MOVING_DOWN_RIGHT,
    )

    # current state private field
    _CURRENT_STATE = STATE_SITTING

    # list of sprite indexes for the currently running animation
    CURRENT_ANIMATION = _CURRENT_STATE[_ANIMATION_LIST]

    """
    Neko Animated Cat Sprite. Extends displayio.TileGrid manages changing the visible
    sprite image to animate Neko in it's various states. Also manages moving Neko's location
    by the step size in the direction Neko is facing.

    :param float animation_time: How long to wait in-between animation frames. Unit is seconds.
     default is 0.3 seconds
    :param tuple display_size: Tuple containing width and height of display.
     Defaults to values from board.DISPLAY. Used to determine when we are at the edge
     of the display, so we know to start scratching.
    """

    def __init__(self, animation_time=0.3, display_size=None):
        if not display_size:
            # if display_size was not passed, try to use defaults from board
            if "DISPLAY" in dir(board):
                self._display_size = (board.DISPLAY.width, board.DISPLAY.height)
            else:
                raise RuntimeError(
                    "Must pass display_size argument if not using built-in display."
                )
        else:
            # use the display_size that was passed in
            self._display_size = display_size

        self._moving_to = None
        # Load the sprite sheet bitmap and palette
        sprite_sheet, neko_palette = adafruit_imageload.load(
            "/neko_cat_spritesheet.bmp",
            bitmap=displayio.Bitmap,
            palette=displayio.Palette,
        )

        # make the first color transparent
        neko_palette.make_transparent(0)

        # Create a sprite tilegrid as self
        super().__init__(
            sprite_sheet,
            pixel_shader=neko_palette,
            width=1,
            height=1,
            tile_width=32,
            tile_height=32,
        )

        # default initial location is top left corner
        self.x = 0
        self.y = 0

        # set the animation time into a private field
        self._animation_time = animation_time

    def _advance_animation_index(self):
        """
        Helper function to increment the animation index, and wrap it back around to
        0 after it reaches the final animation in the list.
        :return: None
        """
        self.CURRENT_ANIMATION_INDEX += 1
        if self.CURRENT_ANIMATION_INDEX >= len(self.CURRENT_ANIMATION):
            self.CURRENT_ANIMATION_INDEX = 0

    @property
    def moving_to(self):
        """
        Tuple with x/y location we are moving towards or none if
        not moving to anywhere specific.

        :return Optional(tuple): moving_to
        """
        return self._moving_to

    @moving_to.setter
    def moving_to(self, new_moving_to):

        # if new values is not None
        if new_moving_to:
            # initially start with the new value that is passed in
            _clamped_x = new_moving_to[0]
            _clamped_y = new_moving_to[1]

            # if x location of new value is within 1/2 tile size of left edge of display
            if new_moving_to[0] < self.TILE_WIDTH // 2 + 1:
                # override x to 1/2 tile size away from the left edge of display
                _clamped_x = self.TILE_WIDTH // 2 + 1

            # if x location of new value is within 1/2 tile size of right edge of display
            if new_moving_to[0] > self._display_size[0] - self.TILE_WIDTH // 2 - 1:
                # override x to 1/2 tile size away from right edge of display
                _clamped_x = self._display_size[0] - self.TILE_WIDTH // 2 - 1

            # if y location of new value is within 1/2 tile size of top edge of display
            if new_moving_to[1] < self.TILE_HEIGHT // 2 + 1:
                # override y to 1/2 tile size away from top edge
                _clamped_y = self.TILE_HEIGHT // 2 + 1
            # if y location of new value is within 1/2 tile size of bottom edge of display
            if new_moving_to[1] > self._display_size[1] - self.TILE_HEIGHT // 2 - 1:
                # override y to 1/2 tile size away from bottom edge
                _clamped_y = self._display_size[1] - self.TILE_HEIGHT // 2 - 1

            # update the moving to target location
            self._moving_to = (_clamped_x, _clamped_y)
        else:
            # None means not moving to a target location
            self._moving_to = None

    @property
    def animation_time(self):
        """
        How long to wait in-between animation frames. Unit is seconds.

        :return: animation_time
        """
        return self._animation_time

    @animation_time.setter
    def animation_time(self, new_time):
        self._animation_time = new_time

    @property
    def current_state(self):
        """
        The current state object.
        Format: (ID, (Animation List), (Step Sizes))

        :return tuple: current state object
        """
        return self._CURRENT_STATE

    @current_state.setter
    def current_state(self, new_state):
        # only change if we aren't already in the new_state
        if self.current_state != new_state:
            # update the current state object
            self._CURRENT_STATE = new_state
            # update the current animation list
            self.CURRENT_ANIMATION = new_state[self._ANIMATION_LIST]
            # reset current animation index to 0
            self.CURRENT_ANIMATION_INDEX = 0
            # show the first sprite in the animation
            self[0] = self.CURRENT_ANIMATION[self.CURRENT_ANIMATION_INDEX]
            # update the last state change time
            self.LAST_STATE_CHANGE_TIME = time.monotonic()

    def animate(self):
        """
        If enough time has passed since the previous animation then
        execute the next animation step by changing the currently visible sprite and
        advancing the animation index.

        :return bool: True if an animation frame occurred. False if it's not time yet
         for an animation frame.
        """
        _now = time.monotonic()
        # is it time to do an animation step?
        if _now > self.LAST_ANIMATION_TIME + self.animation_time:
            # update the visible sprite
            self[0] = self.CURRENT_ANIMATION[self.CURRENT_ANIMATION_INDEX]
            # advance the animation index
            self._advance_animation_index()
            # update the last animation time
            self.LAST_ANIMATION_TIME = _now
            return True

        # Not time for animation step yet
        return False

    @property
    def is_moving(self):
        """
        Is Neko currently moving or not.

        :return bool: True if Neko is in a moving state. False otherwise.
        """
        return self.current_state in self.MOVING_STATES

    @property
    def center_point(self):
        """
        Current x/y coordinates Neko is centered on.

        :return tuple: x/y location of Neko's current center point:
        """
        return (self.x + self.TILE_WIDTH // 2, self.y + self.TILE_HEIGHT // 2)

    def update(self):
        # pylint: disable=too-many-branches,too-many-statements
        """
        Do the Following:
         - Attempt to do animation step.
         - Take a step if in a moving state.
         - Change states if needed.

        :return: None
        """
        _now = time.monotonic()

        # if neko is moving to a specific location (i.e. user touched a spot)
        if self.moving_to:

            # if the x of the target location is between the left and right edges of Neko
            if self.x < self.moving_to[0] < self.x + self.TILE_WIDTH:
                # if the y of the target location is between top and bottom edges of Neko
                if self.y < self.moving_to[1] < self.y + self.TILE_HEIGHT:
                    # change to either sleeping or cleaning states
                    self.current_state = random.choice(
                        (self.STATE_CLEANING, self.STATE_SLEEPING)
                    )
                    # clear the moving to target location
                    self.moving_to = None

            # if neko is moving to a specific location (i.e. user touched a spot)
            if self.moving_to:
                # if the target location is right of Neko
                if (
                    self.moving_to[0]
                    > self.center_point[0] + self.CONFIG_STEP_SIZE // 2
                ):
                    # if the target location is below Neko
                    if (
                        self.moving_to[1]
                        > self.center_point[1] + self.CONFIG_STEP_SIZE // 2
                    ):
                        # move down and to the right
                        self.current_state = self.STATE_MOVING_DOWN_RIGHT

                    # if the target location is above Neko
                    elif (
                        self.moving_to[1]
                        < self.center_point[1] - self.CONFIG_STEP_SIZE // 2
                    ):
                        # move up and to the right
                        self.current_state = self.STATE_MOVING_UP_RIGHT

                    # same Y position
                    else:
                        # move to the right
                        self.current_state = self.STATE_MOVING_RIGHT

                # if the target location is left of Neko
                elif (
                    self.moving_to[0]
                    < self.center_point[0] - self.CONFIG_STEP_SIZE // 2
                ):
                    # if the target location is below Neko
                    if (
                        self.moving_to[1]
                        > self.center_point[1] + self.CONFIG_STEP_SIZE // 2
                    ):
                        # move down and to the left
                        self.current_state = self.STATE_MOVING_DOWN_LEFT
                    # if the target location is above Neko
                    elif (
                        self.moving_to[1]
                        < self.center_point[1] - self.CONFIG_STEP_SIZE // 2
                    ):
                        # move up and to the left
                        self.current_state = self.STATE_MOVING_UP_LEFT

                    # same Y position
                    else:
                        # move to the left
                        self.current_state = self.STATE_MOVING_LEFT

                # same X position
                else:
                    # if the target location is below Neko
                    if (
                        self.moving_to[1]
                        > self.center_point[1] + self.CONFIG_STEP_SIZE // 2
                    ):
                        # move downwards
                        self.current_state = self.STATE_MOVING_DOWN
                    # if the target location is above Neko
                    elif (
                        self.moving_to[1]
                        < self.center_point[1] - self.CONFIG_STEP_SIZE // 2
                    ):
                        # move upwards
                        self.current_state = self.STATE_MOVING_UP

        # attempt animation
        did_animate = self.animate()

        # if we did do an animation step
        if did_animate:
            # if Neko is in a moving state
            if self.is_moving:
                # random chance to start sleeping or cleaning
                _roll = random.randint(0, self.CONFIG_STOP_CHANCE_FACTOR - 1)
                if _roll == 0:
                    # change to new state: sleeping or cleaning
                    _chosen_state = random.choice(
                        (self.STATE_CLEANING, self.STATE_SLEEPING)
                    )
                    self.current_state = _chosen_state
            else:  # cat is not moving

                # if we are currently in a scratching state
                if len(self.current_state[self._ANIMATION_LIST]) <= 2:

                    # check if we have scratched the minimum time
                    if (
                        _now
                        >= self.LAST_STATE_CHANGE_TIME + self.CONFIG_MIN_SCRATCH_TIME
                    ):
                        # minimum scratch time has elapsed

                        # random chance to start moving
                        _roll = random.randint(0, self.CONFIG_START_CHANCE_FACTOR - 1)
                        if _roll == 0:
                            # start moving in a random direction
                            _chosen_state = random.choice(self.MOVING_STATES)
                            self.current_state = _chosen_state

                else:  # if we are sleeping or cleaning

                    # if we have done every step of the animation
                    if self.CURRENT_ANIMATION_INDEX == 0:
                        # change to a random moving state
                        _chosen_state = random.choice(self.MOVING_STATES)
                        self.current_state = _chosen_state

            # If we are far enough away from side walls
            # to take a step in the current moving direction
            if (
                0
                <= (self.x + self.current_state[self._MOVEMENT_STEP][0])
                < (self._display_size[0] - self.TILE_WIDTH)
            ):

                # move the cat horizontally by current state step size x
                self.x += self.current_state[self._MOVEMENT_STEP][0]

            else:  # we ran into a side wall
                if self.x > self.CONFIG_STEP_SIZE:
                    # ran into right wall
                    self.x = self._display_size[0] - self.TILE_WIDTH - 1
                    # change state to scratching right
                    self.current_state = self.STATE_SCRATCHING_RIGHT
                else:
                    # ran into left wall
                    self.x = 1
                    # change state to scratching left
                    self.current_state = self.STATE_SCRATCHING_LEFT

            # If we are far enough away from top and bottom walls
            # to step in the current moving direction
            if (
                0
                <= (self.y + self.current_state[self._MOVEMENT_STEP][1])
                < (self._display_size[1] - self.TILE_HEIGHT)
            ):

                # move the cat vertically by current state step size y
                self.y += self.current_state[self._MOVEMENT_STEP][1]

            else:  # ran into top or bottom wall
                if self.y > self.CONFIG_STEP_SIZE:
                    # ran into bottom wall
                    self.y = self._display_size[1] - self.TILE_HEIGHT - 1
                    # change state to scratching down
                    self.current_state = self.STATE_SCRATCHING_DOWN
                else:
                    # ran into top wall
                    self.y = 1
                    # change state to scratching up
                    self.current_state = self.STATE_SCRATCHING_UP


# variable to store the timestamp of previous touch event
LAST_TOUCH_TIME = -1

if USE_TOUCH_OVERLAY:
    # initialize touch overlay
    ts = adafruit_touchscreen.Touchscreen(
        board.TOUCH_XL,
        board.TOUCH_XR,
        board.TOUCH_YD,
        board.TOUCH_YU,
        calibration=((5200, 59000), (5800, 57000)),
        size=(320, 240),
    )

# default to built-in display
display = board.DISPLAY

# create displayio Group
main_group = displayio.Group()

# create background group, seperate from main_group so that
# it can be scaled, which saves RAM.
background_group = displayio.Group(scale=16)

# create bitmap to hold solid color background
background_bitmap = displayio.Bitmap(20, 15, 1)

# create background palette
background_palette = displayio.Palette(1)

# set the background color into the palette
background_palette[0] = BACKGROUND_COLOR

# create a tilegrid to show the background bitmap
background_tilegrid = displayio.TileGrid(
    background_bitmap, pixel_shader=background_palette
)

# append the tilegrid to the group.
background_group.append(background_tilegrid)

# add background_group to main_group
main_group.append(background_group)

# create Neko
neko = NekoAnimatedSprite(animation_time=ANIMATION_TIME)

# put Neko in center of display
neko.x = display.width // 2 - neko.TILE_WIDTH // 2
neko.y = display.height // 2 - neko.TILE_HEIGHT // 2

# add neko to main_group
main_group.append(neko)

# show main_group on the display
display.show(main_group)

if USE_TOUCH_OVERLAY:
    # initialize laser palette
    laser_dot_palette = displayio.Palette(1)
    # set the hex color code for the laser dot
    laser_dot_palette[0] = LASER_DOT_COLOR

    # create a circle to be the laser dot
    circle = vectorio.Circle(
        pixel_shader=laser_dot_palette,
        radius=3,
        x=-10,  # negative values so it starts off the edge of the display
        y=-10,  # won't get shown until the location moves onto the display
    )

    # add it to the main_group so it gets shown on the display when ready
    main_group.append(circle)

while True:
    # update Neko to do animations and movements
    neko.update()

    if USE_TOUCH_OVERLAY:

        # if Neko is not moving to a location
        if not neko.moving_to:
            # hide the laser dot circle by moving it off of the display
            circle.x = -10
            circle.y = -10

        _now = time.monotonic()

        # if the touch cooldown has elapsed since previous touch event
        if _now > LAST_TOUCH_TIME + TOUCH_COOLDOWN:

            # read current touch data from overlay
            touch_location = ts.touch_point

            # if anything is being touched
            if touch_location:
                # update the timestamp for cooldown enforcement
                LAST_TOUCH_TIME = _now

                # move the laser dot circle to the x/y coordinates being touched
                circle.x = touch_location[0]
                circle.y = touch_location[1]

                # print("placing laser dot at: {}".format(touch_location))

                # tell Neko to move to the x/y coordinates being touched.
                neko.moving_to = (touch_location[0], touch_location[1])

This guide was first published on Feb 01, 2022. It was last updated on 2022-02-01 11:49:56 -0500.

This page (Code Walk-Through) was last updated on May 15, 2022.

Text editor powered by tinymce.