################################################################################ ## ## Achievements for Ren'Py by Feniks (feniksdev.itch.io / feniksdev.com) ## ################################################################################ ## This file contains code for an achievement system in Ren'Py. It is designed ## as a wrapper to the built-in achievement system, so it hooks into the ## Steam backend as well if you set up your achievement IDs the same as in ## the Steam backend. ## ## If you use this code in your projects, credit me as Feniks @ feniksdev.com ## ## If you'd like to see how to use this tool, check the other file, ## achievements.rpy! ## ## Leave a comment on the tool page on itch.io or an issue on the GitHub ## if you run into any issues. ################################################################################ init -50 python: import datetime, time from re import sub as re_sub TAG_ALPHABET = "abcdefghijklmnopqrstuvwxyz" def get_random_screen_tag(k=4): """Generate a random k-letter word out of alphabet letters.""" # Shuffle the list and pop k items from the front alphabet = list(store.TAG_ALPHABET) renpy.random.shuffle(alphabet) ## Add the time onto the end so there are no duplicates return ''.join(alphabet[:k] + [str(time.time())]) class Achievement(): """ A class with information on in-game achievements which can be extended to use with other systems (e.g. Steam achievements). Attributes: ----------- name : string The human-readable name of this achievement. May have spaces, apostrophes, dashes, etc. id : string The code-friendly name of this achievement (which can be used for things like the Steam backend). Should only include letters, numbers, and underscores. If not provided, name will be sanitized for this purpose. description : string A longer description for this achievement. Optional. unlocked_image : Displayable A displayable to use when this achievement is unlocked. locked_image : Displayable A displayable to use when this achievement is locked. If not provided, requires an image named "locked_achievement" to be declared somewhere. stat_max : int If provided, an integer corresponding to the maximum progress of an achievement, if the achievement can be partially completed (e.g. your game has 24 chapters and you want this to tick up after every chapter, thus, stat_max is 24). The achievement is unlocked when it reaches this value. stat_progress : int The current progress for the stat. stat_modulo : int The formula (stat_progress % stat_modulo) is applied whenever achievement progress is updated. If the result is 0, the progress is shown to the user. By default this is 0 so all updates to stat_progress are shown. Useful if, for the supposed 24-chapter game progress stat, you only wanted to show updates every time the player got through a quarter of the chapters. In this case, stat_modulo would be 6 (24//4). hidden : bool True if this achievement's description and name should be hidden from the player. hide_description : bool True if this achievement's description should be hidden from the player. Can be set separately from hidden, e.g. with hidden=True and hide_description=False, the player will see the name but not the description. timestamp : Datetime The time this achievement was unlocked at. """ ## A list of all the achievements that exist in this game, ## to loop over in the achievements screen. all_achievements = [ ] achievement_dict = dict() def __init__(self, name, id=None, description=None, unlocked_image=None, locked_image=None, stat_max=None, stat_modulo=None, hidden=False, stat_update_percent=1, hide_description=None): self._name = name # Try to sanitize the name for an id, if possible self.id = id or re_sub(r'\W+', '', name) self._description = description or "" self.unlocked_image = unlocked_image or None self.locked_image = locked_image or "locked_achievement" self.stat_max = stat_max self.stat_modulo = stat_modulo if stat_update_percent != 1 and stat_modulo != 0: raise Exception("Achievement {} has both stat_update_percent and stat_modulo set. Please only set one.".format(self.name)) ## Figure out the modulo based on the percent if stat_update_percent > 1: ## Basically, if stat_max % stat_modulo == 0, then it updates. ## So if it updates every 10%, then stat_max / stat_modulo = 10 self.stat_modulo = int(stat_max * (stat_update_percent / 100.0)) self.hidden = hidden if hide_description is None: self.hide_description = hidden else: self.hide_description = hide_description # Add to list of all achievements self.all_achievements.append(self) # Add to the dictionary for a quick lookup self.achievement_dict[self.id] = self # Register with backends achievement.register(self.id, stat_max=stat_max, stat_modulo=stat_modulo or None) def get_timestamp(self, format="%b %d, %Y @ %I:%M %p"): """ Return the timestamp when this achievement was granted, using the provided string format. """ if self.has(): return datetime.datetime.fromtimestamp( self._timestamp).strftime(format) else: return "" @property def _timestamp(self): if store.persistent.achievement_timestamp is not None: return store.persistent.achievement_timestamp.get(self.id, None) else: return None @property def timestamp(self): """Return the timestamp when this achievement was granted.""" if self.has(): try: ts = datetime.datetime.fromtimestamp(self._timestamp) except TypeError: if config.developer: print("WARNING: Could not find timestamp for achievement with ID {}".format(self.id)) return "" return __("Unlocked ") + ts.strftime(__( "%b %d, %Y @ %I:%M %p{#achievement_timestamp}")) else: return "" @_timestamp.setter def _timestamp(self, value): """Set the timestamp for this achievement.""" if store.persistent.achievement_timestamp is not None: store.persistent.achievement_timestamp[self.id] = value @property def idle_img(self): """Return the idle image based on its locked status.""" if self.has(): return self.unlocked_image else: return self.locked_image @property def name(self): """ Returns the name of the achievement based on whether it's hidden or not. """ if self.hidden and not self.has(): return _("???{#hidden_achievement_name}") else: return self._name @property def description(self): """ Returns the description of the achievement based on whether it's hidden or not. """ if self.hide_description and not self.has(): if self.hide_description is True: return _("???{#hidden_achievement_description}") else: return self.hide_description else: return self._description @property def stat_progress(self): """Return this achievement's progress stat.""" return self.get_progress() def add_progress(self, amount=1): """ Increment the progress towards this achievement by amount. """ self.progress(min(self.stat_progress+amount, self.stat_max)) ## Wrappers for various achievement functionality def clear(self): """Clear this achievement from memory.""" return achievement.clear(self.id) def get_progress(self): """Return this achievement's progress.""" return achievement.get_progress(self.id) def grant(self): """ Grant the player this achievement, and show a popup if this is the first time they've gotten it. """ has_achievement = self.has() x = achievement.grant(self.id) if not has_achievement: # First time this was granted self.achievement_popup() # Save the timestamp self._timestamp = time.time() # Callback if myconfig.ACHIEVEMENT_CALLBACK is not None: renpy.run(myconfig.ACHIEVEMENT_CALLBACK, self) # Double check achievement sync achievement.sync() return x def has(self): """Return True if the player has achieved this achievement.""" return achievement.has(self.id) def progress(self, complete): """ A plugin to the original Achievement class. Sets the current achievement progress to "complete". """ has_achievement = self.has() x = achievement.progress(self.id, complete) if not has_achievement and self.has(): # First time this was granted self.achievement_popup() # Save the timestamp self._timestamp = time.time() # Callback if myconfig.ACHIEVEMENT_CALLBACK is not None: renpy.run(myconfig.ACHIEVEMENT_CALLBACK, self) return x def achievement_popup(self): """ A function which shows an achievement screen to the user to indicate they were granted an achievement. """ if renpy.is_init_phase(): ## This is init time; we don't show a popup screen return elif not self.has(): # Don't have this achievement, so it doesn't get a popup. return elif not myconfig.SHOW_ACHIEVEMENT_POPUPS: # Popups are disabled return if achievement.steamapi and not myconfig.INGAME_POPUP_WITH_STEAM: # Steam is detected and popups shouldn't appear in-game. return # Otherwise, show the achievement screen for i in range(10): if store.onscreen_achievements.get(i, None) is None: store.onscreen_achievements[i] = True break # Generate a random tag for this screen tag = get_random_screen_tag(6) ## Play a sound, if provided if myconfig.ACHIEVEMENT_SOUND: renpy.music.play(myconfig.ACHIEVEMENT_SOUND, channel=myconfig.ACHIEVEMENT_CHANNEL) renpy.show_screen('achievement_popup', a=self, tag=tag, num=i, _tag=tag) def AddProgress(self, amount=1): """Add amount of progress to this achievement.""" return Function(self.add_progress, amount=amount) def Progress(self, amount): """Set this achievement's progress to amount.""" return Function(self.progress, amount) def Toggle(self): """ A developer action to easily toggle the achieved status of a particular achievement. """ return [SelectedIf(self.has()), If(self.has(), Function(self.clear), Function(self.grant))] def Grant(self): """ An action to easily achieve a particular achievement. """ return Function(self.grant) @classmethod def reset(self): """ A class method which resets all achievements and clears all their progress. """ for achievement in self.all_achievements: achievement.clear() @classmethod def Reset(self): """ A class method which resets all achievements and clears all their progress. This is a button action rather than a function. """ return Function(self.reset) @classmethod def num_earned(self): """ A class property which returns the number of unlocked achievements. """ return len([a for a in self.all_achievements if a.has()]) @classmethod def num_total(self): """ A class property which returns the total number of achievements. """ return len(self.all_achievements) class LinkedAchievement(): """ A class which can be used as part of an achievement callback to trigger an achievement when some subset of achievements is unlocked. Attributes: ----------- links : dict A dictionary of the form {achievement.id : [list of final achievement ids]}. This is a reverse of the dictionary passed in to the constructor and is used to look up what final achievements are tied to a given achievement. final_to_list : dict A dictionary of the form {final_achievement.id : [list of achievement ids to check]}. This is the same as the dictionary passed in to the constructor, and is used to look up what achievements are needed to unlock a given final achievement. unlock_after_all : string If this is set to an achievement ID, then that achievement is unlocked after all other achievements are unlocked. """ def __init__(self, **links): """ Create a LinkedAchievement to be used as a callback. Parameters: ---------- links : dict A dictionary of the form {final_achievement.id : [list of achievement ids to check]}. When all of the achievements in the list are unlocked, the final achievement is unlocked. """ ## links comes in the form of ## {final_achievement.id : [list of achievement ids to check]} self.links = dict() values = links.values() if len(values) == 1 and 'all' in values: ## Special case for an achievement that's achieved after ## getting all achievements self.unlock_after_all = ''.join(links.keys()) self.final_to_list = links return else: self.unlock_after_all = False ## Reverse-engineer a dictionary which corresponds to the things ## that are checked, and what they tie back to. for final_achievement, check_achievements in links.items(): for check_achievement in check_achievements: if check_achievement == final_achievement: continue if check_achievement not in links: self.links[check_achievement] = [final_achievement] else: self.links[check_achievement].append(final_achievement) self.final_to_list = links def __call__(self, the_achievement): """ A method which is called when an achievement is unlocked. It checks if the achievement is part of a list of achievements which are needed to unlock a given final achievement, and if the conditions needed to unlock that final achievement are met. If so, it grants that achievement. Parameters: ----------- the_achievement : Achievement The achievement which was just granted. """ if self.unlock_after_all: ## This unlocks after all achievements are earned if all([a.has() for a in Achievement.all_achievements if a.id != self.unlock_after_all]): fa = Achievement.achievement_dict.get(self.unlock_after_all) if fa is not None: fa.grant() return ## Find which final achievements this is attached to final_achievements = self.links.get(the_achievement.id, None) if not final_achievements: return ## Otherwise, see if this was the last achievement which was needed ## to unlock a given final_achievement. for final_achievement in final_achievements: lst = self.final_to_list.get(final_achievement, None) if lst is None: continue ## Check if all the achievements in the list are unlocked if all([achievement.has(a) for a in lst]): fa = Achievement.achievement_dict.get(final_achievement) if fa is not None: fa.grant() return ## Note: DO NOT change these configuration values in this block! See ## `achievements.rpy` for how to change them. This is just for setup so they ## exist in the game, and then you can modify them with `define` in a different ## file. init -999 python in myconfig: _constant = True ## This is a configuration value which determines whether the in-game ## achievement popup should appear when Steam is detected. Since Steam ## already has its own built-in popup, you may want to set this to False ## if you don't want to show the in-game popup alongside it. ## The in-game popup will still work on non-Steam builds, such as builds ## released DRM-free on itch.io. INGAME_POPUP_WITH_STEAM = True ## The length of time the in-game popup spends hiding itself (see ## transform achievement_popout in achievements.rpy). ACHIEVEMENT_HIDE_TIME = 1.0 ## True if the game should show in-game achievement popups when an ## achievement is earned. You can set this to False if you just want an ## achievement gallery screen and don't want any popups. SHOW_ACHIEVEMENT_POPUPS = True ## A callback, or list of callbacks, which are called when an achievement ## is granted. It is called with one argument, the achievement which ## was granted. It is only called if the achievement has not previously ## been earned. ACHIEVEMENT_CALLBACK = None ## A sound to play when the achievement is granted ACHIEVEMENT_SOUND = None ACHIEVEMENT_CHANNEL = "audio" ## Track the time each achievement was earned at default persistent.achievement_timestamp = dict() ## Tracks the number of onscreen achievements, for offsetting when ## multiple achievements are earned at once default onscreen_achievements = dict() ## Required for older Ren'Py versions so the vpgrid doesn't complain about ## uneven numbers of achievements, but True by default in later Ren'Py versions. define config.allow_underfull_grids = True # This, coupled with the timer on the popup screen, ensures that the achievement # is properly hidden before another achievement can be shown in that "slot". # If this was done as part of the timer in the previous screen, then it would # consider that slot empty during the 1 second the achievement is hiding itself. # That's why this timer is 1 second long. screen finish_animating_achievement(num): timer myconfig.ACHIEVEMENT_HIDE_TIME: action [SetDict(onscreen_achievements, num, None), Hide()]