Files
beyond-obs/generate.py

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")