Last active
December 15, 2015 12:49
-
-
Save rmmh/5262815 to your computer and use it in GitHub Desktop.
CastleDoctrine interaction graph generator. Requires CastleDoctrine source code, Python 3, graphviz, imagemagick.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 | |
self.name = name | |
if '%s' in self.name: | |
self.name %= 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): | |
try: | |
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:", self.name, 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) | |
else: | |
if -1 not in self.states: | |
self.add_state(-1, '', set(), self.name + ' *') | |
self.add_edge(event, -1, end) | |
def __repr__(self): | |
state_strs = [] | |
for state in sorted(self.states.values()): | |
state_name = '' | |
if state.name != self.name: | |
state_name = '"%s" ' % state.name | |
state_strs.append('%s: %s{%s}' % (state.num, state_name, ', '.join(state.properties - ignored_properties))) | |
edge_strs = [self.format_edge(edge) for edge in self.edges.values()] | |
return '%s (%s) %s\n\t%s' % (self.short, self.name, ', '.join(state_strs), '\n\t'.join(edge_strs)) | |
def format_edge(self, edge): | |
prop_begin = edge.begin.properties - ignored_properties | |
prop_end = edge.end.properties - 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.events), 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(self.name, self.name) | |
writer.write(' label=<<FONT POINT-SIZE="30"><TABLE BORDER="0"><TR><TD>%s</TD></TR></TABLE></FONT>>;\n' % self.name) | |
for state in sorted(self.states.values()): | |
state_label = state.name | |
if '%s' in state_label: | |
state_label %= self.short.title() | |
if '*' in state_label: | |
state_label = '"(Any)"' | |
else: | |
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(state.properties-ignored_properties)) | |
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 (edge.begin.properties - self.render_properties) == (edge.end.properties - self.render_properties): | |
events_formatted = self.format_events(edge.events) | |
if events_formatted == '': | |
print('skipping', self.name, self.format_edge(edge)) | |
continue | |
bidi = '' | |
reverse = self.edges.get((edge.end, edge.begin), None) | |
if reverse and edge.events == reverse.events: | |
# bidirectional edges | |
if edge.end < edge.begin: | |
continue | |
bidi = ', dir="both"' | |
writer.write(' %s->%s [label="%s"%s];\n' % ( | |
self.get_state_name(edge.begin), | |
self.get_state_name(edge.end), | |
events_formatted, | |
bidi) | |
) | |
writer.write('}}\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')): | |
break | |
out_sprite = os.path.join(sprite_dir, name.replace('.tga', '.png')) | |
if not os.path.exists(out_sprite): | |
print(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) | |
else: | |
ret['files'][fname] = fullpath | |
return ret | |
if __name__ == '__main__': | |
print('\n\n\n\n') | |
if len(sys.argv) != 3: | |
print('usage: %s <CastleDoctrine source dir> <output dir>' % sys.argv[0]) | |
sys.exit(1) | |
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): | |
os.makedirs(out_dir) | |
if not os.path.exists(sprite_dir): | |
os.makedirs(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 | |
continue | |
if obj_name[-1] in '234': | |
# one of the redundant wife/son/daughter objects | |
continue | |
print(obj_name) | |
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(): | |
continue | |
precise = re.search(r'(\S+)\s*#\s*(\w+):(\d+)\s*=>\s*(\d+)', line) | |
all_states = re.search(r'(\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: | |
dotfile.write(''' | |
digraph legend {{ | |
subgraph cluster_legend {{ | |
legend_node [shape=none, label=< | |
<FONT POINT-SIZE="30"> | |
<TABLE BORDER="0" ALIGN="LEFT"> | |
<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/> | |
</TD></TR> | |
</TABLE> | |
</FONT> | |
>]; | |
legend_node -> legend_node [style="invis"]; | |
}} | |
}} | |
'''.format(**escaped_prop_mapping)) | |
for name, obj in sorted(house_objects.items(), key=lambda k: k[1].name): | |
obj.output_dot(dotfile) | |
dotfile.flush() | |
dotfile.close() | |
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)) | |
print("done.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment