changeset 153:704d23022f09

Start of dialogue tree / NPC state machine support.
author Simon Cross <hodgestar@gmail.com>
date Tue, 05 Apr 2011 22:18:26 +0200
parents 60138b935bc0
children 1008a7bae425
files data/npcs/monk.json scripts/npc-test skaapsteker/dialogue.py
diffstat 3 files changed, 207 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /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" : {
+    }
+}
--- /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] <npc json state file>")
+    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
--- /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