Skip to content

Instantly share code, notes, and snippets.

@rmmh rmmh/

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
You can’t perform that action at this time.