443 lines
16 KiB
Python
443 lines
16 KiB
Python
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")
|