hintsys.t

documentation
#charset "us-ascii"

/* 
 *   Copyright (c) 2000, 2006 by Michael J. Roberts.  All Rights Reserved. 
 *   
 *   TADS 3 Library - Hint System
 *   
 *   This module provides a hint system framework.  Games can use this
 *   framework to define context-sensitive hints for players.
 *   
 *   This module depends on the menus module to display the user interface.
 */

/* include the library header */
#include "adv3.h"


/* ------------------------------------------------------------------------ */
/*
 *   We refer to some properties defined primarily in score.t - that's an
 *   optional module, though, so make sure the compiler has heard of these. 
 */
property scoreCount;


/* ------------------------------------------------------------------------ */
/*
 *   A basic hint menu object.  This is an abstract base class that
 *   encapsulates some behavior common to different hint menu classes.  
 */
class HintMenuObject: object
    /*
     *   The topic order.  When we're about to show a list of open topics,
     *   we'll sort the list in ascending order of this property, then in
     *   ascending order of title.  By default, we set this order value to
     *   1000; if individual goals don't override this, then they'll
     *   simply be sorted lexically by topic name.  This can be used if
     *   there's some basis other than alphabetical order for sorting the
     *   list.  
     */
    topicOrder = 1000

    /*
     *   Compare this goal to another, for the purposes of sorting a list
     *   of topics.  Returns a positive number if this goal sorts after
     *   the other one, a negative number if this goal sorts before the
     *   other one, 0 if the relative order is arbitrary.
     *   
     *   By default, we'll sort by topicOrder if the topicOrder values are
     *   different, otherwise alphabetically by title.  
     */
    compareForTopicSort(other)
    {
        /* if the topicOrder values are different, sort by topicOrder */
        if (topicOrder != other.topicOrder)
            return topicOrder - other.topicOrder;

        /* the topicOrder values are the same, so sort by title */
        if (title > other.title)
            return 1;
        else if (title < other.title)
            return -1;
        else
            return 0;
    }
;

/*
 *   A Goal represents an open task: something that the player is trying
 *   to achieve.  A Goal is an abstract object, not part of the simulated
 *   world of the game.
 *   
 *   Each goal is associated with a hint topic (usually shown as a
 *   question, such as "How do I get past the guard?") and an ordered list
 *   of hints.  The hints are usually ordered from most general to most
 *   specific.  The idea is to let the player control how big a hint they
 *   get; we start with a small nudge and work towards giving away the
 *   puzzle completely, so the player can stop as soon as they see
 *   something that helps.
 *   
 *   At any given time, a goal can be in one of three states:
 *   
 *   - Open: this means that the player is (or ought to be) aware of the
 *   goal, but the goal hasn't yet been achieved.  Determining this
 *   awareness is up to the goal.  In some cases, a goal is opened as soon
 *   as the player has seen a particular object or entered a particular
 *   area; in other cases, a goal might be opened by a scripted event,
 *   such as a speech by an NPC telling the player they have to accomplish
 *   something.  A goal could even be opened by viewing a hint for another
 *   goal, because that hint could explain a gating goal that the player
 *   might not otherwise been able to know about.
 *   
 *   - Undiscovered: this means that the player doesn't yet have any
 *   reason to know about the goal.
 *   
 *   - Closed: this means that the player has accomplished the goal, or in
 *   some cases that the goal has become irrelevant. 
 *   
 *   The hint system only shows goals that are Open.  We don't show Closed
 *   goals because the player presumably has no need of them any longer;
 *   we don't show Undiscovered goals to avoid giving away developments
 *   later in the game before they become relevant.  
 */
enum OpenGoal, ClosedGoal, UndiscoveredGoal;
class Goal: MenuTopicItem, HintMenuObject
    /*
     *   The topic question associated with the goal.  The hint system
     *   shows a list of the topics for the goals that are currently open,
     *   so that the player can decide what area they want help on.  
     */
    title = ''

    /*
     *   Our parent menu - this is usually a HintMenu object.  In very
     *   simple hint systems, this could simply be a top-level hint menu
     *   container; more typically, the hint system will be structured
     *   into a menu tree that organizes the hint topics into several
     *   different submenus, for easier navigatino.  
     */
    location = nil

    /*
     *   The list of hints for this topic.  This should be ordered from
     *   most general to most specific; we offer the hints in the order
     *   they appear in this list, so the earlier hints should give away
     *   as little as possible, while the later hints should get
     *   progressively closer to just outright giving away the answer.
     *   
     *   Each entry in the list can be a simple (single-quoted) string, or
     *   it can be a Hint object.  In most cases, a string will do.  A
     *   Hint object is only needed when displaying the hint has some side
     *   effect, such as opening a new Goal.  
     */
    menuContents = []

    /*
     *   An optional object that, when seen by the player character, opens
     *   this goal.  It's often convenient to declare a goal open as soon
     *   as the player enters a particular area or has encountered a
     *   particular object.  For such cases, simply set this property to
     *   the room or object that opens the goal, and we'll automatically
     *   mark the goal as Open the next time the player asks for a hint
     *   after seeing the referenced object.  
     */
    openWhenSeen = nil

    /*
     *   An option object that, when seen by the player character, closes
     *   this goal.  Many goals will be things like "how do I find the
     *   X?", in which case it's nice to close the goal when the X is
     *   found. 
     */
    closeWhenSeen = nil

    /* 
     *   this is like openWhenSeen, but opens the topic when the given
     *   object is described (with EXAMINE) 
     */
    openWhenDescribed = nil

    /* close the goal when the given object is described */
    closeWhenDescribed = nil

    /*
     *   An optional Achievement object that opens this goal.  This goal
     *   will be opened automatically once the goal is achieved, if the
     *   goal was previously undiscovered.  This makes it easy to set up a
     *   hint topic that becomes available after a particular puzzle is
     *   solved, which is useful when a new puzzle only becomes known to
     *   the player after a gating puzzle has been solved.  
     */
    openWhenAchieved = nil

    /*
     *   An optional Achievement object that closes this goal.  Once the
     *   achievement is completed, this goal's state will automatically be
     *   set to Closed.  This makes it easy to associate the goal with a
     *   puzzle: once the puzzle is solved, there's no need to show hints
     *   for the goal any more.  
     */
    closeWhenAchieved = nil

    /*
     *   An optional Topic or Thing that opens this goal when the object
     *   becomes "known" to the player character.  This will open the goal
     *   as soon as gPlayerChar.knowsAbout(openWhenKnown) returns true.
     *   This makes it easy to open a goal as soon as the player comes
     *   across some information in the game.  
     */
    openWhenKnown = nil

    /* an optional Topic or Thing that closes this goal when known */
    closeWhenKnown = nil

    /*
     *   An optional <.reveal> tag name that opens this goal.  If this is
     *   set to a non-nil string, we'll automatically open this goal when
     *   the tag has been revealed via <.reveal> (or gReveal()). 
     */
    openWhenRevealed = nil

    /* an optional <.reveal> tag that closes this goal when revealed */
    closeWhenRevealed = nil

    /*
     *   An optional arbitrary check that opens the goal.  If this returns
     *   true, we'll open the goal.  This check is made in addition to the
     *   other checks (openWhenSeen, openWhenDescribed, etc).  This can be
     *   used for any custom check that doesn't fit into one of the
     *   standard openWhenXxx properties.  
     */
    openWhenTrue = nil

    /* an optional general-purpose check that closes the goal */
    closeWhenTrue = nil

    /*
     *   Determine if there's any condition that should open this goal.
     *   This checks openWhenSeen, openWhenDescribed, and all of the other
     *   openWhenXxx conditions; if any of these return true, then we'll
     *   return true.
     *   
     *   Note that this should generally NOT be overridden in individual
     *   instances; normally, instances would define openWhenTrue instead.
     *   However, some games might find that they use the same special
     *   condition over and over in many goals, often enough to warrant
     *   adding a new openWhenXxx property to Goal.  In these cases, you
     *   can use 'modify Goal' to override openWhen to add the new
     *   condition: simply define openWhen as (inherited || newCondition),
     *   where 'newCondition' is the new special condition you want to
     *   add.  
     */
    openWhen = (
        (openWhenSeen != nil && gPlayerChar.hasSeen(openWhenSeen))
        || (openWhenDescribed != nil && openWhenDescribed.described)
        || (openWhenAchieved != nil && openWhenAchieved.scoreCount != 0)
        || (openWhenKnown != nil && gPlayerChar.knowsAbout(openWhenKnown))
        || (openWhenRevealed != nil && gRevealed(openWhenRevealed))
        || openWhenTrue)

    /*
     *   Determine if there's any condition that should close this goal.
     *   We'll check closeWhenSeen, closeWhenDescribed, and all of the
     *   other closeWhenXxx conditions; if any of these return true, then
     *   we'll return true. 
     */
    closeWhen = (
        (closeWhenSeen != nil && gPlayerChar.hasSeen(closeWhenSeen))
        || (closeWhenDescribed != nil && closeWhenDescribed.described)
        || (closeWhenAchieved != nil && closeWhenAchieved.scoreCount != 0)
        || (closeWhenKnown != nil && gPlayerChar.knowsAbout(closeWhenKnown))
        || (closeWhenRevealed != nil && gRevealed(closeWhenRevealed))
        || closeWhenTrue)

    /*
     *   Has this goal been fully displayed?  The hint system automatically
     *   sets this to true when the last item in our hint list is
     *   displayed.
     *   
     *   You can use this, for example, to automatically remove the hint
     *   from the hint menu after it's been fully displayed.  (You might
     *   want to do this with a hint for a red herring, for example.  After
     *   the player has learned that the red herring is a red herring, they
     *   probably won't need to see that particular line of hints again, so
     *   you can remove the clutter in the menu by closing the hint after
     *   it's been fully displayed.)  To do this, simply add this to the
     *   Goal object:
     *   
     *.    closeWhenTrue = (goalFullyDisplayed)
     */
    goalFullyDisplayed = nil

    /*
     *   Check our menu state and update it if necessary.  Each time our
     *   parent menu is about to display, it'll call this on its sub-items
     *   to let them update their current states.  This method can promote
     *   the state to Open or Closed if the necessary conditions for the
     *   goal have been met.
     *   
     *   Sometimes it's more convenient to set a goal's state explicitly
     *   from a scripted event; for example, if the goal is associated
     *   with a scored achievement, awarding the goal's achievement will
     *   set the goal's state to Closed.  In these cases, there's no need
     *   to use this method, since you're managing the goal's state
     *   explicitly.  The purpose of this method is to make it easy to
     *   catch goal state changes that can be reached by several different
     *   routes; in these cases, you can just write a single test for
     *   those conditions in this method rather than trying to catch every
     *   possible route to the new conditions and writing code in all of
     *   those.
     *   
     *   The default implementation looks at our openWhenSeen property.
     *   If this property is not nil, then we'll check the object
     *   referenced in this property; if our current state is
     *   Undiscovered, and the object referenced by openWhenSeen has been
     *   seen by the player character, then we'll change our state to
     *   Open.  We'll make the corresponding check for openWhenDescribed.  
     */
    updateContents()
    {
        /* 
         *   If we're currently Undiscovered, and our openWhenSeen object
         *   has been seen by the player charater, change our state to
         *   Open.  Likewise, if our gating achievement has been scored,
         *   open the goal.  
         */
        if (goalState == UndiscoveredGoal && openWhen)
        {
            /* 
             *   the player has encountered our gating object, so open
             *   this goal 
             */
            goalState = OpenGoal;
        }

        /* 
         *   if we're currently Undiscovered or Open, and our Achievement
         *   has been scored, then change our state to Closed - once the
         *   goal has been achieved, there's no need to offer hints on the
         *   topic any longer 
         */
        if (goalState is in (UndiscoveredGoal, OpenGoal) && closeWhen)
        {
            /* the goal has been achieved, so close it */
            goalState = ClosedGoal;
        }
    }

    /* display a sub-item, keeping track of when we've shown them all */
    displaySubItem(idx, lastBeforeInput, eol)
    {
        /* do the inherited work */
        inherited(idx, lastBeforeInput, eol);

        /* if we just displayed the last item, note it */
        if (idx == menuContents.length())
            goalFullyDisplayed = true;
    }

    /* we're active in our parent menu if our goal state is Open */
    isActiveInMenu = (goalState == OpenGoal)

    /* 
     *   This goal's current state.  We'll start off undiscovered.  When a
     *   goal should be open from the very start of the game, this should
     *   be overridden and set to OpenGoal. 
     */
    goalState = UndiscoveredGoal
;

/*
 *   A Hint encapsulates one hint from a topic.  In many cases, hints can
 *   be listed in a topic simply as strings, rather than using Hint
 *   objects.  Hint objects provide a little more control, though; in
 *   particular, a Hint object can specify some additional code to run
 *   when the hint is shown, so that it can apply any side effects of
 *   showing the hint (for example, when a hint is shown, it could mark
 *   another Goal object as Open, which might be desirable if the hint
 *   refers to another topic that the player might not yet have
 *   encountered).  
 */
class Hint: MenuTopicSubItem
    /* the hint text */
    hintText = ''

    /*
     *   A list of other Goal objects that this hint references.  By
     *   default, when we show this hint for the first time, we'll promote
     *   each goal in this list from Undiscovered to Open.
     *   
     *   Sometimes, it's necessary to solve one puzzle before another can
     *   be solved.  In these cases, some hints for the first puzzle
     *   (which depends on the second), especially the later, more
     *   specific hints, might need to refer to the other puzzle.  This
     *   would make the player aware of the other puzzle even if they
     *   weren't already.  In such cases, it's a good idea to make sure
     *   that we make hints for the other puzzle available immediately,
     *   since otherwise the player might be confused by the absence of
     *   hints about it.  
     */
    referencedGoals = []

    /*
     *   Get my hint text.  By default, we mark as Open any goals listed
     *   in our referencedGoals list, then return our hintText string.
     *   Individual Hint objects can override this as desired to apply any
     *   additional side effects.
     */
    getItemText()
    {
        /* scan the referenced goals list */
        foreach (local cur in referencedGoals)
        {
            /* if this goal is not yet discovered, open it */
            if (cur.goalState == UndiscoveredGoal)
                cur.goalState = OpenGoal;
        }

        /* return our hint text */
        return hintText;
    }
;

/*
 *   A hint menu.  This same class can be used for the top-level hints
 *   menu and for sub-menus within the hints menu.
 *   
 *   The typical hint menu system will be structured into a top-level hint
 *   menu that contains a set of sub-menus for the main areas of the game;
 *   each sub-menu will have a series of Goal items, each Goal providing a
 *   set of answers to a particular question.  Something like this:
 *   
 *   topHintMenu: TopHintMenu 'Hints';
 *.  + HintMenu 'General Questions';
 *.  ++ Goal 'What am I supposed to be doing?' [answer, answer, answer];
 *.  ++ Goal 'Amusing things to try' [thing, thing, thing];
 *.  + HintMenu 'First Area';
 *.  ++ Goal 'How do I get past the shark?' [answer, answer, answer];
 *.  ++ Goal 'How do I open the fish tank?' [answer, answer, answer];
 *.  + HintMenu 'Second Area';
 *.  ++ Goal 'Where is the gold key?' [answer, answer, answer];
 *.  ++ Goal 'How do I unlock the gold door?' [answer, answer, answer];
 *   
 *   Note that there's no requirement that the hint menu tree takes
 *   exactly this shape.  A very small game could dispense with the
 *   submenus and simply put all of the goals directly in the top hint
 *   menu.  A very large game with lots of goals could add more levels of
 *   sub-menus to make it easier to navigate the large number of topics.  
 */
class HintMenu: MenuItem, HintMenuObject
    /* the menu's title */
    title = ''

    /* update our contents */
    updateContents()
    {
        local vec = new Vector(16);
        
        /* 
         *   First, run through all of our sub-items, and update their
         *   contents.  We only want to show our active contents, so we
         *   need to check with each item to find out which is active. 
         */
        foreach (local cur in allContents)
            cur.updateContents();

        /* create a vector containing all of our active items */
        foreach (local cur in allContents)
        {
            /* if this item is active, add it to the active vector */
            if (cur.isActiveInMenu)
                vec.append(cur);
        }

        /* set our contents list to the list of active items */
        contents = vec;
    }

    /* we're active in a menu if we have any active contents */
    isActiveInMenu = (contents.length() != 0)

    /* add a sub-item to our contents */
    addToContents(obj)
    {
        /* 
         *   add the sub-item to our allContents list rather than our
         *   active contents 
         */
        allContents += obj;
    }

    /* initialize our contents list */
    initializeContents()
    {
        /* sort our allContents list in the object-defined sorting order */
        allContents = allContents.sort(
            SortAsc, {a, b: a.compareForTopicSort(b)});
    }

    /* 
     *   our list of all of our sub-items (some of which may not be
     *   active, in which case they'll appear in this list but not in our
     *   'contents' list, which contains only active contents) 
     */
    allContents = []
;

/*
 *   A hint menu version of the long topic menu.
 */
class HintLongTopicItem: MenuLongTopicItem, HintMenuObject
    /* 
     *   presume these are always active - they're usually used for things
     *   like hint system instructions that should always be available 
     */
    isActiveInMenu = true
;

/*
 *   Top-level hint menu.  As a convenience, an object defined of this
 *   class will automatically register itself as the top-level hint menu
 *   during pre-initialization.  
 */
class TopHintMenu: HintMenu, PreinitObject
    /* register as the top-level hint menu during pre-initialization */
    execute() { hintManager.topHintMenuObj = self; }
;

/* ------------------------------------------------------------------------ */
/*
 *   The default hint system user interface implementation.  All of the
 *   hint-related verbs operate by calling methods in the object stored in
 *   the global variable gHintSystem, which we'll by default initialize
 *   with a reference to this object.  Games can replace this with their
 *   own implementations if desired.  
 */
hintManager: PreinitObject
    /* during pre-initialization, register as the global hint manager */
    execute() { gHintManager = self; }
    
    /*
     *   Disable hints - this is invoked by the HINTS OFF action.
     *   
     *   Some users don't like on-line hint systems because they find them
     *   to be too much of a temptation.  To address this concern, we
     *   provide this HINTS OFF command.  Players who want to ensure that
     *   their will-power won't crumble later on in the face of a
     *   difficult puzzle can type HINTS OFF early on, before the going
     *   gets rough; this will disable hints for the rest of the session.
     *   It's kind of like giving your credit card to a friend before
     *   going to the mall, making the friend promise that they won't let
     *   you spend more than such and such an amount, no matter how much
     *   you beg and plead.  
     */
    disableHints()
    {
        /* 
         *   Remember that hints have been disabled.  Keep this
         *   information in the transient session object, since we want
         *   the disabled status to last for the rest of this session,
         *   even if we restore or restart later.  
         */
        sessionHintStatus.hintsDisabled = true;

        /* acknowledge it */
        mainReport(gLibMessages.hintsDisabled);
    }

    /*
     *   The top-level hint menu.  This must be provided by the game, and
     *   should be set during initialization.  If this is nil, hints won't
     *   be available.
     *   
     *   We don't provide a default top-level hint menu because we want to
     *   give the game maximum flexibility in defining this object exactly
     *   as it wants.  For convenience, an object of class TopHintMenu
     *   will automatically register itself during pre-initialization -
     *   but note that there should be only one such object in the entire
     *   game, since if there are more than one, only one will be
     *   arbitrarily chosen as the registered object.  
     */
    topHintMenuObj = nil

    /*
     *   Show hints - invoke the hint system. 
     */
    showHints()
    {
        /* if there is no top-level hint menu, no hints are available */
        if (topHintMenuObj == nil)
        {
            mainReport(gLibMessages.hintsNotPresent);
            return;
        }

        /* if hints are disabled, reject the request */
        if (sessionHintStatus.hintsDisabled)
        {
            mainReport(gLibMessages.sorryHintsDisabled);
            return;
        }

        /* bring the hint menu tree up to date */
        topHintMenuObj.updateContents();

        /* if there are no hints available, say so and give up */
        if (topHintMenuObj.contents.length() == 0)
        {
            mainReport(gLibMessages.currentlyNoHints);
            return;
        }
        
        /* if we haven't warned about hints, do so now */
        if (!showHintWarning())
            return;

        /* display the hint menu */
        topHintMenuObj.display();

        /* all done */
        mainReport(gLibMessages.hintsDone);
    }

    /*
     *   Show a warning before showing any hints.  By default, we'll show
     *   this at most once per session or once per saved game.  Returns
     *   true if we are to proceed to the hints, nil if not.  
     */
    showHintWarning()
    {
        /* 
         *   If we have previously warned in this session, or if we've
         *   warned in a previous session and the same game was later
         *   saved and restored, don't warn again.  The transient session
         *   object tells us if we've asked in this session; the normal
         *   persistent object tells us if we've asked in a previous
         *   session that we've since saved and restored. 
         */
        if (!sessionHintStatus.hintWarning && !gameHintStatus.hintWarning)
        {
            /* 
             *   we haven't asked yet in either the session or the game,
             *   so show the warning now 
             */
            gLibMessages.showHintWarning();

            /* note that we've shown the warning */
            sessionHintStatus.hintWarning = true;
            gameHintStatus.hintWarning = true;

            /* don't proceed to hints now; let them ask again */
            return nil;
        }

        /* 
         *   They've already seen the warning before.  It's possible that
         *   they've seen it in a past session with the game and not
         *   otherwise during this session, but now that we're accessing
         *   the hint system once, don't bother with another warning for
         *   the rest of this session.  
         */
        sessionHintStatus.hintWarning = true;

        /* proceed to the hints */
        return true;
    }
;

/*
 *   We keep several pieces of information about the status of the hint
 *   system.  Some of it pertains to the current session, independently of
 *   any saving/restoring/restarting, so we keep this information in a
 *   transient object.  Some pertains to the present game, so we keep it
 *   in an ordinary persistent object, so that it's saved and restored
 *   along with the game.  
 */
transient sessionHintStatus: object
    /* flag: we've warned about the hint system in this session */
    hintWarning = nil

    /* flag: we've disabled hints for this session */
    hintsDisabled = nil
;

gameHintStatus: object
    /* flag: we've warned about the hint system in this session */
    hintWarning = nil
;

TADS 3 Library Manual
Generated on 5/16/2013 from TADS version 3.1.3