INPUT (Click me! Click me!)

One of the most important elements of game-play is user interaction.

So many RPG games are great tactical games, but lose the sense of real-time enjoyment while bouncing through menus. An early prototype of my game was menu centric. But as I make some mini demos, I'm trying to limit the amount of time spent in the menus in favor of faster input choices.

For this demo, there is no need for a menu. One click on a character selects him and then click the enemy and he attacks. OR, click and drag to aim the projectile, release to fire. Click twice on the character (not double-click for reasons I will explain) and then click an empty space to move them.

I'd like to keep it setup in a way that may be ported to touch screen devices. I imagine that will introduce new challenges. Perhaps I'll learn Objective-C first and make an app... ?

Anyway. Step one is capturing the click. Since everything is done inside the canvas, we use the following code:

window.onload = function (){
    var canvas = document.getElementById("gamewindow");
    document.getElementById('gamewindow').onmousemove = moveHandler;
    document.getElementById('gamewindow').onmousedown = clickHandler; //onclick is too slow for timing
    document.getElementById('gamewindow').onmouseup = clickReleaseHandler;
    document.onkeypress = onkeypressHandler;
}
function moveHandler(event){}
function clickHandler(event){}
function clickReleaseHandler(event){}
function onkeypressHandler(event){}
Notice that I used mousedown instead of onclick. This is important for calling functions quickly so that when the player tries to synchronize with the music, it is more accurate. Plus we can catch the mouseup event separately. The downside, is that differentiating click from double click is harder, so we just don't do it!

Each event listener is directed to a handler function. Now we have to do, you know, logic.

Unlike normal interactive elements, there are a few challenges to overcame when dealing with a live game environment. Starting with the fact that everything moves. Thus, step one is finding out what was clicked.

Or as I quickly learned, the FIRST step is to offset your click location to account for the placement of the canvas and scrolling...

function clickHandler(event){
    // cache the time/location of the click
    clickTime = audioctx.currentTime;

    event = event || window.event;
    var scrollX = window.pageXOffset || document.body.scrollLeft;
    var scrollY = window.pageYOffset || document.body.scrollTop;
    clickX = event.clientX + scrollX - xoffset;
    clickY = event.clientY + scrollY - yoffset;

    var gclick = guyClicked(clickX,clickY,"Allies",true);
}

function guyClicked(X,Y,dic,mustBeReady){
    var dict = activeGuyDict[dic];
    for (var guy in dict){
        if (!(X < dict[guy].pos.x + dict[guy].bounding.x || X > dict[guy].pos.x + dict[guy].bounding.x + dict[guy].bounding.w || Y < dict[guy].pos.y + dict[guy].bounding.y || Y > dict[guy].pos.y + dict[guy].bounding.y + dict[guy].bounding.h)){
            if ((mustBeReady && dict[guy].ready) || !mustBeReady) return guy;
            //character must be ready if "mustBeReady" is true
            else return null;
        }
    }
    //no guy was found
    return null;
}
If you don't feel like ripping through that IF statement in guyClicked, here's the rough explanation. It's a beautiful use of NOT OR logic. We need to see if the click occurred INSIDE the bounding box for the character. If the box is 50x100 at point (200,130), we see if the x was between 200 and 250 and if y was between 130 and 230, right?

We could setup the logic to see if x > 200 AND x < 250 AND y > 130 AND y < 230...

OR

... we could check if the click falls OUTSIDE the parameters, and use NOT OR. Only one condition must be true for the whole thing to fail. Not sure if it matters here, but this is useful if you are comparing BOXES to look for overlap.

For my game, we also check to see if the character is "ready" before selecting. Now onto the real tricky logic.

First click down gets the aiming out. Then release to choose an enemy. Then click an enemy or click the good guy again. Or click another guy to change the selection. Then click a spot to move. Or if an enemy was selected, click again to time the attack. Or, um... what else can you do with just a click? It gets complicated fast.

I found that (although counter-intuitive) it works best to write the logic backwards. Start with the most specific scenario first and work backwards through input order. That way, the handler can check for the specific scenarios and bail, or move on.

In plain text it looks like this:
  • Check to see if a selected character is waiting to move.
    • If a character was clicked, change focus
    • If an empty space was clicked, move
  • Check to see if an attacking character is "PRIMING".
    • Was the appropriate enemy clicked?
  • Check to see if an enemy must be selected to attack.
    • Did you click an enemy?
    • If a character was clicked change focus
  • If none of those criteria are met, select the clicked character.
   
for (var pg in activeGuyDict.Allies){
    // check to see if a character needs to move
    if (activeGuyDict.Allies[pg].moveForm){
        activeGuyDict.Allies[pg].moveForm = false;
        activeGuyDict.Allies[pg].selected = false;
        colorWheel.reset();
        //need to check if the space is occupied by the other character
        if (guyClicked(clickX,clickY,"Allies")) continue;
        activeGuyDict.Allies[pg].formation = {"name":"Formation","pos":{cx:clickX,cy:clickY},"halo":{cr:0,r:5}};
        activeGuyDict.Allies[pg].action = "FORMATION";
        activeGuyDict.Allies[pg].target = activeGuyDict.Allies[pg].formation;
        return;
    }
       
    if (activeGuyDict.Allies[pg].animation.name != 'PRIME') continue;
    if (!activeGuyDict.Allies[pg].attackTarget) continue;
    else var primeClick = guyClicked(clickX,clickY,"Enemies");
    if (!primeClick) continue;
    if (primeClick === activeGuyDict.Allies[pg].attackTarget){
        var cT = clickTime; //un-cache the click time
        activeGuyDict.Allies[pg].setAnim('ATTACK',cT);
     
        //this code determines the power, based on the timing
        var sync = 0;
        var hbl = secondsPerBeat / 2; //half a beat length
        if (cT - lastBeatTime < nextBeatTime - cT) sync = Math.round((cT - lastBeatTime)/hbl * 50);
        else sync = Math.round((nextBeatTime - cT)/hbl * -50);   
        //calculate the power/damage here
        var power = activeGuyDict.Allies[pg].power(sync);
        activeGuyDict.Enemies[activeGuyDict.Allies[pg].attackTarget].takeHit(power,pg);//determine the damage taken from the hit
        clickTime = null;
        return; //don't do anything else with the click
    }
}//end FOR loop
   
if (selectThis === "ENEMY"){//picking an enemy to attack
    var gc = guyClicked(clickX,clickY,"Allies",true);
    var g = findSelectedChar();
    if (gc) { // a good guy was clicked... handle it
        if (gc != g){ //a different ready character was selected..switches focus
            selectThis = "";
            activeGuyDict.Allies[g].selected = false;
            activeGuyDict.Allies[g].action = '';
            var cT = clickTime;
            activeGuyDict.Allies[gc].selected = true;
            colorWheel.newSC(gc);
            selectThis = "AIM";
            activeGuyDict.Allies[gc].setAnim('AIM',cT);
            activeGuyDict.Allies[gc].action = 'ATTACK';
            mouseDown = true;//keep track of the mouse state
            return;
        } else if (gc === g) { //same character was clicked... moveForm
            activeGuyDict.Allies[g].moveForm = true;
            //cancel ATTACK action
            selectThis = "";
            activeGuyDict.Allies[g].action = "";
            return;
        }
    }
    var e = guyClicked(clickX,clickY,"Enemies");
    if (!e) return; //make sure an enemy was clicked
    if (activeGuyDict.Allies[g].action === 'ATTACK'){
        activeGuyDict.Allies[g].ready = false;
        activeGuyDict.Allies[g].selected = false;
        colorWheel.reset();
        activeGuyDict.Allies[g].target = {"name":e,"pos":activeGuyDict.Enemies[e].pos,"halo":activeGuyDict.Enemies[e].halo};
        selectThis = "";
        colorWheel.reset();//close the color wheel
    } else if (activeGuyDict.Allies[g].action === 'TAUNT'){
        activeGuyDict.Enemies[e].taunted(activeGuyDict.Allies[g].pos,activeGuyDict.Allies[g].halo);
        activeGuyDict.Allies[g].animation.name = 'TAUNT';
        activeGuyDict.Allies[g].selected = false;
        colorWheel.reset();
        selectThis = "";
    }
    return;
}
//see if a ready guy was clicked
var gclick = guyClicked(clickX,clickY,"Allies",true);
if (gclick){
    var cT = clickTime;
    activeGuyDict.Allies[gclick].selected = true;
    colorWheel.newSC(gclick);
    selectThis = "AIM";
    activeGuyDict.Allies[gclick].setAnim('AIM',cT);
    activeGuyDict.Allies[gclick].action = 'ATTACK';
    mouseDown = true;//keep track of the mouse state
}
This is the mouseup handler. It only pays attention if the player is holding the mouse down to "AIM". Thus avoiding unnecessary event functions.
function clickReleaseHandler(event){
    // cache the time/location of the click
    if (selectThis != "AIM") return;
    clickTime = audioctx.currentTime;  
    event = event || window.event;
    var scrollX = window.pageXOffset || document.body.scrollLeft;
    var scrollY = window.pageYOffset || document.body.scrollTop;
    clickX = event.clientX + scrollX - xoffset;
    clickY = event.clientY + scrollY - yoffset;
            
    var g = findSelectedChar();
    var gc = guyClicked(clickX,clickY,"Allies");
    if (gc === g) {
            selectThis = "ENEMY";
            activeGuyDict.Allies[g].clearAnim();
            mouseDown = false;
            return; //bail
    } else {
        //they released on the aimed area
        var cT = clickTime;   
        var sync = 0;
        var hbl = secondsPerBeat / 2; //half a beat length
        //click is closer to last beat
        if (cT - lastBeatTime < nextBeatTime - cT) sync = Math.round((cT - lastBeatTime)/hbl * 50);
        else sync = Math.round((nextBeatTime - cT)/hbl * -50);//click is closer to next beat
        //click was before last beat (in case the beat changes between click and frame)
        //if (cT < lastBeatTime) sync = 50 - Math.round((lastBeatTime - cT)/hbl * 50);
        var power = activeGuyDict.Allies[g].power(sync);
        clickTime = null;

        //create the projectile
        projectiles.add("wave",activeGuyDict.Allies[g].pos,clickX,clickY,power,8,500,activeGuyDict.Allies[g].color);

        selectThis = "";
        activeGuyDict.Allies[g].ready = false;
        activeGuyDict.Allies[g].selected = false;
        activeGuyDict.Allies[g].setAnim('ATTACK',cT);
        activeGuyDict.Allies[g].target = null;
        mouseDown = false;
        return;
    }
}
As always, feel free to steal my code. Just let me know how you are using it! I'm curious to know how and if anyone finds this code useful!

And as promised, here's a link to the demo!

Instructions:
  • CLICK and DRAG from a character to shoot a projectile. They will not do damage, but different colors will have different effects,
  • PRESS Z or X to change the color of a character after he is selected.
  • Just CLICK a character once to select him, and then click an enemy to attack. The character will chase the enemy. You MUST CLICK the enemy once he has arrived in order to initiate the attack. Try to time it with the pulsing to do extra damage.
  • CLICK the selected character again, and then click an empty space to move him.
  • No damage indication is given, just keep attacking, and the enemies will eventually disappear.

Trevor Gast

Some say he’s half man half fish, others say he’s more of a seventy/thirty split. Either way he’s a fishy bastard.

0 comments: