mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-05-18 20:23:27 +02:00
Basic implementation, can fill in some attributes on the character sheet.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
"""Tools for describing a player character."""
|
||||
|
||||
import re
|
||||
|
||||
from .stats import Stat
|
||||
from .dice import read_dice_str
|
||||
|
||||
dice_re = re.compile('(\d+)d(\d+)')
|
||||
|
||||
class Character():
|
||||
"""A generic player character. Intended to be subclasses by the
|
||||
various classes.
|
||||
|
||||
"""
|
||||
# General attirubtes
|
||||
name = ""
|
||||
class_name = ""
|
||||
player_name = ""
|
||||
level = 1
|
||||
alignment = 'true neutral'
|
||||
xp = 0
|
||||
armor_class = 10
|
||||
speed = 30 # In feet
|
||||
# Hit points
|
||||
hp_max = 10
|
||||
hit_dice_num = 1
|
||||
hit_dice_faces = 8
|
||||
# Base stats (ability scores
|
||||
strength = Stat()
|
||||
dexterity = Stat()
|
||||
constitution = Stat()
|
||||
intelligence = Stat()
|
||||
wisdom = Stat()
|
||||
charisma = Stat()
|
||||
|
||||
def __init__(self, **attrs):
|
||||
"""Takes a bunch of attrs and passes them to ``set_attrs``"""
|
||||
self.set_attrs(**attrs)
|
||||
|
||||
def set_attrs(self, **attrs):
|
||||
"""Bulk setting of attributes. Useful for loading a character from a
|
||||
dictionary."""
|
||||
for attr, val in attrs.items():
|
||||
setattr(self, attr, val)
|
||||
|
||||
@property
|
||||
def hit_dice(self):
|
||||
"""What type and how many dice to use for re-gaining hit points.
|
||||
|
||||
To change, set hit_dice_num and hit_dice_faces."""
|
||||
return f"{self.hit_dice_num}d{self.hit_dice_faces}"
|
||||
|
||||
@hit_dice.setter
|
||||
def hit_dice(self, val):
|
||||
dice = read_dice_str(val)
|
||||
self.hit_dice_faces = dice.faces
|
||||
self.hit_dice_num = dice.num
|
||||
@@ -0,0 +1,24 @@
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
from .exceptions import DiceError
|
||||
|
||||
dice_re = re.compile('(\d+)d(\d+)', flags=re.I)
|
||||
Dice = namedtuple('Dice', ('num', 'faces'))
|
||||
|
||||
def read_dice_str(dice_str):
|
||||
"""Interpret a D&D dice string, eg. 3d10.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dice : tuple
|
||||
A named tuple with the scheme (num, faces), so '3d10' return
|
||||
(num=3, faces=10)
|
||||
|
||||
"""
|
||||
match = dice_re.match(dice_str)
|
||||
if match is None:
|
||||
raise DiceError(f"Cannot interpret dice string {dice_str}")
|
||||
dice = Dice(num=int(match.group(1)),
|
||||
faces=int(match.group(2)))
|
||||
return dice
|
||||
@@ -0,0 +1,3 @@
|
||||
class DiceError(ValueError):
|
||||
"""Improper formatting for a dice string."""
|
||||
pass
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from fdfgen import forge_fdf
|
||||
|
||||
from dungeonsheets.character import Character
|
||||
|
||||
"""Program to take character definitions and build a PDF of the
|
||||
character sheet."""
|
||||
|
||||
def load_character_file(filename):
|
||||
"""Create a character object from the given definition file.
|
||||
|
||||
The definition file should be an importable python file, filled
|
||||
with variables describing the character.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename : str
|
||||
The path to the file that will be imported.
|
||||
|
||||
"""
|
||||
# Parse the file name
|
||||
dir_, fname = os.path.split(os.path.abspath(filename))
|
||||
module_name, ext = os.path.splitext(fname)
|
||||
if ext != '.py':
|
||||
raise ValueError(f"Character definition {filename} is not a python file.")
|
||||
# Import the module to extract the information
|
||||
spec = importlib.util.spec_from_file_location('module', filename)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# Prepare a list of properties for this character
|
||||
char_props = {}
|
||||
for prop_name in dir(module):
|
||||
if prop_name[0:2] != '__':
|
||||
char_props[prop_name] = getattr(module, prop_name)
|
||||
return char_props
|
||||
|
||||
|
||||
def create_fdf(character, fdfname):
|
||||
# Prepare the list of fields
|
||||
class_level = (character.class_name + ' ' + str(character.level))
|
||||
fields = [
|
||||
# Character description
|
||||
('CharacterName', character.name),
|
||||
('ClassLevel', class_level),
|
||||
('Background', character.background),
|
||||
('PlayerName', character.player_name),
|
||||
('Race ', character.race),
|
||||
('Alignment', character.alignment),
|
||||
('XP', character.xp),
|
||||
# Attributes
|
||||
('STRmod', str(character.strength.value)),
|
||||
('STR', character.strength.modifier_string),
|
||||
('DEXmod ', character.dexterity.value),
|
||||
('DEX', character.dexterity.modifier_string),
|
||||
('CONmod', character.constitution.value),
|
||||
('CON', character.constitution.modifier_string),
|
||||
('INTmod', character.intelligence.value),
|
||||
('INT', character.intelligence.modifier_string),
|
||||
('WISmod', character.wisdom.value),
|
||||
('WIS', character.wisdom.modifier_string),
|
||||
('CHamod', character.charisma.value),
|
||||
('CHA', character.charisma.modifier_string),
|
||||
# Hit points
|
||||
('HDTotal', character.hit_dice),
|
||||
('HPMax', character.hp_max),
|
||||
|
||||
]
|
||||
fdf = forge_fdf("", fields, [], [], [])
|
||||
fdf_file = open(fdfname, "wb")
|
||||
fdf_file.write(fdf)
|
||||
fdf_file.close()
|
||||
|
||||
|
||||
def make_sheet(character_file, flatten=False):
|
||||
"""Prepare a PDF character sheet from the given character file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flatten : bool, optional
|
||||
If true, the resulting PDF will not be a fillable form.
|
||||
|
||||
"""
|
||||
# Create a character from the character definition
|
||||
char_props = load_character_file(character_file)
|
||||
char = Character(**char_props)
|
||||
# Set the fields in the FDF
|
||||
fdfname = os.path.splitext(character_file)[0] + '.fdf'
|
||||
create_fdf(character=char, fdfname=fdfname)
|
||||
# Build the final flattened PDF document
|
||||
dirname = os.path.dirname(os.path.abspath(__file__))
|
||||
src_pdf = os.path.join(dirname, 'blank-character-sheet-default.pdf')
|
||||
dest_pdf = os.path.splitext(character_file)[0] + '.pdf'
|
||||
popenargs = [
|
||||
'pdftk', src_pdf, 'fill_form', fdfname, 'output', dest_pdf,
|
||||
]
|
||||
if flatten:
|
||||
popenargs.append('flatten')
|
||||
subprocess.call(popenargs)
|
||||
# Clean up temporary files
|
||||
os.remove(fdfname)
|
||||
|
||||
|
||||
def main():
|
||||
make_sheet('examples/rogue.py')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
import math
|
||||
|
||||
class Stat():
|
||||
value = 10
|
||||
|
||||
def __init__(self, value=10):
|
||||
self.value = value
|
||||
|
||||
@property
|
||||
def modifier(self):
|
||||
return math.floor((self.value - 10) / 2)
|
||||
|
||||
@property
|
||||
def modifier_string(self):
|
||||
"""Similar to ``modifier`` but as a string.
|
||||
|
||||
This also adds a '+' if necessary.
|
||||
|
||||
"""
|
||||
mod = self.modifier
|
||||
if mod > 0:
|
||||
mod_str = '+' + str(mod)
|
||||
else:
|
||||
mod_str = str(mod)
|
||||
return mod_str
|
||||
|
||||
def __set__(self, obj, val):
|
||||
self.value = val
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
name = 'Mr. Stabby'
|
||||
player_class = 'rogue'
|
||||
player_name = 'Mark'
|
||||
background = "Criminal"
|
||||
race = "Lightfoot halfling"
|
||||
level = 3
|
||||
alignment = "Neutral"
|
||||
class_name = "Rogue"
|
||||
xp = 1984
|
||||
hp_max = 19
|
||||
hit_dice = '3d10'
|
||||
|
||||
# Ability Scores
|
||||
strength = 10
|
||||
dexterity = 15
|
||||
constitution = 12
|
||||
intelligence = 13
|
||||
wisdom = 9
|
||||
charisma = 16
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from distutils.core import setup
|
||||
|
||||
setup(name='dungeonsheets',
|
||||
version='0.1dev',
|
||||
description='Dungeons and Dragons 5e Character Tools',
|
||||
author='Mark Wolfman',
|
||||
author_email='canismarko@gmail.com',
|
||||
url='',
|
||||
packages=['dungeonsheets'],
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from dungeonsheets.character import Character
|
||||
|
||||
class TestCharacter(TestCase):
|
||||
"""Tests for the generic character base class."""
|
||||
|
||||
def test_constructor(self):
|
||||
char = Character(name="Inara")
|
||||
|
||||
def test_hit_dice(self):
|
||||
# Test the getter
|
||||
char = Character()
|
||||
char.hit_dice_faces = 10
|
||||
char.hit_dice_num = 2
|
||||
self.assertEqual(char.hit_dice, '2d10')
|
||||
# Test the setter
|
||||
char.hit_dice = '3d12'
|
||||
self.assertEqual(char.hit_dice_faces, 12)
|
||||
self.assertEqual(char.hit_dice_num, 3)
|
||||
|
||||
def test_set_attrs(self):
|
||||
char = Character()
|
||||
char.set_attrs(name='Inara')
|
||||
self.assertEqual(char.name, 'Inara')
|
||||
@@ -0,0 +1,18 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from dungeonsheets.exceptions import DiceError
|
||||
from dungeonsheets import dice
|
||||
|
||||
class TestDice(TestCase):
|
||||
|
||||
def test_read_dice_str(self):
|
||||
out = dice.read_dice_str('1d6')
|
||||
self.assertEqual(out.faces, 6)
|
||||
self.assertEqual(out.num, 1)
|
||||
# Multiple digits
|
||||
out = dice.read_dice_str('15d10')
|
||||
self.assertEqual(out.faces, 10)
|
||||
self.assertEqual(out.num, 15)
|
||||
# Check a bad value
|
||||
with self.assertRaises(DiceError):
|
||||
dice.read_dice_str('Ed15')
|
||||
@@ -0,0 +1,19 @@
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from dungeonsheets import make_sheets, character
|
||||
|
||||
|
||||
class CharacterFileTestCase(unittest.TestCase):
|
||||
def test_load_character_file(self):
|
||||
charfile = 'examples/rogue.py'
|
||||
result = make_sheets.load_character_file(charfile)
|
||||
self.assertEqual(result['strength'], 10)
|
||||
|
||||
class FDFTestCase(unittest.TestCase):
|
||||
def test_create_fdf(self):
|
||||
fdfname = 'temp.fdf'
|
||||
char = character.Character()
|
||||
make_sheets.create_fdf(char, fdfname=fdfname)
|
||||
self.assertTrue(os.path.exists(fdfname))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from dungeonsheets import stats
|
||||
|
||||
class TestStats(TestCase):
|
||||
|
||||
def test_modifier(self):
|
||||
ranges = [
|
||||
((1,), -5),
|
||||
((2, 3), -4),
|
||||
((4, 5), -3),
|
||||
((6, 7), -2),
|
||||
((8, 9), -1),
|
||||
((10, 11), 0),
|
||||
((12, 13), 1),
|
||||
((14, 15), 2),
|
||||
((16, 17), 3),
|
||||
((18, 19), 4),
|
||||
((20, 21), 5),
|
||||
((22, 23), 6),
|
||||
((24, 25), 7),
|
||||
((26, 27), 8),
|
||||
((28, 29), 9),
|
||||
((30,), 10),
|
||||
]
|
||||
# Test the values for each modifier range
|
||||
stat = stats.Stat()
|
||||
for range_, target in ranges:
|
||||
for value in range_:
|
||||
stat.value = value
|
||||
msg = f"Stat {value} doesn't produce modifier {target} ({stat.modifier})"
|
||||
self.assertEqual(stat.modifier, target, msg)
|
||||
|
||||
def test_modfifier_string(self):
|
||||
stat = stats.Stat()
|
||||
stat.value = 5
|
||||
self.assertEqual(stat.modifier_string, '-3')
|
||||
stat.value = 10
|
||||
self.assertEqual(stat.modifier_string, '0')
|
||||
stat.value = 15
|
||||
self.assertEqual(stat.modifier_string, '+2')
|
||||
|
||||
def test_setter(self):
|
||||
"""Verify that this class works as a data descriptor."""
|
||||
# Set up a dummy class
|
||||
class MyCharacter():
|
||||
stat = stats.Stat()
|
||||
char = MyCharacter()
|
||||
# Check that the stat works as expected once set
|
||||
char.stat = 15
|
||||
self.assertEqual(char.stat.value, 15)
|
||||
self.assertEqual(char.stat.modifier, 2)
|
||||
Reference in New Issue
Block a user