Simple text menus in TADS3

Another contribution instead of a question. This is a simple bit of code that handles things like startup menus.

It lets you declare the menu text, the keywords that can be entered from the menu, and to associate each menu with a method on the menu object which will be called when the player enters the corresponding keyword.

There are also a couple convenience methods for “hit any key to continue” and “hit enter to continue” prompts—a frequent task I want to accomplish in this kind of menu is to display some information (the game’s ABOUT, CREDITS, or other informational messages).

The code:

class TextMenu: object
        menuText = nil
        menuPrompt = '\b>'
        menuOptions = nil
        menuClear = true
        allowBlank = nil

        _showMenu() {
                if(menuClear == true)
                        cls();
                "<<menuText>> ";
        }
        showMenu() {
                local cmd, r;

                _showMenu();
                for(;;) {
                        "<<menuPrompt>>";
                        cmd = inputManager.getInputLine(nil, nil);
                        r = parseInput(cmd);
                        if(r != nil)
                                return(r);
                        _showMenu();
                }
        }
        parseInput(txt) {
                local kw, r;

                if(!txt)
                        return(nil);
                if(allowBlank && (rexMatch('<space>*$', txt) != nil)) {
                        handleDefault();
                        return('');
                }
                if(rexMatch('<space>*(<alpha>+)<space>*$', txt) != nil) {
                        kw = rexGroup(1)[3].toLower();
                } else {
                        return(nil);
                }
                r = nil;
                menuOptions.forEachAssoc(function(k, v) {
                        if(r != nil) return;
                        if(k.startsWith(kw)) {
                                r = k;
                                handleMatch(v);
                        }
                });

                return(r);
        }
        handleDefault() {
        }
        handleMatch(v) {
                switch(dataTypeXlat(v)) {
                        case TypeProp:
                                self.(v)();
                                break;
                        case TypeFuncPtr:
                                v();
                                break;
                }
        }
        anyKeyToContinue(txt?, prompt?) {
                if(txt) "<<txt>>";
                if(prompt == true) {
                        "<br><br>Press <b>any key</b> to continue ";
                } else if(prompt) {
                        "<<prompt>> ";
                }
                for(;;) {
                        inputManager.getKey(nil, nil);
                        return;
                }
        }
        enterToContinue(txt?, prompt?) {
                if(txt) "<<txt>>";
                if(prompt == true) {
                        "<br><br>[<b>Enter</b>] to continue ";
                } else if(prompt) {
                        "<<prompt>> ";
                }
                for(;;) {
                        inputManager.getInputLine(nil, nil);
                        return;
                }
        }
;

This allows you do to declare a menu something like:

mainMenu:       TextMenu
        menuPrompt = '&gt;&gt;'
        menuText = "<br><b>textMenu Test</b><br><br>
                <b>ABOUT</b> for information about this game<br>
                <b>FOO</b> for some filler text<br>
                <b>BAR</b> for an inline function<br>
                <b>QUIT</b> to exit<br><br><br> "
        menuOptions = static [
                'about' -> &aboutMethod,
                'foo'   -> &fooMethod,
                'bar'   -> function() {
                        enterToContinue('Bar.\n ', true); showMenu();
                },
                'quit'  -> &quitMethod
        ]
        aboutMethod() {
                versionInfo.showAbout();
                enterToContinue(nil, true);
                showMenu();
        }
        fooMethod() {
                enterToContinue('This is some "foo" text.\n ', true);
                showMenu();
        }
        quitMethod() {
                "Quitting the game\n ";
        }
;

When invoked, that gives you something like:

textMenu Test

ABOUT for information about this game
FOO for some filler text
BAR for an inline function
QUIT to exit


>>

If the player types ABOUT (or any shortened form of it: “ABOU”, “ABO”, “AB”, or “A”) they get the game’s ABOUT text (such as it is), followed by a prompt to hit any key to continue, after which they’re returned to the menu.

This is controlled by mainMenu.aboutMethod(), which is associated with the ABOUT keyword in mainMenu.menuOptions.

Here’s a complete example that includes all the code above, wrapped in a simple “game” that just displays the menu at startup. Included just because you should be able to cut and paste it and it’ll compile:

#charset "us-ascii"
#include <adv3.h>
#include <en_us.h>

class TextMenu: object
        menuText = nil
        menuPrompt = '\b&gt;'
        menuOptions = nil
        menuClear = true
        allowBlank = nil

        _showMenu() {
                if(menuClear == true)
                        cls();
                "<<menuText>> ";
        }
        showMenu() {
                local cmd, r;

                _showMenu();
                for(;;) {
                        "<<menuPrompt>>";
                        cmd = inputManager.getInputLine(nil, nil);
                        r = parseInput(cmd);
                        if(r != nil)
                                return(r);
                        _showMenu();
                }
        }
        parseInput(txt) {
                local kw, r;

                if(!txt)
                        return(nil);
                if(allowBlank && (rexMatch('<space>*$', txt) != nil)) {
                        handleDefault();
                        return('');
                }
                if(rexMatch('<space>*(<alpha>+)<space>*$', txt) != nil) {
                        kw = rexGroup(1)[3].toLower();
                } else {
                        return(nil);
                }
                r = nil;
                menuOptions.forEachAssoc(function(k, v) {
                        if(r != nil) return;
                        if(k.startsWith(kw)) {
                                r = k;
                                handleMatch(v);
                        }
                });

                return(r);
        }
        handleDefault() {
        }
        handleMatch(v) {
                switch(dataTypeXlat(v)) {
                        case TypeProp:
                                self.(v)();
                                break;
                        case TypeFuncPtr:
                                v();
                                break;
                }
        }
        anyKeyToContinue(txt?, prompt?) {
                if(txt) "<<txt>>";
                if(prompt == true) {
                        "<br><br>Press <b>any key</b> to continue ";
                } else if(prompt) {
                        "<<prompt>> ";
                }
                for(;;) {
                        inputManager.getKey(nil, nil);
                        return;
                }
        }
        enterToContinue(txt?, prompt?) {
                if(txt) "<<txt>>";
                if(prompt == true) {
                        "<br><br>[<b>Enter</b>] to continue ";
                } else if(prompt) {
                        "<<prompt>> ";
                }
                for(;;) {
                        inputManager.getInputLine(nil, nil);
                        return;
                }
        }
;


versionInfo:    GameID
        name = 'sample'
        byline = 'nobody'
        authorEmail = 'nobody <foo@bar.com>'
        desc = '[This space intentionally left blank]'
        version = '1.0'
        showAbout() {
                "This is the ABOUT text. ";
        }
;
mainMenu:       TextMenu
        menuPrompt = '&gt;&gt;'
        menuText = "<br><b>textMenu Test</b><br><br>
                <b>ABOUT</b> for information about this game<br>
                <b>FOO</b> for some filler text<br>
                <b>BAR</b> for an inline function<br>
                <b>QUIT</b> to exit<br><br><br> "
        menuOptions = static [
                'about' -> &aboutMethod,
                'foo'   -> &fooMethod,
                'bar'   -> function() {
                        enterToContinue('Bar.\n ', true); showMenu();
                },
                'quit'  -> &quitMethod
        ]
        aboutMethod() {
                versionInfo.showAbout();
                enterToContinue(nil, true);
                showMenu();
        }
        fooMethod() {
                enterToContinue('This is some "foo" text.\n ', true);
                showMenu();
        }
        quitMethod() {
                "Quitting the game\n ";
        }
;
gameMain:       GameMainDef
        newGame() {
                mainMenu.showMenu();
        }
;

I’m also making it available as a github repo; I’m thinking about doing this with the other little bite-sized libraries/modules I’ve been coding up for my current WIP.

6 Likes

Note that the code in the repo is formatted as a TADS3 module for separate compilation. There’s also some simple documentation, and the code itself is extensively commented.

2 Likes

Is this a different kind of menu system than that provided in the menusys and menucon files?

1 Like

Yeah, more or less completely different. I’m not sure “menu” is even exactly the right word. I’m calling them "menu"s because the functional role they’re designed to fill is the same as what you’d normally call a startup menu or main menu: display a screen of information and wait for the player to pick an option.

But I’m not trying to build any “navigable” UI elements, it’s all just text, and text prompts—the same interface semantics that the player will use in the rest of the game.

I’m not sure if there’s a more common name for this kind of interface. It’s not a unique thing that I’ve come up with, I’ve seen other IF titles use similar systems. But I don’t know what else to call it.

3 Likes

I’ll have to check it out. I’m just at the menu-heavy part of finishing off my game…

1 Like

I’d argue that most parser-based IF titles use a similar form of ‘menu’ at some point, specifically when the game ends: “Would you like to UNDO, RESTART the game, […]”. Inform 7 calls this ‘the Final Question’, but that’s not exactly descriptive of the interaction style, either.

1 Like

Yeah, same kind of idea. Most “menu based” dialog systems are kinda similar too—a l’il modal UI where you’re forcing the player to select something from a list. Here it’s a little more heavyweight than most “Are you sure you want to quit” prompts (or adv3’s builtin yesOrNo()), but much more lightweight than most pick-an-option dialog systems.

Anyway, I slightly tweaked things in the code in the repo. Instead of calling showMenu() again, keyword handlers should use returnToMenu() if they want the menu to be displayed again when they return. The sample code in demo/sample.t has been updated to reflect the new usage.

2 Likes

excellent little contrib library !

Best regards from Italy,
dott. Piergiorgio.

2 Likes