Example #1
0
File: puzzle.py Project: ikn/brjaf
    def load (self, defn, **tiler_kw_args):
        """Initialise puzzle from a definition.

Returns whether the puzzle was resized (may leave areas outside the puzzle
dirty).  Preserves any selection, if possible.

"""
        self._draw_cbs = {}
        lines = defn.split('\n')
        # dimensions in first line
        first = self._next_ints(lines)
        try:
            w, h = first
        except ValueError:
            # also got default surface
            w, h, default_s = first
        else:
            default_s = conf.DEFAULT_SURFACE
        if hasattr(self, 'tiler'):
            # already initialised: need to resize first
            horiz = self.resize(w - self.w, 2) is not False
            vert = self.resize(h - self.h, 3) is not False
            resized = horiz or vert
        else:
            self.w = w
            self.h = h
            self.size = (self.w, self.h)
        self.default_s = default_s
        # extract blocks from definition
        bs = []
        line = self._next_ints(lines)
        while line:
            type_ID, i, j = line
            bs.append((type_ID, (i, j), randrange(4)))
            line = self._next_ints(lines)
        self._init_blocks = bs
        self.blocks = []
        # extract non-default surface types from definition
        ss = []
        line = self._next_ints(lines)
        while line:
            ss.append(line)
            line = self._next_ints(lines)
        self._init_surfaces = ss
        # create grid handler if need to
        if hasattr(self, 'tiler'):
            self.tiler.reset()
        else:
            for key, attr in (('line', 'PUZZLE_LINE_COLOUR'),
                              ('gap', 'PUZZLE_LINE_WIDTH'),
                              ('border', 'PUZZLE_BORDER_WIDTH')):
                if key not in tiler_kw_args:
                    tiler_kw_args[key] = getattr(conf, attr)[conf.THEME]
            self.tiler = Tiler(w, h, self.draw_tile, track_tiles = False,
                               **tiler_kw_args)
            resized = False
        # create grid with default surface
        self.grid = []
        for i in xrange(self.w):
            col = []
            for j in xrange(self.h):
                col.append([self.default_s, None, False])
            self.grid.append(col)
        # preserve selection
        sel = self.selected
        self.reset()
        for pos, colour in sel.iteritems():
            try:
                self.select(pos, colour)
            except IndexError:
                # now out of range
                pass
        return resized
Example #2
0
File: puzzle.py Project: ikn/brjaf
class Puzzle (object):
    def __init__ (self, game, defn, physics = False, sound = False,
                  **tiler_kw_args):
        self.game = game
        self.physics = physics
        self.sound = sound
        self.selected = {}
        self.rect = None
        self.load(defn, **tiler_kw_args)

    def _next_ints (self, lines):
        try:
            line = lines.pop(0).strip()
        except IndexError:
            return None
        if line and line[0] == '#':
            # comment
            return self._next_ints(lines)
        else:
            try:
                return [int(c) for c in line.split(' ') if c]
            except ValueError:
                return False

    def reset (self, *tiles):
        if tiles:
            # reset given tiles
            tiles = [(x, y) for x, y in tiles]
        else:
            # reset all tiles
            tiles = [[(i, j) for i in xrange(self.w)] for j in xrange(self.h)]
            tiles = sum(tiles, [])
        # clear tiles we want to change
        for x, y in tiles:
            self.rm_block(None, x, y)
            self.set_surface(x, y)
        # add initial blocks and surfaces back
        cls = Block if self.physics else BoringBlock
        for type_ID, (x, y), dirn in self._init_blocks:
            if (x, y) in tiles:
                self.add_block((cls, type_ID, dirn), x, y)
        for type_ID, x, y in self._init_surfaces:
            if (x, y) in tiles:
                self.set_surface(x, y, type_ID)

    def load (self, defn, **tiler_kw_args):
        """Initialise puzzle from a definition.

Returns whether the puzzle was resized (may leave areas outside the puzzle
dirty).  Preserves any selection, if possible.

"""
        self._draw_cbs = {}
        lines = defn.split('\n')
        # dimensions in first line
        first = self._next_ints(lines)
        try:
            w, h = first
        except ValueError:
            # also got default surface
            w, h, default_s = first
        else:
            default_s = conf.DEFAULT_SURFACE
        if hasattr(self, 'tiler'):
            # already initialised: need to resize first
            horiz = self.resize(w - self.w, 2) is not False
            vert = self.resize(h - self.h, 3) is not False
            resized = horiz or vert
        else:
            self.w = w
            self.h = h
            self.size = (self.w, self.h)
        self.default_s = default_s
        # extract blocks from definition
        bs = []
        line = self._next_ints(lines)
        while line:
            type_ID, i, j = line
            bs.append((type_ID, (i, j), randrange(4)))
            line = self._next_ints(lines)
        self._init_blocks = bs
        self.blocks = []
        # extract non-default surface types from definition
        ss = []
        line = self._next_ints(lines)
        while line:
            ss.append(line)
            line = self._next_ints(lines)
        self._init_surfaces = ss
        # create grid handler if need to
        if hasattr(self, 'tiler'):
            self.tiler.reset()
        else:
            for key, attr in (('line', 'PUZZLE_LINE_COLOUR'),
                              ('gap', 'PUZZLE_LINE_WIDTH'),
                              ('border', 'PUZZLE_BORDER_WIDTH')):
                if key not in tiler_kw_args:
                    tiler_kw_args[key] = getattr(conf, attr)[conf.THEME]
            self.tiler = Tiler(w, h, self.draw_tile, track_tiles = False,
                               **tiler_kw_args)
            resized = False
        # create grid with default surface
        self.grid = []
        for i in xrange(self.w):
            col = []
            for j in xrange(self.h):
                col.append([self.default_s, None, False])
            self.grid.append(col)
        # preserve selection
        sel = self.selected
        self.reset()
        for pos, colour in sel.iteritems():
            try:
                self.select(pos, colour)
            except IndexError:
                # now out of range
                pass
        return resized

    def add_block (self, block, x, y):
        """Add a block, optionally creating it first.

add_block(block, x, y) -> new_block

block: either a BoringBlock instance or a (block_class, *args) tuple, where:
    block_class: the class to instantiate (BoringBlock or Block).
    args: arguments to pass to the constructor, excluding puzzle and pos.
x, y: tile to place the block on.  If given a Block instance, its pos attribute
      gets set to this value.

new_block: the added block.

"""
        pos = [x, y]
        if isinstance(block, BoringBlock):
            block.pos = pos
        else:
            # create block first; got (class, *args) tuple
            cls, type_ID = block[:2]
            args = block[2:]
            block = cls(type_ID, self, pos, *args)
        # remove existing block, if any
        self.rm_block(None, x, y)
        # add new block
        self.grid[x][y][1] = block
        self.blocks.append(block)
        self.tiler.change((x, y))
        return block

    def rm_block (self, block = None, x = None, y = None):
        # remove a block
        if block is None:
            if None not in (x, y):
                block = self.grid[x][y][1]
            # else got nothing
        else:
            x, y = block.pos
        if block is not None:
            self.grid[x][y][1] = None
            self.blocks.remove(block)
            self.tiler.change((x, y))
            return block
        else:
            # passed nothing or tile has no block
            return None

    def mv_block (self, block, x, y):
        # move a block
        self.rm_block(block)
        self.add_block(block, x, y)

    def set_surface (self, x, y, surface = None):
        # set the surface at a tile
        if surface is None:
            surface = self.default_s
        old_s = self.grid[x][y][0]
        if old_s != surface:
            self.grid[x][y][0] = surface
            self.tiler.change((x, y))
            return old_s
        else:
            return None

    def select (self, pos, secondary_colour = False):
        """Select a tile.

select(pos, secondary_colour = False)

pos: (x, y) tile position.
secondary_colour: whether to use the secondary cursor colour
                  (conf.SECONDARY_SEL_COLOUR instead of conf.SEL_COLOUR for the
                  current theme).

"""
        pos = tuple(pos[:2])
        try:
            if self.selected[pos] in self.selected:
                return
        except KeyError:
            pass
        # either not selected or different colour
        x, y = pos
        # let out-of-bounds errors propagate
        self.grid[x][y][2] = True
        self.selected[pos] = secondary_colour
        self.tiler.change(pos)

    def deselect (self, *tiles):
        """Deselect the given tiles, if selected.

deselect(*tiles)

tiles: each an (x, y) position; if none are given, deselect every selected
       tile.

"""
        for pos in (tiles if tiles else self.selected.keys()):
            x, y = pos = tuple(pos[:2])
            try:
                del self.selected[pos]
                self.grid[x][y][2] = False
            except (KeyError, IndexError):
                # isn't selected or doesn't exist to deselect
                pass
            else:
                self.tiler.change(pos)

    def move_selected (self, direction, amount = 1):
        """Move all selections relative to the current position.

move_selected(direction, amount = 1)

direction: 0/1/2/3 for L/U/R/D.
amount: number of tiles to move.

If the destination tile is out-of-bounds, select the nearest in-bounds tile.

"""
        selected = self.selected.items()
        # deselect all
        self.deselect()
        # reselect one at a time
        for pos, colour in selected:
            pos = list(pos)
            axis = direction % 2
            pos[axis] += amount * (1 if direction > 1 else -1)
            pos[axis] %= self.size[axis]
            self.select(pos, colour)

    def _reset_tiler (self):
        self.tiler.reset()
        self.text_adjust = []

    def tile_size (self, axis):
        n_tiles = self.size[axis]
        border = self.tiler.border[axis]
        gap = self.tiler.gap[axis]
        tile_size = (self.rect.size[axis] - 2 * border - gap * (n_tiles - 1))
        return tile_size / n_tiles

    def resize (self, amount, direction):
        """Resize the puzzle.

resize(amount, direction) -> lost

amount: the number of tiles to resize by, negative to shrink, 1 to grow the
        puzzle.
direction: the direction the 'moved' edge should move in.

lost: list of blocks and surfaces lost because of removed tiles, each in the
      form (block_or_surface_ID, x, y).  If the resize could not be done
      amount is 0 or we would end up with 0 rows or columns), this is False
      instead.

"""
        if amount == 0:
            return False
        # resize one tile at a time
        # get new grid size
        axis = direction % 2
        sign = 1 if amount > 0 else -1
        size = list(self.size)
        size[axis] += sign
        if size[axis] == 0:
            # can't shrink
            return False
        # get amount to offset everything by
        offset = [0, 0]
        offset[axis] += (sign - (1 if direction > 1 else -1)) / 2
        self.size = (w, h) = size
        # resize tiler
        self.tiler.w = w
        self.tiler.h = h
        self._reset_tiler()
        # remove any blocks/surfaces in the lost region
        lost = []
        if sign == -1:
            x0, x1, y0, y1 = ((w, w + 1, 0, h), (0, w, h, h + 1), (0, 1, 0, h),
                              (0, w, 0, 1))[direction]
            for x in xrange(x0, x1):
                for y in xrange(y0, y1):
                    b = self.rm_block(None, x, y)
                    if b is not None:
                        lost.append((b, x, y))
                    s = self.set_surface(x, y)
                    if s is not None:
                        lost.append((s, x, y))
        # create new grid
        grid = []
        di, dj = offset
        for i in xrange(w):
            col = []
            i -= di
            for j in xrange(h):
                j -= dj
                if 0 <= i < self.w and 0 <= j < self.h:
                    col.append(self.grid[i][j])
                else:
                    # doesn't come from current grid
                    col.append([self.default_s, None, False])
            grid.append(col)
        self.grid = grid
        self.w, self.h = self.size
        for pos, colour in self.selected.items():
            orig_pos = pos
            # offset selected tiles
            pos = list(pos)
            pos[0] += di
            pos[1] += dj
            x, y = pos
            # push selection back onto the grid
            if not (0 <= x < self.w and 0 <= y < self.h):
                for axis in (0, 1):
                    limit = self.size[axis] - 1
                    pos[axis] = min(max(pos[axis], 0), limit)
            self.deselect(orig_pos)
            self.select(pos, colour)
        inner_lost = self.resize(amount - sign, direction)
        if inner_lost is not False:
            lost += inner_lost
        return lost

    def definition (self):
        """Return a definition string for the puzzle's current state."""
        # get blocks and surfaces from self.grid
        bs = []
        ss = []
        s_count = {}
        for i in xrange(len(self.grid)):
            col = self.grid[i]
            for j in xrange(len(col)):
                s, b, sel = col[j]
                if b is not None:
                    bs.append('{0} {1} {2}'.format(b.type, i, j))
                ss.append((s, '{0} {1} {2}'.format(s, i, j)))
                try:
                    s_count[s] += 1
                except KeyError:
                    s_count[s] = 1
        # get most common surface type to use as default
        # we only want one, so don't worry about types with the same freqency
        s_count = dict((v, k) for k, v in s_count.iteritems())
        common_s = s_count[max(s_count)]
        default = conf.DEFAULT_SURFACE
        # compile definition
        return '{0} {1}{2}{3}{4}\n\n{5}'.format(
            self.w, self.h, '' if common_s == default else ' ' + str(common_s),
            '\n' if bs else '',
            '\n'.join(bs),
            # don't need individual tiles for most common surface
            '\n'.join(data for s, data in ss if s != common_s)
        )

    def play_snd (self, ID):
        """Wrapper around Game.play_snd."""
        if self.sound:
            self.game.play_snd(ID)

    def step (self):
        if conf.DEBUG:
            print 'start step'
        # apply arrow forces
        for col in self.grid:
            for s, b, sel in col:
                if s in conf.S_ARROWS and b is not None and not is_immoveable(b):
                    b.add_force(conf.S_ARROWS.index(s), conf.FORCE_ARROW)

        # resolve forces into block destinations
        while 1:
            # handle contact forces
            while 1:
                unhandled = [b for b in self.blocks if not b.handled]
                if unhandled:
                    for b in unhandled:
                        b.update()
                else:
                    break

            # compile block destinations
            dest = {}
            for b in self.blocks:
                resultant = b.resultant()
                # get destination
                pos = b.pos[:]
                for axis, force in enumerate(resultant):
                    if force:
                        pos[axis] += 1 if force > 0 else -1
                if pos != b.pos:
                    # lists aren't hashable
                    pos = tuple(pos)
                    try:
                        dest[pos].append(b)
                    except KeyError:
                        dest[pos] = [b]
            if conf.DEBUG and dest:
                print dest

            # resolve conflicts
            rm = []
            for pos, bs in dest.iteritems():
                if len(bs) == 1:
                    dest[pos] = bs[0]
                else:
                    # check if highest force towards destination is unique
                    max_f = 0
                    for b in bs:
                        force = b.resultant()
                        force = abs(force[0]) + abs(force[1])
                        if force == max_f:
                            unique = False
                        elif force > max_f:
                            max_f = force
                            unique = b
                    if unique:
                        # move block with most force
                        dest[pos] = unique
                        bs.remove(unique)
                    else:
                        # don't move any blocks
                        rm.append(pos)
                    # reaction on all blocks that don't move
                    for b in bs:
                        diff = (b.pos[0] - pos[0], b.pos[1] - pos[1])
                        for axis in (0, 1):
                            if diff[axis]:
                                b.reaction(1 + axis + diff[axis])
            for pos in rm:
                del dest[pos]

            if not [b for b in self.blocks if not b.handled]:
                # done
                break

        if conf.DEBUG and dest:
            print dest

        # move blocks
        change = set()
        retain_forces = []
        if dest:
            self.play_snd('move')
        for pos, b in dest.iteritems():
            # remove
            change.add(tuple(b.pos))
            self.grid[b.pos[0]][b.pos[1]][1] = None
            slide = self.grid[pos[0]][pos[1]][0] == conf.S_SLIDE
            if b.type in (conf.B_SLIDE, conf.B_BOUNCE) or slide:
                retain_forces.append(b)
        for pos, b in dest.iteritems():
            # add
            change.add(pos)
            b.pos = list(pos)
            self.grid[pos[0]][pos[1]][1] = b
        self.tiler.change(*change)
        # reset forces
        for b in self.blocks:
            b.reset(b in retain_forces)
        if conf.DEBUG:
            print 'end step'
        return bool(change)

    def add_draw_cb (self, f, call_once = False, *tiles):
        for t in tiles:
            assert t not in self._draw_cbs
            self._draw_cbs[t] = (f, call_once)

    def _draw_from_img (self, surface, rect, prefix, ID, dirn = None):
        ID = prefix + str(ID)
        fn_base = conf.IMG_DIR + conf.THEME + path_sep + ID
        # if have a direction, look for specially rotated image before fallback
        suffixes = (None if dirn is None else ('-' + str(dirn)), '')
        got = False
        for fallback, suffix in enumerate(suffixes):
            if suffix is not None:
                fn = fn_base + suffix + '.png'
                if exists(fn):
                    got = True
                    if not fallback:
                        ID += suffix
                        # this is already rotated: no need to rotate in code
                        dirn = None
                    break
        if got:
            # image might be transparent
            if prefix == 's':
                surface.fill(conf.BG[conf.THEME], rect)
            img = self.game.img(fn, rect)
            # rotate if necessary
            if dirn:
                img = pygame.transform.rotate(img, -90 * dirn)
            surface.blit(img, rect)
            return True
        else:
            return False

    def draw_tile (self, surface, rect, i, j):
        # draw a single tile; called by Tiler
        s, b, selected = self.grid[i][j]
        theme = conf.THEME
        # surface
        if s < 0:
            # blit image if exists, else use colour
            if self._draw_from_img(surface, rect, 's', s):
                colour = ()
            else:
                colour = conf.SURFACE_COLOURS[theme][s]
        else:
            # goal: use block colour
            colour = conf.BLOCK_COLOURS[theme][s]
        if colour:
            surface.fill(colour, rect)
        # selection ring
        if selected:
            width = int(rect[2] * conf.SEL_WIDTH[theme])
            width = max(width, conf.MIN_SEL_WIDTH[theme])
            colour = self.selected[(i, j)]
            if colour:
                colour = conf.SECONDARY_SEL_COLOUR[theme]
            else:
                colour = conf.SEL_COLOUR[theme]
            draw_rect(surface, colour, rect, width)
        # block
        if b is not None:
            if b.type < conf.MIN_CHAR_ID:
                # blit image if exists, else use colour
                if not self._draw_from_img(surface, rect, 'b', b.type, b.dirn):
                    rect = pygame.Rect(rect)
                    p = rect.center
                    r = rect.w / 2
                    pygame.draw.circle(surface, (0, 0, 0), p, r)
                    c = conf.BLOCK_COLOURS[theme][b.type]
                    pygame.draw.circle(surface, c, p, int(r * .8))
            else:
                # draw character in tile
                c = b.type
                if c < conf.SELECTED_CHAR_ID_OFFSET:
                    # normal
                    colour = conf.PUZZLE_TEXT_COLOUR[theme]
                elif c < conf.SPECIAL_CHAR_ID_OFFSET:
                    # selected
                    c -= conf.SELECTED_CHAR_ID_OFFSET
                    colour = conf.PUZZLE_TEXT_SELECTED_COLOUR[theme]
                else:
                    # special
                    c -= conf.SPECIAL_CHAR_ID_OFFSET
                    colour = conf.PUZZLE_TEXT_SPECIAL_COLOUR[theme]
                # render character
                c = chr(c).upper() if conf.PUZZLE_TEXT_UPPER else chr(c)
                h = rect[3]
                font = (conf.PUZZLE_FONT[theme], h, False)
                text, lines = self.game.img((font, c, colour))
                # crop off empty bits
                source = autocrop(text)
                # HACK
                if len(self.text_adjust) < 30 and source:
                    self.text_adjust.append(source[2:])
                if source: # else blank
                    # centre in tile rect
                    target = [rect[0] + (rect[2] - source[2]) / 2,
                              rect[1] + (h - source[3]) / 2]
                    # crop to fit in tile rect (remove previous crop and move
                    # to current target position first)
                    s = pygame.Rect(source).move(-source[0], -source[1])
                    s = s.move(target).clip(rect).move(-target[0], -target[1])
                    # offset the target position by the same amount
                    target = [target[0] + s[0], target[1] + s[1]]
                    source = s.move(source[:2])
                    surface.blit(text, target, source)

    def draw (self, screen, everything = False, size = None):
        # draw grid and tiles
        if everything:
            self._reset_tiler()
        try:
            changed = list(self.tiler._changed)
        except TypeError:
            pass
        rects = self.tiler.draw_changed(screen, size)
        if rects is None:
            cbs = []
            rtn = None
        elif isinstance(rects[0], int):
            # drew everything and got back the tiler rect: store it
            self.rect = pygame.Rect(rects)
            cbs = self._draw_cbs.values()
            rtn = [rects]
        else:
            cbs = [v for k, v in self._draw_cbs.iteritems() if k in changed]
            rtn = rects[1:]
        # call draw callbacks
        cbs = list(set([f for f, once in cbs if once])) + \
              [f for f, once in cbs if not once]
        for f in cbs:
            f(screen)
        return rtn

    def point_in_grid (self, p):
        """Check whether an on-screen point is in the grid."""
        return self.rect is not None and self.rect.collidepoint(p)

    def point_pos (self, p):
        """Get a point's position as a floating-point 'tile' co-ordinate.

Returns None if the point is outside the grid.

"""
        r = self.rect
        return [float(p[i] - r[i]) / r[2 + i] for i in (0, 1)]

    def point_tile (self, p):
        """Get tile containing given (x, y) point.

Returns (x, y) tile position, or False if the point is in the grid but not in a
tile, or None if the point is outside the grid.

"""
        if not self.point_in_grid(p):
            return None
        result = []
        for i in (0, 1):
            border = self.tiler.border[i]
            tile_size = self.tile_size(i)
            gap = self.tiler.gap[i]
            n_tiles = self.size[i]
            pos = int(p[i])
            pos -= self.rect[i] + border
            # take gaps between tiles into account
            if pos % (tile_size + gap) >= tile_size:
                # between tiles/on border
                return False
            tile = pos / (tile_size + gap)
            if 0 <= tile < n_tiles:
                result.append(tile)
            else:
                # on border
                return False
        return result