Tuesday, March 20, 2012

Javascript simple Game writing - main loops

Basic Main Loop
Firstly the demo site is here: main_loop_demo_1

Here the main-character is animated using the following code:

setInterval( function() {game_loop(maze1,ctx2,my_pacman);},50);

The "game_loop" function being:

function game_loop(maze,ctx,pacman) {
move_pacman(maze,pacman);
draw_sprites(maze,ctx,pacman);
}

So every 50ms the game_loop function is called to move the pac-man object and then re-draw it. This is equivalent to 20 frames-per-second - the ideal is 50.

Of course you could simply change the "50" in the "setInterval" command to 1000/50 to "20", but some things should be considered:

  • How to we know whether the client browser can cope with this animation rate?
  • Should our game take all resources it can or take account of the load on the client browser?

My approach is to always take account of the end-user environment - and so if attempting 50 frames per second puts stress on the client system we should not do so. Hence a better approach is to try for 50 frames and reduce if not possible.

An Aside - Closures
Like most things, it took me a while to get to grips with closures. Put simply, they allow references to variables and functions to "hang around" after a calling function has returned. Consider the following code:

function set_up_x() {
var x=20;
  return function next_x() {
    return x++;
  };
}

If you have Node.js installed (very useful) you can run the above interactively and calling "y()" see the returned value increase:

$ node

> function set_up_x() {
... var x=20;
...   return function next_x() {
...     return x++;
...   };
... }




> y=set_up_x(44);
[Function: next_x]
> y();
20
> y();
21
> y();
22




So in the above example the function "set_up_x" has the variable "x" defined locally - and this is visible to the anonymous function (as returned by the function call). The result is the "x" remains visible to the anonymous function ... hence the function gains a "static" variable - most useful!

Notice that we must call "set_up_x" and use the return value (the function) to actually perform the work!

More Advanced Main Loop
We can use the concept of a closure for our main loop - here we don;t actually use one function to return another; but use one function to define another and then use setInterval to call that function - effectively in the closure.

The concept is:

function call_me(var1,var2) {
var closure_var1=xx;
var timer=setInterval(function() {my_hidden_loop(var1,var2);},interval);


  function my_hidden_loop(my_var1,my_var2) {
   // do what we want and alter / remove timer if necessary..
  clearInterval(timer);
  timer=setInterval(function() {my_hidden_loop(var1,var2);},new_interval);
  }
}

So if the code calls "call_me(x,y)" - then the function "my_hidden_loop" will start running at regular intervals ... with access to the current values of "x" and "y". More importantly it can change/access the current values for the variables enclosed by "call_me" - including "closure_var1" and "timer" itself.

Firstly the loop in all it's glory:


function handle_timed_mainloop(maze1,ctx2,my_pacman) {
var want_fps=50;
var min_fps=20;
var current_fps=50;
var calc_interval=1000/current_fps;
var timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);


function game_loop(maze,ctx,pacman) {
var start=new Date().getTime();
move_pacman(maze,pacman);
draw_sprites(maze,ctx,pacman);
var end=new Date().getTime();
if( (end-start*2) < calc_interval && current_fps < want_fps) {
// Not taken much time so increment frame rate
clearInterval(timer);
current_fps++;
calc_interval=1000/current_fps;
timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);
console.log("Increased frame rate to ",current_fps);
} else if((end-start*2) > calc_interval && current_fps > min_fps) {
// Asking too much of system, so slow down...
clearInterval(timer);
current_fps--;
calc_interval=1000/current_fps;
timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);
console.log("Decreased frame rate to ",current_fps);


}
// console.log("Duration in ms = ", end-start);
}
}

Firstly notice that the only things that are actually executed in this function are:

var want_fps=50;
var min_fps=20;
var current_fps=50;
var calc_interval=1000/current_fps;
var timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);

The rest of the function is actually the "game_loop" function which is executed by the final line above. The result is that the call to the "handle_timed_mainloop" function will quickly exit - after ensuring that "game_loop" is started.

What the code above does is attempt to start the game with 50 frames per second - as per the "current_fps" setting. The "want_fps" (which is also 50) defines the ideal and the minimum defines what we should not slow down beyond. This information is used to set "calc_interval" and call "game_loop":


var calc_interval=1000/current_fps;
var timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);

Now of course the aim of "main_loop" is to perform all game logic - for the moment just moving the pacman around the maze and listening to events. The events have actually been defined prior as:

document.onkeydown=function() {handle_key(event,my_pacman)};

This updates the "my_pacman" object as appropriate for the "main_loop" programming to act on.

In essence the "main_loop" is actually just two function calls:

move_pacman(maze,pacman);
draw_sprites(maze,ctx,pacman);

The first function call takes in the current position, current direction and next direction and updates the position of the pacman. The second actually draws the pacman at the new position (erasing the old canvas content first of course).

Now the interesting bit... notice that we wrap the above two calls with getting the current time (in milliseconds)?

var start=new Date().getTime();
move_pacman(maze,pacman);
draw_sprites(maze,ctx,pacman);
var end=new Date().getTime();


So by taking the "end" from the "start" we can estimate the time our current browser takes to perform a frame. We can then use this to increment the frame rate if the time is small enough, or decrease it if the time it takes is too long:


if( (end-start*2) < calc_interval && current_fps < want_fps) {
// Not taken much time so increment frame rate
clearInterval(timer);
current_fps++;
calc_interval=1000/current_fps;
timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);
console.log("Increased frame rate to ",current_fps);
} else if((end-start*2) > calc_interval && current_fps > min_fps) {
// Asking too much of system, so slow down...
clearInterval(timer);
current_fps--;
calc_interval=1000/current_fps;
timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);
console.log("Decreased frame rate to ",current_fps);
}

The only points beyond the obvious this the above code are that if we are changing the interval we clear it, calculate a new interval (based on the new attempted frame rate) and set the timer off again:

clearInterval(timer);
...

calc_interval=1000/current_fps;
timer=setInterval( function() {game_loop(maze1,ctx2,my_pacman);},calc_interval);




So there you have it - a game loop with a dynamic frame rate based on the host performance of the previous frame update.

The code in action can be seen here: main_loop_2nd_demo

Current Problems with Advanced Main Loop

Although the adaptive animation frame rate works ... so does have some issues:
  • The pac-man sprite appears to speed up (or slow down)
  • The rate of pac-man animation appears to speed up (or slow down)
The basic problem is the rate of movement and animation is naive; every time the move (and thus animate) routine is called it will perform the action. What really needs to happen is to separate the two; and only make changes after a particular time has past since the other change.

The next post will alter the game to add these features...

No comments:

Post a Comment