3
\$\begingroup\$

Single-page web app from Applet

I'm seeking feedback on my first single-page web app using RequireJS and a web worker. I converted it from a Java applet, and the code is up on GitHub.

It's a combat simulator based on the old-school (1977) game of Melee. The RequireJS design is based on jrburke's single page app template.

Here's what the whole design looks like in UML:

UML class diagram

Feedback I'm seeking

I'm an experienced Java programmer who, upon realizing Java applets don't run in browsers anymore (I know I'm late to that party!), decided to bite the bullet and move to JavaScript.

The result I have seems to work well and in some ways is probably better than my Java applet originally was. I'm curious for feedback on my approach to converting the applet and the use of RequireJS, web workers, etc.

  • I tried as best I could to reproduce the Java Swing GUI in HTML 5 and BootStrap. I'm not 100% happy with an HTML Select with multiple option to reproduce a Java JList. It's not great in mobile versions of Chrome (Android), as it remains "closed" until you tap on it. There's no easy way to select all, for example (you have to tap once on each item!). But it works great in desktop browsers. You can try the "live" version of the simulator.
  • Since HTML 5 web workers have almost 100% separate memory space from the main thread, I intentionally didn't use the same class with RequireJS in two separate threads (see the UML diagram). Probably this isn't a hard an fast rule. But in an early design, one of my classes (HeroesSingleton) was being loaded twice (once in the main, once in the web worker) and so it wasn't actually a singleton. That seems to be a gotcha with RequireJS and threads.
  • I followed a few templates for RequireJS class designs for the "objects" in the simulator, e.g., Hero, Weapon, etc. In the simulator, only Hero and Game objects are objects that change state (not immutable). I'm pretty sure the methods (getters) I have there are overkill since there is no true private member in JavaScript (as far as I understand RequireJS). I know about underscore convention for indicating private, and I know about closures (but didn't see an obvious way to hide implementation details in RequireJS).
  • Finally, without a good editor/IDE, it's possible to make a lot of mistakes (e.g. unbalanced parentheses, calling the wrong function in a module, etc.) that don't get detected until run-time. I'd love feedback on how to save time with IntelliSense developing a single-page RequireJS or any other modular technique. Visual Studio 2013 isn't bad. For now, I don't want to go into a full-on web framework (such as Angular) since my short-term problem is getting existing Applets to work as single-page apps in JavaScript.

Here are a few classes (not all are complete or even present, but again all the code is up on GitHub):

controller.js

/* global sorttable */
define(["./HeroesSingleton"], function (HeroesSingleton) {
    "use strict";
    var isPoleWeaponsChargeFirstRoundChecked = false;
    var isDefendVsPoleChargeChecked = false;
    var isVerboseChecked = false;

    var webWorker = {};

    // ...

    return {
        start: function () {
            // ...

            // crunch the numbers in a web worker
            var worker = new Worker("app/simulator.js");
            webWorker = worker;
            //worker.postMessage("hello");
            worker.addEventListener("message", function (event) {
                var data = event.data;
                //console.log("Web worker messaged me: " + event.data);
                switch (data.cmd) {
                    case 'worker started':
                        // give worker the info
                        worker.postMessage({ 'selectedHeroes': selectedHeroes, 'boutCount': boutCount, 'isPoleWeaponsChargeFirstRound': isPoleWeaponsChargeFirstRoundChecked, 'isDefendVsPoleCharge': isDefendVsPoleChargeChecked, 'isVerbose': isVerboseChecked });
                        var p = document.createElement('p');
                        p.className = "bg-info";
                        p.appendChild(document.createTextNode("Calculating results - please wait."));
                        document.getElementById("matchupWins").appendChild(p);
                        p = document.createElement('p');
                        p.className = "bg-info";
                        p.appendChild(document.createTextNode("Calculating results - please wait.")); document.getElementById("heroWins").appendChild(p);
                        break;

                    case 'log':
                        logBuffer += data.message + "\n";
                        break;

                    case 'progressUpdate':
                        progressBar.value = data.progress;
                        break;

                    case 'finished':
                        /**
                         * Clear messages 
                         */
                        clearDiv("heroWins");
                        clearDiv("matchupWins");
                        var heroWinsTable = createTableFromProperties(data.heroWins, (selectedHeroes.length - 1) * boutCount, "Results for " + selectedHeroes.length + " heroes, paired up for " + boutCount + " bouts each", false);
                        document.getElementById("heroWins").appendChild(heroWinsTable);
                        sorttable.makeSortable(heroWinsTable);

                        var matchupWinsTable = createTableFromProperties(data.matchupWins, boutCount, "Pairwise results for " + selectedHeroes.length + " heroes, paired up for " + boutCount + " bouts each:", true);
                        document.getElementById("matchupWins").appendChild(matchupWinsTable);
                        sorttable.makeSortable(matchupWinsTable);


                        /**
                         * Force tables to be sorted
                         */
                        var myTH = document.getElementById("matchwins");
                        sorttable.innerSortFunction.apply(myTH, []); // once for ascending
                        sorttable.innerSortFunction.apply(myTH, []); // again for descending (stupid but it's how it works)
                        myTH = document.getElementById("wins"); // top table last, since the icon only shows on last table sorted...
                        sorttable.innerSortFunction.apply(myTH, []);
                        sorttable.innerSortFunction.apply(myTH, []);

                        verboseOutputText.value = logBuffer;

                        startButton.disabled = false;
                        stopButton.disabled = true;
                        break;

                    default:
                        console.log("Unrecognized message from web worker: ");
                        console.log(data);
                        break;
                }
            });

            worker.addEventListener("error", function () {
                console.log("WORKER ERROR", arguments);
            });

            // worker takes over leaving the GUI thread free to update
        },
        stop: function () {
            /**
             * Stop the web worker
             */
            var stopButton = this;
            this.disabled = true;
            webWorker.terminate();
            /**
             * Clear results from previous run 
             */
            clearDiv("heroWins");
            clearDiv("matchupWins");

            var p = document.createElement('p');
            p.className = "bg-warning";
            p.appendChild(document.createTextNode("No results becase the simulator was stopped before it finished."));
            document.getElementById("matchupWins").appendChild(p);
            p = document.createElement('p');
            p.className = "bg-warning";
            p.appendChild(document.createTextNode("No results becase the simulator was stopped before it finished.")); document.getElementById("heroWins").appendChild(p);
            document.getElementById("startSimulation").disabled = false;
        }
    };
});

simulator.js -- note: loaded from an HTML 5 web worker

importScripts('../lib/require.js');

require(["./HeroesSingleton", "./Hero", "./Game", "./controller", "./Logger"], function (HeroesSingleton, Hero, Game, controller, Logger) {
    "use strict";

    var poleWeaponsChargeFirstRound = null;
    var defendVsPoleCharge = null;

    self.postMessage({ "cmd": "worker started" });

    self.addEventListener('message', function (event) {
        /**
         * Only one type of message to start this thread
         */
        var data = event.data;
        var heroSet = [];  // list of heroes to fight

        Game.createHeroesMap();
        var completeHeroMap = Game.getHeroMap();
        data.selectedHeroes.forEach(function (heroName) {
            var hero = completeHeroMap[heroName];
            heroSet.push(hero);
        }, this);

        /**
         * Configure simulator options
         */
        Logger.setMute(!data.isVerbose);
        poleWeaponsChargeFirstRound = data.isPoleWeaponsChargeFirstRound;
        defendVsPoleCharge = data.isDefendVsPoleCharge;

        tryAllCombinations(heroSet, data.boutCount);
    });

    function tryAllCombinations(heroSet, boutCount) {
        var matchupWins = {};  // map of hero and integer
        var heroWins = {};
        var game = null;
        var score = [2];
        var progress = 0;
        // how many bouts total is N * N-1 * boutCount
        var totalIterations = heroSet.length * (heroSet.length - 1) * boutCount / 2;
        var iterationCount = 0;
        heroSet.forEach(function (hero1) {
            heroWins[hero1.getName()] = 0;
            heroSet.forEach(function (hero2) {
                if (hero1 != hero2) matchupWins[hero1.getName() + "/" + hero2.getName()] = 0;
            }, this);
        }, this);
        //console.log(heroWins);

        for (var h1 = 0; h1 < heroSet.length; h1++) {
            var hero1 = heroSet[h1];
            var h2 = 0;
            var hero2 = heroSet[h2];

            for (h2 = h1 + 1; h2 < heroSet.length; h2++) {
                hero2 = heroSet[h2];
                var sumRounds = 0;
                score[0] = 0;
                score[1] = 0;
                Logger.log('Matchup: ' + hero1.getName() + ' vs. ' + hero2.getName());

                for (var bout = 0; bout < boutCount; bout++) {
                    Logger.log("Bout: " + bout + 1 + " of " + boutCount);
                    /**
                    * update progress bar on page (assumes max is 100)
                    */
                    progress = Math.ceil((++iterationCount / totalIterations) * 100);
                    self.postMessage({ "cmd": "progressUpdate", "progress": progress });

                    // clone heroes (resets them) prior to fighting
                    var fightingHero1 = Object.create(hero1);
                    var fightingHero2 = Object.create(hero2);
                    // console.log(fightingHero1);
                    // console.log(fightingHero2);
                    game = new Game(fightingHero1, fightingHero2, poleWeaponsChargeFirstRound, defendVsPoleCharge);
                    var winningFighter = game.fightToTheDeath();

                    if (winningFighter !== null) {
                        var losingFighter = (winningFighter == fightingHero1 ? fightingHero2 : fightingHero1);
                        score[(winningFighter == fightingHero1 ? 0 : 1)]++;
                        matchupWins[winningFighter.getName() + "/" + losingFighter.getName()]++;
                    }
                    sumRounds += game.round;
                }
                /**
                 * Update the total stats for these heroes
                 */
                heroWins[hero1.getName()] += score[0];
                heroWins[hero2.getName()] += score[1];
            }

        }
        /**
         * Put stats back on page
         */
        self.postMessage({ "cmd": "finished", "heroWins": heroWins, "matchupWins": matchupWins });
    }


    function updateProgress() {
        var progressBar = document.getElementById("progress");
        if (progressBar.value < progressBar.max) {
            setTimeout(updateProgress, 80);
        } else {
            return;
        }
    }

});

Hero.js

define(["./Weapon", "./Logger"], function (Weapon, Logger) {
    "use strict";
    // Start with the constructor
    function Hero(name, st, dx, weapon, armor, shield) {
        this.name = name;
        this.st = st;
        this.dx = dx;
        this.readiedWeapon = weapon;
        this.armor = armor;
        this.shield = shield;
        this.knockedDown = false;
        this.standingUp = false;
        this.pickingUpWeapon = false;
        this.droppedWeapon = Weapon.NONE;

        this.damageTaken = 0;
        this.damageTakenThisRound = 0;
        this.injuryDexPenalty = false;
        this.recovering = false;
        this.defending = false;
        this.charging = false;
    }

    // Now add methods
    Hero.prototype.getName = function () {
        return this.name;
    };

    Hero.prototype.getST = function () {
        return this.st;
    };

    Hero.prototype.adjST = function () {
        return Math.max(this.st - this.damageTaken, 0);
    };

    Hero.prototype.getDX = function () {
        return this.dx;
    };

    Hero.prototype.adjustedDx = function () {
        return this.dx - this.armor.getDexAdjustment() - this.shield.getDexAdjustment() - (this.injuryDexPenalty ? 2 : 0) - (this.isStrengthLowPenalty() ? 3 : 0);
    };

    Hero.prototype.isAlive = function () {
        return (this.st - this.damageTaken > 0);
    };

    Hero.prototype.isConscious = function () {
        return (this.st - this.damageTaken > 1);
    };

    Hero.prototype.isKnockedDown = function () {
        return this.knockedDown;
    };

    Hero.prototype.standUp = function () {
        this.standingUp = true;
    };

    /**
     * These rules maybe should go into Game (better cohesion)
     */
    Hero.prototype.newRound = function () {
        this.charging = false;
        this.defending = false;
        this.damageTakenThisRound = 0;
        if (this.standingUp) {
            this.knockedDown = false;
            this.standingUp = false;
        }
        else if (this.pickingUpWeapon)  // technically "was" picking up weapon last round
        {
            this.readiedWeapon = this.droppedWeapon;
            this.droppedWeapon = Weapon.NONE;
            this.pickingUpWeapon = false;
        }

        /*
         * Dex penalty due to injury lasts one complete round
         */
        if (this.injuryDexPenalty && this.recovering) {
            this.injuryDexPenalty = false;
            this.recovering = false;
        }
        else if (this.injuryDexPenalty) {
            this.recovering = true;
        }
    };

    Hero.prototype.takeHits = function (hits) {
        var armorPoints = this.armor.hitsStopped() + this.shield.hitsStopped();
        var damageDone = hits - armorPoints;
        if (damageDone < 0) damageDone = 0;


        Logger.log(this.name + " taking " + hits + " hits.");
        Logger.log(this.armor.getName() + " stops " + this.armor.hitsStopped() );
        Logger.log(this.shield.getName() + " stops " + this.shield.hitsStopped());
        Logger.log(this.name + " taking " + damageDone + " damage.");

        this.takeDamage(damageDone);
        return damageDone;
    };

    /**
     * After it's got past armor, etc.
     */
    Hero.prototype.takeDamage = function (damageDone) {
        this.damageTaken += damageDone;
        this.damageTakenThisRound += damageDone;
        this.injuryDexPenalty = this.sufferingDexPenalty();

        if (this.injuryDexPenalty) Logger.log(this.name + " has an adjDx penalty of -2 for remainder of this round and the NEXT round.");
        Logger.log(this.name + " has now taken " + this.damageTaken + " points of damage, ST = " + this.st + (this.damageTaken >=this.st ? " and is DEAD." : (this.st - this.damageTaken == 1 ? " and is UNCONSCIOUS." : ".")));

        if (this.damageTakenThisRound >= 8) {
            this.knockedDown = true;
            Logger.log(this.name + " has been knocked down by damage.");
        }
        if (this.isStrengthLowPenalty()) Logger.log(this.name + " has an additional DX adjustment of -3 due to ST <= 3.");

    }

    Hero.prototype.sufferingDexPenalty = function () {
        return (this.damageTakenThisRound >= 5 || this.recovering);
    };

    Hero.prototype.isStrengthLowPenalty = function () {
        return (this.st - this.damageTaken <= 3);
    };

    Hero.prototype.setDefending = function () {
        this.defending = true;
    };

    Hero.prototype.isDefending = function () {
        return this.defending;
    };

    Hero.prototype.setCharging = function (isCharging) {
//        Logger.log("Hero: setCharge to " + isCharging);
        this.charging = isCharging;
    };

    Hero.prototype.isCharging = function () {
        return this.charging;
    };

    Hero.prototype.isProne = function () {
        return this.pickingUpWeapon;
    };

    Hero.prototype.getWeapon = function () {
        return this.weapon;
    };

    Hero.prototype.getReadiedWeapon = function () {
        return this.readiedWeapon;
    }

    Hero.prototype.dropWeapon = function () {
        this.droppedWeapon = this.readiedWeapon;
        this.readiedWeapon = Weapon.NONE;
    };

    Hero.prototype.breakWeapon = function () {
        this.readiedWeapon = Weapon.NONE;
        this.droppedWeapon = Weapon.NONE; // shouldn't need this, but just in case
    };

    Hero.prototype.getDroppedWeapon = function () {
        return this.droppedWeapon;
    }

    Hero.prototype.pickUpWeapon = function () {
        this.pickingUpWeapon = true;
    }

    Hero.prototype.getArmor = function () {
        return this.armor;
    };

    Hero.prototype.armorPoints = function () {
        this.armor.hitsStopped() + this.shield.hitsStopped();
    };

    Hero.prototype.getShield = function () {
        return this.shield;
    };

    Hero.prototype.toString = function () {
        return this.name + "\n" + this.armor.toString() + "\n" + this.readiedWeapon.toString();
    }

    Hero.prototype.canDoDamage = function () {
        return this.isConscious() && (this.readiedWeapon !== Weapon.NONE || this.droppedWeapon !== Weapon.NONE);
    }

    // And now return the constructor function
    return Hero;
});

Weapon.js

define(["./Die","./Logger"],function (Die, Logger) {
    "use strict";
    // Pattern from https://stackoverflow.com/a/10280735/1168342
    // Start with the constructor
    function Weapon(name, st, dice, modifier, isTwoHanded, isThrown, isPole) {
        if (!(this instanceof Weapon)) {
            throw new TypeError("Weapon constructor cannot be called as a function.");
        }
        this.name = name;
        this.st = st;
        this.dice = dice;
        this.modifier = modifier;
        this.isTwoHanded = isTwoHanded;
        this.isThrown = isThrown;
        this._isPole = isPole;
    }

    // Now add methods
    Weapon.prototype.getName = function () {
        return this.name;
    };

    Weapon.prototype.isTwoHanded = function () {
        return this.isTwoHanded;
    }

    Weapon.prototype.isThrown = function () {
        return this.isThrown;
    }

    Weapon.prototype.isPole = function () {
        return this._isPole;
    }

    Weapon.prototype.doDamage = function () {
            Logger.log(
                    "Rolling for weapon doing "
                            + this.dice
                            + "d"
                            + ((this.modifier > 0) ? "+" : "")
                            + ((this.modifier != 0) ? this.modifier : "")
                            + " damage.");
        var damage = 0;
        for (var i = 0; i < this.dice; i++)
        {
            damage += Die.roll();
        }
        damage += this.modifier;
        if (this.modifier != 0) Logger.log(((this.modifier > 0) ? "+" : "") + this.modifier);
        if (damage < 0) damage = 0;
        Logger.log("Total weapon damage: " + damage);
        return damage;
    }

    Weapon.prototype.toString = function () {
        return this.name + " (" + this.dice + "D" + ((this.modifier > 0) ? "+" : "") + ((this.modifier != 0) ? this.modifier : "") + ")";
    }

    Weapon.NONE = new Weapon("None", 0, 0, 0, false, false, false);
    Weapon.DAGGER = new Weapon("Dagger", 0, 1, -1, true, false, false); 
    Weapon.RAPIER = new Weapon("Rapier", 9, 1, 0, false, false, false);
    Weapon.CLUB = new Weapon("Club", 9, 1, 0, true, false, false);
    Weapon.HAMMER = new Weapon("Hammer", 10, 1, 1, true, false, false);
    Weapon.CUTLASS = new Weapon("Cutlass", 10, 2, -2, false, false, false);
    Weapon.SHORTSWORD = new Weapon("Shortsword", 11, 2, -1, false, false, false);
    Weapon.MACE = new Weapon("Mace", 11, 2, -1, true, false, false);
    Weapon.SMALL_AX = new Weapon("Small ax", 11, 1, 2, false, false, false);
    Weapon.BROADSWORD = new Weapon("Broadsword", 12, 2, 0, false, false, false);
    Weapon.MORNINGSTAR = new Weapon("Morningstar", 13, 2, 1, false, false, false);
    Weapon.TWO_HANDED_SWORD = new Weapon("Two-handed sword", 14, 3, -1, false, true, false);
    Weapon.BATTLEAXE = new Weapon("Battleaxe", 15, 3, 0, false, true, false);

    // pole weapons
    Weapon.JAVELIN = new Weapon("Javelin", 9, 1, -1, true, false, true);
    Weapon.SPEAR = new Weapon("Spear", 11, 1, 1, true, true, true);
    Weapon.HALBERD = new Weapon("Halberd", 13, 2, -1, false, true, true);
    Weapon.PIKE_AXE = new Weapon("Pike axe", 15, 2, 2, false, true, true);    // And now return the constructor function

    return Weapon;
});
\$\endgroup\$
4
\$\begingroup\$

I'm an experienced Java programmer who, upon realizing Java applets don't run in browsers anymore (I know I'm late to that party!), decided to bite the bullet and move to JavaScript.

Yay! Welcome to JavaScript, where everything looks broken! >:D

I'd love feedback on how to save time with intellisense developing a single-page requirejs or any other modular technique.

I'd recommend Intellij IDEA (the paid one) as it has some pretty good code analysis, and it really figures out what goes where. For free alternatives, Visual Studio or even Visual Studio Code are good too. If you just want plain syntax highlighting, linting, indentation etc. then Sublime Text 3 with a bunch of linter plugins (too many to mention) should also suffice.

importScripts('../lib/require.js');

require(["./HeroesSingleton", "./Hero", "./Game", "./controller", "./Logger"], function (HeroesSingleton, Hero, Game, controller, Logger) {

Based on your diagram, the simulator only needs Hero, Game, and Logger. Not sure why HeroesSingleton and even controller is present here. It's also worth noting that inside a worker, you don't have the usual globals you would find in the browser thread. You can't even access the DOM.

Also, unlike Java which ignores unused imports, module management systems like RequireJS don't have a way to know what imports aren't used. They just load everything you tell them to. If that happens, and if you happen to execute DOM-related code or non-existent globals inside a worker (like say use controller in the context of a worker), then your code might break.

var p = document.createElement('p');
p.className = "bg-warning";
p.appendChild(document.createTextNode("No results becase the simulator was stopped before it finished."));
document.getElementById("matchupWins").appendChild(p);
p = document.createElement('p');
p.className = "bg-warning";
p.appendChild(document.createTextNode("No results becase the simulator was stopped before it finished.")); document.getElementById("heroWins").appendChild(p);
document.getElementById("startSimulation").disabled = false;

Since your code is more of a "data, render, clear" operation, which simply just rendering and re-rendering really, consider using a template library like Mustache. That way, you can simply just write plain HTML with some "mustaches" (points where data interpolates with the template). Then in one render call, you get a full HTML which you can pop in to the page using innerHTML.

Now with regards to "threading", I believe your solution is so... Java-ish or CPP-ish. The beauty of JS is that it runs on one thread (forget thread pools, synchronizations etc), but it's very fast. Instead of spawning "threads" to operate different multiple jobs, I believe JS is fast enough to execute all of them in the same thread... given some trickery.

You can simply opt to do async operations instead. That is, single thread, multiple tasks, specially scheduled so that each has its opportunity to operate. You can spawn instances of your simulator and listen to them finish using Promises (I believe in Java they call them Futures?). You can use setInterval timer instead of loop so that you have asynchronous iterations.

For instance, something like this:

function simulate(config){
  return new Promise(function(resolve, reject){
    // Do async stuff here
    if(successful) resolve(results);
    else reject(error);
  });
}

// Create 20 instances of the simulator by iterating through an array
// created by _.range() (there's no range function in JS, check lodash)
var simulatorPromises = _.range(20).map(function(index){
  return simulate(config);
});

// Assign listeners for when all simulators finish
Promise.all(simulatorPromises).then(function(results){
  // All simulators succeeded

  // `results` is an array of resolved values from the instances in the
  // order they were in the `simulatorPromises` array.

}, function(error){
  // Not all simulators succeeded
});

// By now, simulators should be running asynchronously. Your listener
// will eventually fire when they're done.
\$\endgroup\$
  • \$\begingroup\$ Thanks for the answer. Threads were needed because with 2000+ iterations of my simulator, it takes, say 30+ seconds. Nothing updates in the GUI if all simulator processing is done in the main thread. I use onmessage to relay info back and forth, precisely because there is no access to DOM. I like that design decision. In Java you have to deal with thread safety and web workers reduce the problem, even if communication and synchronization are more complex. \$\endgroup\$ – Fuhrmanator Oct 23 '15 at 18:15
  • \$\begingroup\$ Web workers seems more intuitive than promises, etc. But I'm an old dog and I do appreciate you mentioning the new tricks. \$\endgroup\$ – Fuhrmanator Oct 23 '15 at 18:22
  • \$\begingroup\$ @Fuhrmanator Ahh, yes the freezing UI. That's where async operations come in, allowing the tasks to take turns. But yeah, 2000... workers may be better indeed. You may also want to consider offloading the operation to a server too. \$\endgroup\$ – Joseph Oct 23 '15 at 18:31
  • 1
    \$\begingroup\$ Web Essentials in Visual Studio gave me JSHint and it found the unused things you mentioned, as well as a lot of other issues. Curiously JSHint doesn't have an explicit setting for RequireJS. I had to declare "require" and "define" as predefined globals in .jshintrc. to get rid of warnings. There's a Web Workers setting, however. \$\endgroup\$ – Fuhrmanator Oct 26 '15 at 15:12
  • 1
    \$\begingroup\$ @Fuhrmanator JSHint only provides a few environments out of the box. RequireJS isn't one of them sadly. Also, I recommend the CommonJS style imports. That way, they look like var dep = require('dep'), which becomes easy to spot and delete if unused compared to the deps list + args style of AMD syntax. \$\endgroup\$ – Joseph Oct 26 '15 at 15:30

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.