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
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