3
\$\begingroup\$

I'm building a small game in pure JS for my own learning purposes, using sprites to model players.

Before I go further, I want to try to get the animation down. The below code should produce a blank page with a skelton sprite that responds to arrow key presses. It should work in both firefox and chromium (but no other browser tested).

I feel there is a lot more code than necessary, I'm new to javascript so any feedback is welcome.

var X_OFFSETS = [-71.5,
                 -135.5,
                 -199.5,
                 -263.5,
                 -327.5,
                 -391.5,
                 -455.5,
                 -519.5,
                 -7.5];

var WALKU_Y_OFFSET = 820;
var WALKL_Y_OFFSET = 756;
var WALKD_Y_OFFSET = 692;
var WALKR_Y_OFFSET = 629;

var WALK_STEP_SIZE = 4;

var FRAME_TIMEOUT = 130;

var bones = null;
var bonesPlayground = null;
var bonesReady = false;

function init() {
    bones = document.getElementById('bones');
    bonesPlayground = document.getElementById('bonesPlayground');
    updateBonesPlayground();
    document.addEventListener("keydown", function (e) {
        if(bonesReady && e.keyCode == '38') {
            bonesReady = false;
            bonesUp(bones);
        }
        if(bonesReady && e.keyCode == '37') {
            bonesReady = false;
            bonesLeft(bones);
        }
        if(bonesReady && e.keyCode == '40') {
            bonesReady = false;
            bonesDown(bones);
        }
        if(bonesReady && e.keyCode == '39') {
            bonesReady = false;
            bonesRight(bones);
        }
    });
    bonesReady = true;
}

function updateBonesPlayground() {
    bonesPlayground.style.height = window.innerHeight.toString() + "px";
    bonesPlayground.style.width = window.innerWidth.toString() + "px";
}

function bonesUp(bones, frameCount) {
    if (typeof(frameCount) == 'undefined') {frameCount = 0;}
    bones.style.backgroundPosition = X_OFFSETS[frameCount].toString() + "px " + WALKU_Y_OFFSET.toString() + "px";
    bones.style.top = (parseInt(bones.style.top) - WALK_STEP_SIZE).toString() + "px";
    if (++frameCount != X_OFFSETS.length) {
        setTimeout(bonesUp, FRAME_TIMEOUT, bones, frameCount);
    }
    else {
        setTimeout(function() {bonesReady = true}, FRAME_TIMEOUT);
    }
}

function bonesLeft(bones, frameCount) {
    if (typeof(frameCount) == 'undefined') {frameCount = 0;}
    bones.style.backgroundPosition = X_OFFSETS[frameCount].toString() + "px " + WALKL_Y_OFFSET.toString() + "px";
    bones.style.left = (parseInt(bones.style.left) - WALK_STEP_SIZE).toString() + "px";
    if (++frameCount != X_OFFSETS.length) {
        setTimeout(bonesLeft, FRAME_TIMEOUT, bones, frameCount);
    }
    else {
        setTimeout(function() {bonesReady = true}, FRAME_TIMEOUT);
    }
}

function bonesDown(bones, frameCount) {
    if (typeof(frameCount) == 'undefined') {frameCount = 0;}
    bones.style.backgroundPosition = X_OFFSETS[frameCount].toString() + "px " + WALKD_Y_OFFSET.toString() + "px";
    bones.style.top = (parseInt(bones.style.top) + WALK_STEP_SIZE).toString() + "px";
    if (++frameCount != X_OFFSETS.length) {
        setTimeout(bonesDown, FRAME_TIMEOUT, bones, frameCount);
    }
    else {
        setTimeout(function() {bonesReady = true}, FRAME_TIMEOUT);
    }
}

function bonesRight(bones, frameCount) {
    if (typeof(frameCount) == 'undefined') {frameCount = 0;}
    bones.style.backgroundPosition = X_OFFSETS[frameCount].toString() + "px " + WALKR_Y_OFFSET.toString() + "px";
    bones.style.left = (parseInt(bones.style.left) + WALK_STEP_SIZE).toString() + "px";
    if (++frameCount != X_OFFSETS.length) {
        setTimeout(bonesRight, FRAME_TIMEOUT, bones, frameCount);
    }
    else {
        setTimeout(function() {bonesReady = true}, FRAME_TIMEOUT);
    }
}

window.onload = init;
window.onresize = updateBonesPlayground;
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Bones</title>
    <style>
     body, html {
         margin: 0;
         padding: 0;
         border: 0;
     }
     #bones {
         width: 50px;
         height: 50px;
         background: url(http://s12.postimg.org/3p9w0d7ul/ss_skel.png);
         background-position: -7.5px 692px;
         position: relative;
     }
    </style>
    <script src="bones.js"></script>
  </head>
  <body>
    <div id="bonesPlayground">
      <div id="bones" style="top: 10px; left: 10px"></div>
    </div>
  </body>
</html>

\$\endgroup\$
3
\$\begingroup\$
if(bonesReady && e.keyCode == '38') {
  bonesReady = false;
  bonesUp(bones);
}

For one, your keybindings are hard-coded into your logic, which is bad in the long term. Mappings may change, and you wouldn't want digging in the code only to change these. It would be best if you abstracted this away from the logic. The logic should only know about an "up", "down", "left" and "right" direction.

Controls.on(Controls.UP, function(){
  // Fire this when the button for "up" is pressed
});

Controls.UP hides the numbers from the code and instead exposes a meaningful name. We know it's "up" in the game domain, regardless of what key "up" is to the user (could be arrow keys, could be WASD, could be numpad). Then if I just wanted to swap to WASD, all I need is to update the key map, ie. updating the value of Controls.UP and the game wouldn't even know.

Another issue I see with the code is that you require your animations to finish the entire sequence before they are ready for another action. That means, if the walk sequence took 14 frames, you're completing those 14 frames first before accepting any other input. That's because your loop is based on the frames.

What you could do is separate animation from state. State should not depend on animation, but the other way around. State should not be aware that the animation has finished or not. The animation should just animate whatever is provided by state, and state should just update whenever it feels like updating. If I press "right", the skeleton should be in "walking" and "right" state. The animation then renders accordingly. If I release the button, then the state turns from "walking" to "idle". Immediately, the animation should reflect the update.

// When manipulating the animation, you only need to modify these:
var direction = Controls.DOWN
var currentFrame = 0;
var isWalking = false;

function renderLoop(){
  requestAnimationFrame(renderLoop);

  // If not time to update, don't do anything
  // Update state
  // - Check direction
  // - Check walking state
  // - - If frame at last frame, reset to 0
  // - - If frame not last frame, advance current frame
  // - - If not walking, reset frame to 0

  // render state
}

Now off to more advanced things, you can use CSS animations to animate the sprite based on state. State can be represented by the current classes the element has. When you press down, JS adds a "down" class to your sprite, telling CSS that it's in the down state. Depending on the combination, CSS will use different properties on your sprite.

In addition, you should read up on BEM (Block, Element, Modifier). It' a handy naming convention in CSS which makes it easy to manage CSS. In our case, we're interested in the "M" (Modifier) which we can use to indicate state. If bones is walking downwards, it should have bones--walking bones--direction-down classes indicating that bones is walking (tells CSS to execute the walk animation) and that bones is facing downwards (tells CSS to use a specific row in the sheet).

Here's an example where I use CSS to animate the sprite based on the classes JS adds to the sprite element and JS to move the sprite. It uses CSS3 and ES6 syntax so it might not work in the snippets feature of SE. See external link instead. Also, I used jQuery for some stuff because I lazy :P But it shouldn't be that hard to convert them to native though.

// Keymap, which you can modify later
var CONTROLS = {
  UP: 87,
  DOWN: 83,
  LEFT: 65,
  RIGHT: 68,
};

// Mapping controls to various modifier strings
var CONTROLS_TO_DIRECTION = {
  [CONTROLS.UP]: 'up',
  [CONTROLS.DOWN]: 'down',
  [CONTROLS.LEFT]: 'left',
  [CONTROLS.RIGHT]: 'right',
};

var CONTROLS_TO_DIRECTION_MODIFIERS = {
  [CONTROLS.UP]: 'bones--up',
  [CONTROLS.DOWN]: 'bones--down',
  [CONTROLS.LEFT]: 'bones--left',
  [CONTROLS.RIGHT]: 'bones--right',
};

var CONTROLS_TO_INCREMENT_MODIFIERS = {
  [CONTROLS.UP]: '-=',
  [CONTROLS.DOWN]: '+=',
  [CONTROLS.LEFT]: '-=',
  [CONTROLS.RIGHT]: '+=',
};

var WALKING_MODIFIER = 'bones--walking';

var ALL_DIRECION_MODIFIERS = Object.keys(CONTROLS_TO_DIRECTION_MODIFIERS).map(key => CONTROLS_TO_DIRECTION_MODIFIERS[key]).join(' ');

// Speed, distance per unit time. 1px per update.
var SPEED = 1;

// OK, I cheated. I use jQuery but you get the idea
var bones = $('.bones');
var direction = 'down';
var isWalking = false;

// This part dictates the animation. It just adds the right classes for
// CSS to know which animation to play.
$(window).on('keydown', function(event){
  
  var directionClass = CONTROLS_TO_DIRECTION_MODIFIERS[event.which];
  direction = event.which;
  isWalking = true;
  
  bones.removeClass(ALL_DIRECION_MODIFIERS)
       .addClass(WALKING_MODIFIER)
       .addClass(directionClass);
  
}).on('keyup', function(){
  bones.removeClass(WALKING_MODIFIER);
  isWalking = false;
});

// This part dictates movement in the screen.
var timeSinceLastUpdate = 0;
function renderLoop(time){
  requestAnimationFrame(renderLoop);
  
  // If not yet time to update, return
  if(time - timeSinceLastUpdate < 16) return;
  
  // Store time so we can know when to update next time
  timeSinceLastUpdate = time;
  
  // Determine our top and left
  var incrementModifier = CONTROLS_TO_INCREMENT_MODIFIERS[direction];
  var topSpeed = direction === CONTROLS.UP || direction === CONTROLS.DOWN ? SPEED : 0;
  var leftSpeed = direction === CONTROLS.LEFT || direction === CONTROLS.RIGHT ? SPEED : 0;

  if(isWalking) bones.css('top', incrementModifier + topSpeed)
                     .css('left', incrementModifier + leftSpeed)
}

renderLoop();
.bones {
  top: 0;
  left: 0;
  width: 50px;
  height: 50px;
  background: url(http://s12.postimg.org/3p9w0d7ul/ss_skel.png);
  position: relative;
}
.bones--down{
  background-position: -7.5px 692px;
}
.bones--up{
  background-position: -7.5px 820px;
}
.bones--left{
  background-position: -7.5px 756px;
}
.bones--right{
  background-position: -7.5px 629px;
}
.bones--idle{
  // Placeholder for anything else idle
}
.bones--walking{
  animation-name: bones--walking;
  animation-iteration-count: infinite;   // Infinitely loop the sprite
  animation-duration: 0.9s;              // Defines walking animation speed
  animation-timing-function: step-start; // Prevents the bg sliding
}
// Move from left to right
@keyframes bones--walking{
  0% {background-position-x: -7.5px}
  14.29% {background-position-x: -71.5px}
  28.57% {background-position-x: -199.5px}
  42.86% {background-position-x: -263.5px}
  57.14% {background-position-x: -327.5px}
  71.43% {background-position-x: -391.5px}
  85.71% {background-position-x: -455.5px}
  100% {background-position-x: -519.5px}
}
<div id="bonesPlayground">
  <div class="bones bones--down" id="bones1"></div>
</div>

\$\endgroup\$
  • \$\begingroup\$ Thanks for your constructive answer. You've given me a lot to think on! Unfortunately the pen in the external link isn't working for me (using firefox). \$\endgroup\$ – user90576 Nov 26 '15 at 11:44
  • \$\begingroup\$ @will-trombone I see movement (use WASD keys) but not sprite animation in Firefox so it must be some animation-related issues. Will check it out. \$\endgroup\$ – Joseph Nov 26 '15 at 11:51
  • \$\begingroup\$ Same issue, no animation in firefox, works great in chromium! \$\endgroup\$ – user90576 Nov 26 '15 at 11:58
  • \$\begingroup\$ @will-trombone seems like background-position-x is non-standard at the moment (It got approved for CSS4.), and Firefox doesn't support it. Oh well... \$\endgroup\$ – Joseph Nov 26 '15 at 12:04
  • \$\begingroup\$ I like the idea of representing state as which css classes the element has, is there a way to add/remove whole classes like this in pure js? \$\endgroup\$ – user90576 Nov 26 '15 at 12:29

Your Answer

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