<< ERP Update: new ui, refactors, helpers, capsule-capsule work Back to index... ERP Update: Change of course, problems, new adventures >>

Minor Gripe

2019-07-23 -- A simple VN engine

Chris Ertel

Introduction

Yesterday, my colleague Anna here at RC got to talking about her game, and being a bit stuck on how to get Javascript to do what she wanted.

The game itself sounds to me like it is a sort of visual novel, which can be summarized as:

This is a super simple and rough thing, but we can implement without too much trouble and get something worth hacking on.

Implementation

In 90 lines of code, here is a self-contained HTML file including the engine and a sample game.

<html>
    <head><title>microVN</title></head>
    <body>
        <div>
            <div id="flashes" style="border: 1px solid yellow;"></div>
            <div id="description" style="border: 1px solid grey;"></div>
            <div id="options" style="border: 1px solid green;"></div>
        </div>
        <script>
            var flashes = document.getElementById('flashes');
            var $description = document.getElementById('description');
            var $options = document.getElementById('options');

            var gInventory = [];
            var gFlashes = [];
            var gCurrentState;

            function renderPath([pathName, path]) {
                return `<li onclick="changeState('${pathName}', '${path.newState}')"> ${path.description} </li>`;
            }

            function drawState( state, inventory ) {
                flashes.innerHTML = gFlashes.join('\n');
                gFlashes = [];                
                $description.innerHTML = state.description;

                var availablePaths = Object.fromEntries(Object.entries(state.paths).filter( ([pathname, path]) => {
                    return path.check(inventory);
                }));
                $options.innerHTML = `<ul> ${Object.entries(availablePaths).map(renderPath).join('\n')} </ul>`;
            }
            
            function hasItem( itemName ) { return gInventory.indexOf(itemName) !== -1; }
            function lacksItem( itemName ) { return gInventory.indexOf(itemName) == -1; }            
            function addItem( itemName ) { if (!hasItem(itemName)) gInventory.push(itemName); }
            function removeItem( itemName ) { gInventory = gInventory.filter( el => {return el != itemName} ); }
            function putFlash( msg ) { gFlashes.push(msg); }
            var nocheck = () => true;
            var noop = () => {};

            function Path( description, newState, check, action) {
                this.description = description;
                this.newState = newState;
                this.check = check || nocheck;
                this.action = action || noop;
            }
            
            function State( description, paths ) {
                this.description = description;
                this.paths = paths;
            }            

            function changeState(pathID, newState) {
                if (!gStates[newState]) {
                    alert(`Invalid state transition ${newState}`);
                } else {
                    gCurrentState.paths[pathID].action();
                    gCurrentState = gStates[newState];                
                    drawState( gCurrentState, gInventory);
                }                
            }

            var gStates =  {
                "outsideRC": (new State("Outside RC. There is a door here.", 
                {
                    "enterRC" : new Path("Open and enter the door.", "RClobby", nocheck),
                    "leaveRC" : new Path("Screw it. F line is still running.", "bergen-station", nocheck)
                })),
                "bergen-station": (new State("The trains have stopped running. There is a dog here. It's sleeping.",
                {
                    "cry" : new Path("Cry in frustration.", "bergen-station", nocheck)
                })),
                "RClobby": (new State("The floor is dusty and covered in painter's tape. Chris has passed out next to the fire escape.", {
                    "mug-chris" : new Path("Check Chris's pockets.", "RClobby", () => lacksItem('rc-keyfob'), () => {
                        addItem('rc-keyfob');
                        putFlash('Rifling through his pockets, you find a key fob.');
                    }),
                    "search-chris" : new Path("Keep checking Chris's pockets.", "RClobby", () => hasItem('rc-keyfob'), () => {                        
                        putFlash('You keep looking through the pockets, only finding lint. Chris snores and rolls over.');
                    }),
                    "use-elevator" : new Path("Swipe in to elevator and ride it up.", "RC4th", () => hasItem('rc-keyfob')),
                })),
                "RC4th": (new State("The 4th floor is where all the people are. You get cake. <h1>The End.</h1>", {                    
                }))
            };
            gCurrentState = gStates["outsideRC"];
            drawState(gCurrentState, gInventory);
        </script>
    </body>
</html>

Commentary

The design of microVN is that you have a State object (which is really just a story node, but I didn’t realize that until this writeup). These objects have a description, a set of paths that can lead to other story nodes, and an identifier (used in the gStates map).

The Path objects consist of a path identifier (from their containing map, similar to gStates), the path description (what you see displayed in the options menu under a state), the identifier of a story node to transition to, an optional validity check which maps the current game state to whether or not a path is available to use (by, for example, gating off an option if you don’t have a certain item), and an optional action that can update the inventory and create flash messages.

Now, to create the game, we make a table of States with Paths, and then initialize the current game state to where we want to start. Then, we call drawState to populate the UI and get the game going.

drawState renders the flashes and then renders the available paths after filtering them by game state. The list items containing the paths have an onClick handler that is given the path name (so that the action can fire correctly) and the story node name to transition to. This bit was kinda hacky when we implemented it, but it worked well enough.

One of the helpful things in making this as simple and terse as it is would be ES6 lambdas (for quick function definitions) and also the new string interpolation functions. Additionally, the Object.entries and Object.fromEntries and destructuring let me write some filter and application code that felt very much like what I’d do in Elixir.

One last thing to note that I thought went well was the definition of several little helper functions (almost a crude DSL) to make writing the game logic easier. I feel like that was a big win.

Future work

First, stylesheets. Even a bare minimum of centering the output would be great.

Second, changing the game description from being nested objects (potentially containing functions) to instead just being simple nested maps of non-function primitives. That way, we could load the game from a JSON blob and thus enable the use of real content creation tools.

Third, a few more helper functions for writing checks. For example, I want to be able to easily and and or things together. However, to do that with syntax that seems reasonable, I’d probably have to change the other helpers to construct a new lambda and return that, so as to make composition easier.

Fourth, it might be convenient to let people write the descriptions as custom elements, hide those with a stylesheet, and then pull their contents into the game logic—perhaps even doing a conversion from Markdown to HTML along the way.


Thanks for reading!


<< ERP Update: new ui, refactors, helpers, capsule-capsule work Back to index... ERP Update: Change of course, problems, new adventures >>