Basic implementation, can fill in some attributes on the character sheet.

This commit is contained in:
Mark Wolfman
2018-03-25 21:08:48 -05:00
parent e823e71fdd
commit 7862d14c9d
14 changed files with 372 additions and 0 deletions
View File
Binary file not shown.
+57
View File
@@ -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
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
class DiceError(ValueError):
"""Improper formatting for a dice string."""
pass
+113
View File
@@ -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()
+28
View File
@@ -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.
+19
View File
@@ -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
+12
View File
@@ -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'],
)
+27
View File
@@ -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')
+18
View File
@@ -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')
+19
View File
@@ -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))
+52
View File
@@ -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)