-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathRPGmusicbox.py
executable file
·1465 lines (1087 loc) · 44.5 KB
/
RPGmusicbox.py
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
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# # # # To do # # # #
#
# Must haves
# - Provide example "box" with songs and sounds and global effects in the public domain
#
# Ideas
# - Config for individual fonts, background image, etc. for box and themes?
# - Allow for "silence" instead of background music (also in addition to background music -> music - 2 min silence - music)
# + This could be realized with a <silence prob="50" duration="10-20"> tag in a theme. prob is the probability (in percent) after each song that silence comes and duration is the possible duration of silence in seconds.
#
# Bugs
# - None known :)
#
import pygame
import sys
import os
import math
import copy
import random
import bisect
import xml.etree.ElementTree as ET
from glob import glob
class NoValidRPGboxError(Exception):
''' Custom Exception for use when there is an error with the RPGbox XML file. '''
pass
class Playlist(object):
''' Contains a playlist that is dynamically extended, taking care that no song is repeated directly '''
def __init__(self, songs, remember = 5):
'''
Initiates the playlist.
:param songs: List of available songs
:param remember: How many songs shall (minimum) be remembered to allow going back
'''
if songs:
self.remember = int(remember)
else:
self.remember = 0
self.songs = songs[:]
self.playlist = []
self.nowPlaying = -1
while len(self.playlist) < self.remember:
self._extendPlaylist()
def _extendPlaylist(self):
''' Extends the playlist, taking care that no song is repeated directly '''
if len(self.songs) == 1:
self.playlist.append(self.songs[0])
else:
newSonglist = self.songs[:]
random.shuffle(newSonglist)
if self.playlist:
# prevent two songs from being played one after another (but don't try it indefinitely long)
i = 0
while newSonglist[0] == self.playlist[-1]:
if i >= 10:
break
random.shuffle(newSonglist)
i += 1
self.playlist.extend(newSonglist)
# I don't need this function until now. If playlists get too long, it would be a good idea to write this function
#def _shortenPlaylist(self):
# ''' Cuts away parts in the beginning of the playlist to save memory '''
#
# pass
def nextSong(self):
''' :returns: The next song '''
if not self.playlist:
return None
if self.nowPlaying > len(self.playlist) - self.remember:
self._extendPlaylist()
self.nowPlaying += 1
return self.playlist[self.nowPlaying]
def previousSong(self):
''' :returns: The previous song (if there is any) '''
if not self.playlist:
return None
self.nowPlaying -= 1
if self.nowPlaying >= 0:
return self.playlist[self.nowPlaying]
else:
self.nowPlaying = 0 # In case, previousSong() is called multiple times while in the beginning of the list, the pointer needs to be reset to 0, such that nextSong() starts at 0 again.
return None
def getSongsForViewing(self):
'''
:returns: A list with three songs that are the previous, current and next song. If there is only one song in the whole playlist, the list will have only one element. If the current song is the first one in the playlist, the list will have only two elements.
'''
if not self.playlist:
return None
# If there is only one song in total
if len(self.songs) == 1:
return self.songs # [the_only_song]
# If the first song did not yet start to play
if self.nowPlaying < 0:
return ['', self.playlist[0]] # ['', next]
# If the first song plays
if self.nowPlaying == 0:
return self.playlist[0:2] # [current, next]
# Usual playing
return self.playlist[self.nowPlaying - 1: self.nowPlaying + 2] # [prev, current, next]
# CLASS Playlist END
class Theme(object):
'''
Container for one theme including its songs and sounds.
'''
def __init__(self, key, name, colorText, colorBackground, colorEmph, colorFade, songs = None, sounds = None, occurences = None):
'''
Initiates the theme.
:param key: The keyboard key to activate the theme. Must be a one-letter string.
:param name: String with the name of the theme.
:param colorText: The text color for this theme
:param colorBackground: The background color for this theme
:param colorEmph: The emphasizing color for this theme
:param colorFade: The fading color for this theme
:param songs: A list with songs in the theme.
:param sounds: A list with sounds in the theme.
:param occurences: A list with occurences of the songs in the theme.
'''
self.key = str(key)[0]
self.name = str(name)
if songs is None:
self.songs = []
else:
self.songs = songs[:]
if sounds is None:
self.sounds = []
else:
self.sounds = sounds[:]
if occurences is None:
self.occurences = []
else:
self.occurences = occurences[:]
self.colorText = colorText
self.colorBackground = colorBackground
self.colorEmph = colorEmph
self.colorFade = colorFade
def __str__(self):
''' :returns: A string representation of the theme with all songs and sounds. '''
ret = []
ret.append(''.join((self.key, ') ', self.name)))
ret.append('Songs:')
for s in self.songs:
ret.append(' ' + str(s))
ret.append('Sounds:')
for s in self.sounds:
ret.append(' ' + str(s))
return '\n'.join(ret)
def addSong(self, song):
'''
Add a song to the theme.
:param song: The Song object to add
'''
self.songs.append(song)
def addSound(self, sound):
'''
Add a sound to the theme
:param sound: The Sound object to add
'''
self.sounds.append(sound)
def addOccurences(self, occurences):
'''
Adds a list of occurences to the theme. The total number of occurences after the addition must be the same as the total number of songs in the theme. So add the songs first.
:param occurences: List of occurences of the songs
:raises KeyError: When the total number of occurences does not fit the total number of songs in the theme
'''
self.occurences.extend(occurences)
if len(self.occurences) != len(self.sounds):
raise KeyError('The number of sounds is not equal to the number of occurences in {}!'.format(self.name))
for i in range(len(self.sounds)):
self.sounds[i].occurence = self.occurences[i]
# CLASS Theme END
class Sound(object):
'''
Container for one sound.
'''
def __init__(self, filename, name, volume = 1, cooldown = 10, occurence = 0.01, loop = False):
'''
Initiates the sound.
:param filename: String with the filename
:param name: String with the name
:param volume: Float with the relative volume (already adjusted by the theme volume)
:param cooldown: Float with the cooldown time in seconds
:param occurence: Float with relative occururence (already adjusted by the theme basetime)
:param loop: Boolean whether the sound shall be played indefinitely or not. `occurence` is disregarded when loop is True.
'''
self.filename = str(filename)
self.name = str(name)
self.volume = float(volume)
self.cooldown = float(cooldown)
self.occurence = float(occurence)
self.loop = bool(loop)
if self.loop:
self.occurence = 0.01
def __str__(self):
''' :returns: A string representation of the sound with all attributes. '''
return ''.join((self.filename, ' (vol: ', str(self.volume), ', occ: ', '{:.4f}'.format(self.occurence), ', cd: ', str(self.cooldown), ', loop: ', str(self.loop), ')'))
# CLASS Sound END
class Song(object):
'''
Container for one song.
'''
def __init__(self, filename, name, volume = 1):
'''
Initiates the song.
:param filename: String with the filename
:param name: String with the name
:param volume: Float with the relative volume (already adjusted by the theme volume)
'''
self.filename = str(filename)
self.name = str(name)
self.volume = float(volume)
def __str__(self):
''' :returns: A string representation of the song with its volume. '''
return ''.join((self.filename, ' (vol: ', str(self.volume), ')'))
# CLASS Song END
class GlobalEffect(object):
'''
Container for one global effect.
'''
def __init__(self, filename, key, name, volume = 1, interrupting = True):
'''
Initiates the global effect.
:param filename: String with the filename
:param key: The keyboard key to activate the global effect. Must be a one-letter string.
:param name: String with the name
:param volume: Float with the relative volume (already adjusted by the theme volume)
:param interrupting: Boolean that indicates, whether the global effect should interrupt playing music and sounds, or not.
'''
self.filename = str(filename)
self.key = str(key)[0]
self.name = str(name)
self.volume = float(volume)
self.interrupting = bool(interrupting)
def __str__(self):
''' :returns: A string representation of the global effect with its attributes. '''
if self.interrupting:
s = ', interrupting'
else:
s = ''
return ''.join((self.key, ') ', self.name, ': ', self.filename, ' (vol: ', str(self.volume), s, ')'))
# CLASS GlobalEffect END
class RPGbox(object):
'''
Contains music and sound information for an RPG evening.
Reads infos from an XML file.
'''
# Default values
DEFAULT_BASETIME = 3600 # Default basetime is 3600 seconds (1 hour)
MIN_BASETIME = 1 # Minimum basetime is 1 second
MAX_BASETIME = 36000 # Maximum basetime is 36 000 seconds (10 hours)
DEFAULT_OCCURENCE = 0.01 # Default occurence is 0.01 (1% of basetime)
MIN_OCCURENCE = 0 # Minimum occurence is 0 (never)
MAX_OCCURENCE = 1 # Maximum occurence is 1 (always)
DEFAULT_VOLUME = 100 # Default volume is 100% (100)
MIN_VOLUME = 0 # Minimum volume is 0%
MAX_VOLUME = 1 # Maximum volume is 100% (1.0)
DEFAULT_COOLDOWN = 10 # Default cooldown is 10 seconds
# MIN and MAX cooldown are not defined, as they are not needed
# Default colors
COLOR_TEXT = '#000000' # Text color: black
COLOR_BG = '#ffffff' # Background color: white
COLOR_EMPH = '#c80000' # Emphasizing color: red
COLOR_FADE = '#7f7f7f' # Fading color: grey
def __init__(self, filename):
'''
Reads all information from the given XML file.
:param filename: String with the filename of the XML file
:raises: NoValidRPGboxError
'''
# Initiate class variables
self.themes = {} # Saves theme keys and connects them to theme object {themeID: Theme(), ...}
self.globalEffects = {} # Saves theme keys and connects them to global effect object {globalEffectID: GlobalEffect(), ...}
# Read in the file, parse it and point to root
root = ET.parse(filename).getroot()
# Basic tag checking
if root.tag != 'rpgbox':
raise NoValidRPGboxError('No valid RPGbox file!')
# If a config is given, read it. If not, use default values.
try:
config = next(root.iter('config'))
self.colorText = pygame.Color(config.get('textcolor', default = self.COLOR_TEXT))
self.colorBackground = pygame.Color(config.get('bgcolor', default = self.COLOR_BG))
self.colorEmph = pygame.Color(config.get('emphcolor', default = self.COLOR_EMPH))
self.colorFade = pygame.Color(config.get('fadecolor', default = self.COLOR_FADE))
except StopIteration:
self.colorText = pygame.Color(self.COLOR_TEXT)
self.colorBackground = pygame.Color(self.COLOR_BG)
self.colorEmph = pygame.Color(self.COLOR_EMPH)
self.colorFade = pygame.Color(self.COLOR_FADE)
# Scan through globals
for globalTag in root.iter('globals'):
# Get the globals volume. If not available, use default volume. If outside margins, set to margins.
# The globals volume is eventually not saved but directly taken account of for each sound effect and music
globalsVolume = int(globalTag.get('volume', default = self.DEFAULT_VOLUME)) / 100.0
for effect in globalTag.iter('effect'):
# Get name of the global effect (each global effect must have a name!)
try:
effectName = effect.attrib['name']
except KeyError:
raise NoValidRPGboxError('A global effect without name was found. Each global effect must have a name!')
# Get the keyboard key of the effect (each global effect must have a unique key!)
try:
effectKey = effect.attrib['key'][0].lower() # get only first char and make it lowercase.
effectID = ord(effectKey)
except KeyError:
raise NoValidRPGboxError('A global effect without key was found. Each global effect must have a unique keyboard key!')
if effectID in self.globalEffects:
raise NoValidRPGboxError('The key {} is already in use.'.format(effectKey))
self._ensureValidID(effectID) # Ensure that the id is valid
# Get the effect file from the tag attribute
try:
effectFile = effect.attrib['file']
if not os.path.isfile(effectFile):
effectFile = None
except KeyError:
raise NoValidRPGboxError('No file given in global effect.')
if effectFile is None:
raise NoValidRPGboxError('File {} not found in global.'.format(effect.attrib['file']))
# Get potential volume of the effect. Alter it by the globals volume
effectVolume = int(effect.get('volume', default = self.DEFAULT_VOLUME)) / 100.0
effectVolume = self._ensureVolume(effectVolume * globalsVolume)
# Check, whether the effect should interrupt everything else
interrupting = ('interrupting' in effect.attrib and self._interpretBool(effect.attrib['interrupting']))
# Save the global effect
self.globalEffects[effectID] = GlobalEffect(filename = effectFile, key = effectKey, name = effectName, volume = effectVolume, interrupting = interrupting)
# Scan through themes
for theme in root.iter('theme'):
# Get the keyboard key of the theme (each theme must have a unique key!)
try:
themeKey = theme.attrib['key'][0].lower() # get only first char and make it lowercase.
themeID = ord(themeKey)
except KeyError:
raise NoValidRPGboxError('A theme without key was found. Each theme must have a unique keyboard key!')
if themeID in self.themes or themeID in self.globalEffects:
raise NoValidRPGboxError('The key {} is already in use. Found in {}'.format(themeKey, themeID))
self._ensureValidID(themeID) # Ensure that the id is valid
# Get the theme name. Each theme must have a name!
try:
themeName = theme.attrib['name']
except KeyError:
raise NoValidRPGboxError('A theme without name was found. Each theme must have a name!')
# Get the theme volume. If not available, use default volume.
# The theme volume is eventually not saved but directly taken account of for each sound effect and music
themeVolume = int(theme.get('volume', default = self.DEFAULT_VOLUME)) / 100.0
# Read theme basetime (How often soundeffects appear)
# The basetime is eventually not saved but directly taken account of for each sound effect
basetime = int(theme.get('basetime', default = self.DEFAULT_BASETIME))
basetime = self._ensureBasetime(basetime)
# If a config is given, read it. If not, use default values.
try:
config = next(theme.iter('config'))
try:
colorText = pygame.Color(config.attrib['textcolor'])
except KeyError:
colorText = self.colorText
try:
colorBackground = pygame.Color(config.attrib['bgcolor'])
except KeyError:
colorBackground = self.colorBackground
try:
colorEmph = pygame.Color(config.attrib['emphcolor'])
except KeyError:
colorEmph = self.colorEmph
try:
colorFade = pygame.Color(config.attrib['fadecolor'])
except KeyError:
colorFade = self.colorFade
except StopIteration:
colorText = self.colorText
colorBackground = self.colorBackground
colorEmph = self.colorEmph
colorFade = self.colorFade
# Create the theme and add it to the themes dict
self.themes[themeID] = Theme(key = themeKey, name = themeName, colorText = colorText, colorBackground = colorBackground, colorEmph = colorEmph, colorFade = colorFade)
# Initiate the occurences list. First element must be 0
occurences = [0]
# Scan through all subtags and get data like background songs and sound effects
for subtag in theme:
# <background> tag found
if subtag.tag == 'background':
# Get the song file(s) from the attribute of the tag (can be a glob)
try:
songFiles = glob(subtag.attrib['file'])
except KeyError:
raise NoValidRPGboxError('No file given in background of {}'.format(themeName))
if not songFiles:
raise NoValidRPGboxError('File {} not found in {}'.format(subtag.attrib['file'], themeName))
# Get potential volume of song. Alter it by the theme volume
volume = int(subtag.get('volume', default = self.DEFAULT_VOLUME)) / 100.0
volume = self._ensureVolume(volume * themeVolume)
# Save each song with its volume. If a filename occurs more than once, basically, the volume is updated
for songFile in songFiles:
name = self.prettifyPath(songFile)
self.themes[themeID].addSong(Song(songFile, name, volume))
# <effect> tag found
elif subtag.tag == 'effect':
# Get the sound file(s) from the attribute of the tag (can be a glob)
try:
soundFiles = glob(subtag.attrib['file'])
except KeyError:
raise NoValidRPGboxError('No file given in effect of {}'.format(themeName))
if not soundFiles:
raise NoValidRPGboxError('File {} not found in {}'.format(subtag.attrib['file'], themeName))
# Get relative volume of the sound. Alter it by the theme volume
volume = int(subtag.get('volume', default = self.DEFAULT_VOLUME)) / 100.0
volume = self._ensureVolume(volume * themeVolume)
# Get occurence of the sound. Alter it by the theme basetime
occurence = int(subtag.get('occurence', default = self.DEFAULT_OCCURENCE * basetime))
occurence = self._ensureOccurence(occurence / basetime)
# Get cooldown of the sound.
cooldown = float(subtag.get('cooldown', default = self.DEFAULT_COOLDOWN))
# Check, whether the effect should run indefinitely (i.e. it should loop)
loop = ('loop' in subtag.attrib and self._interpretBool(subtag.attrib['loop']))
# Save each sound with its volume. If a filename occurs more than once, basically, the volume and occurence are updated
for soundFile in soundFiles:
name = self.prettifyPath(soundFile)
self.themes[themeID].addSound(Sound(soundFile, name=name, volume=volume, cooldown=cooldown, loop=loop))
occurences.append(occurences[-1] + occurence)
# config tag found. That was already analysed, so we just ignore it silently
elif subtag.tag == 'config':
pass
# other tag found. We just ignore it.
else:
print('Unknown Tag {}. Ignoring it.'.format(attr.tag), file=sys.stderr)
# Ensure, that all sounds CAN be played. If the sum of occurences is higher than one, normalize to one
if occurences[-1] > self.MAX_OCCURENCE:
divisor = occurences[-1]
for i in range(len(occurences)):
occurences[i] /= divisor
# Add occurences to the theme
self.themes[themeID].addOccurences(occurences[1:])
# Test, whether there is at least one theme in the whole box
if not self.themes:
raise NoValidRPGboxError('No theme found! There must be at least one theme!')
def __str__(self):
''' :returns: All themes and global effects in the box. '''
ret = ['RPGmusicbox', 'Themes']
for t in sorted(self.themes.keys()):
ret.append(str(self.themes[t]))
ret.append('Global effects')
for e in sorted(self.globalEffects.keys()):
ret.append(str(self.globalEffects[e]))
return '\n'.join(ret)
def prettifyPath(self, path):
'''
Extracts the filename without extension from path and turns underscores to spaces.
:param path: String with the full path
:returns: The prettified filename
'''
path = os.path.basename(path)
path = os.path.splitext(path)[0]
path = path.replace('_', ' ')
return path
def _ensureValidID(self, kid):
'''
Ensures, that a given keyboard key (or rather its ID) is valid for the RPGbox.
:param kid: The key ID to check (not the key, but its ID!)
:raises: NoValidRPGboxError
'''
# Allowed: 0-9, a-z
if not (48 <= kid <= 57 or 97 <= kid <= 122):
raise NoValidRPGboxError('The key {} is not in the allowed range (a-z and 0-9; lowercase only!)'.format(chr(kid)))
def _ensureVolume(self, v):
'''
Ensures that a given volume is within the allowed range.
:param v: The volume to check
:returns: The volume, that is guaranteed to be within the allowed range
'''
if v < self.MIN_VOLUME:
return self.MIN_VOLUME
elif v > self.MAX_VOLUME:
return self.MAX_VOLUME
return v
def _interpretBool(self, s):
'''
Interprets whether a string is "falsy" or "truthy"
:param s: The sting to be interpreted
:returns: True or False depending on the string
'''
return s.lower() in {'yes', 'y', 'true', '1', 'on'}
def _ensureBasetime(self, b):
'''
Ensures that a given basetime is within the allowed range.
:param b: The basetime to check
:returns: The basetime, that is guaranteed to be within the allowed range
'''
if b < self.MIN_BASETIME:
return self.MIN_BASETIME
elif b > self.MAX_BASETIME:
return self.MAX_BASETIME
return b
def _ensureOccurence(self, o):
'''
Ensures that a given occurence is within the allowed range.
:param o: The occurence to check
:returns: The occurence, that is guaranteed to be within the allowed range
'''
if o < self.MIN_OCCURENCE:
return self.MIN_OCCURENCE
elif o > self.MAX_OCCURENCE:
return self.MAX_OCCURENCE
return o
def getIDs(self):
''' :returns: a tuple with two lists: the keys of all global IDs and the keys of all theme IDs '''
return list(self.globalEffects.keys()), list(self.themes.keys())
def getGlobalEffects(self):
''' :returns: all global effects (a dict in the form {globalEffectID: GlobalEffectObject, ...}) '''
return self.globalEffects
def getTheme(self, themeID):
'''
:param themeID: The id of the theme to get
:returns: The Theme object of the desired theme
:raises KeyError: if the given themeID is no themeID
'''
if themeID in self.themes:
return self.themes[themeID]
else:
raise KeyError('The key {} was not found as theme key.'.format(themeID))
# CLASS RPGbox END
class Player(object):
'''
This class can read RPGbox objects and play music and sounds etc.
'''
# Default colors
COLOR_TEXT = (0, 0, 0) # Text color: black
COLOR_BG = (255, 255, 255) # Background color: white
COLOR_EMPH = (200, 0, 0) # Emphasizing color: red
COLOR_FADE = (127, 127, 127) # Fading color: grey
def __init__(self, box, debug = False):
'''
Initiates all necessary stuff for playing an RPGbox.
:param box: The RPGbox object to read from
:param debug: Boolean that states, whether debugging texts should be send to STDOUT
'''
self.debug = debug
self.box = box
# Initialize pygame, screen and clock
pygame.init()
self.clock = pygame.time.Clock()
self.screen = pygame.display.set_mode((800, 600)) # Screen is 800*600 px large
pygame.display.set_caption('RPGbox player') # Set window title
# Get colors
self.colorText = self.box.colorText
self.colorBackground = self.box.colorBackground
self.colorEmph = self.box.colorEmph
self.colorFade = self.box.colorFade
# Fill background
self.background = pygame.Surface(self.screen.get_size()).convert()
self.background.fill(self.colorBackground)
self.screen.blit(self.background, (0, 0))
pygame.display.flip()
# Create my own event to indicate that a song stopped playing to trigger a new song
self.SONG_END = pygame.USEREVENT + 1
pygame.mixer.music.set_endevent(self.SONG_END)
# Reserve a channel for global sound effects, such that a global sound can always be played
self.GLOBAL_END = pygame.USEREVENT + 2
pygame.mixer.set_reserved(1)
self.globalChannel = pygame.mixer.Channel(0)
self.globalChannel.set_endevent(self.GLOBAL_END)
# Initiate text stuff
self.standardFont = pygame.font.Font(None, 24)
self.headerFont = pygame.font.Font(None, 32)
w, h = self.background.get_size()
self.displayWidth = w
self.displayPanelWidth = w // 3
self.displayFooterWidth = w // 6
self.displayHeight = h
self.displayPanelHeight = h - 2 * self.standardFont.size(' ')[1]
self.displayFooterHeight = h - self.displayPanelHeight
self.displayBorder = 5
self.textGlobalKeys = pygame.Surface((self.displayPanelWidth, self.displayPanelHeight))
self.textThemeKeys = pygame.Surface((self.displayPanelWidth, self.displayPanelHeight))
self.textNowPlaying = pygame.Surface((self.displayWidth - 2*self.displayPanelWidth, self.displayPanelHeight)) # The displayWidth - 2*panelWidth fills the rounding error pixels on the right side
self.textFooter = pygame.Surface((self.displayWidth, self.displayFooterHeight)) # The footer stretches horizontally to 100%. The displayFooterWidth is for the single elements in the footer.
# Initialize variables
self.globalIDs, self.themeIDs = self.box.getIDs()
self.globalEffects = None
self.initializeGlobalEffects()
self.activeSounds = []
self.activeGlobalEffect = None
self.occurences = []
self.playlist = Playlist([])
self.activeTheme = None
self.activeThemeID = None
self.cycle = 0
self.allowMusic = True
self.allowSounds = True
self.allowCustomColors = True
self.paused = False
self.newSongWhilePause = False
self.interruptingGlobalEffect = False
self.activeChannels = []
self.blockedSounds = {} # {filename: timeToStartAgain, ...}
# Start visualisation
self.updateTextAll()
def debugPrint(self, t):
'''
Prints the given text, if debugging (self.debug) is active.
:param t: The text to print.
'''
if self.debug:
print(t)
def initializeGlobalEffects(self):
'''
Loads the file for each global effect to RAM and adjust its volume to have it ready.
'''
self.globalEffects = self.box.getGlobalEffects()
for e in self.globalEffects:
self.globalEffects[e].obj = pygame.mixer.Sound(self.globalEffects[e].filename)
self.globalEffects[e].obj.set_volume(self.globalEffects[e].volume)
def toggleDebugOutput(self):
''' Allows or disallows debug output to stdout '''
if self.debug:
self.debug = False
else:
self.debug = True
self.debugPrint('Debug printing activated')
self.updateTextFooter()
def togglePause(self):
''' Pause or unpause music and sounds, depending on the self.paused and self.interruptingGlobalEffect variables. '''
if self.paused:
self.debugPrint('Player unpaused')
self.paused = False
if self.interruptingGlobalEffect:
self.globalChannel.unpause()
else:
pygame.mixer.music.unpause()
pygame.mixer.unpause()
if self.newSongWhilePause:
self.newSongWhilePause = False
pygame.mixer.music.play()
else:
pygame.mixer.music.pause()
pygame.mixer.pause()
self.debugPrint('Player paused')
self.paused = True
self.updateTextFooter()
def toggleAllowMusic(self):
''' Allow or disallow music to be played. '''
if self.allowMusic:
self.allowMusic = False
pygame.mixer.music.stop()
self.playlist.nowPlaying -= 1 # Necessary to start with the same song, when music is allowed again
self.updateTextNowPlaying()
self.debugPrint('Music switched off')
else:
self.allowMusic = True
pygame.event.post(pygame.event.Event(self.SONG_END))
self.debugPrint('Music switched on')
self.updateTextFooter()
def toggleAllowSounds(self):
''' Allow or disallow sounds to be played. '''
if self.allowSounds:
self.allowSounds = False
if self.activeChannels:
for c in self.activeChannels:
c[1].stop()
self.updateTextNowPlaying()
self.debugPrint('Sound switched off')
else:
self.allowSounds = True
self.debugPrint('Sound switched on')
self.updateTextFooter()
def toggleAllowCustomColors(self):
''' Allow or disallow custom colors. '''
if self.allowCustomColors:
self.colorText = self.COLOR_TEXT
self.colorBackground = self.COLOR_BG
self.colorEmph = self.COLOR_EMPH
self.colorFade = self.COLOR_FADE
self.allowCustomColors = False
else:
if self.activeTheme is None:
self.colorText = self.box.colorText
self.colorBackground = self.box.colorBackground
self.colorEmph = self.box.colorEmph
self.colorFade = self.box.colorFade
else:
self.colorText = self.activeTheme.colorText
self.colorBackground = self.activeTheme.colorBackground
self.colorEmph = self.activeTheme.colorEmph
self.colorFade = self.activeTheme.colorFade
self.allowCustomColors = True
self.updateTextAll()
def updateTextAll(self):
''' Update the whole screen. '''
self.background.fill(self.colorBackground)
self.updateTextGlobalEffects(update = False)
self.updateTextThemes(update = False)
self.updateTextNowPlaying(update = False)
self.updateTextFooter(update = False)
pygame.display.flip()
def showLine(self, area, t, color, font):
'''
Prints one line of text to a panel.
:param area: A rect with information, where the text shall be blitted on the background
:param t: The text to be rendered
:param color: The color of the text
:param font: The font object that shall be rendered
'''
textRect = font.render(t, True, color)
self.background.blit(textRect, area)
area.top += font.get_linesize()
def updateTextGlobalEffects(self, update = True):
'''
Update the global effects panel
:param update: Boolean to state, whether the display should be updated
'''
self.textGlobalKeys.fill(self.colorBackground)
r = self.background.blit(self.textGlobalKeys, (0, 0))
area = self.textGlobalKeys.get_rect()
area.left = self.displayBorder
area.top = self.displayBorder
self.showLine(area, 'Global Keys', self.colorText, self.headerFont)
self.showLine(area, '', self.colorText, self.standardFont)
for k in sorted(self.globalEffects.keys()):
t = ''.join((chr(k), ' - ', self.globalEffects[k].name))
if k == self.activeGlobalEffect:
self.showLine(area, t, self.colorEmph, self.standardFont)
else:
self.showLine(area, t, self.colorText, self.standardFont)
self.screen.blit(self.background, (0, 0))
if update:
pygame.display.update(r)
def updateTextThemes(self, update = True):
'''
Update the themes panel
:param update: Boolean to state, whether the display should be updated
'''
self.textThemeKeys.fill(self.colorBackground)
r = self.background.blit(self.textThemeKeys, (self.displayPanelWidth, 0))
area = self.textThemeKeys.get_rect()
area.left = self.displayPanelWidth + self.displayBorder
area.top = self.displayBorder
self.showLine(area, 'Themes', self.colorText, self.headerFont)
self.showLine(area, '', self.colorText, self.standardFont)
for k in sorted(self.box.themes.keys()):
t = ''.join((chr(k), ' - ', self.box.themes[k].name))
if k == self.activeThemeID:
self.showLine(area, t, self.colorEmph, self.standardFont)
else:
self.showLine(area, t, self.colorText, self.standardFont)
self.screen.blit(self.background, (0, 0))
if update:
pygame.display.update(r)