source: trunk/game/scripts/dialogueengine.py @ 680

Revision 680, 12.2 KB checked in by technomage, 9 years ago (diff)

Patch by Technomage

  • Updated/wrote code documentation for the core dialogue subsystem modules dialogue.py, dialogueengine.py, dialogueactions.py, and dialogueparsers.py.
  • Updated/created flowcharts and UML diagrams explaining the new DialogueEngine? and how it functions.
  • Made a few minor changes to the dialoguegui.py module to make the class logger less visible.
  • Removed the "this is a sample dialogue file" crud from the old_man.yaml test dialogue file.
  • Property svn:mime-type set to text/plain
Line 
1#!/usr/bin/env python
2#
3#   This file is part of PARPG.
4#
5#   PARPG is free software: you can redistribute it and/or modify
6#   it under the terms of the GNU General Public License as published by
7#   the Free Software Foundation, either version 3 of the License, or
8#   (at your option) any later version.
9#
10#   PARPG is distributed in the hope that it will be useful,
11#   but WITHOUT ANY WARRANTY; without even the implied warranty of
12#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13#   GNU General Public License for more details.
14#
15#   You should have received a copy of the GNU General Public License
16#   along with PARPG.  If not, see <http://www.gnu.org/licenses/>.
17"""
18Provides the core interface to the dialogue subsystem used to process player
19L{Dialogues<Dialogue>} with NPCs.
20
21@author: or1andov (original design)
22@author: M. George Hansen <technopolitica@gmail.com> (redesign and current
23    maintainer)
24"""
25import logging
26
27def setup_logging():
28    """Set various logging parameters for this module."""
29    module_logger = logging.getLogger('dialogueengine')
30    if (__debug__):
31        module_logger.setLevel(logging.DEBUG)
32setup_logging()
33
34class DialogueEngine(object):
35    """
36    Primary interface to the dialogue subsystem used to initiate and process a
37    L{Dialogue} with an NPC.
38   
39    The L{DialogueEngine} is a singleton class that exposes the interface to
40    the dialogue subsystem via class methods and attributes, and so should not
41    be instantiated.
42   
43    To begin a dialogue with an NPC the L{DialogueEngine} must first be
44    initialized with a L{Dialogue} defining the dialogue data to process and a
45    dictionary of Python objects defining the game state for testing of
46    response conditionals. Once the L{DialogueEngine} is initialized processing
47    of L{DialogueSections<DialogueSection>} and
48    L{DialogueResponses<DialogueResponse>} can be initiated via the
49    L{continueDialogue} and L{reply} class methods.
50   
51    The state of dialogue processing is stored via the
52    L{dialogue_section_stack} class attribute, which stores a list of
53    L{DialogueSections<DialogueSection>} that have been or are currently being
54    processed. Each time L{reply} is called with a L{DialogueResponse} its
55    next_section_id attribute is used to select a new L{DialogueSection} from
56    the L{current_dialogue}. The selected L{DialogueSection} is then pushed
57    onto the end of the L{dialogue_section_stack}, ready to be processed via
58    L{continueDialogue}. The exception to this rule occurs when L{reply} is
59    called with a L{DialogueResponse} whose next_section_id attribute is "end"
60    or "back". "end" terminates the dialogue as described below, while "back"
61    removes the last L{DialogueSection} on the L{dialogue_section_stack}
62    effectively going back to the previous section of dialogue.
63   
64    The L{DialogueEngine} terminates dialogue processing once L{reply} is
65    called with a L{DialogueResponse} whose next_section_id == 'end'.
66    Processing can also be manually terminated by calling the L{endDialogue}
67    class method.
68   
69    @note: See the dialogue_demo.py script for a complete example of how the
70        L{DialogueEngine} can be used.
71   
72    @cvar current_dialogue: dialogue data currently being processed.
73    @type current_dialogue: L{Dialogue}
74    @cvar dialogue_section_stack: sections of dialogue that have been or are
75        currently being processed.
76    @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>}
77    @cvar game_state: objects defining the game state that should be made
78        available for testing L{DialogueResponse} conditionals.
79    @type game_state: dict of Python objects
80    @cvar in_dialogue: whether a dialogue has been initiated.
81    @type in_dialogue: Bool
82   
83    Usage:
84    >>> game_state = {'pc': player_character, 'quest': quest_engine}
85    >>> DialogueEngine.initiateDialogue(dialogue, game_state)
86    >>> while DialogueEngine.in_dialogue:
87    ...     valid_responses = DialogueEngine.continueDialogue()
88    ...     response = choose_response(valid_responses)
89    ...     DialogueEngine.reply(response)
90    """
91    current_dialogue = None
92    dialogue_section_stack = []
93    game_state = {}
94    in_dialogue = False
95    _logger = logging.getLogger('dialogueengine.DialogueEngine')
96   
97    def __init__(self):
98        raise TypeError('DialogueEngine cannot be instantiated')
99   
100    @classmethod
101    def initiateDialogue(cls, dialogue, game_state):
102        """Initialize the L{DialogueEngine} with a L{Dialogue} to process.
103       
104        If the DialogueEngine has already been initialized and is currently
105        processing a L{Dialogue} then L{endDialogue} will be called to
106        terminate processing before re-initializing the L{DialogueEngine} with
107        the new L{Dialogue}.
108       
109        @param dialogue: dialogue data to process.
110        @type dialogue: L{Dialogue}
111        @param game_state: objects defining the game state that should be made
112            available for testing L{DialogueResponse} conditions.
113        @type game_state: dict of objects
114        """
115        if (cls.in_dialogue):
116            # DialogueEngine has already been initialized, so end the current
117            # dialogue processing before (re-)initialization.
118            cls.endDialogue()
119        cls.current_dialogue = dialogue
120        cls.game_state = game_state
121        cls.in_dialogue = True
122        cls._logger.info(
123            'initiated dialogue {0}'.format(dialogue)
124        )
125        try:
126            start_section_id = dialogue.start_section_id
127        except AttributeError, KeyError:
128            cls._logger.error(('unable to determine start DialogueSection for '
129                              '{0}').format(dialogue))
130            cls.endDialogue()
131        else:
132            cls.dialogue_section_stack.append(
133                dialogue.sections[start_section_id]
134            )
135            cls.current_dialogue = dialogue
136   
137    @classmethod
138    def continueDialogue(cls):
139        """
140        Process the L{DialogueSection} at the top of the
141        L{dialogue_section_stack}, run any L{DialogueActions<DialogueActions>}
142        it contains and return a list of valid
143        L{DialogueResponses<DialogueResponses> after evaluating any response
144        conditionals.
145       
146        @returns: valid responses.
147        @rtype: list of L{DialogueResponses<DialogueResponse>}
148        """
149        current_dialogue_section = cls.getCurrentDialogueSection()
150        cls.runDialogueActions(current_dialogue_section)
151        valid_responses = cls.getValidResponses(current_dialogue_section)
152       
153        return valid_responses
154   
155    @classmethod
156    def getCurrentDialogueSection(cls):
157        """
158        Return the L{DialogueSection} at the top of the
159        L{dialogue_section_stack}.
160       
161        @returns: section of dialogue currently being processed.
162        @rtype: L{DialogueSection}
163        """
164        try:
165            current_dialogue_section = cls.dialogue_section_stack[-1]
166        except IndexError:
167            cls._logger.error(
168                'no DialogueSections are in the stack: either an error '
169                'occurred or DialogueEngine.initiateDialogue was not called '
170                'first')
171            current_dialogue_section = None
172       
173        return current_dialogue_section
174   
175    @classmethod
176    def runDialogueActions(cls, dialogue_node):
177        """
178        Execute all L{DialogueActions<DialogueActions>} contained by a
179        L{DialogueSection} or L{DialogueResponse}.
180       
181        @param dialogue_node: section of dialogue or response containing the
182            L{DialogueActions<DialogueAction>} to execute.
183        @type dialogue_node: L{DialogueNode}
184        """
185        cls._logger.info('processing commands for {0}'.format(dialogue_node))
186        for command in dialogue_node.actions:
187            try:
188                command(cls.game_state)
189            except Exception as error:
190                cls._logger.error('failed to execute DialogueAction {0}: {1}'
191                                 .format(command.keyword, error))
192                # TODO Technomage 2010-11-18: Undo previous actions when an
193                #     action fails to execute.
194                return
195            else:
196                cls._logger.debug('ran {0} with arguments {1}'
197                                 .format(getattr(type(command), '__name__'),
198                                                 command.arguments))
199   
200    @classmethod
201    def getValidResponses(cls, dialogue_section):
202        """
203        Evaluate all L{DialogueResponse} conditions for a L{DialogueSection}
204        and return a list of valid responses.
205       
206        @param dialogue_section: section of dialogue containing the
207            L{DialogueResponses<DialogueResponse>} to process.
208        @type dialogue_section: L{DialogueSection}
209       
210        @return: responses whose conditions were met.
211        @rtype: list of L{DialogueResponses<DialogueResponse>}
212        """
213        if (dialogue_section is None):
214            # die nicely when the dialogue_section doesn't exist
215            return
216        valid_responses = []
217        for dialogue_response in dialogue_section.responses:
218            condition = dialogue_response.condition
219            try:
220                condition_met = condition is None or \
221                                eval(condition, cls.game_state)
222            except Exception as error:
223                cls._logger.error(
224                    ('evaluation of condition "{0}" for {1} failed with '
225                     'error: {2}').format(dialogue_response.condition,
226                                   dialogue_response, error)
227                )
228            else:
229                cls._logger.debug(
230                    'condition "{0}" for {1} evaluated to {2}'
231                    .format(dialogue_response.condition, dialogue_response,
232                            condition_met)
233                )
234                if (condition_met):
235                    valid_responses.append(dialogue_response)
236       
237        return valid_responses
238   
239    @classmethod
240    def reply(cls, dialogue_response):
241        """
242        Reply with a L{DialogueResponse}, execute the
243        L{DialogueActions<DialogueAction>} it contains and push the next
244        L{DialogueSection} onto the L{dialogue_section_stack}.
245       
246        @param dialogue_response: response to reply with.
247        @type dialogue_response: L{DialogueReponse}
248        """
249        cls._logger.info('replied with {0}'.format(dialogue_response))
250        cls.runDialogueActions(dialogue_response)
251        next_section_id = dialogue_response.next_section_id
252        if (next_section_id == 'back'):
253            if (len(cls.dialogue_section_stack) == 1):
254                cls._logger.error('attempted to run goto: back action but '
255                                 'stack does not contain a previous '
256                                 'DialogueSection')
257            else:
258                try:
259                    cls.dialogue_section_stack.pop()
260                except IndexError:
261                    cls._logger.error('attempted to run goto: back action but '
262                                     'the stack was empty: most likely '
263                                     'DialogueEngine.initiateDialogue was not '
264                                     'called first')
265                else:
266                    cls._logger.debug(
267                        'ran goto: back action, restored last DialogueSection'
268                    )
269        elif (next_section_id == 'end'):
270            cls.endDialogue()
271            cls._logger.debug('ran goto: end action, ended dialogue')
272        else:
273            # get a n
274            try:
275                next_dialogue_section = \
276                    cls.current_dialogue.sections[next_section_id]
277            except KeyError:
278                cls._logger.error(('"{0}" is not a recognized goto: action or '
279                                  'DialogueSection identifier')
280                                 .format(next_section_id))
281            else:
282                cls.dialogue_section_stack.append(next_dialogue_section)
283   
284    @classmethod
285    def endDialogue(cls):
286        """
287        End the current dialogue and clean up any resources in use by the
288        L{DialogueEngine}.
289        """
290        cls.dialogue_stack = []
291        cls.current_dialogue = None
292        cls.in_dialogue = False
Note: See TracBrowser for help on using the repository browser.