12
\$\begingroup\$

My entry for Weekend Challenge #5.

In scope :

  • Old Skool green on black text adventure
  • Keyboard handling
  • A story with 2 mini-quests
  • 2 endings
  • Virtues ( Hi Ultima ) save the day

Out of scope :

  • Mouse handling, save games, high scores

Biggest concern :

  • Because both controller and view have a reference to model, I cannot simply re-create model as a new Object for a new game. My JSON solution feels awkward.

An online version can be found here. Updated to prevent winning without all virtues collected.

$(function ()
{
  var model = (function createModel()
  {
    var title = "Belle's Christmas\n=================",
      originalScenes = {
        menu:
        {
          text: "",
          options: ["1. Start Game", "2. View Highscores"].join("\n")
        },
        garden:
        {
          text: 'You are standing in a white frosted garden in front of an ancient manor, 2 snowmen are flanking the front door.',
          options: ["1. Enter the manor", "2. Look at the snowmen"].join("\n")
        },
        snowmen:
        {
          text: 'Against all odds, one of the poor snowmen seems to be shivering, he reminds you of the town drunkard, but frostier.',
          dressedText: 'Both snowmen are now guarding valiantly the manor, no longer bothered by the cold.',
          offendedText: 'The snowman on the left looks at you crossly, the snowman on the right is guarding valiantly the manor',
          options: ["1. Offer your jacket", "2. Enter the manor"].join("\n")
        },
        lobby:
        {
          text: 'Lumiere the magical candelabra is here, waiting impatiently for you to clean him, he thinks you are late.',
          cleaned1: 'Lumiere exclaims that you missed some spots and wants you to clean him again.',
          cleaned2: 'Lumiere exclaims that he isnt shiny enough and insists you should clean him further.',
          cleaned3: 'Lumiere is so clean now that he cant stop babbling about it!',
          options: ['1. Go to the ballroom', '2. Clean Lumiere', '3. Exit the manor'].join("\n")
        },
        room:
        {
          text: 'You are in your bed room, it is small and badly lit',
          options: ['1. Go to bed', '2. Go back the the ballroom'].join("\n")
        },
        ballroom:
        {
          text: 'You see Gaston hunched over a book, wearing festive clothing.',
          options: ['1. Go to your room', '2. Tell Gaston what you did so far',"3. Exit the ballroom"].join("\n")
        },
        win:
        {
          text: 'Gaston is so touched with your charity, kindness, patience and temperance that he stands up, starts music and dances the night away with you. Best Christmas ever.',
          options: 'Game Over. Press any key'
        },
        loose:
        {
          text: 'You fall asleep, dreaming feverishly about Monsieur D\'Arque and his crazy plans, worst Christmas ever',
          options: 'Game Over. Press any key'
        }
      },
      scenes = JSON.parse(JSON.stringify(originalScenes));

    function reset()
    {
      this.scenes = JSON.parse( JSON.stringify( originalScenes ) );
      this.virtues = {};
    }

    return {
      scenes: scenes,
      title: title,
      warning: '',
      virtues: {},
      reset: reset
    };
  }());

  var view = (function createView()
  {
    var $body = $(document.body),
        model;

    function init(_model, options)
    {
      options = options || {};
      model = _model;

      $body.css('background-color', options.backgroundColor || 'black')
           .css('color', options.textColor || 'limegreen')
           .css('font-weight', 'Bold')
           .css('font-family', 'Lucida Console')
           .css('white-space', 'pre');

      $body.html(model.title);
    }

    function draw()
    {
      //Update the text
      $body.html( model.title + "\n" +
                  model.scene.text + "\n\n" +
                  model.scene.options + "\n\n" +
                  model.warning );
      //Only show the warning once
      model.warning = '';
      //Do not show 'feature not implemented'
      return true;
    }

    return {
      init: init,
      draw: draw,
      element: $body
    };

  }());

  var controller = (function createController()
  {

    var model, view, keyboardHandler;

    function wireKeyboard($element)
    {
      $element.keypress(function (e)
      {
        if(!keyboardHandler(String.fromCharCode(e.which)))
          warnNotImplemented();
      });
    }

    function attachHandlersToModel()
    {

      model.scenes.menu.handler = function (c)
      {
        if(c == '1') //1. Start game
          return changeScene('garden');
      };

      model.scenes.garden.handler = function (c)
      {
        if(c == '1') //1. Enter the manor
        {
          model.scenes.snowmen.text = model.scenes.snowmen.offendedText;
          return changeScene('lobby');
        }
        if(c == '2') //2. Look at the snowmen
        {
          return changeScene('snowmen');
        }
      };

      model.scenes.snowmen.handler = function (c)
      {
        if(c == '1') //1. Offer your jacket
        {
          if(!model.virtues.dress)
          {
            model.scene.text = model.scene.dressedText;
            model.virtues.dress = true;
          }
          else
          {
            model.warning = 'You already offered your jacket.';
          }
          return view.draw();
        }
        if(c == '2') //2. Enter the manor
        {
          if(!model.virtues.dress)
            model.scene.text = model.scene.offendedText;
          return changeScene('lobby');
        }
      };

      //'1. Go to the ballroom' , '2. Clean Lumiere' , '3. Exit the manor'
      model.scenes.lobby.handler = function (c)
      {
        if(c == '1') //2. Enter the ballroom
          return changeScene('ballroom');

        if(c == '2')
        {
          if(!model.virtues.kindness)
          {
            model.scene.text = model.scene.cleaned1;
            model.virtues.kindness = true;
          }
          else if(!model.virtues.patience)
          {
            model.scene.text = model.scene.cleaned2;
            model.virtues.patience = true;
          }
          else if(!model.virtues.temperance)
          {
            model.scene.text = model.scene.cleaned3;
            model.virtues.temperance = true;
          }
          else
          {
            model.warning = 'Lumiere confirms that he is shiny enough';
          }
          return view.draw();
        }
        if(c == '3') //2. Exit the manor
          return changeScene('garden');
      };

      model.scenes.ballroom.handler = function (c)
      {
        if(c == '1') //1. Go to your room'
          return changeScene('room');
        if(c == '2') //2. Tell Gaston what you did so far'
        {
          if(Object.keys(model.virtues).length != 4)
          {
            model.warning = 'Gaston listens politely with half an ear.';
            return view.draw();
          }
          else
          {
            return changeScene('win');
          }
        }
        if( c == '3' )
          return changeScene('lobby');
      };

      model.scenes.room.handler = function (c)
      {
        if(c == '1') // '1. Go to bed'
          return changeScene('loose');
        if(c == '2') // '2. Go back the the ballroom'
          return changeScene('ballroom');
      };

      function gameOverHandler()
      {
        //Whatever the player presses, just go to the main menu
        return changeScene('menu');
      }

      //Both cases require the same keyboard handling
      model.scenes.win.handler = gameOverHandler;
      model.scenes.loose.handler = gameOverHandler;

    }

    function warnNotImplemented()
    {
      model.warning = 'That feature is not yet implemented';
      view.draw();
    }

    function changeScene(name)
    {
      //This resets the model ( for n+1 plays of the game )
      if(name == 'menu')
      {
        model.reset();
        attachHandlersToModel();
      }
      //Look up the scene, wire keyboard, draw the scene
      model.scene = model.scenes[name];
      keyboardHandler = model.scene.handler;
      return view.draw();
    }

    function init(_model, _view)
    {
      model = _model;
      view = _view;
      wireKeyboard(view.element);
      changeScene("menu");
      view.draw();
    }

    return {
      init: init
    };
  }());

  view.init(model);
  controller.init(model, view);

});
\$\endgroup\$
2
  • \$\begingroup\$ It's an uplifting story for all Gastons out there ;) \$\endgroup\$
    – konijn
    Dec 29 '13 at 23:19
  • 1
    \$\begingroup\$ Loose is the opposite of tight, but the opposite of win is lose. \$\endgroup\$ Jan 20 '14 at 7:00
6
+50
\$\begingroup\$

I enjoyed playing and won on my first attempt. I suppose that means I'm quite virtuous! :)

Quick Tips

  • Since changeScene calls view.draw() at the end, you can drop the same call at the end of init of the controller.

  • I would put the extra handling of changing to the menu scene in a separate method such as returnToMenu that calls changeScene itself. While the menu is implemented as just another scene, functionally it's special and thus deserves to be made clear.

  • Use jQuery.extend to apply default option values so they appear at the top instead of mixed in at every access.

    options = $.extend({
        backgroundColor: 'black',
        textColor: 'limegreen'
    }, options);
    
  • If you pass true as the first parameter, you can even use this as a deep copy instead of converting to JSON and back.

    scenes = $.extend(true, {}, originalScenes);
    

Design Review

As the story grows you'll find yourself spending a lot of time tracking down copy-n-paste and errors in the handlers. A lot of the code is repeated with minimal changes: key numbers, scene names, and virtues mostly. Now that you have a representative example for each type of scene it's time to generalize the handler code and move the data behind the logic into the model.

The simplest is the menu as there are only two choices (one implemented) with no warnings or virtues. I've removed the explicit keys since you're using numbers and it shortens the code.

menu: {
    text: "Main Menu",
    choices: [{
        text: "Start Game",
        scene: "garden"
    }, {
        text: "High Scores",
        stub: true
    }]
}

The handler for this is straight-forward, but let's break it up in anticipation of adding more features. Note that you no longer swap out the handler for each scene. Instead, a single handler picks the course of action based on the current scene and the key pressed.

$element.keypress(function (e) {
    // convert key "1".."9" into 0..8 for index into choices array
    // values outside the available range will be undefined
    handleChoice(model.scene.choices[e.which - 49]);
});

function handleChoice(choice) {
    if (!choice) {
        warnInvalidChoice();
    }
    else if (choice.stub) {
        warnNotImplemented();
    }
    else {
        if (choice.scene) {
            changeScene(choice.scene);
        }
    }
}

If you were implementing a simple, stateless choose-your-own-adventure (no virtues or changing text), you'd be done. Adding to the story would require changing only the scenes in the model.

The next example shows how to set virtues using the snowmen.

snowmen: {
    text: "Against all odds, one of the poor snowmen seems to be shivering.",
    choices: [{
        text: "Offer your jacket",
        virtue: {
            name: "charity",
            text: "Both snowmen are now guarding valiantly the manor.",
            warning: "You already offered your jacket."
        }
    }, {
        text: "Enter the manor",
        scene: "lobby"
    }]
}

The handler sets the virtue, issues a warning if the virtue is already set, and changes the scene text. It does not, however, handle changing the scene text if it was bypassed on the first attempt. I'll leave that to you.

function handleChoice(choice) {
    ...
    else {
        if (choice.virtue) {
            handleVirtue(choice.virtue);
        }
        if (choice.scene) {
            changeScene(choice.scene);
        }
    }
}

function handleVirtue(virtue) {
    if (!model.virtues[virtue.name]) {
        model.virtues[virtue.name] = true;
        if (virtue.text) {
            model.scene.text = virtue.text;
        }
    }
    else if (virtue.warning) {
        model.scene.warning = virtue.warning;
    }
}

I hope this gives you enough to go on. The idea is to move the scene-specific logic into the model so that you can continue the story without writing more code.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.