-
Notifications
You must be signed in to change notification settings - Fork 1
/
achievement_backend.rpy
492 lines (440 loc) · 20.7 KB
/
achievement_backend.rpy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
################################################################################
##
## 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()]