10
\$\begingroup\$

I am writing a Nonogram game in JavaScript.

I would like to know your opinions on the code and suggestions on how to improve it.

I'm using a MVC approach.

  • the Model has two matrices (represented as linear arrays): one with the actual Nonogram cells and the other with the guesses of the player;
  • cells are just strings: Nonogram cells can be either "filled" or "empty"; player guesses can be either "unknown", "filled" or "empty"
  • by sequence I mean a sequence of consecutive filled cells
  • by definition I mean the series of sequences a row/column has

Here is the source code (I am going to release it under the GNU GPL license):

utils.js

if (!window.koala) {
    window.koala = {};
}

koala.utils = {
    randomIntegerInRange: function (min, max) {
        return min + Math.floor(Math.random() * (max - min + 1));
    }
};

koala.utils.Event = function (sender) {
    this._sender = sender;
    this._listeners = [];
};

koala.utils.Event.prototype = {
    attach: function (listener) {
        this._listeners.push(listener);
    },

    notify: function (args) {
        for (var index = 0, nlisteners = this._listeners.length; index < nlisteners; index++) {
            this._listeners[index](this._sender, args);
        }
    }
};

nonograms.js

if (!window.koala) {
    window.koala = {};
}

koala.nonograms = {};


/*

    The game uses the MVC pattern.

*/


/*
  __  __           _      _ 
 |  \/  |         | |    | |
 | \  / | ___   __| | ___| |
 | |\/| |/ _ \ / _` |/ _ \ |
 | |  | | (_) | (_| |  __/ |
 |_|  |_|\___/ \__,_|\___|_|

*/

koala.nonograms.Model = function (opts) {
    if (!opts) opts = {};
    this._opts  = opts;

    this.width  = opts.width  = (opts.width  ? Number(opts.width) : 10);
    this.height = opts.height = (opts.height ? Number(opts.height) : 10);

    // Events fired by the model
    this.events = {};

    // The ussr guessed the content of a cell
    this.events.guessChanged     = new koala.utils.Event(this);

    // The nonogram has been changed
    this.events.nonogramChanged  = new koala.utils.Event(this);

    // The user solved the nonogram
    this.events.nonogramSolved   = new koala.utils.Event(this);

    // The nonogram was solved but now it isn't
    this.events.nonogramUnsolved = new koala.utils.Event(this);
};


koala.nonograms.Model.prototype = {
    getCellAt: function (x, y) {
        var index = this._indexFromXY(x, y);
        var cell = this._actual[index];

        return (cell === undefined) ? "empty" : cell;
    },

    getGuessAt: function (x, y) {
        var index = this._indexFromXY(x, y);
        var guess = this._guess[index];

        return (guess === undefined) ? "unknown" : guess;
    },

    setGuessAt: function (x, y, guess) {
        var oldGuess = this.getGuessAt(x, y);
        var index = this._indexFromXY(x, y);
        this._guess[index] = guess;

        this.events.guessChanged.notify({
                   x: x,
                   y: y,
            oldGuess: oldGuess,
            newGuess: guess
        });

        var was_solved = this.isSolved();
        this._checkIfSolved();
        var is_solved = this.isSolved();

        if (was_solved && !is_solved) {
            this.events.nonogramUnsolved.notify();
        } else if (!was_solved && is_solved) {
            this.events.nonogramSolved.notify();
        }
    },

    isSolved: function () {
        return this._solved;
    },

    getRowDefinition: function (row) {
        var definition = [];

        var sequenceBegin;
        var sequenceEnd;
        var sequenceSolved;
        var sequenceLength = 0;

        for (var x = 0; x < this.width; x++) {
            if (this.getCellAt(x, row) === "filled") {
                if (sequenceLength === 0) sequenceBegin = x;
                sequenceLength++;
            } else if (sequenceLength) {
                sequenceEnd = x-1;

                sequenceSolved = false;
                if ((sequenceBegin === 0 || this.getGuessAt(sequenceBegin-1, row) === "empty")
                    && (sequenceEnd === this.width-1 || this.getGuessAt(sequenceEnd+1, row) === "empty")) {

                    sequenceSolved = true;
                    for (var index = sequenceBegin; index <= sequenceEnd; index++) {
                        if (this.getGuessAt(index, row) != "filled") {
                            sequenceSolved = false;
                            break;
                        }
                    }
                }

                definition.push({
                    length: sequenceLength,
                    solved: sequenceSolved
                });

                sequenceLength = 0;
            }
        }

        if (sequenceLength) {
            sequenceEnd = x-1;

            sequenceSolved = false;
            if ((sequenceBegin === 0 || this.getGuessAt(sequenceBegin-1, row) === "empty")
                && (sequenceEnd === this.width-1 || this.getGuessAt(sequence_end+1, row) === "empty")) {

                sequenceSolved = true;
                for (var index = sequenceBegin; index <= sequenceEnd; index++) {
                    if (this.getGuessAt(index, row) != "filled") {
                        sequenceSolved = false;
                        break;
                    }
                }
            }

            definition.push({
                length: sequenceLength,
                solved: sequenceSolved
            });
        }
        return definition;
    },

    getColumnDefinition: function (col) {
        var definition = [];

        var sequenceBegin;
        var sequenceEnd;
        var sequenceSolved;
        var sequenceLength = 0;

        for (var y = 0; y < this.height; y++) {
            if (this.getCellAt(col, y) === "filled") {
                if (sequenceLength === 0) sequenceBegin = y;
                sequenceLength++;
            } else if (sequenceLength) {
                sequenceEnd = y-1;

                sequenceSolved = false;
                if ((sequenceBegin === 0 || this.getGuessAt(col, sequenceBegin-1) === "empty")
                    && (sequenceEnd === this.height-1 || this.getGuessAt(col, sequenceEnd+1) === "empty")) {

                    sequenceSolved = true;
                    for (var index = sequenceBegin; index <= sequenceEnd; index++) {
                        if (this.getGuessAt(col, index) != "filled") {
                            sequenceSolved = false;
                            break;
                        }
                    }
                }

                definition.push({
                    length: sequenceLength,
                    solved: sequenceSolved
                });

                sequenceLength = 0;
            }
        }

        if (sequenceLength) {
            sequenceEnd = y-1;

            sequenceSolved = false;
            if ((sequenceBegin === 0 || this.getGuessAt(col, sequenceBegin-1) === "empty")
                && (sequenceEnd === this.height-1 || this.getGuessAt(col, sequenceEnd+1) === "empty")) {

                sequenceSolved = true;
                for (var index = sequenceBegin; index <= sequenceEnd; index++) {
                    if (this.getGuessAt(col, index) != "filled") {
                        sequenceSolved = false;
                        break;
                    }
                }
            }

            definition.push({
                length: sequenceLength,
                solved: sequenceSolved
            });
        }

        return definition;
    },

    randomize: function (density) {
        this._setupNonogram();

        var ncells = this.width * this.height;
        var to_be_filled = Math.floor(ncells * density);
        if (to_be_filled > ncells) to_be_filled = ncells;

        var index;
        while (to_be_filled) {
            index = this._getRandomIndex();
            if (this._actual[index] === undefined) {
                this._actual[index] = "filled";
                to_be_filled--;
            }
        }

        this.events.nonogramChanged.notify();
    },

    // Private methods
    _setupNonogram: function () {
        var ncells = this.width * this.height;

        this._actual = new Array(ncells);
        this._guess  = new Array(ncells);
        this._solved = false;
    },

    _indexFromXY: function (x, y) {
        return y * this.width + x;
    },

    _getRandomIndex: function () {
        var ncells = this.width * this.height;
        return koala.utils.randomIntegerInRange(0, ncells - 1);
    },

    _checkIfSolved: function () {
        for (var index = 0, ncells = this._actual.length; index < ncells; index++) {
            if ((this._actual[index] === "filled" && this._guess[index] != "filled") ||
                (this._actual[index] != "filled" && this._guess[index] == "filled")) {
                this._solved = false;
                return;
             }
        }
        this._solved = true;
    },
};






/*
 __      ___               
 \ \    / (_)              
  \ \  / / _  _____      __
   \ \/ / | |/ _ \ \ /\ / /
    \  /  | |  __/\ V  V / 
     \/   |_|\___| \_/\_/  

 */

koala.nonograms.View = function (model, container) {
    this._model = model;
    this._container = $(container);
    this._id = "nonogram" + koala.utils.randomIntegerInRange(0, 1000000);
    this._theme = "default";

    // Events fired by the View
    this.events = {};

    this.events.clickOnCell     = new koala.utils.Event(this);
    this.events.mouseDownOnCell = new koala.utils.Event(this);
    this.events.mouseUp         = new koala.utils.Event(this);
    this.events.mouseEntersCell = new koala.utils.Event(this);
    this.events.mouseLeavesCell = new koala.utils.Event(this);
}

koala.nonograms.View.prototype = {
    show: function () {
        this.rebuildNonogram();
    },

    setSolved: function () {
        $("#" + this._id).removeClass("nonogram_playing").addClass("nonogram_solved");
    },

    setUnsolved: function () {
        $("#" + this._id).removeClass("nonogram_solved").addClass("nonogram_playing");
    },

    setTheme: function (theme) {
        $("#" + this._id).removeClass(this._theme).addClass(theme);
        this._theme = theme;
    },

    highlightColumn: function (col) {
        this._container.find(".nonogram_column_" + col + "_cell").addClass("nonogram_hovered_column");
        this._container.find("#" + this._idOfColumnDefinition(col)).addClass("nonogram_hovered_column");
    },

    unhighlightColumn: function (col) {
        this._container.find(".nonogram_column_" + col + "_cell").removeClass("nonogram_hovered_column");
        this._container.find("#" + this._idOfColumnDefinition(col)).removeClass("nonogram_hovered_column");
    },

    setGuessAt: function (x, y, newGuess) {
        var cell = $("#" + this._idOfCell(x, y));
        var oldGuess = cell.data().guess;

        cell
        .removeClass("nonogram_correct_guess")
        .removeClass(this._guessToCSSClass(oldGuess))
        .addClass(this._guessToCSSClass(newGuess))
        .data({guess: newGuess});

        if (this._model.getCellAt(x, y) === newGuess) {
            cell.addClass("nonogram_correct_guess");
        }

        // Update row & column definitions
        $("#" + this._idOfRowDefinition(y)).html(this._rowDefinitionToHTML(this._model.getRowDefinition(y)));
        $("#" + this._idOfColumnDefinition(x)).html(this._columnDefinitionToHTML(this._model.getColumnDefinition(x)));
    },

    rebuildNonogram: function () {
        var width  = this._model.width,
            height = this._model.height;

        var x, y, tr;

        var table = $("<table/>").attr("id", this._id).addClass("nonogram").addClass(this._theme);
        if (this._model.isSolved())
            table.addClass("nonogram_solved");
        else
            table.addClass("nonogram_playing");

        // Column Definitions Row
        tr = $("<tr/>").addClass("nonogram_row");

        // Top Left cell
        $("<td>").addClass("nonogram_top_left_cell").appendTo(tr);

        for (x = 0; x < width; x++) {
            if (x && x % 5 === 0) {
                $("<td/>").addClass("nonogram_separation_column").appendTo(tr);
            }

            $("<td/>")
            .attr("id", this._idOfColumnDefinition(x))
            .addClass("nonogram_definition nonogram_column_definition")
            .html(this._columnDefinitionToHTML(this._model.getColumnDefinition(x)))
            .appendTo(tr);
        }
        tr.appendTo(table);

        for (y = 0; y < height; y++) {
            // Separate groups of five rows
            if (y && y % 5 == 0) {
                $("<tr/>")
                .addClass("nonogram_separation_row")
                .append($("<td colspan='" + (width + width - 1) + "'/>"))
                .appendTo(table);
            }

            // Create new row
            tr = $("<tr/>").addClass("nonogram_row");

            // Create definition for the current row
            $("<td/>")
            .attr("id", this._idOfRowDefinition(y))
            .addClass("nonogram_definition nonogram_row_definition")
            .html(this._rowDefinitionToHTML(this._model.getRowDefinition(y)))
            .appendTo(tr);

            for (x = 0; x < width; x++) {
                // Separate groups of five columns
                if (x && x % 5 === 0) {
                    $("<td/>")
                    .addClass("nonogram_separation_column")
                    .appendTo(tr);
                }

                // Build the actual nonogram cell
                $("<td/>")
                .attr("id", this._idOfCell(x, y))
                .addClass(this._CSSClassesForCell(x, y))
                .data({
                        x: x,
                        y: y,
                    guess: this._model.getGuessAt(x, y)
                })
                .appendTo(tr);
            }
            tr.appendTo(table);
        }

        this._container
        .hide()
        .empty()
        .append(table)
        .fadeIn(500);

        // Events firing code
        var view = this;
        table.click(function (e) {
            if (e.target.nodeName != "TD") return;

            e.preventDefault();
            var cellData = $(e.target).data();
            view.events.clickOnCell.notify(cellData);
        });

        table.mousedown(function (e) {
            if (e.target.nodeName != "TD") return;

            e.preventDefault();
            var cellData = $(e.target).data();
            view.events.mouseDownOnCell.notify(cellData);
        });

        $(document).mouseup(function (e) {
            e.preventDefault();
            view.events.mouseUp.notify({event: e});
        });

        table.mouseover(function (e) {
            if (e.target.nodeName != "TD") return;

            e.preventDefault();
            var cellData = $(e.target).data();
            view.events.mouseEntersCell.notify(cellData);
        });

        table.mouseout(function (e) {
            if (e.target.nodeName != "TD") return;

            e.preventDefault();
            var cellData = $(e.target).data();
            view.events.mouseLeavesCell.notify(cellData);
        });
    },

    // Private methods
    _idOfCell: function (x, y) {
        return this._id + "_x_" + x + "_y_" + y;
    },

    _idOfRowDefinition: function (row) {
        return this._id + "_row_" + row + "_definition";
    },

    _idOfColumnDefinition: function (col) {
        return this._id + "_column_" + col + "_definition";
    },

    _rowDefinitionToHTML: function (sequences) {
        var html = "<nobr>";
        for (var index = 0; index < sequences.length; index++) {
            if (index) html += "&nbsp;";
            html += "<span class='nonogram_sequence";
            if (sequences[index].solved) {
                html += " nonogram_solved_sequence";
            }
            html += "'>" + sequences[index].length + "</span>";
        }
        html += "</nobr>";
        return html;
    },

    _columnDefinitionToHTML: function (sequences) {
        var html = "";
        for (var index = 0; index < sequences.length; index++) {
            if (index) html += "<br>";
            html += "<nobr><span class='nonogram_sequence";
            if (sequences[index].solved) {
                html += " nonogram_solved_sequence";
            }
            html += "'>" + sequences[index].length + "</span></nobr>";
        }
        return html;
    },

    _CSSClassesForCell: function (x, y) {
        var cellGuess  = this._model.getGuessAt(x, y);
        var actualCell = this._model.getCellAt(x, y);

        var classes = [];

        classes.push("nonogram_cell");
        classes.push("nonogram_column_" + x + "_cell");
        classes.push(this._guessToCSSClass(cellGuess));

        if (cellGuess === actualCell) {
            classes.push("nonogram_correct_guess");
        }

        return classes.join(" ");
    },

    _guessToCSSClass: function (guess) {
        return "nonogram_" + guess + "_cell";
    }
};


/*
   _____            _             _ _           
  / ____|          | |           | | |          
 | |     ___  _ __ | |_ _ __ ___ | | | ___ _ __ 
 | |    / _ \| '_ \| __| '__/ _ \| | |/ _ \ '__|
 | |___| (_) | | | | |_| | | (_) | | |  __/ |   
  \_____\___/|_| |_|\__|_|  \___/|_|_|\___|_|   

*/

koala.nonograms.Controller = function (model, view) {
    this._dragHelper = new koala.nonograms.dragHelper();

    this._model = model;
    this._view = view;
    var controller = this;

    // Application Logic
    model.events.nonogramChanged.attach(function () {
        view.rebuildNonogram();
    });

    model.events.guessChanged.attach(function (model, opts) {
        view.setGuessAt(opts.x, opts.y, opts.newGuess);
    });

    model.events.nonogramSolved.attach(function () {
        view.setSolved();
    });

    model.events.nonogramUnsolved.attach(function () {
        view.setUnsolved();
    });

    view.events.mouseDownOnCell.attach(function (view, cell) {
        controller._dragHelper.start(cell.x, cell.y, controller._nextGuess(model.getGuessAt(cell.x, cell.y)));
        controller._previewDragging();
    });

    view.events.mouseUp.attach(function () {
        if (!controller._dragHelper.isDragging())
            return;
        controller._dragHelper.stop();
        controller._cancelDraggingPreview();
        controller._applyDragging();
    });

    view.events.mouseEntersCell.attach(function (view, cell) {
        view.highlightColumn(cell.x);
        if (!controller._dragHelper.isDragging()) return;

        controller._cancelDraggingPreview();
        controller._dragHelper.to(cell.x, cell.y);
        controller._previewDragging();
    });

    view.events.mouseLeavesCell.attach(function (view, cell) {
        view.unhighlightColumn(cell.x);
    });
}

koala.nonograms.Controller.prototype = {
    // Private methods

    // cycles in [unknown, filled, empty]
    _nextGuess: function (guess) {
        if (guess === "unknown") {
            return "filled";
        } else if (guess === "filled") {
            return "empty";
        }
        return "unknown";
    },

    _previewDragging: function () {
        var view = this._view;

        this._dragHelper.iterateOverDraggedCells(function (x, y, guess) {
            view.setGuessAt(x, y, guess);
        });
    },

    _applyDragging: function () {
        var model = this._model;

        this._dragHelper.iterateOverDraggedCells(function (x, y, guess) {
            model.setGuessAt(x, y, guess);
        });
    },

    _cancelDraggingPreview: function () {
        var model = this._model;
        var view  = this._view;

        this._dragHelper.iterateOverDraggedCells(function (x, y, guess) {
            view.setGuessAt(x, y, model.getGuessAt(x, y));
        });
    }
};

/*****************************************************/

koala.nonograms.dragHelper = function () {
    this._dragging = false;
};

koala.nonograms.dragHelper.prototype = {
    start: function (x, y, guess) {
        this._x1 = this._x2 = x;
        this._y1 = this._y2 = y;
        this._guess = guess;

        this._dragging = true;
    },

    to: function (x, y) {
        this._x2 = x;
        this._y2 = y;
    },

    stop: function () {
        this._dragging = false;
    },

    isDragging: function () {
        return this._dragging;
    },

    iterateOverDraggedCells: function (fn) {
        var x1 = this._x1;
        var y1 = this._y1;
        var x2 = this._x2;
        var y2 = this._y2;

        var fromX, toX, stepX, fromY, toY, stepY;

        if (Math.abs(x1-x2) > Math.abs(y1-y2)) {
            // Horizontal Line
            stepX = 1;
            stepY = 0;

            fromY = toY = y1;

            if (x1 < x2) {
                fromX = x1;
                toX = x2;
            } else {
                fromX = x2;
                toX = x1;
            }
        } else {
            // Vertical line
            stepX = 0;
            stepY = 1;

            fromX = toX = x1;

            if (y1 < y2) {
                fromY = y1;
                toY = y2;
            } else {
                fromY = y2;
                toY = y1;
            }
        }

        for (var x = fromX, y = fromY; x <= toX && y <= toY; x += stepX, y += stepY) {
            fn(x, y, this._guess);
        }
    }
};

/*****************************************************/
/*  The main Nonogram object                         */
/*****************************************************/

koala.nonograms.Nonogram = function (container, opts) {
    this._container = container;
    opts = opts || {};

    this._opts = {
         width: opts.width  || 10,
        height: opts.height || 10,
         theme: opts.theme  || "default"
    };

    this._model = new koala.nonograms.Model({
         width: this._opts.width,
        height: this._opts.height
    });
    this._view = new koala.nonograms.View(this._model, this._container);
    this._view.setTheme(this._opts.theme);
    this._controller = new koala.nonograms.Controller(this._model, this._view);
}

koala.nonograms.Nonogram.prototype = {
    show: function () {
        this._view.show();
    },

    randomize: function (opts) {
        var density = 0.60;
        if (opts && opts.density) {
            density = opts.density;
        }

        this._model.randomize(density);
    },

    setTheme: function (theme) {
            this._opts.theme = theme;
            this._view.setTheme(theme);
    }
};

If you want to try it in action I uploaded it here.

\$\endgroup\$
0

1 Answer 1

5
\$\begingroup\$

In general, it looks good to me.

My 2 cents:

  • I am not sure why you have event related things in your model

  • iterateOverDraggedCells looked like it could use some refactoring.

    • Whenever you start with x1,y1,x2,y2, I think it is better to have a point object with x and y.
    • You can use Math.min() and Math.max() to find which value is the lowest/highest
    • It looks like you went too far with DRY in that last for() loop
 iterateOverDraggedCells: function ( fn )
 {
   var p1 = {  x : this._x1 , y : this._y1 },
       p2 = {  x : this._x2 , y : this._y2 },
       from, to;

   if( Math.abs( p1.x - p2.x ) > Math.abs( p1.y - p2.y ) ) 
   {
     from = { x : Math.min( p1.x ,p2.x ) , y :  p1.y  };
     to   = { x : Math.max( p1.x ,p2.x ) };
     for( var x = from.x, y = from.y ; x <= to.x , x++ )
       fn(x, y, this._guess);   
   }
   else
   {
     from  = { y : Math.min( p1.y , p2.y ) , x :  p1.x  };
     to    = { y : Math.max( p1.y , p2.y ) };
     for( var x = from.x, y = from.y ; y <= to.y , y++ )
       fn(x, y, this._guess);
   }

 }

Taking that into account, you could try something like the above.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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