Apr 25, 2011

Memylon: the language wars are over!

Meet my cat:
He's programming (and JavaScript in particular) illiterate, but likes to program anyway. And situation like this is perfectly fine, unless that code, which is written to learn things, goes into production. We all know that never happens, though. We do, right?..

Anyway, one day he wanted to play with Html5 canvas and make a small silly game or something.

And that's what he came up with (press "Start Game" to begin):


No canvas for you, sorry. Please use a proper browser.



This is a classical Memory game:
  • At the beginning all the cards are shown for a couple of seconds. Try to remember their positions!
  • Then click on two cards at time to flip them over.
  • If a matching pair is revealed, then those cards disappear.
  • But the cards flip back if they do not match, and this counts as a "miss".
  • The goal is to find all the matching pairs with the least amount of misses.
As you may have noticed, the cards are corresponding to different programming languages, thus the name of the game: Memylon (what cat does not love cheap puns?..)

The full source code to the game can be found on github.

The unsophisticated animal did not use any existing JavaScript libraries. He definitely should have, but sometimes reinventing a bicycle is so much fun (oh yeah, of course he did peek into the jQuery source a couple of times, to check what shape the wheels are there)!

And of course, please pardon the silly animal if it does not work in your browser. It seemed to in the latest Chrome, Opera, Firefox and Konqueror on Linux, as well as in Chrome, Opera, Firefox, Safari and IE9 on Windows (even though with Chrome on Linux the global alpha value on the canvas' context did not seem to affect text transparency... which might be a bug or again a result of illiteracy - go figure).

First thing he did is running GIMP and gathering a collection of programming language pics into an "atlas" image (it should be mentioned that all the language logos/trademarks belong to the corresponding authors/owners, and background image is a courtesy of Renaissance):


Of course, every programmer knows that one should start from the hardest things. Not from the ones which are the most fun!!!

But come on, he's just a cat.

After that the illiterate creature opened a text editor and typed in a pile of JavaScript code:

//  the main entry point 
window.onload = function () { 
    Memylon.init(); 
} 
 
//  the game module 
var Memylon = function() { 
    //  constants 
    var FRAME_TIME = 50, FLASH_TIME = 1000; 
    var CARD_W = 64, CARD_H = 60, CARDS_IN_ROW = 11, CARDS_NUM_VARIATIONS = 54; 
    var FLIP_TIME = 500, HIDE_TIME = 500, SHOW_TIME = 300; 
    //  variables 
    var cards  = [], anims = []; 
    var numCardsW = 6, numCardsH = 4; 
    var prevIdx = -1; 
    var numMisses = 0; 
    var canInteract = false; 
    var bgImage, cardsImage; 
    var context; 
 
    // the initialization function 
    function init() { 
        var canvas = Utils.$('gameArea'); 
        if (canvas && canvas.getContext) { 
            context = canvas.getContext('2d'); 
            Utils.$('restartGame').onclick = function () { 
                this.value = 'Restart'; 
                resetGame(); 
            }; 
            canvas.addEventListener('mousedown', function (e) { 
                    onMouseClick(e.clientX - canvas.offsetLeft, e.clientY - canvas.offsetTop); 
                }, false); 
            setInterval(updateBoard, FRAME_TIME); 
        } 
 
        bgImage = Utils.$("bg"); 
        cardsImage = Utils.$("cards"); 
    } 
    //  draw a card sprite on canvas 
    function drawCard(cardID, x, y, scaleX, scaleY) { 
        var glyphID = cardID > 0 ? cardID : 0; 
        var cx = x + CARD_W*(1 - scaleX)/2; 
        var cy = y + CARD_H*(1 - scaleY)/2; 
        var glyphX = (glyphID%CARDS_IN_ROW)*CARD_W; 
        var glyphY = Math.floor(glyphID/CARDS_IN_ROW)*CARD_H; 
 
        context.drawImage(cardsImage, 
            glyphX, glyphY, CARD_W, CARD_H, 
            cx, cy, CARD_W*scaleX, CARD_H*scaleY);    
    } 
    //  play a "flash cards" animation 
    function playFlashCards() { 
        canInteract = false; 
        for (var i in cards) { 
            var card = cards[i]; 
            var startDelay = (i%numCardsW + 1)*80; 
            var flashTime = FLASH_TIME*numCardsW; 
            card.anim = createAnim( 
                ['pose', startDelay, card.id], 
                ['flip', FLIP_TIME,  card.id], 
                ['pose', flashTime, -card.id], 
                ['flip', FLIP_TIME, -card.id]); 
        } 
        anims = [createAnim(['exec', numCardsW*80 + FLIP_TIME*2 + flashTime, 
            function () { canInteract = true; }])]; 
    } 
    //  game-specific animation update functions 
    var AnimFunctions = { 
        //  "draw static card" animation 
        pose : function(t, cardID, card) { 
            drawCard(cardID, card.x, card.y, 1, 1); 
        }, 
        //  "flip card" animation 
        flip : function(t, cardID, card) { 
            var scale = Math.cos(Math.PI*t); 
            var id = (scale > 0) ? cardID : -cardID; 
            drawCard(id, card.x, card.y, Math.abs(scale), 1); 
        }, 
        //  "dissolve card" animation 
        hide : function(t, cardID, card) { 
            if (t < 1) { 
                context.globalAlpha = 1 - t; 
                drawCard(cardID, card.x, card.y, 1, 1); 
                context.globalAlpha = 1; 
            } 
        }, 
        //  flying text animation 
        text : function (t, params) { 
            //  append missing parameters with defaults 
            Utils.setDefaults(params, {sizeFrom: 10, xFrom: 182, yFrom: 150, alphaFrom: 1, 
                font: 'Arial', bold: true, color: '#2222AA'}); 
            Utils.setDefaults(params, {sizeTo: params.sizeFrom, alphaTo: params.alphaFrom, 
                xTo: params.xFrom, yTo: params.yFrom}); 
            //  interpolate current text parameters 
            var size = Utils.lerp(params.sizeFrom, params.sizeTo, t); 
            var x = Utils.lerp(params.xFrom, params.xTo, t); 
            var y = Utils.lerp(params.yFrom, params.yTo, t); 
            var alpha = Utils.lerp(params.alphaFrom, params.alphaTo, t); 
 
            context.font         = (params.bold ? 'bold ' : '') + size + 'px ' + params.font; 
            context.fillStyle    = params.color; 
            context.textBaseline = 'middle'; 
            context.textAlign    = 'center'; 
            context.globalAlpha  = alpha; 
            context.fillText(params.text, x, y); 
            context.globalAlpha  = 1; 
        }, 
        //  executes a function at the end of animation period 
        exec : function(t, fn) { 
            if (t === 1) { 
                fn(); 
            } 
        }, 
        //  empty "spin doing nothing" animation 
        wait : function(t) { 
        } 
    }; 
    //  create an animation sequence 
    function createAnim() { 
        var arg, animType, duration, param; 
        var anim = new AnimSequence(); 
        for (var i = 0, nArg = arguments.length; i < nArg; i++) { 
            arg = arguments[i]; 
            animType = arg[0]; 
            duration = arg[1]; 
            param = arg[2]; 
            anim.add(AnimFunctions[animType], duration, param);    
        } 
        return anim; 
    } 
    //  play the "game is won" scene 
    function playFinalScene(cardID) { 
        var xCenter = 190; 
        anims = [ 
            createAnim(['wait', 1000], ['text', 500, { 
                text: 'This is it.', 
                color: '#5F5B60', alphaTo: 0.8, 
                yFrom: 30, xFrom: 400, xTo: xCenter, 
                sizeFrom: 50 
            }]), 
            createAnim(['wait', 2000], ['text', 700, { 
                text: 'the language wars', 
                color: '#735551', 
                yFrom: 75, xFrom: -200, xTo: xCenter, 
                sizeFrom: 30 
            }]), 
            createAnim(['wait', 2700], ['text', 1000, { 
                text: 'ARE OVER.', 
                color: '#81878C', 
                xFrom: xCenter, yTo: 120, 
                sizeFrom: 0, sizeTo: 40 
            }]), 
            createAnim(['wait', 5000], ['text', 1500, { 
                text: 'and the winner is...', 
                color: '#B2A89B', 
                xFrom: 500, xTo: xCenter, yFrom: 160, 
                sizeFrom: 30 
            }]), 
            createAnim(['wait', 6500], ['text', 5000, { 
                text: getCardInfo(cardID).name, 
                color: '#D91122', 
                xFrom: -100, xTo: xCenter, yFrom: 210, 
                sizeFrom: 0, sizeTo: 55 
            }]), 
            createAnim(['exec', 10000, function () { 
                var winnerURL = Utils.$('winnerURL'); 
                winnerURL.style.visibility = 'visible'; 
                winnerURL.href = getCardInfo(cardID).url; 
            }]) 
        ]; 
    } 
    //  process the "card clicked" event 
    function clickCard(cardIdx) { 
        var card = cards[cardIdx]; 
        if (prevIdx === -1) { 
            //  no cards are flipped yet 
            prevIdx = cardIdx; 
            card.anim = createAnim(['flip', FLIP_TIME, card.id]); 
            card.id = -card.id; 
        } else { 
            var prevCard = cards[prevIdx]; 
            if (Math.abs(card.id) === Math.abs(prevCard.id)) { 
                //  animation for the already flipped card: wait and dissolve 
                prevCard.anim = 
                    createAnim(['pose', FLIP_TIME, prevCard.id], 
                               ['hide', HIDE_TIME, prevCard.id]); 
                //  animation for the current card: flip and dissolve 
                card.anim = 
                    createAnim(['flip', FLIP_TIME, card.id], 
                               ['hide', HIDE_TIME, -card.id]); 
                //  animation of the flying card caption 
                anims = [createAnim(['text', 2000, { 
                    text: getCardInfo(card.id).name, 
                    alphaFrom: 1, alphaTo: 0.01, 
                    sizeFrom: 10, sizeTo: 300}], ['wait'])]; 
                var cardID = card.id; 
                prevCard.id = card.id = 0; 
                if (Utils.every(cards, function (card) { return (card.id === 0); })) { 
                    //  all matches are found, show the final scene animation 
                    playFinalScene(cardID); 
                } 
            } else { 
                //  animation for the already flipped card: wait and flip back  
                prevCard.anim = 
                    createAnim(['pose', FLIP_TIME + SHOW_TIME, prevCard.id], 
                               ['flip', FLIP_TIME,  prevCard.id], 
                               ['pose', undefined, -prevCard.id]);                    
                //  animation for the current card: flip, wait, flip back  
                card.anim = 
                    createAnim(['flip', FLIP_TIME,  card.id], 
                               ['pose', SHOW_TIME, -card.id], 
                               ['flip', FLIP_TIME, -card.id], 
                               ['pose', undefined,  card.id]); 
                prevCard.id = -prevCard.id; 
                setNumMisses(numMisses + 1); 
                } 
            prevIdx = -1; 
        } 
    } 
    //  reset the game board into the initial state 
    function resetGame() { 
        var ids, i, idx1, idx2; 
 
        //  generate a random subset of card variations 
        ids = []; 
        for (i = CARDS_NUM_VARIATIONS - 1; i >= 0; i--) ids[i] = i + 1; 
        Utils.shuffleArray(ids); 
        cards = []; 
 
        for (i = numCardsW*numCardsH - 1; i >= 0; i--) { 
            cards[i] = { 
                id   : -ids[Math.floor(i/2)], 
                anim : createAnim(['pose']) 
            } 
        } 
        Utils.shuffleArray(cards); 
 
        //  cache the card positions 
        for (i in cards) { 
            cards[i].x = (i%numCardsW)*CARD_W; 
            cards[i].y = Math.floor(i/numCardsW)*CARD_H; 
        } 
 
        setNumMisses(0); 
        anims = []; 
        prevIdx = -1; 
 
        playFlashCards(); 
        Utils.$('winnerURL').style.visibility = 'hidden'; 
    } 
    //  update/draw the game board 
    function updateBoard () { 
        var i; 
        context.drawImage(bgImage, 0, 0); 
        for (i in cards) { 
            var card = cards[i]; 
            card.anim && card.anim.update(FRAME_TIME, card); 
        }    
        for (i in anims) { 
            anims[i] && anims[i].update(FRAME_TIME); 
        }      
    } 
    //  update the miss counter 
    function setNumMisses(num) { 
        numMisses = num; 
        Utils.$('attemptsCounter').innerHTML = numMisses; 
    } 
    //  mouse click handler 
    function onMouseClick(x, y) { 
        var cardIdx = Math.floor(x/CARD_W) + numCardsW*Math.floor(y/CARD_H); 
        var card = cards[cardIdx]; 
        if (canInteract && card && (cardIdx !== prevIdx) && (card.id !== 0)) { 
            clickCard(cardIdx); 
        } 
    } 
    function getCardInfo(cardID) { 
        var cardIdx = Math.abs(cardID) - 1; 
        var a = Utils.$('links').children[cardIdx]; 
        return {name:a.innerHTML, url:a.href}; 
    } 
 
    return { 
        init : init 
    } 
}(); 
 
//  helper utility functions 
var Utils = { 
    //  randomly shuffles the array using Fisher-Yates algorithm 
    shuffleArray : function (arr) { 
        for (var i in arr) { 
            var k = Math.floor(Math.random()*i); 
            var val = arr[k]; 
            arr[k] = arr[i]; 
            arr[i] = val; 
        } 
    }, 
    //  returns true if all elements of the array satisfy the given predicate 
    every : function (arr, predicate) { 
        for (var i in arr) { 
            if (!predicate(arr[i])) return false; 
        } 
        return true; 
    }, 
    //  linear interpolation between a and b with factor t 
    lerp : function (a, b, t) { 
        return a + t*(b - a); 
    }, 
    //  append parameters in "params" with default values from "defaults" 
    setDefaults : function (params, defaults) { 
        for (var i in defaults) { 
            if (params[i] == undefined) { 
                params[i] = defaults[i]; 
            } 
        } 
    }, 
    //  poor man's jQuery 
    $ : function (id) { 
        return document.getElementById(id); 
    } 
} 
 
// a simple class representing a list of animations played in sequence 
AnimSequence = function () { 
    this.anims = []; 
    this.curAnim = 0; 
    this.curTime = 0; 
}; 
AnimSequence.prototype.add = function (updateFn, duration, startParam) { 
    this.anims.push({updateFn: updateFn, duration: duration, startParam: startParam}); 
}; 
AnimSequence.prototype.update = function (dt, updateParam) { 
    var anim = this.anims[this.curAnim]; 
    this.curTime += dt; 
    if (anim.duration && this.curTime >= anim.duration) { 
        anim.updateFn(1, anim.startParam, updateParam); 
        if (this.curAnim < this.anims.length - 1) { 
            this.curTime = 0; 
            this.curAnim++; 
        } 
    } else { 
        anim.updateFn(anim.duration && this.curTime/anim.duration, 
            anim.startParam, updateParam); 
    } 
}; 


That's what happens when you give a keyboard to a cat.

Of course, the cat ran the program through JSLint, and it hurt his feelings.

And that was it.


4 comments :

turchynp said...

It's a shame that poor animal didn't learn hardcore multi-core. After all, many humans, who are programming illiterate as well, started to do that for fun and profit.

Phillip said...

On my drive home tonight I was thinking about gaming software in education. Ya know, something like this could be used in the lab for matching pictures with definitions.

Vote for Pedro!

ruslans said...

turchynp,

Who knows, the animal might eventually grow some manly chesthair and expand on hardcore multicore (just for fun, of course).

Stay tuned :)

ruslans said...

Phillip,

Indeed, there appears to be quite a potential in the "games to help education" approach.

I've been amused by this topic for a while, in fact... my previous blog post is also alluding to that, actually (the one with WoW and probability theory).