class Map: """Stores map tile data, entity positions, and draws the map. The map is made of 1.0x1.0 blocks. The map is rendered in square chunks of blocks, which are cached as surfaces to reduce the number of blits needed to draw the map. A chunk only needs to be redrawn when a block it contains is changed. """ def __init__(self, size): """Create map of size*size tiles.""" self.TILE_SIZE = 20 # pixels self.CHUNK_SIZE = 8 # blocks square self.BLOCK_UPDATE_SIZE = (40, 40) # rect size around player in blocks self.BLOCK_UPDATE_FREQ = 1000 # number of blocks to update per second self._blocks = [] # block_ids of the map in row-major order self.size = size # (width, height) of the map in blocks self.entities = [] # list of MapEntities in the map self._blocks = map_generation.generate_map(size) self._particle_systems = [] # (ParticleSystem, pos) tuples self.cursor_pos = None # pixel coords or None self.light = Light(self) # prime the chunk cache by rendering every chunk in the map self._chunk_cache = {} print "priming chunk cache..." for chunk in self.get_chunks_in_rect(((0, 0) + self.size)): s = pygame.Surface((self.CHUNK_SIZE * self.TILE_SIZE, self.CHUNK_SIZE * self.TILE_SIZE)) self.draw_chunk(s, chunk) self._chunk_cache[chunk] = s print "cached %i chunks" % len(self._chunk_cache) def get_block(self, x, y): """Return the block id at coordinates, or None if out of range.""" if (x in xrange(0, self.size[0]) and y in xrange(0, self.size[1])): i = (y * self.size[1]) + x return self._blocks[i] else: return None def set_block(self, x, y, block_id): """Set the block at coordinates to block_id. Fails if block coords are out of range. """ assert self.get_block(x, y) != None i = (y * self.size[1]) + x self._blocks[i] = block_id # invalidate the chunk cache cx = x / self.CHUNK_SIZE * self.CHUNK_SIZE cy = y / self.CHUNK_SIZE * self.CHUNK_SIZE if (cx, cy) in self._chunk_cache: del self._chunk_cache[(cx, cy)] # TODO: reuse surface? # update lighting and invalidate changed chunks # for every block with changed lighting, invalidate its chunk changed_blocks = self.light.update_light(x, y) for block in changed_blocks: cx = block[0] / self.CHUNK_SIZE * self.CHUNK_SIZE cy = block[1] / self.CHUNK_SIZE * self.CHUNK_SIZE if (cx, cy) in self._chunk_cache: del self._chunk_cache[(cx, cy)] def is_solid_block(self, x, y): """Return True if block at given coordinates is solid. Assumes blocks outside map are not solid. """ block_id = self.get_block(x, y) return (block_id != None and Block(block_id).is_solid) def draw_chunk(self, surf, pos): """Draw a self.CHUNK_SIZE square of the map tiles. surf: Surface to draw to. pos: The top-left position of the map chunk to draw. """ surf.fill((100, 100, 255)) # figure out range of tiles in this chunk tile_range = [(int(pos[i]), int(ceil(pos[i] + surf.get_size()[i] / self.TILE_SIZE))) for i in [0, 1]] for x in xrange(*(tile_range[0])): for y in xrange(*(tile_range[1])): (px, py) = self.grid_to_px(pos, (x,y)) bid = self.get_block(x, y) if bid != None: block = Block(bid) light_level = self.light.get_light(x, y) block_surf = block.lit_surfs[light_level] surf.blit(block_surf, (px, py)) else: pass #FIXME def get_chunks_in_rect(self, rect): """Generate the list of chunks inside a rect.""" x_min = rect[0] x_max = rect[0] + rect[2] y_min = rect[1] y_max = rect[1] + rect[3] chunks_x = (int(x_min) / self.CHUNK_SIZE * self.CHUNK_SIZE, int(x_max) / self.CHUNK_SIZE * self.CHUNK_SIZE) chunks_y = (int(y_min) / self.CHUNK_SIZE * self.CHUNK_SIZE, int(y_max) / self.CHUNK_SIZE * self.CHUNK_SIZE) # loop over every chunk and yield it for x in [c for c in xrange(chunks_x[0], chunks_x[1]+1) if c % self.CHUNK_SIZE == 0]: for y in [c for c in xrange(chunks_y[0], chunks_y[1]+1) if c % self.CHUNK_SIZE == 0]: yield (x, y) def draw(self, surf, pos): """Draw the map tiles and entites. surf: Surface to draw to. pos: Top-left grid position to draw. surf_size: Note: negative chunks will causing rounding in the wrong direction, causing undrawn margins. Fix this by not drawing empty chunks. """ # figure out which chunks are onscreen # get topleft and bottomright grid positions of viewport topleft = pos bottomright = self.px_to_grid(pos, surf.get_size()) # get min and max grid positions inside viewport x_min = topleft[0] x_max = bottomright[0] y_min = topleft[1] y_max = bottomright[1] # get min and max chunk positions inside viewport chunks_x = (int(x_min) / self.CHUNK_SIZE * self.CHUNK_SIZE, int(x_max) / self.CHUNK_SIZE * self.CHUNK_SIZE) chunks_y = (int(y_min) / self.CHUNK_SIZE * self.CHUNK_SIZE, int(y_max) / self.CHUNK_SIZE * self.CHUNK_SIZE) # loop over every chunk to draw for x in [c for c in xrange(chunks_x[0], chunks_x[1]+1) if c % self.CHUNK_SIZE == 0]: for y in [c for c in xrange(chunks_y[0], chunks_y[1]+1) if c % self.CHUNK_SIZE == 0]: # redraw the chunk if it's not cached if (x, y) not in self._chunk_cache: print "chunk cache miss on %i,%i" % (x, y) now = pygame.time.get_ticks() # TODO: add margin to allow tile overlapping between chunks s = pygame.Surface((self.CHUNK_SIZE * self.TILE_SIZE, self.CHUNK_SIZE * self.TILE_SIZE)) self.draw_chunk(s, (x, y)) self._chunk_cache[(x, y)] = s elapsed = pygame.time.get_ticks() - now print "cached new chunk in %i ms" % elapsed # rounding pos and blit_pos seems to fix edges between chunks blit_pos = self.grid_to_px((round(pos[0], 2), round(pos[1], 2)), (x, y)) blit_pos = (round(blit_pos[0]), round(blit_pos[1])) surf.blit(self._chunk_cache[(x, y)], blit_pos) # figure out which entities are onscreen and draw them for entity in self.entities: topleft = (entity.x, entity.y) p_tl = self.grid_to_px(pos, topleft) # TODO: clip entities offscreen entity.draw(p_tl[0], p_tl[1], entity.width * self.TILE_SIZE, entity.height * self.TILE_SIZE, surf=surf) # draw particle systems for ps_pos in self._particle_systems: (ps, g_pos) = ps_pos p_pos = self.grid_to_px(pos, g_pos) # TODO: clip ps.draw(surf, p_pos) # draw selected block if self.cursor_pos != None: selected_block = self.px_to_grid(pos, self.cursor_pos) selected_block = (int(selected_block[0]), int(selected_block[1])) rect = (self.grid_to_px(pos, selected_block) + (self.TILE_SIZE, self.TILE_SIZE)) pygame.draw.rect(surf, (255, 0, 0), rect, 2) def grid_to_px(self, topleft, pos): """Return pixel coordinate from a grid coordinate. topleft: top-left of map being drawn. pos: grid position to convert. """ return ((pos[0] - topleft[0]) * self.TILE_SIZE, (pos[1] - topleft[1]) * self.TILE_SIZE) def px_to_grid(self, topleft, pos): """Return grid coordinate from a pixel coordinate. topleft: top-left of map being drawn. pos: pixel position to convert. """ return ((float(pos[0]) / self.TILE_SIZE) + topleft[0], (float(pos[1]) / self.TILE_SIZE) + topleft[1]) def update(self, millis): """Update entities, particle systems, and blocks in the map. Blocks are updated in a rectangle around the player. Random blocks in this rectangle are chosen to be updated each call. """ for entity in self.entities: entity.update(millis, self) for ps_pos in self._particle_systems: (ps, pos) = ps_pos ps.update(millis) if ps.is_expired(): self._particle_systems.remove(ps_pos) # TODO: hack to get player pos update_center = (int(self.entities[0].x), int(self.entities[0].y)) # loop so updates occur at specified frequency for i in xrange(int(ceil(millis / float(self.BLOCK_UPDATE_FREQ * 1000)))): x = randrange(update_center[0] - self.BLOCK_UPDATE_SIZE[0]/2, update_center[0] + self.BLOCK_UPDATE_SIZE[0]/2 + 1) y = randrange(update_center[1] - self.BLOCK_UPDATE_SIZE[1]/2, update_center[1] + self.BLOCK_UPDATE_SIZE[1]/2 + 1) # update block at (x, y) bid = self.get_block(x, y) if bid == Block(name="grass").id: # kill grass which is too dark if self.light.get_light(x, y) < self.light.MAX_LIGHT_LEVEL: self.set_block(x, y, Block(name="dirt").id) # spread grass to adjacent blocks which are bright enough for pos in [(x-1,y),(x+1,y),(x,y-1),(x,y+1),(x-1,y-1), (x+1,y+1),(x-1,y+1),(x+1,y-1)]: if (self.light.get_light(*pos) == self.light.MAX_LIGHT_LEVEL and self.get_block(*pos) == Block(name="dirt").id): self.set_block(pos[0], pos[1], Block(name="grass").id) def rect_colliding(self, rect, assume_solid=None): """Return true if the given rect will collide with the map. This works by finding all the map blocks inside the rect, and returning true if any of them are solid blocks. rect should be a (x, y, w, h) tuple since pygame Rects don't use floats. If assume_solid is an (x, y) tuple, that block will be assumed solid. """ r_x = rect[0] r_y = rect[1] r_w = rect[2] r_h = rect[3] # get range of blocks inside the given rect x_range = (int(r_x), int(ceil(r_x + r_w))) y_range = (int(r_y), int(ceil(r_y + r_h))) for x in xrange(x_range[0], x_range[1]): for y in xrange(y_range[0], y_range[1]): if self.is_solid_block(x, y) or (x, y) == assume_solid: return True return False