# HG changeset patch # User Simon Cross # Date 1302034706 -7200 # Node ID 704d23022f09422777903204f7613ea95b5e85ca # Parent 60138b935bc048ae08520f3fd4ed04f92a800fee Start of dialogue tree / NPC state machine support. diff -r 60138b935bc0 -r 704d23022f09 data/npcs/monk.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/data/npcs/monk.json Tue Apr 05 22:18:26 2011 +0200 @@ -0,0 +1,31 @@ +{ + "start" : { + "text" : "Oh poor thing, a tailless fox! What happened to you, little one?", + "choices" : [ + { "text": "Yip!" }, + { "text": "Grrrr...", "next": "state.unfortunate" } + ] + }, + "unfortunate" : { + "text" : "Unfortunate, I’m sure. That’s why I keep my lucky fox tail handy.", + "choices" : [ + { "text": "Yip yip!", "next": "state.you_dont_speak" } + ] + }, + "you_dont_speak" : { + "text" : "You don’t speak, of course. Forgive a silly old man. I lose my senses when I haven’t had my tea! Mmm, a fine brew of oolong! But there’s a demon in the attic and I’m too scared to fetch the tea leaves. Pity you don’t understand me, or you could get me a cup!", + "auto_next": "state.no_tea" + }, + "no_tea" : { + "text" : "Oh, what I wouldn’t do for a cup of tea.", + "events" : [ + { "matches" : "world.fox_has_tea()", "next": "state.got_tea" } + ] + }, + "got_tea" : { + "text" : "My tea! Oh, heavenly! Such aroma, what a taste... Just what I need. Mmm...", + "auto_next": "state.distracted" + }, + "distracted" : { + } +} diff -r 60138b935bc0 -r 704d23022f09 scripts/npc-test --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/scripts/npc-test Tue Apr 05 22:18:26 2011 +0200 @@ -0,0 +1,50 @@ +#!/usr/bin/env python +"Skaapsteker npc tester" + +import os.path +import sys +import optparse +from pprint import pprint + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from skaapsteker.dialogue import DSM, DsmEvent, DummyWorld + + +def run(npc_file): + world = DummyWorld() + dsm = DSM(npc_file, world) + + print "States:" + print "-------" + pprint(dsm.states.keys()) + print + + while True: + state = dsm.get_state() + print "%s:" % dsm.state, state.text + print "--" + for i, choice in state.choices: + print "%d: %s" % (i, choice) + print "L: Leave" + print "--" + + key = raw_input("Choice? ") + key = key.strip().upper() + if key == "L": + break + elif key.isdigit(): + dsm.choice(int(key)) + + print "--" + + +def main(): + p = optparse.OptionParser(usage="%prog [options] ") + opts, args = p.parse_args() + if len(args) != 1: + p.error("Must provide an npc json file") + run(args[0]) + +if __name__ == '__main__': + main() \ No newline at end of file diff -r 60138b935bc0 -r 704d23022f09 skaapsteker/dialogue.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/skaapsteker/dialogue.py Tue Apr 05 22:18:26 2011 +0200 @@ -0,0 +1,126 @@ +import json + +from . import data + + +class DSM(object): + """Dialogue State Machine! + + Parameters + ---------- + json_filename : str + Path to file under data/ that contains JSON description + of state machine. + world : object + Something to allow states to introspect the game state with. + """ + + def __init__(self, json_filename, world): + self.world = world + self.state = "start" + self.states = AttrDict() + src = json.loads(data.load(json_filename).read()) + for state, state_src in src.iteritems(): + pseudo_path = [json_filename, state] + self.states[state] = DsmState(state, state_src, pseudo_path) + assert self.state in self.states, "DSM must have start state" + + def get_state(self): + return self.states[self.state] + + def event(self, ev): + my_locals = { + "state": self.states, + "world" : self.world, + } + my_locals.update(ev.items) + state = self.states[self.state] + next_state = state.event(my_locals) + if next_state.name in self.states: + self.state = next_state.name + + def choice(self, i): + self.event(DsmEvent(choice=i)) + + +class AttrDict(dict): + + def __getattr__(self, name): + if name not in self: + raise AttributeError("No attribute %r" % (name,)) + return self[name] + + +class DsmEvent(object): + + def __init__(self, choice=None): + self.items = { + "choice": choice, + } + + +class DsmState(object): + """State within a DSM. + """ + + def __init__(self, name, state_src, base_path): + self.name = name + self.text = state_src.get("text", None) + self.choices = [] + self.triggers = [] + + choices = state_src.get("choices", []) + for i, choice in enumerate(choices): + pseudo_path = base_path + ["choice-%d" % i] + self.choices.append((i, choice["text"])) + next_state_code = choice.get("next", None) + if next_state_code is not None: + self.triggers.append( + Trigger("choice == %d" % i, next_state_code, pseudo_path)) + + events = state_src.get("events", []) + for i, event in enumerate(events): + pseudo_path = base_path + ["event-%d" % i] + self.triggers.append(Trigger(event["matches"], event["next"], + pseudo_path)) + + auto_next = state_src.get("auto_next", None) + if auto_next is not None: + pseudo_path = base_path + ["auto_next"] + self.triggers.append(Trigger("""True""", auto_next, pseudo_path)) + + def __repr__(self): + return "<%r name=%r>" % (self.__class__.__name__, self.name) + + def event(self, my_locals): + for trigger in self.triggers: + next_state = trigger.fire(my_locals) + if next_state is not None: + return next_state + + +class Trigger(object): + """Matches DSM events and triggers state transitions. + """ + + def __init__(self, matches_code, next_state_code, pseudo_path): + self._matches = compile(matches_code, + "<%s>" % ":".join(pseudo_path + ["match"]), + "eval") + self._next_state = compile(next_state_code, + "<%s>" % ":".join(pseudo_path + ["next"]), + "eval") + + def fire(self, my_locals): + if eval(self._matches, {}, my_locals.copy()): + return eval(self._next_state, {}, my_locals.copy()) + return None + + +class DummyWorld(object): + + def __init__(self): + self._fox_has_tea = False + + def fox_has_tea(self): + return self._fox_has_tea