Skip to content

Instantly share code, notes, and snippets.



Last active Dec 15, 2015
What would you like to do?
CastleDoctrine interaction graph generator. Requires CastleDoctrine source code, Python 3, graphviz, imagemagick.
#!/usr/bin/env python3
import collections
import os
import re
import sys
import subprocess
State = collections.namedtuple('State', 'num properties name sprite')
Edge = collections.namedtuple('Edge', 'events begin end')
def set_to_short(s):
return '%s' % ','.join(s)
prop_mapping = {
'deadly': '☠', # skull and crossbones
'powered': 'ϟ', # lightning bolt (koppa)
'conductive': '🔌', # cross
'conductiveLeftToRight': '⇄', # left/right arrow
'conductiveTopToBottom': '⇅', # up/down arrow
'playerSeeking': '❤', # black heart
'playerAvoiding': '💔', # broken heart
'blocking': '🚷', # emoji crossed pedestrian
'stuck': '∞', # infinity (persistent state)
'visionBlocking': '🙈', # emoji see no evil
'mobileBlocking': '📵', # emoji crossed phone
'family': '👪',
escaped_prop_mapping = {prop: '&#%d;' % ord(glyph) for prop, glyph in prop_mapping.items()}
# some objects have different prefixes for their states
object_prefixes = {
'Wired Wooden Wall': 'Wooden Wall',
'Pressure Toggle Switch (Starts On)': 'Pressure Toggle Switch',
'Pressure Toggle Switch (Starts Off)': 'Pressure Toggle Switch',
'Powered Door': 'Door'
# don't display these
ignored_properties = {'darkHaloBehind', 'underLayerShaded', 'shadowMaking', 'noDropShadow', 'forceUnderShadows',
'onTopOfPlayer', 'wall', 'structural', 'mobile', 'interactingWithPlayer'}
# these edges are for changing rendering of things fallen into laddered / closed
# pits or trapdoors, and aren't useful
ignored_edges = {'trapdoor:2', 'trapdoor:3', 'trapdoor:4', 'pit:3'}
# prettier names for various events
event_names = {
'ammoniumNitrateBomb': 'bomb',
'pit:1': 'pit:open',
'trapdoor:1': 'trapdoor:open',
'floor_electric:2': 'electric_floor:on',
'pitbull:4': 'pitbull:attacking'
def properties_to_glyphs(props):
ret = ''
for prop in props:
ret += escaped_prop_mapping.get(prop, '')
return ret
class HouseObject:
def __init__(self, short, name):
self.short = short = name
if '%s' in %= self.short.title()
self.states = {}
self.edges = {}
def add_state(self, num, sprite, properties, name):
if '%s' in name:
name %= self.short.title()
self.states[num] = State(num, frozenset(properties), name, sprite)
def add_edge(self, event, begin, end):
begin = self.states[begin]
end = self.states[end]
if (begin, end) not in self.edges:
self.edges[begin, end] = Edge(set(), begin, end)
self.edges[begin, end].events.add(event)
except KeyError:
print("INVALID TRANSITION:",, event, begin, end)
def add_edge_universal(self, event, end):
if len(self.states) < 5:
for state in self.states:
if state != end:
self.add_edge(event, state, end)
if -1 not in self.states:
self.add_state(-1, '', set(), + ' *')
self.add_edge(event, -1, end)
def __repr__(self):
state_strs = []
for state in sorted(self.states.values()):
state_name = ''
if !=
state_name = '"%s" ' %
state_strs.append('%s: %s{%s}' % (state.num, state_name, ', '.join( - ignored_properties)))
edge_strs = [self.format_edge(edge) for edge in self.edges.values()]
return '%s (%s) %s\n\t%s' % (self.short,, ', '.join(state_strs), '\n\t'.join(edge_strs))
def format_edge(self, edge):
prop_begin = - ignored_properties
prop_end = - ignored_properties
lost_props = ' -' + set_to_short(prop_begin-prop_end) if prop_begin-prop_end else ''
add_props = ' +' + set_to_short(prop_end-prop_begin) if prop_end-prop_begin else ''
return '%s: %d->%d%s%s' % (','.join(, edge.begin.num, edge.end.num, lost_props, add_props)
def output_dot(self, writer):
if len(self.edges) == 0:
return # nothing interesting to graph
writer.write('digraph %s { subgraph cluster_%s { node [color=grey; style=filled; rankType=source]; \n' % (self.short, self.short))
replace_text = object_prefixes.get(,
writer.write(' label=<<FONT POINT-SIZE="30"><TABLE BORDER="0"><TR><TD>%s</TD></TR></TABLE></FONT>>;\n' %
for state in sorted(self.states.values()):
state_label =
if '%s' in state_label:
state_label %= self.short.title()
if '*' in state_label:
state_label = '"(Any)"'
state_label = '<<TABLE BORDER="0" CELLSPACING="0"><TR><TD><IMG SRC="{img}"/></TD></TR><TR><TD>{label}</TD></TR><TR><TD>{props}</TD></TR></TABLE>>'.format( #'<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0"><TR><TD><IMG SRC="{img}"/></TD></TR><TR><TD>{label}</TD></TR><TR><TD>{num} {props}</TD></TR></TABLE>>'.format(
img=state.sprite, label=state_label, props=properties_to_glyphs(
writer.write(' %s [label=%s, shape="box"];\n' % (self.get_state_name(state), state_label.replace(replace_text, '').strip()))
for edge in self.edges.values():
# remove some useless edges from the graph
#if ( - self.render_properties) == ( - self.render_properties):
events_formatted = self.format_events(
if events_formatted == '':
print('skipping',, self.format_edge(edge))
bidi = ''
reverse = self.edges.get((edge.end, edge.begin), None)
if reverse and ==
# bidirectional edges
if edge.end < edge.begin:
bidi = ', dir="both"'
writer.write(' %s->%s [label="%s"%s];\n' % (
def get_state_name(self, state):
state_name = '%s_%d' % (self.short, state.num)
if '%s' in state_name:
state_name %= self.short
return state_name.replace('-', '_')
def format_events(self, events):
events = sorted(event_names.get(event, event) for event in events
if event not in ignored_edges)
return ','.join(events)
def get_sprite(files, sprite_dir):
''' find a sprite and convert it to png format '''
count = 0
for name, path in files.items():
if 'tga' in name and not any(extra in name for extra in ('shadeMap', 'behind', 'under')):
out_sprite = os.path.join(sprite_dir, name.replace('.tga', '.png'))
if not os.path.exists(out_sprite):
subprocess.check_output(['convert', path, '-crop', '32x64+0+0', '-sample', '32', '-gravity', 'South', '-extent', '32x32',
'-fill', 'transparent', '-draw', 'color 0,0 replace ', out_sprite])
assert os.path.exists(out_sprite)
return os.path.abspath(out_sprite)
def read_dir(path):
ret = {'dirs': {}, 'files': {}}
for fname in os.listdir(path):
fullpath = os.path.join(path, fname)
if os.path.isdir(fullpath):
ret['dirs'][fname] = read_dir(fullpath)
ret['files'][fname] = fullpath
return ret
if __name__ == '__main__':
if len(sys.argv) != 3:
print('usage: %s <CastleDoctrine source dir> <output dir>' % sys.argv[0])
src_dir, out_dir = sys.argv[1:]
sprite_dir = os.path.join(out_dir, 'sprite')
ge_dir = os.path.join(src_dir, 'gameElements')
game_elements = read_dir(ge_dir)
if not os.path.exists(out_dir):
if not os.path.exists(sprite_dir):
house_objects = {}
for obj_name, obj_tree in game_elements['dirs']['houseObjects']['dirs'].items():
if len(obj_tree['dirs']) == 1:
# only one state, nothing interesting to generate
if obj_name[-1] in '234':
# one of the redundant wife/son/daughter objects
obj_info = open(obj_tree['files']['info.txt']).read().split('\n')
desc = obj_info[1].strip('"')
obj = HouseObject(obj_name, desc)
for state_name, state_tree in obj_tree['dirs'].items():
properties = set(open(state_tree['files']['properties.txt']).read().split())
name = desc
if 'subInfo.txt' in state_tree['files']:
name = open(state_tree['files']['subInfo.txt']).read().strip('"')
obj.add_state(int(state_name), get_sprite(state_tree['files'], sprite_dir), properties, name)
house_objects[obj_name] = obj
for line in open(game_elements['files']['transitions.txt']).readlines():
if not line.strip():
precise ='(\S+)\s*#\s*(\w+):(\d+)\s*=>\s*(\d+)', line)
all_states ='(\S+)\s*#\s*(\w+)\s*=>\s*(\d+)', line)
if precise:
event, obj_name, begin, end = precise.groups()
if begin == '1':
begin = 0
if end == '1':
end = 0
if obj_name in house_objects:
house_objects[obj_name].add_edge(event, int(begin), int(end))
elif all_states:
event, obj_name, end = all_states.groups()
if obj_name in house_objects:
house_objects[obj_name].add_edge_universal(event, int(end))
for name, obj in sorted(house_objects.items()):
print(' ', obj)
dotpath = os.path.join(out_dir, 'transition')
dotfile = open(dotpath + '.dot', 'w')
# spit out legend:
digraph legend {{
subgraph cluster_legend {{
legend_node [shape=none, label=<
<TR><TD COLSPAN="2">Castle Doctrine v5 Interactions</TD></TR>
<TR><TD COLSPAN="2">created by rmmh 2013</TD></TR>
<TR BORDER="0"><TD> </TD></TR>
<TR><TD BALIGN="LEFT">Symbol Meanings <BR/>
{deadly} Deadly <BR/>
{powered} Power Source <BR/>
{conductive} Conductive <BR/>
{conductiveLeftToRight} Conductive Horizontally <BR/>
{conductiveTopToBottom} Conductive Vertically <BR/>
{playerSeeking} Seeks Player<BR/>
{playerAvoiding} Avoids Player<BR/>
{blocking} Impassable<BR/>
{visionBlocking} Blocks Vision <BR/>
{mobileBlocking} Blocks Animals <BR/>
{family} Family Member <BR/>
{stuck} Persistent <BR/>
legend_node -> legend_node [style="invis"];
for name, obj in sorted(house_objects.items(), key=lambda k: k[1].name):
os.system("gvpr 'BEGIN{graph_t newg;node_t n;}BEG_G{newg=clone(NULL, $G);}N{if(degree==0){n=clone(newg, $);delete(newg,n);}}END_G{$O=newg;}'"
+ " {0}.dot | dot | gvpack -g | neato -s -n2 -Tpng > {0}.png".format(dotpath))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment