Search code examples
javascriptcssanimationgame-physicscss-transforms

How do I give this spaceship acceleration?


Have a very small snippet of an asteroids-like game I'm working on using only the DOM without Canvas. I have the "ship" moving pretty smoothly when arrow keys are pressed but how would I go about making the ship accelerate ( in speed and rotation ) when an arrow key is held down for a longer length of time?

window.onkeyup = function( e ) {
  var kc = e.keyCode;
  e.preventDefault();

  if ( kc === 37 ) Keys.left = false;
  else if ( kc === 38 ) Keys.up = false;
  else if ( kc === 39 ) Keys.right = false;
  else if ( kc === 40 ) Keys.down = false;
};

function update() {
  if ( Keys.up ) {
    document.querySelector( 'div' ).style.transform += 'translateY( -1px )';
  }
  else if ( Keys.down ) {
    document.querySelector( 'div' ).style.transform += 'translateY( 1px )';
  }

  if ( Keys.left ) {
    document.querySelector( 'div' ).style.transform += 'rotate( -1deg )';
  }
  else if ( Keys.right ) { 
    document.querySelector( 'div' ).style.transform += 'rotate( 1deg )';
  }
  
  requestAnimationFrame( update );
}
requestAnimationFrame( update );
@import url( "https://fonts.googleapis.com/css?family=Nunito" );

html, body {
  height: 100%;
  margin: 0;
  padding: 0;
  font-family: "Nunito", sans-serif;
  font-size: 2rem;
}

body {
  display: flex;
  justify-content: center;
  align-items: center;
}

b {
  display: block;
  transform: rotate( 180deg );
}
<div>
  <b>v</b>
</div>

<script>
  var Keys = {
    up: false,
    down: false,
    left: false,
    right: false
  }

  window.onkeydown = function( e ) {
    var kc = e.keyCode;
    e.preventDefault();

    if ( kc === 37 ) Keys.left = true;
    else if ( kc === 38 ) Keys.up = true;
    else if ( kc === 39 ) Keys.right = true;
    else if ( kc === 40 ) Keys.down = true;
  };
</script>

Use the arrow keys to control snippet.


Solution

  • Updated. As another similar question had a answer based on this previous version answer I have changed the answer to the better answer.

    Transformations, Acceleration, Drag, and Rocket Ships.

    There are so many ways to apply movement to a asteroids type game. This answer shows the most basic method and then gives an example showing variations on the basic methods to produce different feels. This answer also give a brief overview of how to set the CSS transformation using matrix (2D)

    The basics

    At the most basic you have a number that represents some component of the position or rotation. To move you add a constant x += 1; move in x one unit at a time when you let go of the control you don`t add and you stop.

    But things don't move like that, they accelerate. So you create a second value that holds the speed (formerly the one from x += 1) and call it dx (delta X). When you get input you increase the speed by some small amount dx += 0.01 so that in time the speed increases gradually.

    But the problem is the longer you hold the controls the faster you go, and when you let go of the controls the ship keeps going (which is all normal for space but a pain in a game) so you need to limit the speed and bring it gradually back down to zero. You can do that by applying a scale to the delta X value each frame. dx *= 0.99;

    Thus you have the basic acceleration, drag, and speed limited value

    x += dx;
    dx *= 0.99;
    if(input){ dx += 0.01);
    

    Do that for both the x, y and angle. Where input is directional you need to use vectors for x, y as follows.

    x += dx;
    y += dy;
    angle += dAngle;
    dx *= 0.99;
    dy *= 0.99;
    dAngle *= 0.99;
    if(turnLeft){
         dAngle += 0.01;
    }
    if(turnRight){
         dAngle -= 0.01;
    }
    if(inputMove){ 
        dx += Math.cos(angle) * 0.01;
        dy += Math.sin(angle) * 0.01;
    }
    

    That is the most basic space game movement.

    Setting the CSS transform.

    Setting the CSS transform is easiest apply via the matrix command. eg setting default transform element.style.transform = "matrix(1,0,0,1,0,0)";

    The six values often named a,b,c,d,e 'matrix(a,b,c,d,e,f)' or m11, m12, m21, m22, m31, m32 or horizontal scaling, horizontal skewing, vertical skewing, vertical scaling, horizontal moving, vertical moving and represent a shortened version of the 3 by 3 2D matrix.

    I find that the majority of confusion about how this matrix works and why it is not often used is in part due to the naming of the variables. I prefer the description as x axis x, x axis y, y axis x, y axis y, origin x, origin y and simply describe the direction and scale of the x and y axis and the position of the origin all in CSS pixel coordinates.

    The following image illustrates the matrix. The red box is a element that has been rotated 45 deg (Math.PI / 4 radians) and its origin moved to CSS pixel coordinated 16,16.

    Grid shows CSS pixels. The right grid shows a zoomed view of the matrix showing the X Axis vector (a,b) = (cos(45), sin(45)), Y Axis vector (c,d) = (cos(45 + 90), sin(45 + 90)) and the Origin (e,f) = (16, 16)

    Image Grid shows CSS pixels. The right grid shows a zoomed view of the matrix showing the X Axis vector (a,b) = (cos(45), sin(45)), Y Axis vector (c,d) = (cos(45 + 90), sin(45 + 90)) and the Origin (e,f) = (16, 16)

    Thus is I have the values for an element in terms of angle, position (x,y), scale (x,y). We then create the matrix as follows

    var scale = { x : 1, y : 1 };
    var pos = {x : 16, y : 16 };
    var angle = Math.PI / 4; // 45 deg
    var a,b,c,d,e,f; // the matrix arguments
    // the x axis and x scale
    a = Math.cos(angle) * scale.x;
    b = Math.sin(angle) * scale.x;
    // the y axis which is at 90 degree clockwise of the x axis
    // and the y scale
    c = -Math.sin(angle) * scale.y;
    d = Math.cos(angle) * scale.y;
    // and the origin
    e = pos.x;
    f = pos.y;
    
    element.style.transform = "matrix("+[a,b,c,d,e,f].join(",")+")";
    

    As most of the time we dont skew the transform and use a uniform scale we can shorten the code. I prefer to use a predefined array to help keep GC hits low.

    const preDefinedMatrix = [1,0,0,1,0,0]; // define at start
    
    // element is the element to set the CSS transform on.
    // x,y the position of the elements local origin
    // scale the scale of the element
    // angle the angle in radians
    function setElementTransform (element, x, y, scale, angle) {
        var m = preDefinedMatrix;
        m[3] = m[0] = Math.cos(angle) * scale;
        m[2] = -(m[1] = Math.sin(angle) * scale);
        m[4] = x;
        m[5] = y;
        element.style.transform = "matrix("+m.join(",")+")";
    }
    

    I use a slightly different function in the demo. ship.updatePos and uses the ship.pos and ship.displayAngle to set the transformation relative to the containing elements origin (top,left)

    Note that the 3D matrix though a little more complex (includes the projection) is very similar to the 2D matrix, it describes the x,y, and z axis as 3 vectors each with 3 scalars (x,y,z) with the y axis at 90 deg to the x axis and the z axis at 90 deg to both the x and y axis and can be found with the cross product of the x dot y axis. The length of each axis is the scale and the origin is a point coordinate (x,y,z).


    Demo:

    The demo shows 4 5 variants. Use the keyboard 1,2,3,4,5 to select a ship (it will turn red) and use the arrow keys to fly. Basic up arrow you go, left right you turn.

    The maths for each ship is in the object ship.controls

    requestAnimationFrame(mainLoop);
    const keys = {
        ArrowUp : false,
        ArrowLeft : false,
        ArrowRight : false,
        Digit1 : false,
        Digit2 : false,
        Digit3 : false,
        Digit4 : false,
        Digit5 : false,
        event(e){ 
            if(keys[e.code] !== undefined){ 
                keys[e.code] = event.type === "keydown" ;
                e.preventDefault();
            } 
        },
    }
    addEventListener("keyup",keys.event);
    addEventListener("keydown",keys.event);
    focus();
    const ships = {
        items : [],
        controling : 0,
        add(ship){ this.items.push(ship) },
        update(){
            var i;
            
            for(i = 0; i < this.items.length; i++){
                if(keys["Digit" + (i+1)]){
                    if(this.controling !== -1){
                        this.items[this.controling].element.style.color = "green";
                        this.items[this.controling].hasControl = false;
                    }
                    this.controling = i;
                    this.items[i].element.style.color = "red";
                    this.items[i].hasControl = true;
                }
                this.items[i].updateUserIO();
                this.items[i].updatePos();
            }
        }
        
    }
    const ship = {
        element : null,
        hasControl : false,
        speed : 0,
        speedC : 0,  // chase value for speed limit mode
        speedR : 0,  // real value (real as in actual speed)
        angle : 0,
        angleC : 0,  // as above
        angleR : 0,
        engSpeed : 0,
        engSpeedC : 0,
        engSpeedR : 0,
        displayAngle : 0, // the display angle
        deltaAngle : 0,
        matrix : null,  // matrix to create when instantiated 
        pos : null,     // position of ship to create when instantiated 
        delta : null,   // movement of ship to create when instantiated 
        checkInView(){
            var bounds = this.element.getBoundingClientRect();
            if(Math.max(bounds.right,bounds.left) < 0 && this.delta.x < 0){
                this.pos.x = innerWidth;
            }else if(Math.min(bounds.right,bounds.left) > innerWidth  && this.delta.x > 0){
                this.pos.x = 0;
            }
            if(Math.max(bounds.top,bounds.bottom) < 0  && this.delta.y < 0){
                this.pos.y = innerHeight;
            }else if( Math.min(bounds.top,bounds.bottom) > innerHeight  && this.delta.y > 0){
                this.pos.y = 0;
            }
            
        },
        controls : {
            oldSchool(){
                if(this.hasControl){
                    if(keys.ArrowUp){
                        this.delta.x += Math.cos(this.angle) * 0.1;
                        this.delta.y += Math.sin(this.angle) * 0.1;
                    }
                    if(keys.ArrowLeft){
                        this.deltaAngle -= 0.001;
                    }
                    if(keys.ArrowRight){
                        this.deltaAngle += 0.001;
                    }
                }
                this.pos.x += this.delta.x;
                this.pos.y += this.delta.y;
                this.angle += this.deltaAngle;
                this.displayAngle = this.angle;
                this.delta.x *= 0.995;
                this.delta.y *= 0.995;
                this.deltaAngle *= 0.995;            
            },
            oldSchoolDrag(){
                if(this.hasControl){
                    if(keys.ArrowUp){
                        this.delta.x += Math.cos(this.angle) * 0.5;
                        this.delta.y += Math.sin(this.angle) * 0.5;
                    }
                    if(keys.ArrowLeft){
                        this.deltaAngle -= 0.01;
                    }
                    if(keys.ArrowRight){
                        this.deltaAngle += 0.01;
                    }
                }
                this.pos.x += this.delta.x;
                this.pos.y += this.delta.y;
                this.angle += this.deltaAngle;
                this.delta.x *= 0.95;
                this.delta.y *= 0.95;
                this.deltaAngle *= 0.9;
                this.displayAngle = this.angle;
            },
            speedster(){
                if(this.hasControl){
                    
                    if(keys.ArrowUp){
                        this.speed += 0.02;
                    }
                    if(keys.ArrowLeft){
                        this.deltaAngle -= 0.01;
                    }
                    if(keys.ArrowRight){
                        this.deltaAngle += 0.01;
                    }
                }
                this.speed *= 0.99;
                this.deltaAngle *= 0.9;
                this.angle += this.deltaAngle;
                this.delta.x += Math.cos(this.angle) * this.speed;
                this.delta.y += Math.sin(this.angle) * this.speed;
                this.delta.x *= 0.95;
                this.delta.y *= 0.95;
                this.pos.x += this.delta.x;
                this.pos.y += this.delta.y;
                this.displayAngle = this.angle;
            },
            engineRev(){  // this one has a 3 control. Engine speed then affects acceleration. 
                if(this.hasControl){
                    if(keys.ArrowUp){
                        this.engSpeed = 3
                    }else{
                        this.engSpeed *= 0.9;
                    }
                    if(keys.ArrowLeft){
                        this.angle -= 0.1;
                    }
                    if(keys.ArrowRight){
                        this.angle += 0.1;
                    }
                }else{
                    this.engSpeed *= 0.9;
                }
                this.engSpeedC += (this.engSpeed- this.engSpeedR) * 0.05;
                this.engSpeedC *= 0.1;
                this.engSpeedR += this.engSpeedC;
                this.speedC += (this.engSpeedR - this.speedR) * 0.1;
                this.speedC *= 0.4;
                this.speedR += this.speedC;
                this.angleC += (this.angle - this.angleR) * 0.1;
                this.angleC *= 0.4;
                this.angleR += this.angleC;
                this.delta.x += Math.cos(this.angleR) * this.speedR * 0.1; // 0.1 reducing this as easier to manage speeds when values near pixel size and not 0.00umpteen0001
                this.delta.y += Math.sin(this.angleR) * this.speedR * 0.1;
                this.delta.x *= 0.99;
                this.delta.y *= 0.99;
                this.pos.x += this.delta.x;
                this.pos.y += this.delta.y;
                this.displayAngle = this.angleR;
            },
            speedLimiter(){
                if(this.hasControl){
        
                    if(keys.ArrowUp){
                        this.speed = 15;
                    }else{
                        this.speed = 0;
                    }
                    if(keys.ArrowLeft){
                        this.angle -= 0.1;
                    }
                    if(keys.ArrowRight){
                        this.angle += 0.1;
                    }
                }else{
                    this.speed = 0;
                }
                this.speedC += (this.speed - this.speedR) * 0.1;
                this.speedC *= 0.4;
                this.speedR += this.speedC;
                this.angleC += (this.angle - this.angleR) * 0.1;
                this.angleC *= 0.4;
                this.angleR += this.angleC;
                this.delta.x = Math.cos(this.angleR) * this.speedR;
                this.delta.y = Math.sin(this.angleR) * this.speedR;
                this.pos.x += this.delta.x;
                this.pos.y += this.delta.y;
                this.displayAngle = this.angleR;
            }
        },
        updateUserIO(){
        },
        updatePos(){
            this.checkInView();
            var m = this.matrix;
            m[3] = m[0] = Math.cos(this.displayAngle);
            m[2] = -(m[1] = Math.sin(this.displayAngle));
            m[4] = this.pos.x;
            m[5] = this.pos.y;
            this.element.style.transform = `matrix(${m.join(",")})`;
        },
        create(shape,container,xOff,yourRide){  // shape is a string
            this.element = document.createElement("div")
            this.element.style.position = "absolute";
            this.element.style.top = this.element.style.left = "0px";
            this.element.style.fontSize = "24px";
            this.element.textContent = shape;
            this.element.style.color  = "green";
            this.element.style.zIndex  = 100;
    
            container.appendChild(this.element);
            this.matrix = [1,0,0,1,0,0];
            this.pos = { x : innerWidth / 2 + innerWidth * xOff, y : innerHeight / 2 };
            this.delta = { x : 0, y : 0};
            this.updateUserIO = this.controls[yourRide];
            return this;
        }
    }
    var contain = document.createElement("div");
    contain.style.position = "absolute";
    contain.style.top = contain.style.left = "0px";
    contain.style.width = contain.style.height = "100%";
    contain.style.overflow = "hidden";
    document.body.appendChild(contain);
    window.focus();
    
    
    
    
    ships.add(Object.assign({},ship).create("=Scl>",contain,-0.4,"oldSchool"));
    ships.add(Object.assign({},ship).create("=Drg>",contain,-0.25,"oldSchoolDrag"));
    ships.add(Object.assign({},ship).create("=Fast>",contain,-0.1,"speedster"));
    ships.add(Object.assign({},ship).create("=Nimble>",contain,0.05,"speedLimiter"));
    ships.add(Object.assign({},ship).create("=Rev>",contain,0.2,"engineRev"));
    function mainLoop(){
        ships.update();
        requestAnimationFrame(mainLoop);
    }
    body {
      font-family : verdana;
      background : black;
      color : #0F0;
     }
       Click to focus then keys 1, 2, 3, 4, 5 selects a ship. Arrow keys to fly. Best full page.

    A zillion variants

    There are many other variants and ways. I like the using a second derivative (first derivative dx/dt (dt is time) from x += dx, second de/dt for engine power) that simulates an engine ramping up power and winding down which can give a very nice feel. Basicly its

     x += dx;
     dx += de;
     dx *= 0.999;
     de *= 0.99;
     if(input){ de += 0.01 }
    

    What is suited for your game is up to you, you don't have to follow the rules so try out different values and methods till you are happy.