import warnings from PIL import Image, ImageDraw, ImageFont import requests, os from time import sleep DNDB_BASE_URL = "https://character-service.dndbeyond.com/character/v5/character" DNDB_CHARS = [ { "characterId": 69760590, "characterName": "Вевель", "characterClass": "острожник 10", "portraitName": "vevel", "characterUrl": "/profile/megavenik/characters/69760590", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/31479/654/150/154/1581111423-69760590.jpeg", "hitpointsCoords": (603, 168), }, { "characterId": 69976266, "characterName": "Милош", "characterClass": "помазанник 10", "portraitName": "milosh", "characterUrl": "/profile/EdgarBrin/characters/69976266", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/31479/725/150/155/1581111423-69976266.jpeg", "hitpointsCoords": (588, 168), }, { "characterId": 69976392, "characterName": "Милица", "characterClass": "голос 10", "portraitName": "militsa", "characterUrl": "/profile/tritiumTino/characters/69976392", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/31479/709/150/154/1581111423-69976392.jpeg", "hitpointsCoords": (566, 168), }, { "characterId": 90463225, "characterName": "Нивенна", "characterClass": "чаровница 10", "portraitName": "nivenna", "characterUrl": "/profile/Maslinium/characters/90463225", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/31479/748/160/150/1581111423-90463225.jpeg", "hitpointsCoords": (566, 168), }, { "characterId": 99072742, "characterName": "Дарпа", "characterClass": "кухарь 10", "portraitName": "darpa", "characterUrl": "/profile/Forfattare/characters/99072742", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/34003/953/150/153/1581111423-99072742.jpeg", "hitpointsCoords": (624, 168), }, { "characterId": 99483409, "characterName": "Деян", "characterClass": "", "portraitName": "deyan", "characterUrl": "/profile/SantaKosh/characters/99483409", "avatarUrl": "https://www.dndbeyond.com/avatars/thumbnails/34005/197/157/150/1581111423-99483409.jpeg", "hitpointsCoords": (670, 168), } ] class AbilityScoreCalculator: @staticmethod def calculate_modifier(stat): import math return math.floor((stat - 10) / 2) class DataModifierService: def getModifiersByType(self, groupedModifiers: dict, type: str, subType: str = "") -> list: matching = [] for __, modifiers in groupedModifiers.items(): filtered = list(filter(lambda item: 'type' in item and type == item['type'], modifiers)) if subType: filtered = list(filter(lambda item: 'subType' in item and subType == item['subType'], filtered)) matching += filtered # print(matching) return matching def getTotalModifierValue(self, groupedModifiers: dict, type: str, subType: str) -> int: modifiers = self.getModifiersByType(groupedModifiers, type, subType) modifier_values = [modifier['value'] for modifier in modifiers] # print(modifier_values) return sum(modifier_values) class CharacterService: def __init__(self): self.ability_score_calculator = AbilityScoreCalculator() self.data_modifier = DataModifierService() # self.item_ac_calculator = item_ac_calculator self.stat_map = { 1: 'strength', 2: 'dexterity', 3: 'constitution', 4: 'intelligence', 5: 'wisdom', 6: 'charisma', } # def calculate_ac(self, character: dict) -> int: # dex_mod = self.get_stat_mod(character, 'dex') # character_ac = 10 + dex_mod # armor_ac = 0 # shield_ac = 0 # item_ac_calculator = self.item_ac_calculator # equipped_items = list(filter(lambda item: not item['equipped'] and item['definition'], character['inventory'])) # equipped_shields = list(filter(lambda item: item['definition']['armorTypeId'] == item_ac_calculator.ARMOR_TYPE_SHIELD, equipped_items)) # equipped_armor = list(filter(lambda item: item_ac_calculator.isArmorItem(item['definition']) and item['definition']['armorTypeId'] != item_ac_calculator.ARMOR_TYPE_SHIELD, equipped_items)) # if equipped_shields: # shield_ac = max(map(lambda item: self.item_ac_calculator.calculateItemAc(item['definition'], dex_mod, False), equipped_shields)) or 0 # if equipped_armor: # armor_ac = max(map(lambda item: self.item_ac_calculator.calculateItemAc(item['definition'], dex_mod, False), equipped_armor)) or 0 # else: # unarmored_modifiers = self.data_modifier.getModifiersByType(character['modifiers'], 'set', 'unarmored-armor-class') or [] # armor_ac = max(map(lambda modifier: character_ac + self.get_stat_mod(character, int(modifier['statId'])) + modifier['value'] if modifier.get('statId') else character_ac + modifier['value'], unarmored_modifiers)) or 0 # bonus_ac = self.data_modifier.getTotalModifierValue(character['modifiers'], 'bonus', 'armor-class') # return max(character_ac, armor_ac) + shield_ac + bonus_ac def get_stat_mod(self, character, stat_name): if isinstance(stat_name, int): stat_name = self.stat_map.get(stat_name, '') stat_name = stat_name.lower() stat_id = False for dndbeyond_id, allowed_stat in self.stat_map.items(): if stat_name == allowed_stat or allowed_stat.startswith(stat_name[:3]): stat_id = dndbeyond_id break if stat_id is False: return 0 stats = {stat['id']: stat.get('value') for stat in character.get('overrideStats', [])} if stat_id not in stats or stats[stat_id] == None: stats = {stat['id']: stat.get('value') or 0 for stat in character.get('stats', [])} if stat_id not in stats: return 0 bonus_stat_name = self.stat_map[stat_id] + '-score' bonus_stats = character.get('bonusStats', {}) bonus_value = sum([bonus_stat['value'] for bonus_stat in bonus_stats if bonus_stat['name'] == bonus_stat_name]) # print(f"getTotalModifierValue({character.get('modifiers', [])}, 'bonus', {bonus_stat_name})") total_bonus = self.data_modifier.getTotalModifierValue(character.get('modifiers', {}), 'bonus', bonus_stat_name) all_stats_value = stats[stat_id] + bonus_value + total_bonus limited_stats_value = all_stats_value if all_stats_value <= 20 else 20 # print(f".calculate_modifier({stats[stat_id]} + {bonus_value} + {total_bonus})") return self.ability_score_calculator.calculate_modifier(limited_stats_value) def get_max_hp(self, character): import math if character.get('overrideHitPoints', False): return character['overrideHitPoints'] max_hp = 0 # if 'hitPointType' in character['preferences']: # bonus_per_level_hp = self.data_modifier.getTotalModifierValue(character['modifiers'], 'bonus', 'hit-points-per-level') # con = self.get_stat_mod(character, 'con') # for class_ in character['classes']: # hit_die = class_['definition']['hitDice'] # adjusted_level = class_['level'] # if 'isStartingClass' in class_ and class_['isStartingClass']: # max_hp += hit_die + con # adjusted_level -= 1 # print(f"(({hit_die} / 2) + 1) + {con}) * {adjusted_level} + {bonus_per_level_hp} * {class_['level']}") # max_hp += (math.ceil((hit_die / 2) + 1) + con) * adjusted_level + bonus_per_level_hp * class_['level'] hp_type = character['preferences'].get('hitPointType', False) if hp_type == 2: max_hp = character.get('baseHitPoints', 0) const_bonus = self.get_stat_mod(character, 'con') max_hp += const_bonus*10 else: max_hp = character.get('baseHitPoints', 0) max_hp += character.get('bonusHitPoints', 0) or 0 return max_hp # def get_passive_score(self, character, proficiency_name): # proficiency_name = proficiency_name.lower() # if proficiency_name in ['insight', 'perception']: # stat_mod = self.get_stat_mod(character, 'wis') # elif proficiency_name == 'investigation': # stat_mod = self.get_stat_mod(character, 'int') # else: # stat_mod = 0 # skill_mod = 0 # active_bonuses = self.data_modifier.getModifiersByType(character['modifiers'], 'proficiency', proficiency_name) # if active_bonuses: # skill_mod = self.get_proficiency_bonus(character) # passive_bonuses = self.data_modifier.getModifiersByType(character['modifiers'], 'bonus', f"passive-{proficiency_name}") # skill_mod += sum([bonus['value'] for bonus in passive_bonuses]) # return 10 + stat_mod + skill_mod # def get_xp_needed(self, character): # xp_per_level = [ # 0, # 300, # 900, # 2700, # 6500, # 14000, # 23000, # 34000, # 48000, # 64000, # 85000, # 100000, # 120000, # 140000, # 165000, # 195000, # 225000, # 265000, # 305000, # 355000, # ] # level = sum([class_['level'] for class_ in character['classes']]) # return xp_per_level[min(level, len(xp_per_level) - 1)] # def get_proficiency_bonus(self, character): # skill_proficiency_by_level = [ # 2, # 2, # 2, # 2, # 3, # 3, # 3, # 3, # 4, # 4, # 4, # 4, # 5, # 5, # 5, # 5, # 6, # 6, # 6, # 6, # ] # level = sum([class_['level'] for class_ in character['classes']]) # level = max(1, min(level, 20)) # return skill_proficiency_by_level[level - 1] def calculate_coordinates(object_width, object_height, center_point): """ Calculates the coordinates to put the center point of an object at a specified point. Args: object_width (int): The width of the object. object_height (int): The height of the object. center_point (tuple): The (x, y) coordinates of the point where the center of the object should be placed. Returns: tuple: The (x, y) coordinates of the top-left corner of the object. """ x = center_point[0] - object_width // 2 y = center_point[1] - object_height // 2 return (x, y) def get_correct_text_sizes(draw, text: str, font: ImageFont.FreeTypeFont): with warnings.catch_warnings(): warnings.simplefilter("ignore") label_width, label_height = draw.textsize(text, font=font) # to fix text placing we need to get offsets # https://stackoverflow.com/questions/59008322/pillow-imagedraw-text-coordinates-to-center offset_x, offset_y = font.getoffset(text) label_width += offset_x label_height += offset_y return (label_width, label_height) def get_char_data_from_dndb(char_id: int): headers = { 'Content-Type': 'text/json', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', 'Referer': 'https://www.dndbeyond.com/' } for i in range(3): response = requests.get(f"{DNDB_BASE_URL}/{char_id}?includeCustomItems=true", headers=headers) if response.status_code == 200: data = response.json()['data'] break sleep(1) else: print("Error: Failed to retrieve data from URL.") return False return data def generate_main(char: dict, scene: str = "main"): STATS_DICT = { 1: { "name": "strength", "coords": (684, 304) }, 2: { "name": "dexterity", "coords": (684, 381) }, 3: { "name": "constitution", "coords": (684, 457) }, 4: { "name": "intelligence", "coords": (767, 304) }, 5: { "name": "wisdom", "coords": (767, 381) }, 6: { "name": "charisma", "coords": (767, 457) }, } ch = CharacterService() print(f"Chars name: {char['characterName']}") max_hp = ch.get_max_hp(char['data']) current_hp = max_hp - int(char['data']['removedHitPoints']) print(f"CHARS HEALTH: {current_hp}/{max_hp}") # Load the background image background = Image.open(f"parts/background-{scene}.png") # Load the image to be placed on top foreground = Image.open(f"parts/{char['portraitName']}-main.png") hitpoints = Image.open(f"parts/{char['portraitName']}-hitpoints.png") # Paste the foreground image onto the background background.paste(foreground, (0, 0), foreground) background.paste(hitpoints, (0, 0), hitpoints) # Create a drawing context draw = ImageDraw.Draw(background) draw_width, _ = draw.im.size # pyright: ignore[reportGeneralTypeIssues] for stat in ch.stat_map.keys(): final_stat = ch.get_stat_mod(char['data'], stat) # Define the font and font size for the text labels stats_font = ImageFont.truetype("fonts/NotoSerif-Regular.ttf", 30) # Add text labels on top of the image stats_label = f"+{final_stat}" if final_stat > 0 else f"{final_stat}" print(f"{STATS_DICT[stat]['name']}: {stats_label}") label_width, label_height = get_correct_text_sizes(draw, stats_label, stats_font) draw.text(calculate_coordinates(label_width, label_height, STATS_DICT[stat]["coords"]), stats_label, font=stats_font, fill=(255, 255, 255)) # hitpoints tag hitpoints_font = ImageFont.truetype("fonts/NotoSerif-Regular.ttf", 30) hitpoints_label = f"{current_hp}" label_width, label_height = get_correct_text_sizes(draw, hitpoints_label, hitpoints_font) draw.text(calculate_coordinates(label_width, label_height, char['hitpointsCoords']), hitpoints_label, font=hitpoints_font, fill=(255, 255, 255)) name_font = ImageFont.truetype("fonts/alaruss.ttf", 96) name_label = char['characterName'].upper() label_width, label_height = get_correct_text_sizes(draw, name_label, name_font) draw.text((draw_width-label_width-20, 120), name_label, font=name_font, fill=(255,255,255)) class_font = ImageFont.truetype("fonts/NotoSerif-Regular.ttf", 32) class_label = char['characterClass'].lower() label_width, label_height = get_correct_text_sizes(draw, class_label, class_font) draw.text((draw_width-label_width-15, 203), class_label, font=class_font, fill=(255,255,255)) results_dir = "results" if not os.path.exists(results_dir): os.makedirs(results_dir) if scene == "battle": background = background.resize((553, 320)) background.save(f"results/{char['portraitName']}-{scene}.png") return True # Save the resulting image # empty image placeholder to show donations underneath empty_image = Image.new("RGBA", (100, 100), (0, 0, 0, 0)) empty_image.save("results/transparent.png") # generate_main(DNDB_CHARS[1]) for char in DNDB_CHARS: char['data'] = get_char_data_from_dndb(char['characterId']) generate_main(char, "main") generate_main(char, "battle")