# coding=utf8

# Copyright 2017 the pycolab Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

""""Prefabricated" `Drape`s with all kinds of useful behaviour."""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import numpy as np

from pycolab import ascii_art
from pycolab import things
from pycolab.protocols import scrolling


class Scrolly(things.Drape):
  """A base class for scrolling `Drape`s, which usually implement game scenery.

  *Note: This `Drape` subclass is mostly intended for games that implement
  scrolling by having their game entities participate in the scrolling protocol
  (see `protocols/scrolling.py`). If your game doesn't do any scrolling, or if
  it is a game with a finite map where scrolling is more easily accomplished
  using a `ScrollingCropper` (which just slides a moving window over the
  observations produced by a pycolab game, giving the illusion of scrolling),
  you probably don't need the added complication of using a `Scrolly`.*

  If your game shows a top-down view of a world that can "scroll" around a
  character as the character moves around inside of it (e.g. River Raid) then a
  `Scrolly` is a `Drape` that contains much of the functionality that you need
  to make the scrolling world. When used in tandem with `MazeWalker`-derived
  `Sprite`s, very little code is required to obtain the basic elements of this
  gameplay concept. The discussion in this documentation will therefore use that
  arrangement as an example to help describe how `Scrolly`s work, although other
  configurations are surely possible.

  ## Essential background: the scrolling protocol

  A `Scrolly` figures out whether and where to scroll with the help of messages
  in the game's `Plot` object (see `plot.py`) that are transacted according to
  the scrolling protocol (see `protocols/scrolling.py`).

  `Sprite`s and `Drape`s that participate in the protocol can be either of two
  types: "egocentric" and not egocentric. An egocentric entity is one that
  expects the world to scroll around them whilst they remain stationary relative
  to the game board (i.e. the "screen")---like the player-controlled airplane in
  River Raid. The other kind of entity should move whenever the rest of the
  world does---like River Raid's helicopters, boats, and so on.

  Egocentric entities are of particular concern to `Scrolly`s, since the game
  probably should not scroll the scenery in a way that corresponds to these
  entities making an "impossible" move. In a maze game, for example, a careless
  implementation could accidentally scroll the player into a wall! Fortunately,
  at each game iteration, all egocentric participants in the scrolling protocol
  declare which kinds of scrolling moves will be acceptable ones. As long as a
  `Scrolly` heeds these restrictions, it can avoid a catastrophe.

  ## What a `Scrolly` does

  **A `Scrolly` should be told to move (or to consider moving) in the same way
  as your game's egocentric `Sprite`s.** To help with this, `Scrolly`s have
  "motion action helper methods" that are just like `MazeWalker`s: `_north`,
  `_south`, `_east`, `_west` and so on. You call these in `update`, which you
  must implement.

  In the simplest case, a `Scrolly` will always scroll the world in the
  corresponding motion direction *provided that* all egocentric entities have
  said (via the scrolling protocol) that the motion is OK. If even one of them
  considers that motion unacceptable, no scrolling will occur!

  (Scrolling the world in a particular direction means moving the game board
  over the world in that direction. A scroll to the "north" will move the
  `Scrolly`'s scenery in the same way that "scrolling up" moves the text in your
  web browser.)

  `Scrolly` objects also support a less aggressive scrolling behaviour in which
  egocentric `Sprite`s trigger scrolling only when they get close to the edges
  of the game board.

  ## Update order, and who's in charge of scrolling

  **For best results, `Scrolly` instances in charge of making non-traversible
  game scenery should update before `Sprite`s do, in a separate update group.**

  `Scrolly`s generally expect to be in charge of scrolling, or at least they
  hope to have another `Scrolly` be in charge of it. The `Scrolly`'s "motion
  action helper method" will issue a scrolling order (or not; see above), and
  all of the other pycolab entities in the game will obey it. In order for them
  to even have the chance to obey, it's necessary for the `Scrolly` to issue the
  order before they are updated.

  If the egocentric `Sprite`s are `MazeWalker`s, then the `Scrolly` should
  perform its update in its own update group. This ensures that by the time the
  `MazeWalker`s see it, the world will appear as it does _after_ the scrolling
  takes place. This allows these `Sprite`s to compute and express (via the
  scrolling protocol) which scrolling motions will be "legal" in the future.

  It's fine to have more than one `Scrolly` in a pycolab game. Unless you are
  doing something strange, the first one will be the one to issue scrolling
  orders, and the rest will just follow along blindly.

  ## Loose interpration of "legal" scrolling motions

  *It's probably safe to skip this discussion if your 'Sprite's and 'Drape's
  will only ever move vertically or horizontally, never diagonally.*

  Per the discussion under the same heading in the docstring at
  `protocols/scrolling.py` (a recommended read!), a `Scrolly` can issue a
  scrolling order that was not expressly permitted by the other egocentric
  participants in the scrolling protocol. It can do this as long as it believes
  that at the end of the game iteration, none of those participants will have
  wound up in an illegal location, either due to them following the scrolling
  order by itself *or* due to moving in response to the agent action *after*
  following the scrolling order.

  Naturally, this requires the scrolling-order-issuing `Scrolly` to know how
  these other participating entities will behave. For this, it makes two
  assumptions:

  1. It interprets all of the scrolling motions permitted by the entities (i.e.
     the 2-tuples used as `motion` arguments by `scrolling` module functions)
     as actual motions that those entities could execute within the game world.
  2. It assumes that the way that it interprets agent actions is identical to
     the way that all the other participating egocentric entities interpret
     them. So, if this object's `update` method maps action `0` to the `_north`
     motion action helper method, which connotes an upward motion of one row,
     it assumes that all Sprites will do the same.

  Based on these assumptions, the `Scrolly` will predict where `Sprite`s will go
  in response to agent actions and will direct only the most pleasing (i.e.
  minimal) amount of scrolling required to keep the egocentric `Sprite`s within
  the margins. If there are no egocentric `Sprite`s at all, then all the
  `Sprite`s are by definition inside the margins, and no scrolling will ever
  occur. (If scrolling is still desired, it could be that the behaviour evinced
  by specifying `None` for the constructor's `scroll_margins` parameter will
  produce the intended behavior; refer to the constructor docstring.)
  """

  # Predefined single-step motions, for internal use only. Positive values in
  # the first field mean move downward; positive values in the second field
  # mean move rightward.
  _NORTH = (-1, 0)
  _NORTHEAST = (-1, 1)
  _EAST = (0, 1)
  _SOUTHEAST = (1, 1)
  _SOUTH = (1, 0)
  _SOUTHWEST = (1, -1)
  _WEST = (0, -1)
  _NORTHWEST = (-1, -1)
  # Not technically a single-step motion.
  _STAY = (0, 0)

  class PatternInfo(object):
    """A helper class that interprets ASCII art scrolling patterns.

    This class exists chiefly to make it easier to build constructor arguments
    for `Scrolly` classes from (potentially large) ASCII art diagrams.
    Additional convenience methods are present which may simplify other aspects
    of game setup.

    As with all the utilities in `ascii_art.py`, an ASCII art diagram is a
    list or tuple of equal-length strings. See docstrings in that file for
    example diagrams.
    """

    def __init__(self, whole_pattern_art, board_art_or_shape,
                 board_northwest_corner_mark, what_lies_beneath):
      """Construct a PatternInfo instance.

      Args:
        whole_pattern_art: an ASCII art diagram depicting a game world. This
            should be a list or tuple whose values are all strings containing
            the same number of ASCII characters.
        board_art_or_shape: either another ASCII art diagram depicting the
            game board itself, or a 2-tuple containing the shape of the board
            (the only thing this object cares about).
        board_northwest_corner_mark: an ASCII character whose sole appearance
            in the `whole_pattern_art` marks the intended initial scrolling
            position of the game board (specifically, its top-left corner) with
            respect to the game world diagram.
        what_lies_beneath: the ASCII character that should replace the
            `board_northwest_corner_mark` in the `whole_pattern_art` when using
            the art to create `Scrolly` constructor arguments.

      Raises:
        ValueError: `what_lies_beneath` was not an ASCII character, or any
            dimension of the game board is larger than the corresponding
            dimension of the game world depicted in `whole_pattern_art`.
        RuntimeError: the `whole_pattern_art` diagram does not contain exactly
            one of the `board_northwest_corner_mark` characters.
      """
      # Verify that what_lies_beneath is ASCII.
      if ord(what_lies_beneath) > 127: raise ValueError(
          'The what_lies_beneath value used to build a Scrolly.PatternInfo '
          'must be an ASCII character.')

      # Convert the pattern art into an array of character strings.
      self._whole_pattern_art = ascii_art.ascii_art_to_uint8_nparray(
          whole_pattern_art)
      self._board_northwest_corner = self._whole_pattern_position(
          board_northwest_corner_mark, 'the Scrolly.PatternInfo constructor')
      self._whole_pattern_art[self._board_northwest_corner] = (
          ord(what_lies_beneath))

      # Determine the shape of the game board. We try two ways---the first way
      # assumes that board_art_or_shape is the game board, the second assumes
      # that it is a 2-tuple with the board shape.
      try:
        self._board_shape = len(board_art_or_shape), len(board_art_or_shape[0])
      except TypeError:
        rows, cols = board_art_or_shape  # Enforce a 2-tuple.
        self._board_shape = (rows, cols)

      if (self._board_shape[0] > self._whole_pattern_art.shape[0] or
          self._board_shape[1] > self._whole_pattern_art.shape[1]):
        raise ValueError(
            'The whole_pattern_art value used to build a Scrolly.PatternInfo '
            '(size {}) cannot completely cover the game board (size '
            '{}).'.format(self._whole_pattern_art.shape, self._board_shape))

    def virtual_position(self, character):
      """Find board-relative position of a character in game world ASCII art.

      The location returned by this method is the "virtual position" of the
      character, that is, a location relative to the game board's (not the game
      world's) top left corner. In contrast to a "true position", a virtual
      position may exceed the bounds of the game board in any direction.

      Args:
        character: the character to search for inside the `whole_pattern_art`
            supplied to the constructor. There must be exactly one of these
            characters inside the game world art.

      Returns:
        A 2-tuple containing the row, column board-relative position of
        `character` in the game world art.

      Raises:
        RuntimeError: the `whole_pattern_art` diagram does not contain exactly
            one of the `board_northwest_corner_mark` characters.
      """
      pattern_position = self._whole_pattern_position(
          character, 'Scrolly.PatternInfo.virtual_position()')
      return (pattern_position[0] - self._board_northwest_corner[0],
              pattern_position[1] - self._board_northwest_corner[1])

    def kwargs(self, character):
      """Build some of the keyword arguments for the `Scrolly` constructor.

      Given a character whose pattern inside the game world ASCII art will be
      the scrollable pattern managed by a `Scrolly`, return a dict which can
      be **expanded into the arguments of the `Scrolly` constructor to provide
      values for its `board_shape`, `whole_pattern`, and
      `board_northwest_corner` arguments.

      Args:
        character: character to use to derive a binary mask from the game world
            ASCII art passed to the constructor. This mask will become the
            scrollable pattern managed by a `Scrolly` instance.

      Returns:
        a partial kwargs dictionary for the `Scrolly` constructor.
      """
      # some (not all) kwargs that you can pass on to Scrolly.__init__.
      return {'board_shape': self._board_shape,
              'whole_pattern': self._whole_pattern_art == ord(character),
              'board_northwest_corner': self._board_northwest_corner}

    def _whole_pattern_position(self, character, error_name):
      """Find the absolute location of `character` in game world ASCII art."""
      pos = list(np.argwhere(self._whole_pattern_art == ord(character)))
      if not pos: raise RuntimeError(
          '{} found no instances of {} in the pattern art used to build this '
          'PatternInfo object.'.format(error_name, repr(character)))
      if len(pos) > 1: raise RuntimeError(
          '{} found multiple instances of {} in the pattern art used to build '
          'this PatternInfo object.'.format(error_name, repr(character)))
      return tuple(pos[0])

  def __init__(self, curtain, character, board_shape,
               whole_pattern, board_northwest_corner,
               scroll_margins=(2, 3), scrolling_group=''):
    """Superclass constructor for `Scrolly`-derived classes.

    `Scrolly` does not define `Drape.update`, so this constructor will fail
    if you attempt to build a `Scrolly` on its own.

    Args:
      curtain: required by `Drape`.
      character: required by `Drape`.
      board_shape: 2-tuple containing the row, column dimensions of the game
          board.
      whole_pattern: a 2-D numpy array with dtype `bool_`, which will be "owned"
          by this `Drape` and made accessible at `self.whole_pattern`.
          Game-board-shaped regions of this array will be used to update this
          `Drape`'s curtain, depending on where the game board has been
          scrolled relative to the pattern.
      board_northwest_corner: a row, column 2-tuple specifying the initial
          scrolling position of the game board relative to the `whole_pattern`.
      scroll_margins: either None, which means that the `Scrolly` should
          scroll whenever all egocentric entities permit it (and as long as it
          hasn't run out of pattern in the scrolling direction), or a 2-tuple
          that controls the `Scrolly`'s "less aggressive" scrolling behaviour
          (see class docstring). In this latter case, the `Scrolly` will only
          attempt to scroll if an egocentric `Sprite` approaches within
          `scroll_margins[0]` rows of the top or bottom of the board, or within
          `scroll_margins[1]` columns of the left or right edges of the board.
          Note that in this case, if there are no egocentric `Sprite`s, no
          scrolling will ever occur!
      scrolling_group: the scrolling group that this `Scrolly` should
          participate in, if not the default (`''`).

    Raises:
      ValueError: any dimension of `board_shape` is larger than the
          corresponding dimension of `whole_pattern`, or either of the margins
          specified in `scroll_margins` so large that it overlaps more than
          half of the game board.
    """
    super(Scrolly, self).__init__(curtain, character)

    # Local copies of certain arguments.
    self._board_shape = board_shape
    self._northwest_corner = board_northwest_corner
    self._scrolling_group = scrolling_group
    # We own this pattern now, and nobody should change our reference to it.
    self._w_h_o_l_e_p_a_t_t_e_r_n = whole_pattern

    # Top-left corner of the board must never exceed these limits.
    self._northwest_corner_limit = (whole_pattern.shape[0] - board_shape[0],
                                    whole_pattern.shape[1] - board_shape[1])
    if any(lim < 0 for lim in self._northwest_corner_limit):
      raise ValueError(
          'The whole_pattern provided to the `Scrolly` constructor (size {}) '
          'cannot completely cover the game board (size {}).'.format(
              whole_pattern.shape, board_shape))

    # If the user has supplied scrolling margins, figure out where they are.
    self._have_margins = scroll_margins is not None
    if self._have_margins:
      # If a visible, egocentric Sprite will move into or beyond any of these
      # bounds, then the Scrolly should scroll those bounds out of the way.
      self._margin_north = scroll_margins[0] - 1
      self._margin_south = board_shape[0] - scroll_margins[0]
      self._margin_west = scroll_margins[1] - 1
      self._margin_east = board_shape[1] - scroll_margins[1]
      if (self._margin_west >= self._margin_east or
          self._margin_north >= self._margin_south):
        raise ValueError(
            'The scrolling margins provided to the `Scrolly` constructor, {}, '
            'are so large that a margin would overlap more than half of the '
            'board.'.format(scroll_margins))

    # Initialise the curtain with the portion of the pattern visible on the
    # game board.
    self._update_curtain()

    # Keep track of the last frame index where which we considered executing a
    # scrolling motion. The pattern_position_* methods use this information to
    # provide consistent information before and after scrolling.
    self._last_maybe_move_frame = -float('inf')
    # Also for the pattern_position_* methods, we save the location of the game
    # board prior to any game iteration's scrolling motion.
    self._prescroll_northwest_corner = self._northwest_corner

  def pattern_position_prescroll(self, virtual_position, the_plot):
    """Get "pattern coordinates" of a pre-scrolling `virtual_position`.

    Most `Sprite`s and other game entities reason about "screen location" in
    game-board-relative coordinates, but some may also need to know their
    "absolute" location---their position with respect to the game scenery. For
    scrolling games that use `Scrolly`s to implement a moving game world, the
    `pattern_position_*` methods provide a way to translate a "virtual position"
    (which is just a game-board-relative position that is allowed to extend
    beyond the game board) to an "absolute" position: to coordinates relative to
    the scrolling pattern managed by this `Scrolly`.

    As the game board's scrolling location can change during a game iteration,
    callers of these methods have to be specific about whether the virtual
    position that they want to translate is a virtual position from before the
    game board moved in the world (i.e. before scrolling) or after. For the
    former, use this method; for the latter, use `pattern_position_postscroll`.

    Args:
      virtual_position: virtual position (as a row, column 2-tuple) to translate
          into (pre-scroll) coordinates relative to this `Scrolly`'s pattern.
      the_plot: this pycolab game's `Plot` object.

    Returns:
      A row, column 2-tuple containing the (pre-scroll) pattern-relative
      translation of `virtual_position` into "absolute" coordinates.
    """
    # This if statement replicates logic from _maybe_move, since this method
    # could be called before _maybe_move does.
    if self._last_maybe_move_frame < the_plot.frame:
      self._prescroll_northwest_corner = self._northwest_corner
    return things.Sprite.Position(
        row=virtual_position[0] + self._prescroll_northwest_corner[0],
        col=virtual_position[1] + self._prescroll_northwest_corner[1])

  def pattern_position_postscroll(self, virtual_position, the_plot):
    """Get "pattern coordinates" of a post-scrolling `virtual_position`.

    The discussion from `pattern_position_prescroll` applies here as well,
    except this method translates `virtual_position` into coordinates relative
    to the pattern after any scrolling has occurred.

    Args:
      virtual_position: virtual position (as a row, column 2-tuple) to translate
          into (post-scroll) coordinates relative to this `Scrolly`'s pattern.
      the_plot: this pycolab game's `Plot` object.

    Returns:
      A row, column 2-tuple containing the (post-scroll) pattern-relative
      translation of `virtual_position` into "absolute" coordinates.

    Raises:
      RuntimeError: this `Scrolly` has not had any of its motion action helper
          methods called in this game iteration, so it hasn't had a chance to
          decide whether and where to scroll yet.
    """
    if self._last_maybe_move_frame < the_plot.frame: raise RuntimeError(
        'The pattern_position_postscroll method was called on a Scrolly '
        'instance before that instance had a chance to decide whether or where '
        'it would scroll.')
    return things.Sprite.Position(
        row=virtual_position[0] + self._northwest_corner[0],
        col=virtual_position[1] + self._northwest_corner[1])

  @property
  def whole_pattern(self):
    """Retrieve the scrolling game world pattern managed by this `Scrolly`."""
    return self._w_h_o_l_e_p_a_t_t_e_r_n

  ### Protected helpers (final, do not override) ###

  def _northwest(self, the_plot):
    """Scroll one row upward and one column leftward, if necessary."""
    return self._maybe_move(the_plot, self._NORTHWEST)

  def _north(self, the_plot):
    """Scroll one row upward, if necessary."""
    return self._maybe_move(the_plot, self._NORTH)

  def _northeast(self, the_plot):
    """Scroll one row upward and one column rightward, if necessary."""
    return self._maybe_move(the_plot, self._NORTHEAST)

  def _east(self, the_plot):
    """Scroll one column rightward, if necessary."""
    return self._maybe_move(the_plot, self._EAST)

  def _southeast(self, the_plot):
    """Scroll one row downward and one column rightward, if necessary."""
    return self._maybe_move(the_plot, self._SOUTHEAST)

  def _south(self, the_plot):
    """Scroll one row downward, if necessary."""
    return self._maybe_move(the_plot, self._SOUTH)

  def _southwest(self, the_plot):
    """Scroll one row downward and one column leftward, if necessary."""
    return self._maybe_move(the_plot, self._SOUTHWEST)

  def _west(self, the_plot):
    """Scroll one column leftward, if necessary."""
    return self._maybe_move(the_plot, self._WEST)

  def _stay(self, the_plot):
    """Remain in place, but apply any other scrolling that may have happened."""
    return self._maybe_move(the_plot, self._STAY)

  ### Private helpers (do not call; final, do not override) ###

  def _maybe_move(self, the_plot, motion):
    """Handle all aspects of single-row and/or single-column scrolling.

    Implements every aspect of deciding whether to scroll one step in any of the
    nine possible gridworld directions (includes staying put). This amounts to:

    1. Checking for scrolling orders from other entities (see
       `protocols/scrolling.py`), and, if present, applying them
       indiscriminately and returning.
    2. Determining whether this `Scrolly` should scroll (e.g. one of the
       sprites is encroaching on the board margins). If not, returning.
    3. Determining whether this `Scrolly` can scroll---that is, it's not
       constrained by egocentric entities, it wouldn't wind up scrolling the
       board off of the pattern, and so on. If not, returning.
    4. Issuing a scrolling order for the scroll, and updating the curtain from
       the pattern.

    Args:
      the_plot: this pycolab game's `Plot` object.
      motion: a 2-tuple indicating the number of rows and columns that the
          game board should move over the pattern if scrolling is both
          appropriate and possible. See class docstring for more details.

    Raises:
      scrolling.Error: another game entity has issued a scrolling order which
          does not have any component in common with `motion`.
    """
    # Save our last board location for pattern_position_prescroll.
    if self._last_maybe_move_frame < the_plot.frame:
      self._last_maybe_move_frame = the_plot.frame
      self._prescroll_northwest_corner = self._northwest_corner

    # First, was a scrolling order already issued by some other entity in this
    # scrolling group? If so, verify that it was the same motion as `motion` in
    # at least one dimension; if it was, apply it without doing any other
    # checking. Otherwise, die.
    scrolling_order = scrolling.get_order(self, the_plot, self._scrolling_group)
    if scrolling_order:
      if motion[0] != scrolling_order[0] and motion[1] != scrolling_order[1]:
        raise scrolling.Error(
            'The Scrolly corresponding to character {} received a fresh '
            'scrolling order, {}, which has no component in common with the'
            'current action-selected motion, which is {}.'.format(
                self.character, scrolling_order, motion))
      self._northwest_corner = things.Sprite.Position(
          row=scrolling_order[0] + self._northwest_corner[0],
          col=scrolling_order[1] + self._northwest_corner[1])
      self._update_curtain()
      return

    # Short-circuit: nothing to do here if instructions say "stay put". But just
    # in case the whole pattern itself has been changed, we update the curtain.
    if motion == self._STAY:
      self._update_curtain()
      return

    # If here, the decision to scroll is ours! The rest of this (long) method
    # is divided into handling the two cases we need to consider:

    #############
    # Case 1: The user made scrolling mandatory (i.e. whenever possible).
    #############

    if not self._have_margins:
      # The main complication in this case has to do with circumstances where
      # only one component of the scrolling motions is possible. The user made
      # scrolling *mandatory*, so we want to scroll as much as we can.

      # The first thing we do is check for the legality of the motion itself.
      # Any scrolling order we issue is issued to accommodate the motion that
      # we expect egocentric entities to take. If they won't all do that motion,
      # there's no good reasson to accommodate it.
      if scrolling.is_possible(self, the_plot, motion, self._scrolling_group):
        # The motion is legal, so now we determine where on the pattern the
        # motion would move the northwest corner of the game board. From this,
        # determine whether and which components of the motion would scroll the
        # game board off of the pattern.
        possible_board_edge_north = self._northwest_corner[0] + motion[0]
        possible_board_edge_west = self._northwest_corner[1] + motion[1]
        can_scroll_vertically = (
            0 <= possible_board_edge_north <= self._northwest_corner_limit[0])
        can_scroll_horizontally = (
            0 <= possible_board_edge_west <= self._northwest_corner_limit[1])

        # The scrolling order that we'll issue and execute will only contain
        # the components of the motion that will *not* scroll the game board
        # off of the pattern. This may mean that we issue a scrolling order that
        # was not expressly by other egocentric game entities. See the "loose
        # interpretation of 'legal' scrolling motions" discussion in the class
        # docstring and elsewhere.
        scrolling_order = (motion[0] if can_scroll_vertically else 0,
                           motion[1] if can_scroll_horizontally else 0)
        self._northwest_corner = things.Sprite.Position(
            row=scrolling_order[0] + self._northwest_corner[0],
            col=scrolling_order[1] + self._northwest_corner[1])
        scrolling.order(self, the_plot, scrolling_order, self._scrolling_group,
                        check_possible=False)

      # Whether we've scrolled or not, update the curtain just in case the whole
      # pattern itself has been changed.
      self._update_curtain()
      return

    #############
    # Case 2: We'll only consider scrolling if one of the visible egocentric
    # sprites will move from the centre region of the board into the margins.
    #############

    action_demands_vertical_scrolling = False
    action_demands_horizontal_scrolling = False

    egocentric_participants = scrolling.egocentric_participants(
        self, the_plot, self._scrolling_group)
    for entity in egocentric_participants:
      # Short-circuit if we already know we're scrolling both ways.
      if (action_demands_vertical_scrolling and
          action_demands_horizontal_scrolling): break

      # See if this entity adds to the axes along which we should scroll because
      # it threatens to enter or move more deeply into a margin. Here we assume
      # that our motion argument is also a motion that this egocentric game
      # entity expects it should attempt to make, relative to the whole world
      # scenery. (This may mean no motion relative to the game board itself,
      # because it's an egocentric entity, after all.)
      if not isinstance(entity, things.Sprite): continue
      burrowing_vertical, burrowing_horizontal = (
          self._sprite_burrows_into_a_margin(entity, motion))
      action_demands_vertical_scrolling |= burrowing_vertical
      action_demands_horizontal_scrolling |= burrowing_horizontal

    # If we don't need to scroll, then we won't do it, and we can stop right
    # here! But just in case the whole pattern itself has been changed, we
    # update the curtain first.
    if not (action_demands_vertical_scrolling or
            action_demands_horizontal_scrolling):
      self._update_curtain()
      return

    # We know we should scroll, now to see what we'd actually do and where we'd
    # wind up (i.e. where the northwest corner of the board would lie on the
    # whole pattern) if we did it. Note here that we might be concocting a
    # scrolling order that may not have been expressly permitted by other
    # egocentric game entities. See the "loose interpretation of 'legal'
    # scrolling motions" discussion in the class docstring and elsewhere.
    scrolling_order = (motion[0] if action_demands_vertical_scrolling else 0,
                       motion[1] if action_demands_horizontal_scrolling else 0)
    possible_northwest_corner = things.Sprite.Position(
        row=scrolling_order[0] + self._northwest_corner[0],
        col=scrolling_order[1] + self._northwest_corner[1])

    # We know we should scroll, now to see whether we can. If we can, do it,
    # and order all other participants in this scrolling group to do it as well.
    we_can_actually_scroll = (
        0 <= possible_northwest_corner[0] <= self._northwest_corner_limit[0])
    we_can_actually_scroll &= (
        0 <= possible_northwest_corner[1] <= self._northwest_corner_limit[1])
    # Note how this test checks for the legality of the *motion*, not the
    # scrolling order itself. This check also lies at the heart of the "loose
    # interpretation of 'legal' scrolling motions" described in the class
    # docstring and elsewhere. The scrolling order we derived just above is
    # meant to accommodate this motion on the part of all of the egocentric
    # entities, but if the motion itself is illegal for them, we won't scroll
    # anywhere at all.
    we_can_actually_scroll &= (
        scrolling.is_possible(self, the_plot, motion, self._scrolling_group))
    if we_can_actually_scroll:
      self._northwest_corner = possible_northwest_corner
      scrolling.order(self, the_plot, scrolling_order, self._scrolling_group,
                      check_possible=False)

    # Whether we've scrolled or not, update the curtain just in case the whole
    # pattern itself has been changed.
    self._update_curtain()

  def _sprite_burrows_into_a_margin(self, sprite, motion):
    """Would `motion` would move `sprite` (deeper) into either margin?

    Args:
      sprite: a `Sprite` instance present in this pycolab game.
      motion: a 2-tuple indicating the number of rows and columns that the
          sprite should add to its current position.

    Returns:
      a 2-tuple whose members are:
      - True iff `sprite` would enter or move deeper into the left or right
        margin.
      - True iff `sprite` would enter or move deeper into the top or bottom
        margin.
    """
    sprite_old_row, sprite_old_col = sprite.position
    sprite_new_row = sprite_old_row + motion[0]
    sprite_new_col = sprite_old_col + motion[1]
    return (
        ((sprite_old_row > sprite_new_row) and  # Moving north into a margin, or
         (sprite_new_row <= self._margin_north)) or
        ((sprite_old_row < sprite_new_row) and  # ...moving south into a margin?
         (sprite_new_row >= self._margin_south)),
        ((sprite_old_col > sprite_new_col) and  # Moving west into a margin, or
         (sprite_new_col <= self._margin_west)) or
        ((sprite_old_col < sprite_new_col) and  # ...moving east into a margin?
         (sprite_new_col >= self._margin_east)))

  def _update_curtain(self):
    """Update this `Scrolly`'s curtain by copying data from the pattern."""
    rows = slice(self._northwest_corner[0],
                 self._northwest_corner[0] + self._board_shape[0])
    cols = slice(self._northwest_corner[1],
                 self._northwest_corner[1] + self._board_shape[1])
    np.copyto(self.curtain, self.whole_pattern[rows, cols])