Denna vecka skall vi öva oss på bl a: extern, static, enum, och separata .c-filer.
Global variabel definierad som static: Variabeln syns bara inom c-filen. Vid länkningen slipper man namnkonflikter med globala variabler i andra .c-filer som heter samma sak.
fil1.c
...
static int tmp;
|
fil2.c
...
static int tmp;
|
Funktion definierad som static: Funktioner ligger alltid i kodsegmentet i programmets adressrymd (som även typiskt är skrivskyddat av operativsystemet). Med static blir synligheten hos funktionsnamnet begränsat till .c-filen. Dvs andra .c-filer kan innehålla en funktion med samma namn utan konflikt vid länkningen.
player.c
...
static void update(...);
|
background.c
...
static void update(...);
|
Deklaration med static: Om man gör en deklaration av en funktion som är definierad med static skall deklarationen också använda static.
exempel.c
...
static void update();
...
static void update(...)
{
...
} |
Extern för funktion: Rena deklarationer är lätt att göra för funktioner, så extern behövs egentligen ej för funktionsdeklarationer:
Deklarationer av en och samma variabel eller funktion får däremot förekomma hur många gånger som helst, så länge deklarationen inte ändras.
exempel.h
#ifndef EXEMPEL_H
#define EXEMPEL_H
typedef struct { // typ-
int x, y; // definition
} Position; //
extern char str1[]; // deklaration
void func(); // deklaration
#endif // EXEMPEL_H
|
exempel.c
#include exempel.h // för typer
// etc.
char str1[] = "hej"; // definition
void func() // definition
{
char str2[] = "då";
}
|
Här är det meningen att du skall lära dig hur man lägger funktionalitet i en .c-fil och gör motsvarande deklaration i .h-filen och inkluderar .h-filen i den/de .c-filer där du behöver använda funktionen, structen eller variabeln.
Vi fortsätter med förra veckans projekt och där vi slutade. Det är lämpligt att separera ut kod för skeppet och bakgrunden (och övriga spelobjekt som du kanske skapat) från main.c till separata filer. Du gör det med hjälp av följande steg:Förra veckan lärde vi oss att gruppera variabler, som på något sätt tillhör ett och samma objekt, i structs. Vi har nu lärt oss att man lägger struct-definitionen i .h-filen, medan definitionerna av metoder och eventuellt andra funktioner som man tycker hör samman med sin struct läggs i .c-filen. Motsvarande deklarationer lägger man i .h-filen.
Inom objektorientering lägger man gärna större structs i filer med samma namn som sin struct. Detta är t ex mycket tydligt i Java. Små structs (som kanske t om endast används av den större struct:en) kan gott och väl ligga i samma filer som den större struct:en. Inom så kallad komponentbaserad programmering till skillnad från objektorientering grupperar man gärna sin programkod baserad på funktionalitet. Det viktiga är att man själv skapar en kod- och filstruktur som man tycker är naturlig och begriplig. Ofta lägger man mycket tankeverksamhet just på att strukturera sin programkod och var man instansierar sina objekt.
Allokera globala variabler i datasegmentet
När vi skapar globala variabler läggs de i datasegmentet för programmets adressrymd. Att ha massa globala variabler utspridda i sin programkod anses dock synnerligen osnyggt. Man vill främja någon form av struktur. Det normala är att man skapar instanser av sina klasser antingen som lokala variabler (med livslängd tills dess att funktionen returnerar) eller genom att allokera dem på heapen.
Allokera på stacken Att allokera sina objektinstanser på stacken är oftast snyggast och att föredra om man kan. Det är då tydligt vilken funktion som instanserna tillhör samt vad de har för livslängd. Det är dock inte alltid det är praktiskt möjligt (t ex om instanserna ska fortsätta leva efter funktionen returnerat) och dessutom har stacken ofta mer begränsad storlek än heapen och datasegmentet. Stora minnesutrymmen allokerar man typiskt på heapen.
Allokera på heapen
I t ex Java, C++, och C# allokerar man på heapen med hjälp av new. I C använder vi malloc().
På MD407:an har vi dock inte automatiskt tillgång till malloc(), då både malloc() och new är operativsystemsberoende funktioner. När malloc allokerar nytt minne från heapen så behöver den då och då be operativsystemet att tillgängliggöra mer virtuellt adressutrymme. Istället skall vi i dessa övningsuppgifter visa att man för många problem ofta klarar sig utmärkt utan att allokera på heapen. Maskinnära programmering innebär ofta programmering av realtidssystem där man emellanåt måste ha noggrann timing av skrivningar/läsningar eller har andra krav att funktioner måste exekvera klart inom en mycket kort tid. Allokering på heapen som kräver operativsystemsanrop tar ofta tämligen lång tid och är inget man vill göra tusentals gånger inuti en realtidsloop. I vissa fall kan ett anrop till malloc/new ta miljontals klockcykler. Istället kan man t ex allokera i datasegmentet (genom globala variabler eller variabler definierade som static).
Var bör vi lägga våra instanser av de två globala variablerna ship och background, samt eventuellt ytterligare spelobjekt? Ska de ligga som globala variabler i respektive .h- och .c-fil. Eller i main.c? Eller i en fil globals.h/.c? Våra objekt är visserligen tillräckligt små för att de ska få plats på stacken som lokala variabler (t ex i main()), men låt oss anta det mer generella fallet att vi kan ha både ordentligt många instanser och minnesmässigt stora objekt.
För våra spelobjekt finns åtminstone tre olika naturliga sätt att placera dessa: i filen som använder objektet (t ex main.c), i .c-filen för sin struct eller i en särskild .c- och .h-fil för globala variabler. I det sistnämnda fallet kan man dessutom skapa en övergripande game-struct.
I .c-filen som använder objektet: dvs main.c i vårt fall, så som vi började innan vi flyttade ship och background till sina respektive .c/.h-filer. En fördel är att detta alternativ är mycket enkelt och ofta tydligt vem som äger objektinstansen. En nackdel är att instansen även kan behövas användas av andra objekt i andra .c-filer. Att exportera det med extern kan försvåra en snygg överskådlig struktur. Man kan skicka med instansen som inparameter till andra objekts metoder. Detta är ofta snyggt och i linje med objektorientering. Men det kan också lätt bli bökigare än att ha globala variabler som synliggörs med extern.
I .c-filen för sin struct: dvs player.c resp. background.c (etc). Fördelen är att man då lättare kan lägga över kontrollen av allokeringen till objektets metoder. Man kan t om ofta med fördel skapa en funktion som är kopplad till klassen/structen som heter Create(...) och returnerar adressen för ett instansierat och färdiginitierat objekt. Men man har, precis som i fallet ovan, fortfarande kvar problemet vad man gör om många andra objekt behöver åtkomst till instansen. Man skulle som sagt kunna lösa det genom att skicka med instansen som inparameter till andra objekts metoder.
Alla globala variabler i en särskild fil globals.h/globals.c Fördelen är att man faktiskt ofta lätt och överskådligt kan organisera alla sina globala variabler i en enda stor struct, t ex struct game som i sin tur innehåller structs levels, etc. En nackdel är att det kan bli otydligt vem som äger vilka instanser och när de skapas respektive när de försvinner. Alla objekt har åtkomst till allt, vilket är både för- och nackdel.
Sammanfattning
Det finns inte endast ett sätt som är korrekt.
Valet av struktur är ofta ett avvägande mellan många parametrar, som storlek av projekt och enkelhet att implementera, att kunna göra tillägg, att förstå för sig själv och/eller andra, att få korrekt kod m.m. Välj ett sätt som passar dig bäst beroende på situationen. I föregående uppgift använde vi mittenalternativet - dvs lägger instanserna i .c-filen för sin struct - och våra lösningsförslag kommer att fortsätta med det. Om ett antal uppgifter kommer vi att lägga till en form av semi-dynamisk minnesallokering där instanserna också ligger i i .c-filen för motsvarande datastruktur.
Låt dina aliens-objekt vara av typen GameObject. Behöver du nya struct-medlemmar för att röra objekten så inför dem. Vi låter fortfarande struct GameObject vara ett superset av alla medlemmar som kan behövas av våra olika spelobjekt.
Vi vet att förkunskaperna hos er elever i den här kursen varierar oerhört, från de som programmerat mycket och som löser punkterna ovan själva galant, till de som har mycket lite tidigare erfarenheter. Vi skulle kunna ge er ovanstående punkter som labuppgifter under ett par veckor tillsammans med mycket handledning för många elever. Men just dessa uppgifter är mer typiska för en kurs inom objektorientering och dels vill vi raskt komma framåt.
Den avancerade eleven är mycket välkommen att implementera helt egna lösningar istället för de följande uppgifternas lösningsförslag. Övriga håller vi i handen en stund till.
(Övningsmoment: låta ett objekts update()-funktion hantera två oberoende förlopp - här förflyttning och animering.)
Nu ska vi unna oss att göra applikationen intressantare genom att stödja animerade objekt.
Uppgift: Lägg till några nya animerade spelobjekt (t ex dessa två till höger) som även rör sig runt på skärmen på något sätt.
För att skapa animerade bilder: Enklast är att skapa en så kallad sprite sheet, d v s en textur (=bild) som innehåller samtliga frames i animeringen, och växla vilken del som skall visas på skärmen under vilken tidpunkt.
• Här finns en sådan förskapad för hjärtat (10 st frames):
• Här hittar du en sådan förskapad för myntet (40 st frames):
• Här hittar du en sådan för ett äpple (25 st frames):
Alternativt kan du finna dina egna gif-animeringar på nätet och konvertera dem till sprite sheets. Här är en sådan online-site som funkar någorlunda men du kan säkert hitta andra:
https://sites.google.com/site/stevedunns/convertanimatedgifstospritemaps.
Vi vill också kunna terminera aktiva spelobjekt och ta bort dem från vår lista gameObjects[] som håller våra aktiva instanser.
Att ta bort ett aktivt spelobjekt: Vi kan låta borttagning av en instans från arrayen gameObjects[] styras av objektets update()-metod. När vi i realtidsloopen i main() loopar över alla aktiva instanser i gameObjects[] kan vi låta update()-metoderna returnera false om de skall tas bort ur gameObjects[] och true om de skall finnas kvar. På så sätt kan anropande loopen ansvara för borttagningen.
Det finns många andra sätt som man kan lösa problemet. Vi skulle kunna låta update()-metoden själv plocka bort sig ur gameObjects[]. Update()-metoden känner inte till sitt index i gameObjects[], vilket behövs för att ta bort rätt element. Men vi hade kunnat låta update()-metoden loopa igenom listan tills dess att det hittar elementet som pekar på samma adress som aktuell instans som skall tas bort. För hundratals eller tusentals instanser riskerar detta dock att ta signifikant lång tid om många spelobjekt skall ta bort sig samtidigt. Vår valda lösning tar nästan ingen tid oberoende av hur många instanser som skall tas bort.
(Övningsmoment: programmering, pekare, medlemmar, metoder, svårighetsgrad: typisk deluppgift eller lättare uppgift på tenta.)
Du kan införa structmedlemmarna int age; och int lifeTime; i struct GameObjects, där age beskriver hur många frames objektet varit aktivt och lifeTime beskriver efter hur många frames det skall plockas bort. Du räknar räknar upp age varje frame i update()-metoden. När age == lifeTime låter du update() returnera false (annars true).
Uppgift:
Olika händelser kan lämpligen initieras med hjälp av olika funktioner. Man kan enkelt kontrollera när dessa olika funktioner skall anropas genom att ha en lista av par av {tidpunkt, funktionspekare}. I realtidsloopen kollar man om det är dags att anropa nästa händelse i listan, och isåfall gör detta och avancerar listiteratorn till nästa element, osv. T ex:Implementera detta. Testa att det fungerar för några spelobjekt vid ett antal givna tider. Givetvis är det lämpligt att du väljer starttiderna så att objekten hinner självterminera innan de startas på nytt.
Vi har ännu inte använt klockan men du kan lika gärna använda en variabel frameID (eller loopIter) som du inkrementerar varje frame och jämför mot.
Listan kan vi enklast implementera som en array av en struct {int time, "funtionspekare"}. För enkelhetens skull är det lämpligt att alla funktionspekare är av samma typ, t ex void (*function) (void), vilket är samma typ som dina create...()-funktioner. T ex:Inuti realtidsloopen i main()-funktionen räcker det med att lägga till 3-4 rader kod:
Checka loopIter mot tidpunkten för nästa element i arrayen. Om det är dags, anropa funktionen och inkrementera nextEvent.
Om man vill att flera events skall kunna utföras på samma tidpunkt kan du helt enkelt använda en while-loop.
Kommentar Det är vanligt att man vill att händelser ska initieras beroende på var spelaren (eller annat objekt) befinner sig. Det kan man lätt lösa genom att ha en tvådimensionell eller tredimensionell datastruktur som checkas baserat på spelarens position - t ex en 2D- eller 3D-array av funktionspekare, istället för vår 1D-array.
När vi för närvarande anropar våra create...()-funktioner är det hela tiden samma instanser som (åter-)initieras gång på gång. Våra nuvarande create-funktioner skapar t ex tre hjärtan, fyra mynt respektive tre aliens. Om vi anropar create-funktionen för aliens två gånger i rad får vi inte sex aliens, vilket skulle vara praktiskt. Ofta har man behov av att dynamiskt kunna skapa helt nya instanser, dvs kunna använda dynamisk minnesallokering.
Med malloc(), som ju är till för dynamisk minnesallokering, kan man helt enkelt allokera nytt minnesutrymme på heapen för nya objekt och delallokera utrymmet med free() när instanserna terminerar. T ex:Dynamisk allokering Om vi vet ett övre max för antalet instanser som ska kunna existera samtidigt kan vi enkelt lösa problemet genom att ha en array (allokerad i datasegmentet) med plats för motsvarande antal element, och för varje allokering av en ny instans leta upp ett ledigt element, markera det upptaget och använda dess minnesutrymme. För att hålla reda på vilka element som är lediga respektive upptagna kan vi ha en array av booleans av samma längd för att flagga elementen (true = upptaget, false = ledigt). Vi kan förpacka funktionaliteten i en funktion allocElement() som returnerar en pekare till det lediga minnesutrymmet.
Deallokering löser vi genom att skapa en funktion freeElement(...) som helt enkelt flaggar elementet som ledigt.
Vår egen motsvarighet till att använda malloc() respektive free() blir alltså:
Dynamisk minnesallokering i datasegmentet
dynamicalloc.h
// This file implements semi-dynamic allocations of type GameObjects
#ifndef DYNAMIC_ALLOC_H
#define DYNAMIC_ALLOC_H
#include "gameobject.h"
GameObject* allocElement(); // Allokerar dynamiskt minnesutrymme för ett objekt
void freeElement(GameObject* pObj); // Deallokerar minnesutrymmet för ett objekt
#endif // DYNAMIC_ALLOC_H
dynamicalloc.c #include "dynamicalloc.h" #define FREE 0 // The definitions are only visible in this .c-file #define BUSY 1 typedef struct { GameObject* pObjects; // pekare till en stor statisk array bool* pSlots; // pekare till en array med en bool per element int nElements; // Antal element i arrayen int currentElement; // senaste lediga plats + 1 (för att snabba // upp sökning efter ny ledig plats } AllocData; #define SIZE 1000 // Max antal samtidigt existerande instanser static GameObject objects[SIZE]; static bool slots[SIZE]; static AllocData allocData = {objects, slots, SIZE, 0}; // Allokerar dynamiskt minnesutrymme för en instans GameObject* allocElement() { // Vi vill att alla slots skall vara initierade till FREE vid uppstart. static bool bFirstTime = true; if(bFirstTime) { bFirstTime = false; for (int i=0; i < SIZE; i++) allocData.pSlots[i] = FREE; } // Sök efter första lediga plats, men max nElements försök for(int i=0; i < allocData.nElements; i++) { int index = allocData.currentElement; allocData.currentElement = (allocData.currentElement + 1) % allocData.nElements; if(allocData.pSlots[index] == FREE) { allocData.pSlots[index] = BUSY; return &allocData.pObjects[index]; // returnera den lediga platsen } } // Ingen ledig plats hittades return 0; } // Sätter ett index till ledigt void freeElement(GameObject* pObj) { int index = pObj - &(allocData.pObjects[0]); if (index >= 0 && index < allocData.nElements) { if(allocData.pSlots[index] == BUSY) { allocData.pSlots[index] = FREE; return; } } // Hamnar vi här har något gått fel } |
Engelskans ord "task" betyder uppgift och syftar inom datavetenskap ofta på process eller tråd. Bredden av ordets betydelse är svåröversatt till svenska så därför används begreppet nedan. I vårt sammanhang syftar uppgift (task) här på styrningen av händelseförloppet för ett objekt. Först kommer vi sammanfatta oberoende tasks och därefter gå in på hur man kan lösa beroenden.
Vad vi har implementerat hittills är egentligen en form av multitasking, där en realtidsloop exekverar programkod för flera oberoende tasks i tur och ordning (skeppet, bakgrunden och övriga spelobjekt). Detta är mycket likt så kallad non-preemptive scheduling för ett operativsystem.
Non-preemptive scheduling hos ett operativsystem innebär att varje program exekverar tills dess att de själva släpper tillbaks kontrollen till operativsystemet (kanske efter några millisekunder), som därefter exekverar nästa program en liten stund o s v. I vårt fall innebär liknelsen att realtidsloopen exekverar våra objekts update()- och render()-metoder var och en för sig i tur och ordning, oberoende av varandra och för en frame i taget. Varje enskilt objekt skulle kunna ses som ett godtyckligt enskilt program och metodanropet till update() kan liknas vid att dess programkod exekveras en iteration.
Preemptive scheduling är motsatsen, dvs att operativsystemet självt avbryter programmen, så att inget program tillåts fullständigt blockera exekverandet av andra program ifall det inte släpper tillbaks kontrollen tillräckligt snabbt. Preemptive scheduling är en stor fördel. Dock finns det tillfällen när man inte vill avbryta ett program, t ex vid timing för kommunikation mot portar. Därav att man ofta kan ha olika prioriteter för kod som exekveras via interrupts.
Moderna operativsystem använder preemptive scheduling. Preemptive scheduling bygger på interrupts och i labbarna kommer ni att få experimentera med interrupts.
Förlopp med sinsemellan beroenden
Du börjar nu få kläm på att hantera flera oberoende förlopp (tasks). I dessa hemuppgifter skall vi nu fortsätta med beroende förlopp, dvs sådana som på något sätt interagerar med varandra. Båda fallen är ofta viktiga i många olika sammanhang, som t ex robotprogrammering och styrsystem (många motorer skall kontrolleras samtidigt och olika sensorer skall läsas av och initiera förlopp samt kontinuerligt styra andra).
Det finns mycket att säga om hur man kan hantera interaktion och beroenden mellan objekt och deras förlopp. Objektorienterad programmering har utvecklats kontinuerligt sedan 50-talet och komponentbaserad programmering sedan 1968. Vi ger här exempel på hur beroenden kan implementeras (det finns ett otal varianter).
Objektorientering
Objektorientering löser ofta beroenden genom att objekten kommunicerar direkt med varandra sinsemellan. Kommunikation mellan instanser sker typiskt genom att objekten anropar metoder hos varandra. Om t ex vårt skepp kolliderar med ett mynt kan objektet för skeppet anropa en metod collect() hos myntet som talar om för myntobjektet att det skall terminera. Vidare kan t ex objektet för skeppet anropa myntets metod getPoints() för att få reda på hur mycket skeppets score ökar. Vem som bör anropa vem beror på programmerarens designval och det är ibland inte uppenbart vilket val som är lämpligast. T ex skulle objektet för myntet istället kunna meddela objektet för skeppet att de har kolliderat. Valet är ditt.
Component-based programming
Component-based programming är ett designmönster som vi inte har pratat om hittills och fortfarande sparsmakat lärs ut på universitet.
Inom komponentbaserad programmering till skillnad från objektorientering grupperar man hellre sin programkod baserad på funktionalitet än objekttillhörighet. För den variant av komponentbaserad programmering som vi här ska kika på löses ofta beroenden med globala funktioner. Men låt oss först beskriva vad component-based programming är.
Component-based programming har ökat i popularitet senaste åren (särskilt inom spelprogrammering) som ett svar på att objektorientering inte passar väl för alla problemtyper. Inom objektorientering representerar man olika typer av objekt med olika klasser. Klasser kan ärva andra klasser och därmed dess typ (dvs i någon mening dess egenskaper). Men i spel är det inte ovanligt att instanser av objekt emellanåt fundamentalt skall ändra egenskaper. Ett spelobjekt dör och kan inte längre styra. Ett spelobjekt får en power-up och kan inte längre bli skadat eller får nya kraftfulla egenskaper. Ett spelobjekt är stillastående och ofarligt och förvandlas av någon anledning till ett rörligt monster. I ett styrsystem för robot/bil skall plötsligt ljudet från radion slås av, utritning på instrumentpanelen slås av för vissa element men slås på för andra. Med klasser är det inte enkelt och smidigt att låta en instans av ett objekt sluta ärva från en klass (för att bli av med dess egenskaper) och börja ärva av en annan klass (för att få dess egenskaper), och antal kombinationer av klasser med olika arv kan snabbt bli ohanterligt. Det är inte omöjligt (och ofta inte ens svårt) att lösa problemet med objektorientering, men grundtanken med klasser och arv är ett verktyg som här ofta passar mindre väl genom att komplicera istället för att förenkla problemet.
Istället delar komponentbaserad programmering upp objekts egenskaper (läs funktionalitet) i separata globala komponenter. Objekt inkluderar därefter de komponenter som de har nytta av för att representera sin sammansatta funktionalitet.
Komponentbaserad programmering finns på olika abstraktionsnivåer och under lite olika namn, som t ex även: Component-Oriented Programming samt Component-based software engineering. Här ska vi prata om komponentbaserad programmering så som det ofta används inom spelprogrammering, dvs när det appliceras som designmönster inom ett och samma projekt och huvudsakligen för endast en applikation, vilket för spel kanske bäst beskrivs av den här informella sidan. Motsatsen är t ex MicroSofts COM-objekt och ActiveX-komponenter eller portabla komponenter inom t ex webprogrammering, där komponenterna är avsedda att vara just portabla och kunna användas för många olika program och av många olika utvecklare.
Den kanske allra enklaste formen av komponentbaserad programmering bygger på att varje egenskap, dvs komponent, som finns motsvaras av en boolesk flagga hos objektet och kan slås på eller av. Dessutom tillkommer ofta parametrar, dvs variabler, för respektive egenskap. Varje objekt innehåller en uppsättning av alla flaggor och tillhörande variabler. Av praktiska skäl implementeras flaggorna för egenskaperna ofta som bitar i en variabel som t ex heter properties (eller flera properties-variabler om bitarna inte räcker till). Då kan egenskaper enkelt ändras hos enskilda instanser genom att bara slå på eller av bitar samt modifiera parametrarna.
En bland många populära lösningar är att låta varje typ av objekt representeras av en och samma klass, just på grund av sin enkelhet - precis som vi har gjort med struct GameObject. Dvs klassen är ett superset av samtliga medlemmar som finns bland alla olika slags objekt.
Logiken som agerar på egenskaperna implementeras i globala funktioner - till skillnad från i metoder per objekt. Varje funktion loopar typiskt igenom samtliga instanser och utför logiken för de instanser som har motsvarande flagga satt. T ex skulle realtidsloopen i main() kunna anropa en funktion renderAllGameObjects() som ligger i gameobjects.c, som i sin tur loopar igenom varje spelobjekt i arrayen gameObjects[] och anropar dess render()-metod om dess flagga "visible" är satt.
Beroenden mellan objekt som består av olika komponenter löses följaktligen typiskt genom att globala funktioner ansvarar för hanterandet. En specifik global funktion, t ex för kollisionsdetektering, känner till åtminstone det som behövs hos respektive inblandat spelobjekt och påverkar spelobjekten direkt.
Både objektorienterad programmering och component-based programming har sina för- och nackdelar och det är fördelaktigt att känna till båda. Om man använder component-based programming är det vanligt att fortfarande utnyttja en stor del objektorientering. Ju fler slags objekt man har som bygger på många olika kombinationer av funktionalitet som ofta är gemensam, desto mer aktuellt blir det att använda komponentbaserad programmering - särskilt om objekten skall kunna ändra egenskaper. Här följer en generalisering.
ingen eller lite gemensam funktionalitet | mycket gemensam funktionalitet | |
---|---|---|
få slags objekt | objektorientering | objektorientering med arv / (komponentbaserad programmering) |
många slags objekt | objektorientering | komponentbaserad programmering |
Parametrarna för respektive komponent implementeras som sagt med variabler. Graden av generalitet och flexibilitet för komponentens funktionalitet styr valet av parametrarnas typer. Funktionspekare ger en användare av komponenten maximal flexibilitet. Exempel:
Fåtal alternativ | Större definitionsmängd | Fullständig flexibilitet |
---|---|---|
bool/enum | int / float / double / array / sträng / struct / klass ... | virtuell metod / funktionspekare |
I nästa uppgift ska vi låta skeppet kunna plocka våra mynt och hjärtan och kasta eller skjuta äpplen på aliens, varpå de träffade objekten avslutas. För träffade aliens, eller om skeppet träffas av aliens, skall avslutningen ske med en explosionsanimering. För sakens skull och för att det passar bra ska vi prova på att implementera kollisionsdetekteringslogiken med en mycket enkel variant inspirerad av komponentbaserad programmering. Komponenten som vi inför kopplar vi till en flagga COLL_CHECK som är 1 för spelobjekt som ska kollisionstestas. Logiken för vad som skall hända vid kollisioner baserar vi på spelobjektens egenskaper som också är representerade som komponenter (PLAYER, ENEMY, COIN, POWER_UP etc.). För att ha fullständig flexibilitet för explosionsanimeringarna använder vi funktionspekare för att välja explosionsanimering samt kunna växla till tillhörande update()-metod. Observera att om man skulle vilja använda komponentbaserad programmering fullt ut behöver man även ändra alla andra objektspecifika metoder (i vårt fall update() och render()) till generella globala metoder.
Objekt som skapar eller terminerar andra objekt
Det är vanligt att objekt har tillåtelse att skapa andra objekt (i vårt fall när player-objektet skapar äpplen) och objekt kan få meddelande om när de ska avslutas. Att låta ett objekt skapa andra typer av objekt är ofta oproblematiskt. Ett objekt kan skapa ett nytt objekt helt själv eller anropa en create()-funktion som implementeras av det skapade objektets .c-fil.
Inom objektorientering använder man normalt en klasspecifik konstruktor, dvs en form av Create-funktion, som automatiskt anropas när objekt allokeras (på stacken eller heapen) för att initiera en instans, samt en klasspecifik destruktor som anropas när instansen deallokeras (från stack eller heap).
Men... det är ofta lämpligt att objekten själva ansvarar för att faktiskt terminera samt exakt när de skall deallokeras. I vårt fall kanske ett träffat eller plockat objekt gör en speciell avslutningsanimering (t ex explosion eller initierar uppspelning av ett ljud). I ett inbyggt system kanske ett objekt som skall avslutas behöver göra några slutberäkningar, spara ner information och frigöra resurser vilket kan involvera ytterligare kommunikation med andra objekt. Principen är densamma.
I övningsuppgifterna ovan låter vi instansers update()-metod returnera false när objekten skall deallokeras (dvs tas bort), vilket duger bra för oss här och nu.
För kollisionsdetektering skapar man typiskt en global funktion CollisionDetection(...) som loopar igenom alla par av objekt och kollisionsdetekterar dem mot varandra.
I en objektorienterad lösning gör man t ex så att man för varje par av objektinstanser, (A,B), som ska kollisionstestas anropar en metod hos objekt A som tar B som inparameter och vice versa. Dvs:
Man behöver alltså kunna särskilja vilka typer av objekt som är inblandade i en given kollision (t ex player vs heart, player vs coin) för att kunna initiera rätt typ av skeende. I objektorienterade språk som Java, C++, C# m.fl. ger språket stöd för att avgöra typer i runtime, men i C är det sämre ställt. Istället är två exempel på tillvägagångssätt för att identifiera objekttypernna att antingen ha dem i separata listor eller ha en struktmedlem som talar om objektets typ. T ex:
Fördelen med objektorientering är flexibilitet. Nackdelen är att varje objekt måste ha en metod collisionDetect(), vilken sannolikt är mer eller mindre identisk för många snarlika typer av objekt. (Man kan visserligen försöka använda arv eller funktionspekare som pekar ut lämplig funktion av ett antal olika varianter för att minska mängden implementation.)
Filosofin med komponentbaserad programmering är istället att bryta loss egenskaperna eller funktionaliteter (dvs komponenterna) ifrån objekttyperna och i största mån skapa global logik som agerar utefter de olika egenskaperna istället för vilka datatyper objekten är. Därmed kan man lätt hantera en mängd olika typer av objekt med olika kombinationer av egenskaper utan att behöva lägga till implementation för varje unik kombination.
Nackdelen är att man kan få en eller ett antal stora komplicerade globala funktioner. Men en annan fördel är att gemensam logik är samlad på ett ställe i programkoden, vilket kan vara överskådligare och göra det enklare att införa modifieringar.
Vilka egenskaper man vill indela i kan förstås variera fullständigt. I vårt spel gäller just nu att ett objekt kan vara: player, enemy, player shot, enemy shot (har vi ännu inte lagt till), poänggivande mynt eller powerup. Än så länge har vi inte implementerat några kombinationer, men man kan enkelt tänka sig objekt som både är enemy och powerup, eller mynt och powerup, samtidigt. Datastrukturen blir då t ex något i stil med:
Vi ska nu låta våra objekt interagera med varandra när de kolliderar. I ett realtidsspel utgör kollisionshanteringen inte sällan en mycket central del i spellogiken. Ofta är det i samband med att objekt kolliderar som objekt och förlopp interagerar med och påverkar varandra. Här ska vi visa ett enkelt men ändå flexibelt sätt att implementera kollisionslogiken.
Detta är sista programmeringsuppgiften för hemuppgifterna. Hemuppgifter förel. 5 presenterar endast hur man skulle kunna gå tillväga för att konvertera vårt projekt till att exekvera på labbdatorn MD407 (vilket kan vara intressant för er inför labb 5). Du kan med fördel läsa igenom det så fort du är färdig med denna uppgift 8.
Vi ska låta aliens explodera om de blir träffade av ett äpple. Vi ska låta skeppet explodera om det kolliderar med en alien. Vi skall också låta skeppet kunna plocka hjärtan och mynt.
Typiskt för kollisionsdetektering är att man har en nästlad loop (i två nivåer) som loopar igenom alla spelobjekt som potentiellt kan kollidera och testar var och ett parvis mot varandra:Vi behöver också kunna särskilja på kollisioner mellan olika slags spelobjekt för att initiera rätt skeenden - här inte nödvändigtvis baserat på deras datatyper (vi använder just nu ändå bara en och samma datatyp GameObjects för alla objekt), utan snarare på vilka sorts spelobjekt det rör sig om. Vi kan specificera alla olika sorter med en enum och ha en variabel i struct GameObjects som talar om vilken sort objektet tillhör. Dock, för att öka generaliteten kan vi representera varje sort med en bitflagga. Då blir det teoretiskt möjligt att låta ett och samma objekt tillhöra mer än en sort (vilket passar komponentbaserad programmering). Vi ska också införa två extra flaggor: en som markerar om objektet skall kollisionsdetekteras överhuvudtaget (COLL_CHECK) samt en flagga (TERMINATING) som vi använder inuti vår globala kollisionsdetekteringsfunktion.
Explosioner: Vi vill initiera explosionsanimeringar om vårt skepp blir träffat av en alien eller om ett äpple träffar en alien. För att enkelt kunna bestämma per instans hur dess explosionsanimering ser ut låter vi struct GameObject innehålla en metod setExplosion() som anropas för att initiera rätt explosion. Då har vi full flexibilitet att både dela implementation mellan olika objekt samt ha unika varianter. Funktionen setExplosions() byter objektets animering och även update()-funktion (eftersom objektet t ex inte längre ska kunna flytta sig). Update()-funktionen spelar explosionsanimeringen en gång och avslutar därefter automatiskt objektet.
Att testa spatialt (rumsligt) överlapp mellan objekt: Om man har objekt som kan rotera och är ungefär lika höga som breda är det enklaste att göra ett test som approximerar varje objekt med en cirkel och testar överlapp mellan två cirklar. Så här kan du göra för att beräkna ett objekts cirkelcentrum och radie, där pObj är en pekare till en instans av typen GameObject:
aliens.c
#include "collision.h"
...
static void init(...
...
pObj->setExplosion = setExplosion1;
pObj->properties = COLL_CHECK | ENEMY;
...
coin.c static void init( ... pObj->lifeTime = 5*600000; // so we have time to take it pObj->properties = COLL_CHECK | COIN; pObj->setExplosion = 0; // no explosion animation heart.c static void init(... ... pObj->lifeTime = 6*600000; // so we have time to take it pObj->properties = COLL_CHECK | POWER_UP; pObj->setExplosion = 0; // no explosion animation player.c #include "collision.h" ... void createShip() { ... ship.setExplosion = setExplosion1; ship.properties = COLL_CHECK | PLAYER; shots.c GameObject* createPlayerShot1(... ... pObj->properties = COLL_CHECK | PLAYER_SHOT; pObj->setExplosion = 0; main.c #include "collision.h" ... // Anropa CollisionDetection() mellan update- och render-looparna: // Update our object(s) ... CollisionDetection(gameObjects, &nGameObjects); // Render our object(s) - background objects first, and then forward objects for( ... collision.h #ifndef COLLISION_H #define COLLISION_H #include "gameobject.h" void CollisionDetection(GameObject* gameObjects[], int *nGameObjects); void setExplosion1(GameObject* gameobj); void setExplosion2(GameObject* gameobj); #endif //COLLISION_H collision.c #include "collision.h" #include "vecmath.h" #include "dynamicalloc.h" #include "player.h" static inline float getRadius(GameObject* pObj) { float radius = sqrt( pObj->gfxObj.outputHeight*pObj->gfxObj.outputHeight + pObj->gfxObj.outputWidth*pObj->gfxObj.outputWidth)*0.25f; return radius; } static inline bool collide(GameObject* pA, GameObject* pB) { vec2f centerA = pA->pos; vec2f centerB = pB->pos; float dx = (centerA.x - centerB.x); float dy = (centerA.y - centerB.y); float dist = sqrt(dx*dx + dy*dy); return (dist <= getRadius(pA) + getRadius(pB)); } static bool update(GameObject* this) { // kolla om animeringen är färdig bool bFinished = this->frame+this->frameSpeed >= this->numFrames; // update which frame in texture to show this->frame = fmod( (this->frame+this->frameSpeed), this->numFrames ); // Hack to make player resurrect after dying. if( bFinished && (this->properties & PLAYER) ) createShip(); return !bFinished; } void setExplosion1(GameObject* pObj) { static GfxObject expl; static bool bFirstTime = true; if(bFirstTime) { bFirstTime = false; // Create a GfxObject for the explosion animation expl = createGfxObject( "../explosion1_10.png" ); } int w = pObj->gfxObj.outputWidth; int h = pObj->gfxObj.outputHeight; pObj->gfxObj = expl; pObj->gfxObj.outputWidth = w*1.5f; pObj->gfxObj.outputHeight = h*1.5f; pObj->frame = 0; pObj->frameSpeed = 0.3f; pObj->moveDir = (vec2f){0,0}; pObj->numFrames = 10; pObj->update = update; } void CollisionDetection(GameObject* gameObjects[], int * outNumGameObjects) { int nGameObjects = *outNumGameObjects; for(int i=0; i < nGameObjects; i++) { GameObject* pA = gameObjects[i]; if( !(pA->properties & COLL_CHECK) ) continue; // A is not collidable // We want to test: PLAYER and PLAYER_SHOT against the other types if( !(pA->properties & (PLAYER | PLAYER_SHOT)) ) continue; for(int j=0; j < nGameObjects; j++) { GameObject* pB = gameObjects[j]; if( !(pB->properties & COLL_CHECK) ) continue; // B is not collidable // Testa A och B mot varandra if(collide(pA, pB)) { if(pB->properties & (ENEMY | ENEMY_SHOT) ) { // Player/playershot can hurt several enemies at once, // so don't turn off collision detection yet. pA->properties |= TERMINATING; // flag object as killed. pB->properties |= TERMINATING; // flag other obj as killed // disable further collision check for the enemy. pB->properties &= ~COLL_CHECK; } if( (pA->properties & PLAYER) && (pB->properties & (COIN | POWER_UP)) ) { // Powerup/coin kan bara plockas av en spelare åt gången. // Därför, slå av dess kollisionsdetektering pB->properties &= ~COLL_CHECK; pB->properties |= TERMINATING; // kill coin/powerup // ... add points or power-up capabilities } } } } // Do something with each of the newly terminating objects. // E.g., initiate termination animation or remove them. int j=0; for(int i=0; i < nGameObjects; i++) { GameObject* pObj = gameObjects[i]; gameObjects[j++] = gameObjects[i]; // lägg tillbaks objektet i arrayen if( pObj->properties & TERMINATING ) { // clear the terminating and coll_check flags pObj->properties &= ~(TERMINATING | COLL_CHECK); if( pObj->setExplosion ) pObj->setExplosion(pObj); else { freeElement(pObj); // deallokera objektet j--; // minska antal objekt } } } *outNumGameObjects = j; // uppdaterar arrayens storlek } |
Just nu är våra hjärtan flaggade som powerups. Men inget händer när skeppet plockar dem. Anledningen är att vi ännu inte har lagt till någon motsvarande logik i kollisionshanteringen. Här är ett utdrag ur koden i collision.c som visar var du lägger till det:
Inför dessa två nya medlemsvariabler i struct GameObjects: