class Level (object): """A simple Puzzle wrapper to handle input and winning. CONSTRUCTOR Level([event_handler][, ID][, definition][, win_cb], sound = True) event_handler: evthandler.EventHandler instance to use for keybindings. If not given, the level cannot be controlled by the keyboard. ID: level ID to load in the form (is_custom, level_ID). definition: a level definition to use; see the puzzle module for details. win_cb: function to call when the player wins, or (function, *args) to pass some arguments to the function. sound: whether to play sounds. One of ID and definition is required. METHODS load move reset solve set_frozen stop_solving start_recording stop_recording update ATTRIBUTES game: None. ID: level ID; None if this is a custom level or a definition was given instead. puzzle: puzzle.Puzzle instance. players: player blocks in the puzzle. msg: puzzle message. won: whether the level has been won. solving: whether the puzzle is currently being solved. solving_index: the current step in the solution being used to solve the puzzle. solutions: a list of solutions to the level. recording: whether input is currently being recorded. frozen: whether the solution being played back is paused. start_time: time the level started; this is altered when unpaused to give the proper amount of time the level has been running, not its actual start time. win_cb: as given. sound: as given. """ def __init__ (self, event_handler = None, ID = None, definition = None, win_cb = None, sound = True): if not hasattr(self, 'game'): self.game = None if event_handler is not None: # add gameplay key handlers args = ( eh.MODE_ONDOWN_REPEAT, max(int(conf.MOVE_INITIAL_DELAY * conf.FPS), 1), max(int(conf.MOVE_REPEAT_DELAY * conf.FPS), 1) ) move = lambda *ds: [(self._move, ds)] freeze = lambda k, e, m: self.set_frozen() l = conf.KB_LAYOUT event_handler.add_key_handlers([ (conf.KEYS_MOVE_LEFT[l], move(0)) + args, (conf.KEYS_MOVE_UP[l], move(1)) + args, (conf.KEYS_MOVE_RIGHT[l], move(2)) + args, (conf.KEYS_MOVE_DOWN[l], move(3)) + args, (conf.KEYS_MOVE_UPLEFT[l], move(0, 1)) + args, (conf.KEYS_MOVE_UPRIGHT[l], move(1, 2)) + args, (conf.KEYS_MOVE_DOWNRIGHT[l], move(2, 3)) + args, (conf.KEYS_MOVE_DOWNLEFT[l], move(3, 0)) + args, (conf.KEYS_SOLN_NEXT, self._step_solution) + args, (conf.KEYS_RESET, self.reset, eh.MODE_ONDOWN), (conf.KEYS_TAB, self._fast_forward, eh.MODE_HELD), (conf.KEYS_NEXT, freeze, eh.MODE_ONDOWN), (conf.KEYS_RIGHT, self._step_solution) + args ]) self.sound = sound self.load(ID, definition) if hasattr(win_cb, '__call__'): self.win_cb = (win_cb,) else: self.win_cb = win_cb def load (self, ID = None, definition = None): """Load a level. Takes ID and definition arguments as in the constructor. """ self.ID = None if ID is None or ID[0] else ID[1] if ID is not None: # get data from file path = conf.LEVEL_DIR_CUSTOM if ID[0] else conf.LEVEL_DIR_MAIN with open(path + ID[1]) as f: definition = f.read() self.puzzle = Puzzle(self.game, definition, True, self.sound) self.players = [b for b in self.puzzle.blocks if b.type == conf.B_PLAYER] # store message and solutions lines = definition.split('\n') msgs = [] solns = [] for line in lines: line = line.strip() for char, val in (('@', msgs), (':', solns)): if line.startswith(char): # add lines (stripped) starting with the character val.append(line[1:].strip()) # won't start with the other one if it starts with this one continue self.msg = msgs[0] if msgs and conf.SHOW_MSG else None self.solutions = solns self._moved = [] self._stored_moves = [] self._winning = False self.won = False self.solving = False self.solving_index = None self._ff = False self.recording = False self.frozen = False self.start_time = time() def move (self, multi, *directions): """Apply force to all player blocks in the given directions.""" if len(directions) == 1 and multi: if self._stored_moves is True: pass elif self._stored_moves: directions += (self._stored_moves[0],) self._stored_moves = True else: self._stored_moves.append(directions[0]) return # only make the move if haven't done already this frame directions = [d for d in directions if d not in self._moved] if not directions: return self._moved += directions if self.recording: self._record(directions) for d in set(directions): for player in self.players: player.add_force(d, conf.FORCE_MOVE) player.set_direction(d) def _move (self, key, event, mods, *directions): """Key callback to move player.""" if not self.solving: for d in directions: self.move(mods & conf.KEYS_MULTI, d) def reset (self, *args): """Reset the level to its state after the last call to Level.load.""" if not self.solving: self.puzzle.reset() self.players = [b for b in self.puzzle.blocks if b.type == conf.B_PLAYER] # restart recording if want to if self.recording and self._blank_on_reset: self.start_recording() self._winning = False self.won = False def _fast_forward (self, key, event, mods): """Key callback to fast-forward solving this frame.""" if self.solving: # if holding ctrl, go even faster if pygame.KMOD_CTRL & mods: self._ff = 2 else: self._ff = 1 def _parse_soln (self, ID, speed = conf.SOLVE_SPEED): """Parse a solution string and return the result.""" soln = self.solutions[ID] parsed = [] for i, s in enumerate(soln.split(',')): s = s.strip() if i % 2: # directions s = [conf.SOLN_DIRS.index(c) for c in s] else: # time delay if s.startswith('['): # got keys to hold for this waiting period end = s.find(']') hold = [conf.SOLN_DIRS.index(c) for c in s[1:end]] s = s[end + 1:].strip() else: hold = () ops = ('>', '<') if any(op in s for op in ops): # minimum and maximum values allowed_range = [None, None] while s: # check for < and > being first for op in ops: if s.startswith(op): s = s[1:].strip() eq = s.startswith('=') if eq: # remove = if found s = s[1:].strip() else: # op is not the first operator continue # the number is everything up to the next operator next_op = len(s) for o in ops: j = s.find(o) # or the end of the string if j == -1: j = len(s) next_op = min(j, next_op) val = int(s[:next_op]) val = int(val) # add/subtract one if >/< val += (-1 if op == '<' else 1) * (1 - eq) allowed_range[ops.index(op)] = val s = s[next_op:].strip() # constrain by given conditions gt, lt = allowed_range s = speed if gt is not None: s = max(s, gt) if lt is not None: s = min(s, lt) else: s = int(s) if s else speed s = (hold, s) parsed.append(s) return parsed def solve (self, solution = 0, stop_on_finish = True): """Solve the puzzle. Takes the solution number to use (its index in the list of solutions ordered as in the puzzle definition). This defaults to 0 (the 'primary' solution). Returns a list of the directions moved. This function is also called to move to the next step of an ongoing solution, in which case it requires no argument. In fact, if a solution is ongoing, it cannot be called as detailed above (any argument is ignored). This makes it a bad idea to call this function while solving. """ i = self.solving_index if i is None: # starting self.reset() self.solving = True self.solving_index = 0 self._solution = self._parse_soln(solution) self._solution_ff = self._parse_soln(solution, 0) self._solve_time = self._solution[0][1] self._solve_time_ff = self._solution_ff[0][1] self._finished_solving = False # store solve method if self.ID is not None: levels = conf.get('completed_levels', []) if self.ID not in levels: solved = conf.get('solve_methods', []) solved.append(False) conf.set(solve_methods = solved) # call this function again to act on the first instruction move = self.solve() elif i == len(self._solution): # finished: just wait until the level ends self._finished_solving = True if stop_on_finish: self.stop_solving() move = [] else: # continuing if i % 2: # make a move move = self._solution[i] self.move(False, *move) i += 1 if i < len(self._solution): self._solve_time = self._solution[i][1] self._solve_time_ff = self._solution_ff[i][1] self.solving_index = i else: # wait # if fast-forwarding, use the quicker solution fast = self._ff or self.frozen t = self._solve_time_ff if fast else self._solve_time if t <= 0: self.solving_index += 1 # do next step now move = self.solve() else: self._solve_time -= 1 self._solve_time_ff -= 1 held = self._solution[self.solving_index][0] if held: # want to send some input every frame for this delay self.move(False, *held) move = held else: move = [] self._ff = False return move def set_frozen (self, frozen = None): """Set paused state of solution, or toggle without an argument.""" if self.solving: self.frozen = not self.frozen if frozen is None else frozen def _step_solution (self, key, event, mods): """If paused, step the solution forwards once.""" if self.solving and self.frozen: self._next_step = True def stop_solving (self): """Stop solving the puzzle.""" if self.solving: self.solving = False self.solving_index = None self.frozen = False del self._solution, self._solution_ff, self._solve_time, \ self._solve_time_ff, self._next_step, self._finished_solving def _record (self, directions): """Add input to the current recording.""" directions = set(directions) recorded = self._recorded frame = self._recording_frame while len(recorded) < frame: # haven't added anything for some previous frames recorded.append(None) if len(recorded) == frame: # haven't added anything for this frame recorded.append(directions) else: # add more to this frame recorded[frame] |= directions self._recorded = recorded def start_recording (self, blank_on_reset = True): """Start recording input to the puzzle (moves). Takes one boolean argument indicating whether to start recording again if the puzzle is reset. If already recording, calling this will delete the current recording. """ self.recording = True self._blank_on_reset = blank_on_reset self._recorded = [] self._recording_frame = 0 def stop_recording (self): """Stop recording input and return the recorded input. The return value is in the standard solution format. If not recording, this function returns None. """ result = '' t = 0 for frame in self._recorded: if frame is None: # wait for a frame with input t += 1 else: # add total wait time result += str(t) + ',' t = 0 # add input result += ''.join(conf.SOLN_DIRS[d] for d in frame) + ',' self.recording = False del self._blank_on_reset, self._recorded, self._recording_frame return result[:-1] def update (self): """Update puzzle and check win conditions. Returns whether anything changed. """ if not self.frozen or self._next_step: # fast-forward by increasing FPS if self.solving and self._ff == 2: if not hasattr(self, '_FRAME'): self._FRAME = self.FRAME self.FRAME /= conf.FF_SPEEDUP elif hasattr(self, '_FRAME'): self.FRAME = self._FRAME del self._FRAME # continue solving if self.solving: self.solve() # continue recording if self.recording: self._recording_frame += 1 # step puzzle forwards rtn = self.puzzle.step() self._next_step = False # reset list of moves made this frame self._moved = [] self._stored_moves = [] else: rtn = False # check for surfaces with their corresponding Block types on them win = True for col in self.puzzle.grid: for s, b, sel in col: # goal surfaces have IDs starting at 0 if s >= 0 and (not isinstance(b, Block) or s != b.type): win = False break # need to stay winning for one frame - that is, blocks must have # stopped on the goals, not just be moving past them if win: if not self._winning: self._winning = True # else if this is the first frame since we've won, elif not self.won: # stop solving if self.solving: if self._finished_solving: self.stop_solving() win = self._winning else: win = False if win: # save to disk if not self.solving and self.ID is not None: levels = conf.get('completed_levels', []) if self.ID not in levels: levels.append(self.ID) conf.set(completed_levels = levels) self.game.set_backend_attrs(menu.MainMenu, 're_init', True) # store solve method solved = conf.get('solve_methods', []) solved.append(True) conf.set(solve_methods = solved) # call win callback if self.win_cb is not None: self.win_cb[0](*self.win_cb[1:]) # play victory sound if self.sound: self.game.play_snd('win') self.won = True else: self._winning = False return rtn