Jeg har bygget et rumskib

Emner på denne side: Computer, Programmering, Spil

Som titlen siger, så har jeg bygget et rumskib, eller rettere sagt så har jeg programmeret et rumskib.

Lige nu er det ultra-simpelt. Man kan styre det med piletasterne, og så flyver det rundt på skærmen, som du kan se på videoen herunder:

Jeg har i længere tid haft lyst til at udvikle et spil, både fordi det kunne være sjovt og ligeledes for læringsoplevelsen i det, da det er nogle helt andre problemstillinger, end hvad min erhvervsmæssige udvikling byder på. I spiludvikling er programmet tidskritisk, og der er masser af matematik.

Jeg er dog hele tiden strandet på det tekniske, fordi min spidskompetence er jo webteknologierne med PHP (komplet uegnet) i spidsen og herefter Javascript. Herudover er jeg også ganske kyndig i Java og kan også begå mig i C++, selv om jeg synes, det er et forfærdeligt sprog. Jeg har tidligere forsøgt mig i Java, men altid gået død i deres uendelige mange klasser og halvdårlige grafiske framework.

For halvandet år siden fik jeg øje på frameworket PIXI.JS til Javascript, der så ret velegnet ud, men jeg kom aldrig videre.

Det gør jeg så nu, for det er Pixi.js, som jeg er vendt tilbage til. Og det fungerer faktisk ret godt.

Initialisering

Man kan få det til at virke på to linjer:

 const game = new PIXI.Application();
$('body').append(game.view);  

Dette initierer motoren, og skaber et HTML canvas-element, som danner grundlaget for, alt hvad man laver efterfølgende.

At få rumskibet ind på scenen er også utrolig simpelt:

 var sprite = PIXI.Sprite.from('spaceship.png');
game.stage.addChild(sprite);  

En sprite er et objekt med en position og en rotation, så rumskibet kan f.eks. befinde sig på koordinat (100, 100) og have en rotation på 0,5 radianer (som vi husker fra matematik, så er en cirkel 2 * pi radianer). Alt hvad der sker herefter er op til mig, så nu skal der nogle flere linjers kode til.

Rumskibets bevægelseslogik

Mit mål er at have et rumskib, jeg kan styre med piletasterne, og som vil accelerere lige frem (i forhold til dets orientering) når man trykker pil op, og bremse når man trykker pil ned. Pil højre og pil venstre skal så dreje rumskibet rundt om sin egen akse.

Jeg vil også gerne have bløde bevægelser, dvs. at det accelererer og bremser blødt, fremfor blot at skyde afsted med fast hastighed, og ligeledes også lave lidt elastik i dreje- bevægelserne.

Det kræver en del forskellige variabler på rumskibet, som jeg gennemgår her:

 {
    acceleration: 350.0,     // Accelerationskraften i pixel/s/s
    deceleration: 25.0,      // Bremsekraften i pixel/s/s
    maximum_velocity: 450.0, // Den maksimale hastighed i pixel/s
    velocity_turn_acceleration: 2.5, // Den kraft som rotationen tiltager med i radianer/s/s (hvis vi påbegynder rotation)
    velocity_turn_deceleration: 5.0, // Den kraft som rotationen aftager med i radianer/s/s (hvis vi afslutter rotation)
    maximum_turn_velocity: 5.0,      // Hvor meget vi maksimalt kan rotere i radianer/s

    velocity: 0.0,           // Den nuværende hastighed i pixel/s
    velocity_turn: 0.0,      // Den nuværende rotationshastighed i radianer/s
    turning: 'no',           // Angiver om skibet er ved at dreje. Kan være no, left eller right
    movement: 'no',          // Angiver om skibet accelererer. Kan være no, accelerate eller decelerate
}  

Jeg har valgt at definere alting i pixel og sekunder, for det er til at forholde sig til. Og med alle variablerne på plads, så er det med at få rumskibet til at opføre sig, som jeg ønsker.

Bevægelse og rotation

PIXI kører med 60 opdateringer i sekundet, og har derfor ganske praktisk lavet et ticker -objekt, som kan kalde spil-relaterede funktioner de samme 60 gange i sekundet.

Det er derfor mit job, at beregne hvad der sker af ændringer, hver gang der går 1/60 sekund.

Først kan jeg bevæge rumskibet, hvilket er ret simpelt. Jeg starter med at undersøge den nuværende rotationshastighed, og herefter roterer jeg min sprite med denne. Herefter undersøger jeg rumskibets hastighed, og så flytter jeg det tilsvarende fremad. Det sidste er lidt tricky, for min sprite har jo en x-position og en y-position, og jeg ville jo gerne have at rumskibet bevægede sig fremad, i forhold til den retning det pegede. Her skal vi jo heldigvis blot have fat i skolematematikken endnu engang, for hvis vi ved hvor mange radianer rumskibet er roteret (og det ved spriten jo), så kan vi blot anvende hhv. sinus og cosinus til at beregne hvor meget af hastigheden, vi henholdsvis skal fordele ud på x-aksen og y-aksen.

Dvs. vi drejer og flytter skibet som følger:

 function updateShip(delta) {
    sprite.rotation += velocity_turn / 60 * delta;
    sprite.x += Math.sin(sprite.rotation) * velocity / 60 * delta;
    sprite.y -= Math.cos(sprite.rotation) * velocity / 60 * delta;
}  

Der sker lidt ting her. Først og fremmest dividerer jeg alting med 60, da jeg jo har defineret mine variabler pr. sekund, og som jeg skrev, opdaterer PIXI 60 gange i sekundet. Det er dog en sandhed med modifikationer, for sandheden er, at den tilstræber at gøre det. Hvis din kode er for kompleks, eller din computer for langsom, så kan det være, at den ikke kan nå at lave 60 opdateringer i sekundet, og det er her delta kommer ind. Delta angiver hvor mange 1/60 der er gået siden sidst funktionen blev kaldt, så når alt kører som det skal, så vil delta være 1, men hvis PIXI misser en eller flere opdateringer, så vil delta blive øget med en for hver misset opdatering, og så ganger man sine beregninger med dette, så det stadig passer med tiden.

Bemærk også at jeg trækker værdien fra y fremfor at lægge den til, hvilket ellers ville være intuitivt. Den simple årsag til dette, er at skærmens koordinatsystem starter oppefra og går nedefter, hvor et traditionelt koordinatsystem starter nedefra og går opefter, så den skal lige vendes for at passe.

Ændringer i bevægelse og rotation

Det var selve bevægelsen, men hvordan påvirker vi så disse værdier? Princippet er faktisk det samme. Vi kan tage udgangspunkt i, hvis skibet er ved at rotere til højre, hvilket er gemt i variablen turning , som i det tilfælde så vil være "right". Så længe det sker, vil jeg accelerere den fart vi roterer med, indtil jeg opnår den maksimalt tilladte hastighed.

 function updateShip(delta) {
    if (turning == 'right') {
        velocity_turn += velocity_turn_acceleration / 60 * delta;
        if (velocity_turn > maximum_turn_velocity) velocity_turn = maximum_turn_velocity;
    }
}  

Først øger jeg vores rotationshastighed, med den rotations-acceleration jeg har defineret, imens jeg igen husker, at det kun er 1/60 af et sekund, vi processerer samt delta. Og herefter undersøger jeg, om vi har overskredet den maksimale rotationshastighed.

Præcis samme princip kan jeg bruge til acceleration.

 function updateShip(delta) {
    if (movement == 'accelerate') {
        velocity += acceleration / 60 * delta;
        if (velocity > maximum_velocity) velocity = maximum_velocity;
    }
}  

Input

Så er vi klar til at flyve. Nu skal keyboardet blot kobles til skibet, og så er det afsted. Dette kan man klare helt uden PIXI, med helt almindelige event listeners:

 $(window).keydown(function(event) {
    switch (event.keyCode) {
        case 37: // Left
            Spaceship.turning = 'left';
            break;
        case 39: // Right
            Spaceship.turning = 'right';
            break;
        case 38: // Up
            Spaceship.movement = 'accelerate';
            break;
        case 40: // Down
            Spaceship.movement = 'decelerate';
            break;
    }
})

$(window).keyup(function(event) {
    switch (event.keyCode) {
        case 37: // Left
        case 39: // Right
            Spaceship.turning = 'none';
            break;
        case 38: // Up
        case 40: // Down
            Spaceship.movement = 'none';
            break;
    }
})  

Jeg checker simpelthen på, når tasterne trykkes ned, og sætter så den tilsvarende bevægelsesvariabel på rumskibet. Når man så slipper tasterne igen, sætter jeg bevægelsen tilbage til "none".

En fejl

Det virkede - og så alligevel ikke, fordi mit rumskib opfører sig ikke helt, som jeg havde ønsket. Jeg havde ønsket en zero-G effekt, dvs. hvis jeg satte mit rumskib i bevægelse og herefter roterede det, så ville jeg gerne have, at det fortsatte i samme retning, indtil jeg begyndte at accelerere igen som illustreret her:

Men den effekt jeg har skabt, er, at hvis jeg accelererer mit rumskib, og efterfølgende roterer det, så ændrer det kurs, hvilket nærmere minder om en bil.

For at opnå den ønskede effekt, bliver jeg nødt til at udvide min model, for i dette tilfælde skal jeg både holde styr på, hvad vej rumskibet vender, og yderligere hvad vej det pt. bevæger sig.

Jeg implementerer dette som en vektor fremfor et radian-tal, da det passer bedre ind i koden.

 {
    velocity_x: 0.0, // Velocity on the x axis in pixels/s
    velocity_y: 0.0, // Velocity on the y axis in pixels/s

}  

Det gør også selve koden der bevæger rumskibet noget simplere.

 function updateShip(delta) {
    sprite.rotation += velocity_turn / 60 * delta;
    sprite.x += velocity_x / 60 * delta;
    sprite.y += velocity_y / 60 * delta;
}  

Det er blot at lægge min hastighedsvektor til den nuværende position. Det komplekse er så, at håndtere når skibet accelererer, men her er det egentligt blot at genbruge de tidligere beregningerne med sinus og cosinus, og blot påvirke bevægelsesvektoren fremfor selve skibets position.

 function updateShip(delta) {
    // Save old velocity
    var old_velocity_x = velocity_x;
    var old_velocity_y = velocity_y;
    // Check if we should accelerate
    if (movement == 'accelerate') {
        velocity_x += Math.sin(sprite.rotation) / 60 * delta * acceleration;
        velocity_y -= Math.cos(sprite.rotation) / 60 * delta * acceleration;
    }
    // Check if speed exceeds maximum velocity
    var speed = Math.sqrt(Math.pow(velocity_x,2)+Math.pow(velocity_y,2));
    if (speed > maximum_velocity) {
        // If it exceeds then revert to old velocity
        velocity_x = old_velocity_x;
        velocity_y = old_velocity_y;
    }
}  

Det bliver alligevel lidt komplekst, for jeg skal jo også sikre, at skibet ikke overstiger sin maksimalt tilladte hastighed, og med min nye bevægelsesmodel kan jeg ikke umiddelbart gennemskue om min acceleration vil øge eller sænke farten på skibet. Derfor gemmer jeg den gamle bevægelsesvektor, laver accelerationen og bruger gode, gamle Pythagoras til at udregne min hastighed. Hvis jeg er for hurtig, så retablerer jeg den gamle vektor.

Version 1

Og dermed er version 1 af mit kommende, episke rumspil klar! Du kan prøve mit rumskib her (hvis du sidder ved en computer og har et tastatur) , og hvis du ønsker hele kildeteksten, er du velkommen til downloade den her .

Appendiks

Jeg skulle lige prale overfor min søn Sylvester, omkring hvad jeg havde begået, og han synes det var "Sejt!". Så kom han med et par hurtige ideer. Vi skulle have et par planeter ind, og da det lykkedes, skulle vi også tilføje en asteroide, der skulle bevæge sig og hvis man blev ramt af den, så skulle ens skib vende tilbage til startpositionen. Det fik vi også styr på.

Til sidst skulle vi lave asteroiden, således at den ville udsøge rumskibet og det tog heller ikke mange minutter. Alt i alt sad vi i 15 minutter og det gav version 1.1 af rumspillet . Jeg tager det nok i en anden retning, men det var ret sjovt, at komme dertil så hurtigt.

Du kan nu læse næste kapitel i Mit rumskib styrter ned