source: trunk/game/scripts/dialogueprocessor.py @ 684

Revision 684, 12.5 KB checked in by technomage, 9 years ago (diff)

Patch by Technomage

  • Refactored the DialogueEngine? singleton into the DialogueProcessor? instance factory, eliminating some of the problems the singleton pattern caused with encapsulation and unit testing.
  • Renamed the dialogueengine.py module to dialogueprocessor.py for clarity.
  • Added type-checking code to the instance variables defined by the DialogueProcessor? and Dialogue classes using assert statements that are removed when the optimization '-O' flag is passed to the Python interpreter.
  • Moved the 'main' logic in dialogue_demo.py script into a 'main' method to avoid cluttering the module namespace with runtime variables.
  • Fixed a few code documentation issues pointed out by aspidites.
  • 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"""
21import logging
22
23if (__debug__):
24    from scripts.dialogue import Dialogue
25
26def setupLogging():
27    """Set various logging parameters for this module."""
28    module_logger = logging.getLogger('dialogueengine')
29    if (__debug__):
30        module_logger.setLevel(logging.DEBUG)
31setupLogging()
32
33class DialogueProcessor(object):
34    """
35    Primary interface to the dialogue subsystem used to initiate and process a
36    L{Dialogue} with an NPC.
37   
38    To begin a dialogue with an NPC a L{DialogueProcessor} must first be
39    instantiated with the dialogue data to process and a dictionary of Python
40    objects defining the game state for testing of response conditionals. The
41    L{initiateDialogue} must be called to initialized the L{DialogueProcessor},
42    and once it is initialized processing of
43    L{DialogueSections<DialogueSection>} and
44    L{DialogueResponses<DialogueResponse>} can be initiated via the
45    L{continueDialogue} and L{reply} class methods.
46   
47    The state of dialogue processing is stored via the
48    L{dialogue_section_stack} class attribute, which stores a list of
49    L{DialogueSections<DialogueSection>} that have been or are currently being
50    processed. Each time L{reply} is called with a L{DialogueResponse} its
51    next_section_id attribute is used to select a new L{DialogueSection} from
52    the L{dialogue}. The selected L{DialogueSection} is then pushed
53    onto the end of the L{dialogue_section_stack}, ready to be processed via
54    L{continueDialogue}. The exception to this rule occurs when L{reply} is
55    called with a L{DialogueResponse} whose next_section_id attribute is "end"
56    or "back". "end" terminates the dialogue as described below, while "back"
57    removes the last L{DialogueSection} on the L{dialogue_section_stack}
58    effectively going back to the previous section of dialogue.
59   
60    The L{DialogueProcessor} terminates dialogue processing once L{reply} is
61    called with a L{DialogueResponse} whose next_section_id == 'end'.
62    Processing can also be manually terminated by calling the L{endDialogue}
63    class method.
64   
65    @note: See the dialogue_demo.py script for a complete example of how the
66        L{DialogueProcessor} can be used.
67   
68    @ivar dialogue: dialogue data currently being processed.
69    @type dialogue: L{Dialogue}
70    @ivar dialogue_section_stack: sections of dialogue that have been or are
71        currently being processed.
72    @type dialogue_section_stack: list of L{DialogueSections<DialogueSection>}
73    @ivar game_state: objects defining the game state that should be made
74        available for testing L{DialogueResponse} conditionals.
75    @type game_state: dict of Python objects
76    @ivar in_dialogue: whether a dialogue has been initiated.
77    @type in_dialogue: Bool
78   
79    Usage:
80    >>> game_state = {'pc': player_character, 'quest': quest_engine}
81    >>> dialogue_processor = DialogueProcessor(dialogue, game_state)
82    >>> dialogue_processor.initiateDialogue()
83    >>> while dialogue_processor.in_dialogue:
84    ...     valid_responses = dialogue_processor.continueDialogue()
85    ...     response = choose_response(valid_responses)
86    ...     dialogue_processor.reply(response)
87    """
88    _logger = logging.getLogger('dialogueengine.DialogueProcessor')
89   
90    def dialogue():
91        def fget(self):
92            return self._dialogue
93       
94        def fset(self, dialogue):
95            assert isinstance(dialogue, Dialogue), \
96                '{0} does not implement Dialogue interface'.format(dialogue)
97            self._dialogue = dialogue
98       
99        def fdel(self):
100            self._dialogue = None
101       
102        return locals()
103    dialogue = property(**dialogue())
104   
105    def dialogue_section_stack():
106        def fget(self):
107            return self._dialogue_section_stack
108       
109        def fdel(self):
110            self._dialogue_section_stack = []
111       
112        return locals()
113    dialogue_section_stack = property(**dialogue_section_stack())
114   
115    def game_state():
116        def fget(self):
117            return self._game_state
118       
119        def fdel(self):
120            self._game_state = {}
121       
122        return locals()
123    game_state = property(**game_state())
124   
125    def in_dialogue():
126        def fget(self):
127            return self._in_dialogue
128       
129        def fset(self, value):
130            assert isinstance(value, bool), '{0} is not a bool'.format(value)
131            self._in_dialogue = value
132       
133        return locals()
134    in_dialogue = property(**in_dialogue())
135   
136    def __init__(self, dialogue, game_state):
137        """
138        Initialize a new L{DialogueProcessor} instance.
139       
140        @param dialogue: dialogue data to process.
141        @type dialogue: L{Dialogue}
142        @param game_state: objects defining the game state that should be made
143            available for testing L{DialogueResponse} conditions.
144        @type game_state: dict of objects
145        """
146        self._dialogue_section_stack = []
147        self._dialogue = dialogue
148        self._game_state = game_state
149        self._in_dialogue = False
150   
151    def initiateDialogue(self):
152        """
153        Prepare the L{DialogueProcessor} to process the L{Dialogue} by pushing the
154        starting L{DialogueSection} onto the L{dialogue_section_stack}.
155        """
156        dialogue = self.dialogue
157        try:
158            start_section_id = dialogue.start_section_id
159            self.dialogue_section_stack.append(
160                dialogue.sections[start_section_id]
161            )
162        except (AttributeError, KeyError):
163            raise TypeError(('unable to determine start DialogueSection for '
164                             '{0}').format(dialogue))
165        else:
166            self.in_dialogue = True
167            self._logger.info('initiated dialogue {0}'.format(dialogue))
168   
169    def continueDialogue(self):
170        """
171        Process the L{DialogueSection} at the top of the
172        L{dialogue_section_stack}, run any L{DialogueActions<DialogueActions>}
173        it contains and return a list of valid
174        L{DialogueResponses<DialogueResponses> after evaluating any response
175        conditionals.
176       
177        @returns: valid responses.
178        @rtype: list of L{DialogueResponses<DialogueResponse>}
179        """
180        current_dialogue_section = self.getCurrentDialogueSection()
181        self.runDialogueActions(current_dialogue_section)
182        valid_responses = self.getValidResponses(current_dialogue_section)
183       
184        return valid_responses
185   
186    def getCurrentDialogueSection(self):
187        """
188        Return the L{DialogueSection} at the top of the
189        L{dialogue_section_stack}.
190       
191        @returns: section of dialogue currently being processed.
192        @rtype: L{DialogueSection}
193        """
194        try:
195            current_dialogue_section = self.dialogue_section_stack[-1]
196        except IndexError:
197            self._logger.error('getCurrentDialogueSection called by no '
198                               'DialogueSections are in the stack')
199            current_dialogue_section = None
200       
201        return current_dialogue_section
202   
203    def runDialogueActions(self, dialogue_node):
204        """
205        Execute all L{DialogueActions<DialogueActions>} contained by a
206        L{DialogueSection} or L{DialogueResponse}.
207       
208        @param dialogue_node: section of dialogue or response containing the
209            L{DialogueActions<DialogueAction>} to execute.
210        @type dialogue_node: L{DialogueNode}
211        """
212        self._logger.info('processing commands for {0}'.format(dialogue_node))
213        for command in dialogue_node.actions:
214            try:
215                command(self.game_state)
216            except (Exception) as error:
217                self._logger.error('failed to execute DialogueAction {0}: {1}'
218                                 .format(command.keyword, error))
219                # TODO Technomage 2010-11-18: Undo previous actions when an
220                #     action fails to execute.
221                return
222            else:
223                self._logger.debug('ran {0} with arguments {1}'
224                                 .format(getattr(type(command), '__name__'),
225                                                 command.arguments))
226   
227    def getValidResponses(self, dialogue_section):
228        """
229        Evaluate all L{DialogueResponse} conditions for a L{DialogueSection}
230        and return a list of valid responses.
231       
232        @param dialogue_section: section of dialogue containing the
233            L{DialogueResponses<DialogueResponse>} to process.
234        @type dialogue_section: L{DialogueSection}
235       
236        @return: responses whose conditions were met.
237        @rtype: list of L{DialogueResponses<DialogueResponse>}
238        """
239        if (dialogue_section is None):
240            # die nicely when the dialogue_section doesn't exist
241            return
242        valid_responses = []
243        for dialogue_response in dialogue_section.responses:
244            condition = dialogue_response.condition
245            try:
246                condition_met = condition is None or \
247                                eval(condition, self.game_state)
248            except (Exception) as error:
249                self._logger.error(
250                    ('evaluation of condition "{0}" for {1} failed with '
251                     'error: {2}').format(dialogue_response.condition,
252                                   dialogue_response, error)
253                )
254            else:
255                self._logger.debug(
256                    'condition "{0}" for {1} evaluated to {2}'
257                    .format(dialogue_response.condition, dialogue_response,
258                            condition_met)
259                )
260                if (condition_met):
261                    valid_responses.append(dialogue_response)
262       
263        return valid_responses
264   
265    def reply(self, dialogue_response):
266        """
267        Reply with a L{DialogueResponse}, execute the
268        L{DialogueActions<DialogueAction>} it contains and push the next
269        L{DialogueSection} onto the L{dialogue_section_stack}.
270       
271        @param dialogue_response: response to reply with.
272        @type dialogue_response: L{DialogueReponse}
273        """
274        self._logger.info('replied with {0}'.format(dialogue_response))
275        self.runDialogueActions(dialogue_response)
276        next_section_id = dialogue_response.next_section_id
277        if (next_section_id == 'back'):
278            if (len(self.dialogue_section_stack) == 1):
279                self._logger.error('attempted to run goto: back action but '
280                                 'stack does not contain a previous '
281                                 'DialogueSection')
282            else:
283                try:
284                    self.dialogue_section_stack.pop()
285                except IndexError:
286                    self._logger.error('attempted to run goto: back action '
287                                       'but the stack was empty')
288                else:
289                    self._logger.debug(
290                        'ran goto: back action, restored last DialogueSection'
291                    )
292        elif (next_section_id == 'end'):
293            self.endDialogue()
294            self._logger.debug('ran goto: end action, ended dialogue')
295        else:
296            # get a n
297            try:
298                next_dialogue_section = \
299                    self.dialogue.sections[next_section_id]
300            except KeyError:
301                self._logger.error(
302                    ('"{0}" is not a recognized goto: action or '
303                     'DialogueSection identifier').format(next_section_id)
304                )
305            else:
306                self.dialogue_section_stack.append(next_dialogue_section)
307   
308    def endDialogue(self):
309        """
310        End the current dialogue and clean up any resources in use by the
311        L{DialogueProcessor}.
312        """
313        del self.dialogue_section_stack
314        self.in_dialogue = False
Note: See TracBrowser for help on using the repository browser.