diff --git a/pokedex/data/csv/item_flag_map.csv b/pokedex/data/csv/item_flag_map.csv index 7ebe46c..842ccfc 100644 --- a/pokedex/data/csv/item_flag_map.csv +++ b/pokedex/data/csv/item_flag_map.csv @@ -484,6 +484,7 @@ item_id,item_flag_id 245,7 246,5 246,7 +246,8 247,5 247,7 248,5 @@ -501,6 +502,7 @@ item_id,item_flag_id 254,7 255,5 255,7 +255,8 256,5 256,7 257,5 diff --git a/pokedex/db/media.py b/pokedex/db/media.py new file mode 100644 index 0000000..9faf19f --- /dev/null +++ b/pokedex/db/media.py @@ -0,0 +1,514 @@ + +"""Media accessors + +Most media accessor __init__s take an ORM object from the pokedex package. +Their various methods take a number of arguments specifying exactly which +file you want (such as the female sprite, backsprite, etc.). +ValueError is raised when the specified file cannot be found. + +The accessors use fallbacks: for example Bulbasaur's males and females look the +same, so if you request Bulbasaur's female sprite, it will give you the common +image. Or for a Pokemon without individual form sprites, you will get the +common base sprite. Or for versions witout shiny Pokemon, you will always +get the non-shiny version (that's how shiny Pokemon looked there!). +However arguments such as `animated` don't use fallbacks. +You can set `strict` to True to disable these fallbacks and cause ValueError +to be raised when the exact specific file you asked for is not found. This is +useful for listing non-duplicate sprites, for example. + +Use keyword arguments when calling the media-getting methods, unless noted +otherwise. + +The returned "file" objects have useful attributes like relative_path, +path, and open(). + +All images are in the PNG format, except animations (GIF). All sounds are OGGs. +""" + +import os +import pkg_resources + +class MediaFile(object): + """Represents a file: picture, sound, etc. + + Attributes: + relative_path: Filesystem path relative to the media directory + path: Absolute path to the file + + exists: True if the file exists + + open(): Open the file + """ + def __init__(self, *path_elements): + self.path_elements = path_elements + self._dexpath = '/'.join(('data', 'media') + path_elements) + + @property + def relative_path(self): + return os.path.join(*self.path_elements) + + @property + def path(self): + return pkg_resources.resource_filename('pokedex', self._dexpath) + + def open(self): + """Open this file for reading, in the appropriate mode (i.e. binary) + """ + return open(self.path, 'rb') + + @property + def exists(self): + return pkg_resources.resource_exists('pokedex', self._dexpath) + + def __eq__(self, other): + return self.path == other.path + + def __ne__(self, other): + return self.path != other.path + + def __str__(self): + return '' % self.relative_path + +class BaseMedia(object): + def from_path_elements(self, path_elements, basename, extension, + surely_exists=False): + filename = basename + extension + path_elements = [self.toplevel_dir] + path_elements + [filename] + mfile = MediaFile(*path_elements) + if surely_exists or mfile.exists: + return mfile + else: + raise ValueError('File %s not found' % mfile.relative_path) + +class _BasePokemonMedia(BaseMedia): + toplevel_dir = 'pokemon' + has_gender_differences = False + form = None + introduced_in = 0 + + # Info about of what's inside the pokemon main sprite directories, so we + # don't have to check directory existence all the time. + _pokemon_sprite_info = { + 'red-blue': (1, set('back gray'.split())), + 'red-green': (1, set('back gray'.split())), + 'yellow': (1, set('back gray gbc'.split())), + 'gold': (2, set('back shiny'.split())), + 'silver': (2, set('back shiny'.split())), + 'crystal': (2, set('animated back shiny'.split())), + 'ruby-sapphire': (3, set('back shiny'.split())), + 'emerald': (3, set('animated back shiny frame2'.split())), + 'firered-leafgreen': (3, set('back shiny'.split())), + 'diamond-pearl': (4, set('back shiny female frame2'.split())), + 'platinum': (4, set('back shiny female frame2'.split())), + 'heartgold-soulsilver': (4, set('back shiny female frame2'.split())), + 'black-white': (5, set('back shiny female'.split())), + } + + def __init__(self, pokemon_id, form_postfix=None): + BaseMedia.__init__(self) + self.pokemon_id = str(pokemon_id) + self.form_postfix = form_postfix + + def _get_file(self, path_elements, extension, strict, surely_exists=False): + basename = str(self.pokemon_id) + if self.form_postfix: + fullname = basename + self.form_postfix + try: + return self.from_path_elements( + path_elements, fullname, extension, + surely_exists=surely_exists) + except ValueError: + if strict: + raise + return self.from_path_elements(path_elements, basename, extension, + surely_exists=surely_exists) + + def sprite(self, + version='black-white', + + # The media directories are in this order: + animated=False, + back=False, + color=None, + shiny=False, + female=False, + frame=None, + + strict=False, + ): + """Get a main sprite sprite for a pokemon. + + Everything except version should be given as a keyword argument. + + Either specify version as an ORM object, or give the version path as + a string (which is the only way to get 'red-green'). Leave the default + for the latest version. + + animated: get a GIF animation (currently Crystal & Emerald only) + back: get a backsprite instead of a front one + color: can be 'color' (RGBY only) or 'gbc' (Yellow only) + shiny: get a shiny sprite. In old versions, gives a normal sprite unless + `strict` is set + female: get a female sprite instead of male. For pokemon with no sexual + dimorphism, gets the common sprite unless `strict` is set. + frame: set to 2 to get the second frame of the animation + (Emerald, DPP, and HG/SS only) + + If the sprite is not found, raise a ValueError. + """ + if isinstance(version, basestring): + version_dir = version + try: + generation, info = self._pokemon_sprite_info[version_dir] + except KeyError: + raise ValueError('Version directory %s not found', version_dir) + else: + version_dir = version.identifier + try: + generation, info = self._pokemon_sprite_info[version_dir] + except KeyError: + version_group = version.version_group + version_dir = '-'.join( + v.identifier for v in version_group.versions) + generation, info = self._pokemon_sprite_info[version_dir] + if generation < self.introduced_in: + raise ValueError("Pokemon %s didn't exist in %s" % ( + self.pokemon_id, version_dir)) + path_elements = ['main-sprites', version_dir] + if animated: + if 'animated' not in info: + raise ValueError("No animated sprites for %s" % version_dir) + path_elements.append('animated') + extension = '.gif' + else: + extension = '.png' + if back: + if version_dir == 'emerald': + # Emerald backsprites are the same as ruby/sapphire + if strict: + raise ValueError("Emerald uses R/S backsprites") + if animated: + raise ValueError("No animated backsprites for Emerald") + path_elements[1] = version_dir = 'ruby-sapphire' + if version_dir == 'crystal' and animated: + raise ValueError("No animated backsprites for Crystal") + path_elements.append('back') + if color == 'gray': + if 'gray' not in info: + raise ValueError("No grayscale sprites for %s" % version_dir) + path_elements.append('gray') + elif color == 'gbc': + if 'gbc' not in info: + raise ValueError("No GBC sprites for %s" % version_dir) + path_elements.append('gbc') + elif color: + raise ValueError("Unknown color scheme: %s" % color) + if shiny: + if 'shiny' in info: + path_elements.append('shiny') + elif strict: + raise ValueError("No shiny sprites for %s" % version_dir) + if female: + female_sprite = self.has_gender_differences + # Chimecho's female back frame 2 sprite has one hand in + # a slightly different pose, in Platinum and HGSS + # (we have duplicate sprites frame 1, for convenience) + if self.pokemon_id == '358' and back and version_dir in ( + 'platinum', 'heartgold-soulsilver'): + female_sprite = True + female_sprite = female_sprite and 'female' in info + if female_sprite: + path_elements.append('female') + elif strict: + raise ValueError( + 'Pokemon %s has no gender differences' % self.pokemon_id) + if not frame or frame == 1: + pass + elif frame == 2: + if 'frame2' in info: + path_elements.append('frame%s' % frame) + else: + raise ValueError("No frame 2 for %s" % version_dir) + else: + raise ValueError("Bad frame %s" % frame) + return self._get_file(path_elements, extension, strict=strict, + # Avoid a stat in the common case + surely_exists=(self.form and version_dir == 'black-white' + and not back and not female + and not self.form_postfix)) + + def _maybe_female(self, path_elements, female, strict): + if female: + if self.has_gender_differences: + elements = path_elements + ['female'] + try: + return self._get_file(elements, '.png', strict=strict) + except ValueError: + if strict: + raise + elif strict: + raise ValueError( + 'Pokemon %s has no gender differences' % self.pokemon_id) + return self._get_file(path_elements, '.png', strict=strict) + + def icon(self, female=False, strict=False): + """Get the Pokemon's menu icon""" + return self._maybe_female(['icons'], female, strict) + + def sugimori(self, female=False, strict=False): + """Get the Pokemon's official art, drawn by Ken Sugimori""" + return self._maybe_female(['sugimori'], female, strict) + + def overworld(self, + direction='down', + shiny=False, + female=False, + frame=1, + strict=False, + ): + """Get an overworld sprite + + direction: 'up', 'down', 'left', or 'right' + shiny: true for a shiny sprite + female: true for female sprite (or the common one for both M & F) + frame: 2 for the second animation frame + + strict: disable fallback for `female` + """ + path_elements = ['overworld'] + if shiny: + path_elements.append('shiny') + if female: + if self.has_gender_differences: + path_elements.append('female') + elif strict: + raise ValueError('No female overworld sprite') + else: + female = False + path_elements.append(direction) + if frame and frame > 1: + path_elements.append('frame%s' % frame) + try: + return self._get_file(path_elements, '.png', strict=strict) + except ValueError: + if female and not strict: + path_elements.remove('female') + return self._get_file(path_elements, '.png', strict=strict) + else: + raise + + def footprint(self, strict=False): + """Get the Pokemon's footprint""" + return self._get_file(['footprints'], '.png', strict=strict) + + def trozei(self, strict=False): + """Get the Pokemon's animated Trozei sprite""" + return self._get_file(['trozei'], '.gif', strict=strict) + + def cry(self, strict=False): + """Get the Pokemon's cry""" + return self._get_file(['cries'], '.ogg', strict=strict) + + def cropped_sprite(self, strict=False): + """Get the Pokemon's cropped sprite""" + return self._get_file(['cropped'], '.png', strict=strict) + +class PokemonFormMedia(_BasePokemonMedia): + """Media related to a Pokemon form + """ + def __init__(self, pokemon_form): + pokemon_id = pokemon_form.form_base_pokemon_id + if pokemon_form.identifier: + form_postfix = '-' + pokemon_form.identifier + else: + form_postfix = None + _BasePokemonMedia.__init__(self, pokemon_id, form_postfix) + self.form = pokemon_form + pokemon = pokemon_form.form_base_pokemon + self.has_gender_differences = pokemon.has_gender_differences + self.introduced_in = pokemon.generation_id + +class PokemonMedia(_BasePokemonMedia): + """Media related to a Pokemon + """ + def __init__(self, pokemon): + _BasePokemonMedia.__init__(self, pokemon.id) + self.form = pokemon.default_form + self.has_gender_differences = (pokemon.has_gender_differences) + self.introduced_in = pokemon.generation_id + +class UnknownPokemonMedia(_BasePokemonMedia): + """Media related to the unknown Pokemon ("?") + + Note that not a lot of files are available for it. + """ + def __init__(self): + _BasePokemonMedia.__init__(self, '0') + +class EggMedia(_BasePokemonMedia): + """Media related to a pokemon egg + + Note that not a lot of files are available for these. + + Give a Manaphy as `pokemon` to get the Manaphy egg. + """ + def __init__(self, pokemon=None): + if pokemon and pokemon.identifier == 'manaphy': + postfix = '-manaphy' + else: + postfix = None + _BasePokemonMedia.__init__(self, 'egg', postfix) + +class SubstituteMedia(_BasePokemonMedia): + """Media related to the Substitute sprite + + Note that not a lot of files are available for Substitute. + """ + def __init__(self): + _BasePokemonMedia.__init__(self, 'substitute') + +class _BaseItemMedia(BaseMedia): + toplevel_dir = 'items' + def underground(self, rotation=0): + """Get the item's sprite as it appears in the Sinnoh underground + + Rotation can be 0, 90, 180, or 270. + """ + if rotation: + basename = self.identifier + '-%s' % rotation + else: + basename = self.identifier + return self.from_path_elements(['underground'], basename, '.png') + +class ItemMedia(_BaseItemMedia): + """Media related to an item + """ + def __init__(self, item): + self.item = item + self.identifier = item.identifier + + def sprite(self, version=None): + """Get the item's sprite + + If version is not given, use the latest version. + """ + identifier = self.identifier + # Handle machines + # We check the identifier, so that we don't query the machine + # information for any item. + if identifier.startswith(('tm', 'hm')): + try: + int(identifier[2:]) + except ValueError: + # Not really a TM/HM + pass + else: + machines = self.item.machines + if version: + try: + machine = [ + m for m in machines + if m.version_group == version.version_group + ][0] + except IndexError: + raise ValueError("%s doesn't exist in %s" % ( + identifier, version.identifier)) + else: + # They're ordered, so get the last one + machine = machines[-1] + type_identifier = machine.move.type.identifier + identifier = identifier[:2] + '-' + type_identifier + elif identifier.startswith('data-card-'): + try: + int(identifier[10:]) + except ValueError: + # Not a real data card??? + pass + else: + identifier = 'data-card' + if version is not None: + generation_id = version.generation.id + if generation_id <= 3 and identifier == 'dowsing-mchn': + identifier = 'itemfinder' + try: + gen = 'gen%s' % generation_id + return self.from_path_elements([gen], identifier, '.png') + except ValueError: + pass + return self.from_path_elements([], identifier, '.png', + surely_exists=True) + + def underground(self, rotation=0): + """Get the item's sprite as it appears in the Sinnoh underground + + Rotation can be 0, 90, 180, or 270. + """ + if not self.item.appears_underground: + raise ValueError("%s doesn't appear underground" % self.identifier) + return super(ItemMedia, self).underground(rotation=rotation) + + def berry_image(self): + """Get a berry's big sprite + """ + if not self.item.berry: + raise ValueError("%s is not a berry" % self.identifier) + return self.from_path_elements(['berries'], self.identifier, '.png') + +class UndergroundRockMedia(_BaseItemMedia): + """Media related to a rock in the Sinnoh underground + + rock_type can be one of: i, ii, o, o-big, s, t, z + """ + def __init__(self, rock_type): + self.identifier = 'rock-%s' % rock_type + +class UndergroundSphereMedia(_BaseItemMedia): + """Media related to a sphere in the Sinnoh underground + + color can be one of: red, blue, green, pale, prism + """ + def __init__(self, color, big=False): + self.identifier = '%s-sphere' % color + if big: + self.identifier += '-big' + +class _SimpleIconMedia(BaseMedia): + def __init__(self, thing): + self.identifier = thing.identifier + + def icon(self): + return self.from_path_elements([], self.identifier, '.png') + +class DamageClassMedia(_SimpleIconMedia): + toplevel_dir = 'damage-classes' + +class HabitatMedia(_SimpleIconMedia): + toplevel_dir = 'habitats' + +class ShapeMedia(_SimpleIconMedia): + toplevel_dir = 'shapes' + +class ItemPocketMedia(_SimpleIconMedia): + toplevel_dir = 'item-pockets' + def icon(self, selected=False): + if selected: + return self.from_path_elements( + ['selected'], self.identifier, '.png') + else: + return self.from_path_elements([], self.identifier, '.png') + +class _LanguageIconMedia(_SimpleIconMedia): + def icon(self, lang='en'): + return self.from_path_elements([lang], self.identifier, '.png') + +class ContestTypeMedia(_LanguageIconMedia): + toplevel_dir = 'contest-types' + +class TypeMedia(_LanguageIconMedia): + toplevel_dir = 'types' + +''' XXX: No accessors for: +chrome +fonts +ribbons +''' diff --git a/pokedex/tests/test_media.py b/pokedex/tests/test_media.py new file mode 100644 index 0000000..e84aad4 --- /dev/null +++ b/pokedex/tests/test_media.py @@ -0,0 +1,255 @@ + +"""Test the media accessors. + +If run directly from the command line, also tests the accessors and the names +of all the media by getting just about everything in a naive brute-force way. +This, of course, takes a lot of time to run. +""" + +import os +import re + +from nose.tools import * +from nose.plugins.skip import SkipTest +import nose +import pkg_resources + +from pokedex.db import tables, connect, media + +session = connect() +basedir = pkg_resources.resource_filename('pokedex', 'data/media') + +path_re = re.compile('^[-a-z0-9./]*$') + +def test_totodile(): + """Totodile's female sprite -- same as male""" + totodile = session.query(tables.Pokemon).filter_by(identifier=u'totodile').one() + accessor = media.PokemonMedia(totodile) + assert accessor.sprite() == accessor.sprite(female=True) + +def test_chimecho(): + """Chimecho's Platinum female backsprite -- diffeent from male""" + chimecho = session.query(tables.Pokemon).filter_by(identifier=u'chimecho').one() + accessor = media.PokemonMedia(chimecho) + male = accessor.sprite('platinum', back=True, frame=2) + female = accessor.sprite('platinum', back=True, female=True, frame=2) + assert male != female + +def test_venonat(): + """Venonat's shiny Yellow sprite -- same as non-shiny""" + venonat = session.query(tables.Pokemon).filter_by(identifier=u'venonat').one() + accessor = media.PokemonMedia(venonat) + assert accessor.sprite('yellow') == accessor.sprite('yellow', shiny=True) + +def test_arceus_icon(): + """Arceus fire-form icon -- same as base icon""" + arceus = session.query(tables.Pokemon).filter_by(identifier=u'arceus').one() + accessor = media.PokemonMedia(arceus) + fire_arceus = [f for f in arceus.forms if f.identifier == 'fire'][0] + fire_accessor = media.PokemonFormMedia(fire_arceus) + assert accessor.icon() == fire_accessor.icon() + +@raises(ValueError) +def test_strict_castform(): + """Castform rainy form overworld with strict -- unavailable""" + castform = session.query(tables.Pokemon).filter_by(identifier=u'castform').first() + rainy_castform = [f for f in castform.forms if f.identifier == 'rainy'][0] + rainy_castform = media.PokemonFormMedia(rainy_castform) + rainy_castform.overworld('up', strict=True) + +@raises(ValueError) +def test_strict_exeggcute(): + """Exeggcutes's female backsprite, with strict -- unavailable""" + exeggcute = session.query(tables.Pokemon).filter_by(identifier=u'exeggcute').one() + accessor = media.PokemonMedia(exeggcute) + accessor.sprite(female=True, strict=True) + + + +def get_all_filenames(): + print 'Reading all filenames...' + + all_filenames = set() + + for dirpath, dirnames, filenames in os.walk(basedir): + for filename in filenames: + path = os.path.join(dirpath, filename) + assert path_re.match(path), path + all_filenames.add(path) + + return all_filenames + +def hit(filenames, method, *args, **kwargs): + """ + Run the given accessor method with args & kwargs; if found remove the + result path from filenames and return True, else return False. + """ + try: + medium = method(*args, **kwargs) + #print 'Hit', medium.relative_path + assert medium.exists + except ValueError, e: + #print 'DNF', e + return False + except: + print 'Error while processing', method, args, kwargs + raise + try: + filenames.remove(medium.path) + except KeyError: + pass + return True + +def check_get_everything(): + """ + For every the accessor method, loop over the Cartesian products of all + possible values for its arguments. + Make sure we get every file in the repo, and that we get a file whenever + we should. + + Well, there are exceptions of course. + """ + + versions = list(session.query(tables.Version).all()) + versions.append('red-green') + + black = session.query(tables.Version).filter_by(identifier=u'black').one() + + filenames = get_all_filenames() + + # Some small stuff first + + for damage_class in session.query(tables.MoveDamageClass).all(): + assert hit(filenames, media.DamageClassMedia(damage_class).icon) + + for habitat in session.query(tables.PokemonHabitat).all(): + assert hit(filenames, media.HabitatMedia(habitat).icon) + + for shape in session.query(tables.PokemonShape).all(): + assert hit(filenames, media.ShapeMedia(shape).icon) + + for item_pocket in session.query(tables.ItemPocket).all(): + assert hit(filenames, media.ItemPocketMedia(item_pocket).icon) + assert hit(filenames, media.ItemPocketMedia(item_pocket).icon, selected=True) + + for contest_type in session.query(tables.ContestType).all(): + assert hit(filenames, media.ContestTypeMedia(contest_type).icon) + + for elemental_type in session.query(tables.Type).all(): + assert hit(filenames, media.TypeMedia(elemental_type).icon) + + # Items + versions_for_items = [ + None, + session.query(tables.Version).filter_by(identifier='emerald').one(), + ] + + for item in session.query(tables.Item).all(): + accessor = media.ItemMedia(item) + assert hit(filenames, accessor.berry_image) or not item.berry + for rotation in (0, 90, 180, 270): + assert hit(filenames, accessor.underground, rotation=rotation) or ( + not item.appears_underground or rotation) + for version in versions_for_items: + success = hit(filenames, accessor.sprite, version=version) + if version is None: + assert success + + for color in 'red green blue pale prism'.split(): + for big in (True, False): + accessor = media.UndergroundSphereMedia(color=color, big=big) + assert hit(filenames, accessor.underground) + + for rock_type in 'i ii o o-big s t z'.split(): + accessor = media.UndergroundRockMedia(rock_type) + for rotation in (0, 90, 180, 270): + success = hit(filenames, accessor.underground, rotation=rotation) + assert success or rotation + + # Pokemon! + accessors = [] + + accessors.append(media.UnknownPokemonMedia()) + accessors.append(media.EggMedia()) + manaphy = session.query(tables.Pokemon).filter_by(identifier=u'manaphy').one() + accessors.append(media.EggMedia(manaphy)) + accessors.append(media.SubstituteMedia()) + + print 'Loading pokemon' + + for form in session.query(tables.PokemonForm).filter(tables.PokemonForm.identifier != '').all(): + accessors.append(media.PokemonFormMedia(form)) + + for pokemon in session.query(tables.Pokemon).all(): + accessors.append(media.PokemonMedia(pokemon)) + + for accessor in accessors: + assert hit(filenames, accessor.footprint) or not accessor.form + assert hit(filenames, accessor.trozei) or not accessor.form or ( + accessor.form.pokemon.generation.id > 3) + assert hit(filenames, accessor.cry) or not accessor.form + assert hit(filenames, accessor.cropped_sprite) or not accessor.form + for female in (True, False): + assert hit(filenames, accessor.icon, female=female) or not accessor.form + assert hit(filenames, accessor.sugimori, female=female) or ( + not accessor.form or accessor.form.pokemon.id >= 647) + for shiny in (True, False): + for frame in (1, 2): + for direction in 'up down left right'.split(): + assert hit(filenames, accessor.overworld, + direction=direction, + shiny=shiny, + female=female, + frame=frame, + ) or not accessor.form or ( + accessor.form.pokemon.generation.id > 4) + for version in versions: + for animated in (True, False): + for back in (True, False): + for color in (None, 'gray', 'gbc'): + success = hit(filenames, + accessor.sprite, + version, + animated=animated, + back=back, + color=color, + shiny=shiny, + female=female, + frame=frame, + ) + if (version == black and not animated + and not back and not color and not + shiny and not female and + frame == 1): + # All pokemon are in Black + assert success or not accessor.form + if (str(accessor.pokemon_id) == '1' + and not animated and not color and + frame == 1): + # Bulbasaur is in all versions + assert success + + # Remove exceptions + exceptions = [os.path.join(basedir, dirname) for dirname in + 'chrome fonts ribbons'.split()] + exceptions.append(os.path.join(basedir, 'items', 'hm-')) + exceptions = tuple(exceptions) + + for filename in tuple(filenames): + if filename.startswith(exceptions): + filenames.remove(filename) + + if len(filenames): + print + print '-----------------' + print 'Unaccessed stuff:' + for filename in sorted(filenames): + print filename + print len(filenames), 'unaccessed files :(' + + return (not filenames) + +if __name__ == '__main__': + result = nose.run(defaultTest=__file__) + result = result and check_get_everything() + exit(not result)