305 lines
10 KiB
Python
305 lines
10 KiB
Python
"""
|
|
Inscryption Card Generator
|
|
A command-line tool for generating custom Inscryption game cards.
|
|
Supports customization of card backgrounds, portraits, symbols, abilities, tribes,
|
|
costs, and other visual elements.
|
|
Authors: Kolyah35, FullHarmony
|
|
License: MIT
|
|
Usage:
|
|
python main.py <filename> [options]
|
|
Examples:
|
|
# Generate a single card
|
|
python main.py grizzly.png -p grizzly -ca 3 -ct blood -r 2 -n Grizzly -hp 6 -d 4
|
|
|
|
# Generate a card set from JSON
|
|
python main.py cardset_output_dir -cs cardset.json
|
|
|
|
# Generate with custom background and symbols
|
|
python main.py card.png -b rare -s ability_sharp ability_costly -t bird
|
|
Dependencies:
|
|
- PIL (Pillow)
|
|
"""
|
|
|
|
from PIL import Image, ImageFont, ImageDraw
|
|
import argparse
|
|
import os
|
|
import json
|
|
|
|
FONT_HEAVY_PATH = './assets/HEAVYWEIGHT.ttf'
|
|
|
|
def load_images_with_prefix(folder, prefix):
|
|
images = {}
|
|
|
|
for filename in os.listdir(folder):
|
|
if not filename.startswith(prefix):
|
|
continue
|
|
|
|
key = filename[len(prefix):]
|
|
key = os.path.splitext(key)[0]
|
|
|
|
images[key] = os.path.join(folder, filename)
|
|
|
|
return images
|
|
|
|
background_dict = load_images_with_prefix('./assets/backgrounds', '')
|
|
symbol_dict = load_images_with_prefix('./assets/symbols', 'ability_')
|
|
portrait_dict = load_images_with_prefix('./assets/portraits', 'portrait_')
|
|
deathcard_dict = load_images_with_prefix('./assets/deathcard', 'deathcard_')
|
|
decals_dict = load_images_with_prefix('./assets/decals', 'decal_')
|
|
tribes_dict = load_images_with_prefix('./assets/tribes', 'tribeicon_')
|
|
stats_dict = load_images_with_prefix('./assets/stats', 'stat_icon_')
|
|
|
|
def damage_or_staticon(value) -> int|str:
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
if value in stats_dict.keys():
|
|
return value
|
|
raise argparse.ArgumentTypeError(
|
|
f"Expected hp number or one of {stats_dict.keys()}"
|
|
)
|
|
|
|
parser = argparse.ArgumentParser(prog='Inscryption Card Generator')
|
|
|
|
parser.add_argument('filename', help='Path to a directory to generate a card set, or a file name to generate a single card')
|
|
parser.add_argument('-cs', '--cardset', type=str, help='Path to a card set')
|
|
parser.add_argument('-r', '--scale', type=int, default=2, help='Scale multiplier for card render')
|
|
parser.add_argument('-b', '--background', choices=background_dict.keys(), type=str, default='common', help='Card background type')
|
|
parser.add_argument('-s', '--symbols', type=str, nargs='+', choices=symbol_dict.keys(), help='Card symbols (max 4)')
|
|
parser.add_argument('-p', '--portrait', type=str, choices=portrait_dict.keys(), help='Card portrait type')
|
|
parser.add_argument('-dec', '--decal', type=str, choices=decals_dict.keys(), help='Card decal type')
|
|
parser.add_argument('-t', '--tribe', type=str, choices=list(tribes_dict.keys()).append('all'), help='Card tribe type')
|
|
parser.add_argument('-ca', '--cost-amount', type=int)
|
|
parser.add_argument('-ct', '--cost-type', type=str, choices=['blood', 'bone'])
|
|
parser.add_argument('-n', '--name', type=str, help='Card name')
|
|
parser.add_argument('-hp', '--hp', type=int, default=0)
|
|
parser.add_argument('-d', '--damage', type=damage_or_staticon, default=0)
|
|
parser.add_argument('--show', action='store_true', help='Show result in system image viewer')
|
|
|
|
args = parser.parse_args()
|
|
|
|
scale = args.scale
|
|
|
|
def img_scale(img, factor = scale):
|
|
return img.resize(
|
|
(int(img.width * factor), int(img.height * factor)),
|
|
Image.Resampling.NEAREST
|
|
)
|
|
|
|
def img_paste(img, dest, pos):
|
|
"""Paste image with position scaling"""
|
|
black = Image.new('RGBA', img.size, (0, 0, 0, 255))
|
|
dest.paste(black, (pos[0] * scale, pos[1] * scale), img)
|
|
|
|
def img_fit_text(img, text, font_path, box, max_size=200, min_size=8, align='center'):
|
|
draw = ImageDraw.Draw(img)
|
|
x, y, w, h = box
|
|
|
|
best_font = None
|
|
best_bbox = None
|
|
|
|
for size in range(max_size, min_size - 1, -1):
|
|
font = ImageFont.truetype(font_path, size)
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
|
|
tw = bbox[2] - bbox[0]
|
|
th = bbox[3] - bbox[1]
|
|
|
|
if tw <= w and th <= h:
|
|
best_font = font
|
|
best_bbox = (tw, th)
|
|
break
|
|
|
|
if best_font is None:
|
|
return False
|
|
|
|
if align == 'left':
|
|
tx = x
|
|
elif align == 'right':
|
|
tx = x + w - best_bbox[0]
|
|
else:
|
|
tx = x + (w - best_bbox[0]) // 2
|
|
|
|
ty = y + (h - best_bbox[1]) // 2
|
|
|
|
draw.text((tx, ty), text, font=best_font, fill=(0, 0, 0, 255))
|
|
return True
|
|
|
|
repeat_names = {}
|
|
|
|
# Generating image
|
|
|
|
def generate_card(
|
|
background: str,
|
|
name: str,
|
|
portrait: str,
|
|
symbols: list,
|
|
cost_type: str,
|
|
cost_amount: int,
|
|
hp: int,
|
|
damage: int|str,
|
|
decal: str,
|
|
tribe: str):
|
|
|
|
ca = cost_amount
|
|
ct = cost_type
|
|
|
|
if ca > 0 and not ct: ct = 'blood'
|
|
|
|
if (ct == 'blood' and ca > 4) or (ct == 'bone' and ca > 10):
|
|
print(f'Error: Cost "{ct}" can not be more than {4 if ct == "blood" else 10}!')
|
|
exit()
|
|
|
|
workspace = Image.open(f'./assets/backgrounds/{background}.png').convert('RGB')
|
|
workspace = workspace.resize((workspace.width * scale, workspace.height * scale), resample=Image.Resampling.NEAREST)
|
|
|
|
print(f'Generating card {name} - {workspace.width}x{workspace.height}...')
|
|
|
|
s_count = len(symbols) if symbols else 0
|
|
for i in range(s_count):
|
|
s = symbols[i]
|
|
|
|
s_variants = [
|
|
[(49, 144)],
|
|
[(49, 168), (74, 143)],
|
|
[(49, 143), (74, 143), (61, 168)],
|
|
[(49, 143), (74, 143), (49, 168), (74, 168)],
|
|
]
|
|
|
|
sl = Image.open(symbol_dict[s]).convert('RGBA')
|
|
|
|
s_scale = (1 if s_count == 1 else 0.5)
|
|
sl = img_scale(sl, scale * s_scale)
|
|
|
|
pos = s_variants[s_count-1][i]
|
|
img_paste(sl, workspace, pos)
|
|
|
|
if portrait:
|
|
if type(portrait) is str:
|
|
p = Image.open(portrait_dict[portrait]).convert('RGBA')
|
|
p = img_scale(p)
|
|
img_paste(p, workspace, (17, 44))
|
|
elif type(portrait) is dict:
|
|
for part in ('base', 'eyes', 'mouth'):
|
|
p = Image.open(deathcard_dict[f'{part}_{portrait[part]}']).convert('RGBA')
|
|
p = img_scale(p)
|
|
img_paste(p, workspace, (17, 44))
|
|
|
|
if tribe:
|
|
tribe_pos = {
|
|
'bird': (16, 18),
|
|
'canine': (90, 18),
|
|
'hooved': (164, 18),
|
|
'insect': (53, 167),
|
|
'reptile': (136, 167)
|
|
} if tribe == 'all' else {'_': (16, 18)}
|
|
|
|
tribes_to_render = tribe_pos.items() if tribe == 'all' else [(tribe, tribe_pos['_'])]
|
|
|
|
for tribe_name, pos in tribes_to_render:
|
|
tribe_key = tribe_name if tribe == 'all' else tribe
|
|
tribe_img = Image.open(f'./assets/tribes/tribeicon_{tribe_key}.png').convert('RGBA')
|
|
tribe_img = img_scale(tribe_img, scale / 2)
|
|
r, g, b, a = tribe_img.split()
|
|
a = a.point(lambda x: x * 0.5)
|
|
tribe_img = Image.merge('RGBA', (r, g, b, a))
|
|
workspace.paste(tribe_img, (pos[0] * int(scale / 2), pos[1] * int(scale / 2)), tribe_img)
|
|
|
|
if cost_type or cost_amount > 0:
|
|
cost_img = Image.open(f'./assets/costs/cost_{cost_amount}{cost_type}.png').convert('RGBA')
|
|
cost_img = img_scale(cost_img, scale)
|
|
workspace.paste(cost_img, (67 * scale, 27 * scale), cost_img)
|
|
|
|
if name.startswith('$'):
|
|
p = Image.open(f'./assets/{name[1:]}.png').convert('RGBA')
|
|
p = img_scale(p)
|
|
img_paste(p, workspace, (17, 16))
|
|
|
|
name = portrait
|
|
else:
|
|
img_fit_text(workspace, name, FONT_HEAVY_PATH, (20 * scale, 20 * scale, 107 * scale, 17 * scale))
|
|
|
|
if damage:
|
|
if type(damage) is int:
|
|
img_fit_text(workspace, str(damage), FONT_HEAVY_PATH, (23 * scale, 144 * scale, 20 * scale, 30 * scale))
|
|
|
|
if type(damage) is str:
|
|
stat_img = Image.open(f'./assets/stats/stat_icon_{damage}.png').convert('RGBA')
|
|
stat_img = img_scale(stat_img, scale)
|
|
img_paste(stat_img, workspace, (11, 138))
|
|
|
|
if hp:
|
|
img_fit_text(workspace, str(hp), FONT_HEAVY_PATH, (110 * scale, 158 * scale, 20 * scale, 30 * scale))
|
|
|
|
if decal:
|
|
decal_img = Image.open(f'./assets/decals/decal_{decal}.png').convert('RGBA')
|
|
decal_img = img_scale(decal_img)
|
|
workspace.paste(decal_img, (11 * scale, 11 * scale), decal_img)
|
|
|
|
if name not in repeat_names:
|
|
repeat_names[name] = 0
|
|
else:
|
|
repeat_names[name] += 1
|
|
|
|
if repeat_names[name] > 0:
|
|
name = f'{name}-{repeat_names[name]}'
|
|
|
|
return workspace, name
|
|
|
|
if args.cardset:
|
|
folder = os.path.join('.', args.filename)
|
|
if not os.path.exists(folder):
|
|
os.mkdir(folder)
|
|
|
|
cardset = json.load(open(args.cardset, encoding="utf-8"))
|
|
|
|
for card in cardset:
|
|
defaults = {
|
|
'background': 'common',
|
|
'name': '',
|
|
'portrait': '',
|
|
'symbols': [],
|
|
'cost-type': '',
|
|
'cost-amount': 0,
|
|
'hp': 0,
|
|
'damage': 0,
|
|
'decal': '',
|
|
'tribe': ''
|
|
}
|
|
|
|
for opt in defaults.items():
|
|
if opt[0] not in card:
|
|
card[opt[0]] = opt[1]
|
|
|
|
gen_card, name = generate_card(
|
|
background=card['background'],
|
|
name=card['name'],
|
|
portrait=card['portrait'],
|
|
symbols=card['symbols'],
|
|
cost_type=card['cost-type'],
|
|
cost_amount=card['cost-amount'],
|
|
hp=card['hp'],
|
|
damage=card['damage'],
|
|
decal=card['decal'],
|
|
tribe=card['tribe']
|
|
)
|
|
gen_card.save(os.path.join(folder, name + '.png'))
|
|
else:
|
|
gen_card, name = generate_card(
|
|
background=args.background,
|
|
name=args.name,
|
|
portrait=args.portrait,
|
|
symbols=args.symbols,
|
|
cost_type=args.cost_type,
|
|
cost_amount=args.cost_amount,
|
|
hp=args.hp,
|
|
damage=args.damage,
|
|
decal=args.decal,
|
|
tribe=args.tribe
|
|
)
|
|
gen_card.save(args.filename)
|
|
|
|
if args.show:
|
|
gen_card.show()
|
|
|